@aiquants/daily-report 0.42.0 → 0.44.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
@@ -3,13 +3,14 @@
3
3
  Reusable **daily-report** feature package: a virtual-scrolling report list and detail UI with optimistic updates and SSE real-time sync (React Router
4
4
  v7 / v8), and a drizzle (**mssql**) server layer. It follows the `@aiquants/authz-*` DI layering: every app-specific concern (authentication, user
5
5
  resolution, id obfuscation, Redis, legacy tables, the page chrome) is injected through ports, so the package carries no application- or
6
- domain-specific knowledge. It is one module with three entries:
6
+ domain-specific knowledge. It is one module with four entries:
7
7
 
8
8
  | Entry | Contents | Environment |
9
9
  | :--- | :--- | :--- |
10
- | `@aiquants/daily-report` | Types; the strict wire readers (`dailyReportDetailSchema` and `dailyReportSseMessageSchema`, built on the package's in-place reader; `readIdsStreamChunkLine` with `forEachIdsStreamRow`; `readDailyReportSearchMarksAnswer`); the action wire contract and the endpoint tables; the attachment URL codec and the thumbnail variants; the business-date normalizer and the echo-id pattern; the logger contract; the search wire types | isomorphic |
10
+ | `@aiquants/daily-report` | Types; the strict wire readers (`dailyReportDetailSchema` and `dailyReportSseMessageSchema`, built on the package's in-place reader; `readIdsStreamChunkLine` with `forEachIdsStreamRow`; `readDailyReportSearchMarksAnswer`); the action wire contract and the endpoint tables; the attachment URL codec and the thumbnail variants; the business-date normalizer and the echo-id pattern; the logger contract; the search wire types (the search, the search-marks and the per-id `reports` answers) | isomorphic |
11
11
  | `@aiquants/daily-report/client` | `DailyReportPage`, list/detail components, hooks, action context, route helpers | browser (React) |
12
12
  | `@aiquants/daily-report/server` | `createDailyReportServer` (service + loaders/actions), `defineDailyReportSchema`, SSE fan-out reader, SQL result cache, the sharp thumbnail renderer | node |
13
+ | `@aiquants/daily-report/testing` | `createDailyReportTestInstrumentation` (the views' test handle, the row pass's counters and the re-estimate counters, provided through the client entry's `DailyReportInstrumentationContext` in a host's test build only) and the types of the row pass's events | browser (tests) |
13
14
 
14
15
  ## Contents
15
16
 
@@ -87,7 +88,7 @@ pnpm add @aiquants/daily-report @aiquants/virtualscroll @aiquants/swipe-overlay
87
88
  names with ref callbacks that return a cleanup.
88
89
  `zod` may be 3.25.76 or later, zod 4 included (`>=3.25.76 <5.0.0`): the package reads zod only through its `zod/v3` entry, which every version in that
89
90
  range ships, so the shipped declarations compile and a mismatch names the same issue codes whichever version the host installs.
90
- The peer floor of `@aiquants/virtualscroll` is **3.17.0**: install 3.17.0 or later.
91
+ The peer floor of `@aiquants/virtualscroll` is **3.18.0**: install 3.18.0 or later.
91
92
  Both views name their active row through its `activeItemIndex`, which renders `aria-activedescendant` in the same render as the row and keeps that
92
93
  row mounted, count that row as shown only beyond its `activeItemVisibleInset`, describe an empty composite by its empty text, and skip the rendering
93
94
  of far rows through its `itemZones` and `useVirtualScrollItem`; they bind its focus-ring colour variable `--aqvs-focus-ring-color` to the row outline
@@ -545,9 +546,12 @@ its payload — with one strict parser each, after authentication and before any
545
546
  The mismatch names the failing paths and the issue codes only, never a value (`<path>: <code>` items joined by a semicolon and a space, the root as
546
547
  `(root)`), so a host debugging a version skew or a rewriting proxy learns which fields failed and nothing it received reaches a log.
547
548
  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
548
- `business-date` endpoints with strict schemas (`{ report }` and `{ reports }` of `dailyReportDetailSchema`): a body that does not match rejects with
549
+ `business-date` endpoints with strict schemas (`{ report }` and `{ reports }` of `dailyReportDetailSchema`) and the `reports` endpoint with the
550
+ strict schema of its per-id outcomes (`{ outcomes }`: exactly one outcome per requested id, in request order, so a missing, duplicate, unrequested
551
+ or reordered id fails the whole answer): a body that does not match rejects with
549
552
  `[daily-report] <endpoint> answered a body that does not match its schema (<mismatch>)`, a status other than 2xx with
550
- `[daily-report] <endpoint> answered HTTP <status>`.
553
+ `[daily-report] <endpoint> answered HTTP <status>`, and a `failed` outcome rejects only that report's readers with
554
+ `[daily-report] reports answered the outcome failed for report <id>` (the batch's other reports settle; [Requests](#requests) under the text search).
551
555
  Every detail on the wire — an SSE message's report or posted comment, the action's answer and the read endpoints' answers — is read by one strict
552
556
  reader of the shape, on which `dailyReportDetailSchema` is built: it checks the parsed JSON value where it lies and outputs that same value when it
553
557
  conforms, so a conforming answer is neither copied nor reshaped (a zod object schema would copy every report's whole tree).
@@ -569,8 +573,9 @@ its payload — with one strict parser each, after authentication and before any
569
573
  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
570
574
  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
571
575
  viewer's own comments look like someone else's.
572
- - `redis` — `getClient()` (get/incr/xAdd/xRange/xRevRange) + `createClient()` (blocking xRead). Structurally matches a `node-redis` v5 client. If
573
- omitted, SSE publishing is skipped (with a warning) and epoch is disabled.
576
+ - `redis` — `getClient()` (get/incr/xAdd/xRange/xRevRange/xInfoStream) + `createClient()` (blocking xRead). Structurally matches a `node-redis` v5
577
+ client. The SSE catch-up from the stream origin reads XINFO STREAM's `length` and `entries-added`, which Redis 7.0 and later report (**Catch-up**
578
+ under the SSE route). If omitted, SSE publishing is skipped (with a warning) and epoch is disabled.
574
579
  - `externalSources[]` — When `Hub.source_type` matches, performs a `LEFT JOIN` on `Hub.source_id_num = idColumn` and converts fields via `mapRow`.
575
580
  - `draftLabelNames` — **Required**: the database label names that mark a draft, the canonical one first (such as `["Draft", "Work in Progress"]`,
576
581
  since sources may call their drafts by different names). `createDailyReportService` copies and freezes the list, and throws
@@ -595,7 +600,7 @@ its payload — with one strict parser each, after authentication and before any
595
600
  shared draft-label test, beside the hub row) or true when its creation category is one of the names; a row whose flag reads as anything but 1 or 0
596
601
  throws.
597
602
  The client compares no name: the views open the viewer's own draft in the editor by `isDraft` alone, so a draft named by any of the names, or by a
598
- variant the collation equates, behaves as one ([Client wiring](#client-wiring)). The SSE stream's key carries the wire version (`<streamKey>:v3`),
603
+ variant the collation equates, behaves as one ([Client wiring](#client-wiring)). The SSE stream's key carries the wire version (`<streamKey>:v4`; [Realtime architecture](#realtime-architecture)),
599
604
  so an entry written before `isDraft` existed is never read.
600
605
  - `resolveVisibleSourceTypes(request, { reason })` — **Required row-level authorization port.** Returns the `Hub.source_type` values this request may
601
606
  view, or the explicit opt-out `{ unrestricted: "<reason>" }` of a deployment that does not restrict by source type. Applies uniformly to every server
@@ -639,7 +644,7 @@ its payload — with one strict parser each, after authentication and before any
639
644
 
640
645
  The client has no host logger port: its four channels of the same list write to the console from `info` — `[DailyReportActionProvider]` (the action
641
646
  provider: a failed action, the queue of SSE messages about uncached reports, a cache reload), `[DailyReportDetailLoader]` (the detail loading: a
642
- stale update of a report's cache, a failed prefetch of a business date),
647
+ stale update of a report's cache, a failed prefetch of a business date, a failed refetch that left the shown report in place),
643
648
  `[DailyReportIdsStream]` (the ids stream client: a revalidation that gave up, a rejected malformed line, which ends the attempt, a skipped line of
644
649
  an unknown kind, a malformed stream anchor) and `[DailyReportSseConnection]` (the SSE connection hook).
645
650
  Every line, the server's and the client's, carries exactly one channel prefix: no message spells a `[DailyReport…]` prefix of its own.
@@ -698,12 +703,15 @@ writes no line in the refusal record below (it is not a cross-site refusal). Eac
698
703
  Each route's decisions — the name its refusals are recorded under (`api`, `action`, `sse` or `attachment`), the navigations it serves from another
699
704
  origin, its answer to a data request and its 403 — come from one policy of that route, so they cannot be paired wrongly, and an endpoint added with a
700
705
  streaming body is refused from the start.
706
+ The cross-origin rule itself is `crossOriginRefusal` of `@aiquants/request-isolation` (a dependency of this package, so every package that adopts it
707
+ refuses the same requests): a route's policy extends that package's `CrossOriginPolicy` (which navigations from another origin it serves), and the
708
+ rule answers why it refuses (`cross-origin-fetch` or `foreign-origin`, below), which the refusal record writes.
701
709
 
702
710
  | Request | Answer |
703
711
  | --- | --- |
704
712
  | `Sec-Fetch-Site: same-origin` or `none` (a load the user started: the address bar, a bookmark) | Served: authentication and the route's own checks follow |
705
- | Any other `Sec-Fetch-Site` — `same-site` (another origin of the same site, such as a sibling subdomain), `cross-site` or an unknown value | 403 before authentication, unless the request is a top-level document navigation that the route serves: method `GET`, `Sec-Fetch-Mode: navigate`, `Sec-Fetch-Dest` exactly `document`, and a route policy that accepts it. Only the attachment route accepts one, and only for an original (`inline` or `download`, read by the loader's own strict query parser): a link in another page opens or downloads the original. The API, SSE and thumbnail routes accept no navigation from another origin |
706
- | No `Sec-Fetch-Site` | A safe method (`GET`, `HEAD`, `OPTIONS`) is served. An unsafe method is decided by `Origin`: no `Origin` is served (not a browser page), an `Origin` whose host is the host the request was addressed to (`new URL(request.url).host`, which the adapter builds from `Host`) is served, and `null`, an unparsable value, another host or another port is 403 |
713
+ | Any other `Sec-Fetch-Site` — `same-site` (another origin of the same site, such as a sibling subdomain), `cross-site` or an unknown value | 403 (`cross-origin-fetch`) before authentication, unless the request is a top-level document navigation that the route serves: method `GET`, `Sec-Fetch-Mode: navigate`, `Sec-Fetch-Dest` exactly `document`, and a route policy that accepts it. Only the attachment route accepts one, and only for an original (`inline` or `download`, read by the loader's own strict query parser): a link in another page opens or downloads the original. The API, SSE and thumbnail routes accept no navigation from another origin |
714
+ | No `Sec-Fetch-Site` | A safe method (`GET`, `HEAD`, `OPTIONS`) is served. An unsafe method is decided by `Origin`: no `Origin` is served (not a browser page), an `Origin` whose host is the host the request was addressed to (`new URL(request.url).host`, which the adapter builds from `Host`) is served, and `null`, an unparsable value, another host or another port is 403 (`foreign-origin`) |
707
715
 
708
716
  - **Requests without Fetch Metadata**: a browser sends no `Sec-Fetch-*` to an origin that is not potentially trustworthy (plain HTTP other than
709
717
  `localhost`), so such a request comes either from a client that is not a browser or from a page of such an origin. A browser always sends `Origin`
@@ -721,7 +729,9 @@ streaming body is refused from the start.
721
729
  - **The refusal record**: each handler factory keeps one bounded record of its refusals (state per factory), written on the `[DailyReportIsolation]`
722
730
  channel: to the configured `logger` with that prefix, or by default to the console from the `warn` level.
723
731
  A refusal with no open window writes one warn line at once,
724
- `cross_site_rejected route=<api|action|sse|attachment> site=<value> mode=<value> dest=<value> method=<method>` (`-` for an absent header; a value
732
+ `cross_site_rejected route=<api|action|sse|attachment> reason=<cross-origin-fetch|foreign-origin> site=<value> mode=<value> dest=<value> method=<method>`
733
+ (the reason says which half of the rule refused: `cross-origin-fetch` when the Fetch Metadata names another origin, `foreign-origin` when a request
734
+ without Fetch Metadata uses an unsafe method and an `Origin` that does not name the request's host; it carries nothing the request sent; `-` for an absent header; a value
725
735
  outside the specification's shape — lower-case letters and hyphens, at most 32 characters — is written as the JSON string of its first 32
726
736
  characters, so a sender cannot forge the line's fields), and opens a window of 60 s that every route of the factory shares; later refusals in the
727
737
  window are only counted, per route.
@@ -784,8 +794,9 @@ reason is the audit trail, so `grep -rn "unrestricted:"` in the host lists every
784
794
  `"Internal"`, the package's own source type for the reports it creates (`DAILY_REPORT_INTERNAL_SOURCE_TYPE` on the shared entry). They are *not* your
785
795
  authorization resource keys; mapping a resource key (e.g. `report_legacy`) onto a source type (e.g. `legacy`) is the consuming app's job. Read the
786
796
  canonical set at boot from `dailyReportServer.knownSourceTypes` (frozen: `Internal` first, then each adapter's in order) and assert your mapping
787
- against it — an unknown token silently matches zero rows. Fold your tokens with `foldDailyReportSourceTypeToken` (shared entry), the fold the server
788
- applies to the tokens it reads, instead of a copy of it.
797
+ against it — an unknown token silently matches zero rows. Fold your tokens with `foldDailyReportSourceTypeToken` (the shared entry, and the client entry for route modules that ship
798
+ to the browser: the shared entry is not split, so importing the fold from it carries a second copy of shared modules into the route's chunk), the
799
+ fold the server applies to the tokens it reads, instead of a copy of it.
789
800
  Two adapters whose source types are equal ignoring case (an exact duplicate included), or an adapter whose source type is `Internal` in any case,
790
801
  stop `createDailyReportService` and `createDailyReportServer` with a `RangeError`
791
802
  (`[daily-report] externalSources[1].sourceType must be a source type that differs, ignoring case, from externalSources[0].sourceType "legacy"; got "Legacy"`):
@@ -1617,8 +1628,8 @@ and without the retry the victim would answer 500 and lose what it wrote.
1617
1628
 
1618
1629
  ### Realtime architecture
1619
1630
 
1620
- 1. Mutation services publish to Redis Stream (`<streamKey>:v3`, `daily-report:sse-stream:v3` by default: the key carries the wire version of the
1621
- embedded report, so a deployment that changes a required key of it reads no entry an older version wrote, and the stream of the previous version
1631
+ 1. Mutation services publish to Redis Stream (`<streamKey>:v4`, `daily-report:sse-stream:v4` by default: the key carries the wire version of the
1632
+ stream's messages and their embedded report, so a deployment that changes a required key of them reads no entry an older version wrote, and the stream of the previous version
1622
1633
  stops growing and can be deleted by hand; a host that reads or writes the stream itself, such as an integration test, builds the key with the
1623
1634
  server entry's `buildDailyReportSseStreamKey(streamKey)` instead of restating the version) in sequence:
1624
1635
  `invalidate -> epoch increment -> SSE publish`.
@@ -1627,20 +1638,27 @@ and without the retry the victim would answer 500 and lose what it wrote.
1627
1638
  `DailyReportSseReaderPort` — `ready()` and `subscribe(onEntry, onError)`, with the contract under **`connected` frame** below — which is the type a
1628
1639
  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.
1629
1640
  The reader reads each live entry once per process and hands every subscriber the same frozen value (`DailyReportSseStreamEntry`:
1630
- `{ id, message }`): `message` is the entry's `data` field parsed as JSON and validated by `dailyReportSseMessageSchema` — the validated message
1631
- (`parsed`), the JSON value as published (`published`, with the server-only fields) and its text (`text`) — or `null` when the field is absent, is
1632
- 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
1633
- read position moves past it).
1634
- Each connection only filters that value (recipient, source-type visibility, comment redaction) and never changes it, and the frame texts of the
1635
- variants that differ from the published text — without `recipientRawUserId`, and with the embedded report's comments emptied — are built at most
1636
- 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
1637
- connection, not one parse per connection.
1641
+ `{ id, message }`): `message` is the entry's `data` field parsed as JSON and validated by the stream form `dailyReportSseStreamMessageSchema` (the
1642
+ only form that declares the server-only fields: the six types that may be addressed — status-update, comment-add, comment-delete, report-create,
1643
+ report-update and report-publish — carry the recipient's internal id `recipientRawUserId`, and the three that embed a report also the writer's,
1644
+ `actorRawUserId`) — the validated message (`parsed`) and the JSON value as published (`published`, which only the recipient filter reads) — or
1645
+ `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 —
1646
+ fail-closed — while the reader's own read position moves past it).
1647
+ Each connection only filters that value (recipient, source-type visibility, comment redaction, the actor) and never changes it. Every frame is
1648
+ built from the parsed message, never from the published value or text: the message without its server-only fields, typed as the delivered form,
1649
+ so a server-only field left in a frame fails the type check, and a key the stream schema does not know never reaches a viewer. A message that
1650
+ embeds no report has one frame for every viewer; one that embeds a report has at most four — with the viewer's `viewerIsActor`, its comments
1651
+ emptied for a viewer who may not read them — each built at most once per message and shared by every connection that writes it, so a live entry
1652
+ costs one parse in proportion to its size plus a filter per connection, not one parse per connection, and every frame is one line of JSON.
1638
1653
  A host's own reader keeps the same contract: it reads an entry once — `readDailyReportSseStreamEntry(raw, logger)` (server entry) reads it as the
1639
1654
  package's reader does — and hands the same value to every subscriber.
1640
1655
  3. `sse.loader` is built on `@aiquants/sse/server` (`createSseResponse`, `terminalStreamResponse`, `startSseHeartbeat`, `readLastEventId`). Its
1641
1656
  responses carry the SSE response headers of `@aiquants/sse`, the routes' security headers (**Security headers** in [The API route](#the-api-route))
1642
- and the forwarded `Set-Cookie`, and no `Connection` or other hop-by-hop header (which HTTP/2 forbids). `recipientRawUserId` is filtered server-side
1643
- and removed before transmission to prevent internal ID leaks. Catch-up and live entries pass through the same filters (recipient, source-type
1657
+ and the forwarded `Set-Cookie`, and no `Connection` or other hop-by-hop header (which HTTP/2 forbids). `recipientRawUserId` and `actorRawUserId` are read
1658
+ server-side and never written to a viewer, to prevent internal ID leaks; each connection writes `viewerIsActor` instead (whether its viewer made
1659
+ the write), so the delivered form (`dailyReportSseMessageSchema`, the root entry's `DailyReportSseMessage`) declares `viewerIsActor` and no internal id
1660
+ on any type. The delivered schema does not refuse a key it does not know (it drops it), so a long-lived tab keeps accepting a newer server's
1661
+ messages that carry additive fields; what a viewer receives is decided by the frame builder above. Catch-up and live entries pass through the same filters (recipient, source-type
1644
1662
  visibility, comment redaction).
1645
1663
  4. On the client, `useDailyReportSseConnection` runs on `useReopeningEventSource` (`@aiquants/sse/react`): full-jitter backoff from 2 s to 30 s, the
1646
1664
  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
@@ -1658,6 +1676,9 @@ and without the retry the victim would answer 500 and lose what it wrote.
1658
1676
  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`
1659
1677
  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
1660
1678
  comment's temporary id becomes its real id only through its own action's answer.
1679
+ A report message (create, update, publish) is the viewer's own write exactly when it carries `viewerIsActor: true`: the client then caches the
1680
+ payload's read and star state as its own write's answer, so the writer's other tabs and devices see their own create as read; otherwise it merges the
1681
+ shared fields and keeps the viewer's own read and star state (false for a report it has not cached), so no write leaks its writer's state.
1661
1682
  Each action holds its own lock on the report it changes (its release frees only that lock and only once), so an overlapping action, an SSE change
1662
1683
  (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.
1663
1684
 
@@ -1682,15 +1703,17 @@ unexpected error, a request that is not the package's client (another origin's p
1682
1703
  | Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
1683
1704
  | 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 |
1684
1705
  | 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 |
1685
- | Empty stream, or a cursor inside the retained window or in the future | 200, `connected` → heartbeat → catch-up (every entry) → live | continues |
1686
- | Redis failure during the catch-up, or the stream tail cannot be read while the shared reader fixes its read position (`ready()` rejects; on the path without a cursor before `connected` is written) | the body ends (`producer-failed`) | one native reconnect with `Last-Event-ID`, then the utility's own backoff; the cursor is kept |
1706
+ | The stream origin `0-0` as the cursor (the anchor of an ids read on an empty stream) while the stream has removed an entry (XINFO STREAM's `entries-added` exceeds its `length`: trimmed or deleted) | 200, `connected` → heartbeat → `retry: 86400000` → `event: resync-required` → end | same as above |
1707
+ | Empty stream (any cursor: no page is read, and entries appended later arrive live), a cursor inside the retained window or in the future, or the origin `0-0` while the stream has removed no entry (the catch-up starts at the stream's first entry) | 200, `connected` → heartbeat → catch-up (every entry) → live | continues |
1708
+ | Redis failure during the catch-up, an XINFO STREAM reply without the counts (`length` and `entries-added` must be safe integers with `entries-added` ≥ `length` ≥ 0; a Redis older than 7.0 has no `entries-added`), or the stream tail cannot be read while the shared reader fixes its read position (`ready()` rejects; on the path without a cursor before `connected` is written) | the body ends (`producer-failed`) | one native reconnect with `Last-Event-ID`, then the utility's own backoff; the cursor is kept |
1687
1709
 
1688
1710
  - **Resume cursor**: `readLastEventId` reads the `Last-Event-ID` header first, then the `lastEventId` query parameter
1689
1711
  (`DAILY_REPORT_SSE_CURSOR_PARAM`). The browser's own reconnect reuses the URL it opened with (a stale query) but sends a fresh header, so the header
1690
1712
  is always the newer value. A cursor must be a Redis stream id of the form `<ms>-<seq>` with 1–15 digits per part; anything else is answered with
1691
1713
  `resync-required`. The client URL-encodes the cursor.
1692
1714
  - **`connected` frame**: `data: {"type":"connected"}` with `id:` set to the resume position (the cursor, or the newest entry at connect time when
1693
- there is no cursor). On the cursor path it is written before any Redis round trip.
1715
+ there is no cursor), and without `id:` while that position names no entry (the origin cursor `0-0`, or an empty stream without a cursor). On the
1716
+ cursor path it is written before any Redis round trip.
1694
1717
  Without a cursor it is written only after the shared reader has fixed its read position (`DailyReportSseReader.ready()`), so every entry appended
1695
1718
  after a client sees `connected` reaches that client; on the cursor path the catch-up is read after that point instead, so nothing appended between
1696
1719
  the catch-up and the position fix is lost.
@@ -1711,9 +1734,15 @@ unexpected error, a request that is not the package's client (another origin's p
1711
1734
  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
1712
1735
  small events takes 113 round trips (one check of the oldest entry and 112 pages, each new page reading 9 entries after its start).
1713
1736
  - **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
1714
- 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
1715
- 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
1716
- cursor that is simply not an entry id inside the retained window continues).
1737
+ id) until the stream end; every entry is delivered. The catch-up first reads the oldest retained entry. An empty stream reads no page, for any
1738
+ cursor: the shared reader, subscribed before the catch-up, delivers every entry appended later live (a page read would take an entry appended
1739
+ meanwhile for a trimmed range). A cursor that names an entry is witnessed by the oldest retained entry: when that is newer than the cursor, the
1740
+ range after the cursor may have been trimmed, so the connection ends with `resync-required`. The stream origin `0-0` names no entry, so its witness
1741
+ is that the stream has never removed an entry (XINFO STREAM's `entries-added` equals its `length`): the oldest retained entry is then the first the
1742
+ stream ever got, and the first page starts there and delivers it too; otherwise the connection ends with `resync-required`. When a page does not
1743
+ 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
1744
+ connection ends with `resync-required` instead of silently skipping it (a cursor that is simply not an entry id inside the retained window
1745
+ continues).
1717
1746
  - **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
1718
1747
  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
1719
1748
  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
@@ -1725,8 +1754,11 @@ unexpected error, a request that is not the package's client (another origin's p
1725
1754
  provisional line). The service reads it inside the cached fetcher **before** the SQL, so every cache path (bucket hit,
1726
1755
  joining an in-flight fetch, refresh-ahead) returns an array that already reflects every update up to the anchor. The
1727
1756
  SSE hook does not connect until an anchor arrives and then uses it as the first cursor, which closes the gap between
1728
- loading the ids and opening the stream. `null` means "position unknown" (empty stream, no redis, read failure): the
1729
- hook then opens without a cursor and the server starts from the newest entry at connect time.
1757
+ loading the ids and opening the stream. An empty stream anchors at the stream origin `"0-0"`, which the hook sends as
1758
+ its cursor like any other anchor, so a report published between the ids read and the connect arrives through the
1759
+ catch-up (**Catch-up** above). `null` means only "position unknown" (no redis, a read failure, or a viewer without an
1760
+ internal user): the hook then opens without a cursor and the server starts from the newest entry at connect time,
1761
+ so an entry appended between the ids read and the connect is not delivered (the gap that an unknown position leaves).
1730
1762
  - **Delivery is idempotent**: an anchor can be up to one ids-cache TTL old; replaying from it is safe because `report-*` messages upsert and
1731
1763
  `comment-add` is matched by comment id.
1732
1764
  - **Event size**: the service writes an event to the stream only when its JSON is at most 6,356,992 bytes: the action's form cap (1 MiB,
@@ -1978,37 +2010,51 @@ The 400s come in order, all before any service call: `q` and `combine` (`Invalid
1978
2010
  (`Invalid search query` with the plan's `reason`). `q` stays required: a blank `q` (`q=` included) with a filter is the filters-only search, and
1979
2011
  without one it is still the 400 `empty`.
1980
2012
  A request without filter parameters adds no condition to the SQL: its statement and parameters are those of the text search alone.
1981
- `GET {apiBasePath}/reports?ids=<id,id,...>` (distinct canonical ids, at most 100) answers the visible details among them, `{ reports }`. Neither is
1982
- cached on the server.
2013
+ `GET {apiBasePath}/reports?ids=<id,id,...>` (distinct canonical ids, at most 100) answers one outcome per requested id, in request order, `{ outcomes }`
2014
+ (`DailyReportSearchDetailsAnswer` of `DailyReportSearchDetailOutcome`): `ok` with the report (its delivered JSON copy, read strictly), `not_found`
2015
+ for an id the viewer cannot read, a missing id and a deleted report alike (never told apart, so the answer is no existence oracle), or `failed` for a
2016
+ report the viewer may read whose own assembly failed (its display fields, its mapping, its JSON copy or the strict read of that copy); the server logs a
2017
+ `failed` report's id and cause on its logger, and the answer carries no cause, so the batch's other ids are answered. The cause is logged once per
2018
+ read and report in a window of 60 s (`[DailyReportService] <read>: report <id> passed the visibility predicate but its own assembly failed` at
2019
+ `error`, the read being the service method, `getDailyReportDetailsByIdsByExternalId` here and `markDailyReportSearchHits` for the marks); later
2020
+ failures of that report in that read are only counted, and the window's end writes them as one `warn` line,
2021
+ `[DailyReportService] failed_report_suppressed count=<n> since=<the window's start, ISO> read=<read> report=<id>`, so a broken report that every
2022
+ batch asks for writes a bounded number of lines. The reports are read in slices under the request's signal, like the marks (**Slices** below).
2023
+ A failure before or across the
2024
+ visibility predicate (the viewer's resolution, the query, the batched label, comment and attachment reads, the canonical draft label) fails the whole
2025
+ request with 500, since a per-id `failed` for an id that never passed the predicate would tell it apart from `not_found`. Neither is cached on the
2026
+ server.
1983
2027
  `reports` has an admission of its own from the same factory, never the search's: 2 × the larger of `concurrency` and
1984
2028
  `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` (2, the batch reads the client keeps in flight) reads at once per process, of which one viewer holds at most
1985
2029
  half, so a viewer always passes the client's two concurrent reads (a `concurrency` of 1 admits 4 per process),
1986
2030
  and 10 × `rateLimitPerMinute` reads per viewer and minute, in a bucket apart from the search's, so paging through a large answer neither runs out of
1987
2031
  reads nor spends the searches.
1988
- Its order is the search's: the ids (400), then a viewer without an internal user answered `{ reports: [] }` with no admission and no dataset query,
2032
+ Its order is the search's: the ids (400), then a viewer without an internal user answered every id `not_found` with no admission and no dataset query,
1989
2033
  then the admission with the request's signal (an aborted request takes no slot, returns its token and gets the bodyless 503; a read that finds the
1990
2034
  gate full or its viewer at its share waits for a slot like a search-marks batch, for at most 2 s, then gets 429 with `Retry-After: 1`; an empty bucket
1991
- is 429 with one window's `Retry-After`), the query, and the slot's release whatever the outcome. Its refusal lines name `route=reports`.
2035
+ is 429 with one window's `Retry-After`), the read with the request's signal (in slices, yielding to the event loop between them), and the slot's
2036
+ release whatever the outcome. Its refusal lines name `route=reports`.
1992
2037
  `GET {apiBasePath}/search-marks?q=<text>&combine=intersection|union&ids=<id,id,...>` (the shown answer's query and at most 100 distinct canonical ids,
1993
2038
  the rows a view draws) answers `{ wordsDigest, reports, deferred }` (`DailyReportSearchMarksAnswer`): in `reports`, for each requested report the
1994
- viewer may read that the answer's budget holds (below), once, its id, the mask of the keywords it matches (`matchedSets`: bit i for keyword i, for an
2039
+ viewer may read, whose delivered copy the server assembles (as `reports` does: a report that fails to assemble is logged and left out like an unreadable
2040
+ one, so one broken report never removes the marks of its neighbours) and that the answer's budget holds (below), once, its id, the mask of the keywords it matches (`matchedSets`: bit i for keyword i, for an
1995
2041
  OR of two or more keywords;
1996
2042
  `null` otherwise) and the marks of every non-empty field value the views draw (`fields`, by key: `subject`, `content`, `author`, `employeeName`,
1997
2043
  `updatedBy`, `category`, `creationCategory`, `customerName`, `interviewer:<i>:name|affiliation`, `attachment:<i>` and `comment:<id>:author|content`,
1998
2044
  the comment's id in the list `useDailyReportComments` returns).
1999
2045
  In `deferred`, the ids of the readable requested reports past the budget, in the answer's order (empty when it held them all), so a requested id is in
2000
- `reports`, in `deferred`, or — not readable — in neither, and never twice. `wordsDigest` is the digest of the answer's `words`.
2046
+ `reports`, in `deferred`, or — not readable, or failed to assemble — in neither, and never twice. `wordsDigest` is the digest of the answer's `words`.
2001
2047
  A field's marks (`DailyReportSearchFieldMarks`) are the fingerprint of the raw value they were computed on (FNV-1a 32 over its UTF-16 units) and
2002
2048
  either `ranges`, flat `[start, end, tier]` triples in UTF-16 offsets of that value,
2003
2049
  or `dense: true` without `ranges`, for a field with more ranges than `maxFieldRanges` (10,000: more marks than a reader can tell apart, whose drawing
2004
2050
  alone would add tens of ms to the client's render; a one-letter word over a report of 21,200 units marks about 2,000). The client's reader refuses a
2005
2051
  field with both, a `dense` that is not `true`, a `deferred` id that was not requested, is listed twice or also has marks, and an answer that defers
2006
- ids without carrying a report (the server always answers the first report, so such an answer breaks the contract, and asking again would make no
2007
- progress).
2052
+ ids without carrying a report (the server always answers the first assembled report, so such an answer breaks the contract, and asking again would
2053
+ make no progress).
2008
2054
  A field's triples are its maximal runs of one tier, overlapping and adjacent occurrences merged into one, so they never overlap and a field carries at
2009
2055
  most one triple per UTF-16 unit of its value.
2010
2056
  An answer has a budget: at most 500,000 ranges and 4,000,000 raw UTF-16 units of the fields it marks.
2011
- The reports are marked in the answer's order, and the first is always answered, so every request makes progress; from the first later report that
2057
+ The reports are marked in the answer's order, and the first assembled report is always answered, so every request makes progress; from the first later report that
2012
2058
  would take the answer past either bound (its units are counted before a field is marked, its ranges after), every report is deferred: its fields are
2013
2059
  not marked and its id goes to `deferred`, for the client to ask again (**Arrival** under **Highlights** below).
2014
2060
  The budget holds whole a one-letter word over 100 reports of 21,200 units each (about 200,000 ranges and 2,120,000 units; 1.6–2.9 MB, against 2.2 MB
@@ -2026,7 +2072,9 @@ runs, and its marks equal those of the per-occurrence definition byte for byte.
2026
2072
  work passes 8 ms or 5 ms have passed on the
2027
2073
  process's monotonic clock, and the first step of a slice always runs, so every slice makes progress), and the service yields to the event loop and
2028
2074
  checks the request's signal between two slices,
2029
- inside a field too. No step of the work is longer than a block of 0.5 ms of estimated work, except the index text of one piece of a line (at most
2075
+ inside a field too. The delivered read slices the same way, with the same slicing, before the marks and as the whole of `reports` (a report's JSON
2076
+ copy and strict read are one step, so the read yields before the next report once a slice has run 5 ms, and the longest synchronous block of a
2077
+ `reports` answer is its one serialization); before the marks the service yields once more after it, so the marks start a fresh slice. No step of the work is longer than a block of 0.5 ms of estimated work, except the index text of one piece of a line (at most
2030
2078
  4,000 units) and one grapheme cluster longer than a
2031
2079
  block of the segmentation: on values of 1,000,000 units — only line breaks, one letter per line, invisible characters with line breaks, a fullwidth
2032
2080
  `AB` line with no cut between its pieces,
@@ -2049,17 +2097,19 @@ Its order is the search's and the details': 404 without the `search` block, 400
2049
2097
  400 for the ids, then a viewer without an internal user answered `{ wordsDigest, reports: [], deferred: [] }` with no admission and no dataset query,
2050
2098
  then the admission with the request's signal, the marks (in slices, yielding to the event loop between slices, inside a field too), the slot's release
2051
2099
  whatever the outcome, and the JSON. It is not cached on the server either.
2052
- The service takes the request's signal too (`markDailyReportSearchHits(scope, userId, plan, ids, { signal })`) and checks it before each read and
2053
- after each yield, stopping at the next slice once the client has left, between two slices of one field too.
2054
- Options that are not an object carrying an `AbortSignal` stop the call before any query with a host-facing error: an omitted `options` or `signal` is
2055
- a `TypeError`
2056
- (`[daily-report] markDailyReportSearchHits: options.signal must be the request's AbortSignal (new AbortController().signal for a call that is never aborted)`),
2057
- any other value a `RangeError` ending with `; got <value>`.
2058
- The route always passes the request's signal; drizzle cannot cancel a running query, so the slot is held until the read in progress ends. A request
2100
+ Both per-id reads of the service take the request's signal (`markDailyReportSearchHits(scope, userId, plan, ids, { signal })` and
2101
+ `getDailyReportDetailsByIdsByExternalId(scope, ids, externalId, { signal })`) and check it before each query and after each yield, stopping at the
2102
+ next slice once the client has left, between two slices of one field too.
2103
+ Options that are not an object carrying an `AbortSignal` stop the call before any query with a host-facing error naming the method: an omitted
2104
+ `options` or `signal` is a `TypeError`
2105
+ (`[daily-report] markDailyReportSearchHits: options.signal must be the request's AbortSignal (new AbortController().signal for a call that is never aborted)`,
2106
+ and the same with `getDailyReportDetailsByIdsByExternalId`), any other value a `RangeError` ending with `; got <value>`.
2107
+ The two routes always pass the request's signal; drizzle cannot cancel a running query, so the slot is held until the read in progress ends. A request
2059
2108
  whose client left — while it waited, when its slot was granted, at one of the service's checks or after the service answered — is answered 503 without
2060
2109
  a body, with `Retry-After` and `Cache-Control: no-store` and no ETag, and writes no error line;
2061
- the four search routes share this answer and its checks (the search, `reports` and `search-facets` pass no signal to their services and check it when
2062
- the slot is granted and when the read ends; a read that fails with an error of its own is still the 500), and the refusal of an aborted request is
2110
+ the four search routes share this answer and its checks (the search and `search-facets` pass no signal to their services and check it when the slot
2111
+ is granted and when the read ends, `reports` and `search-marks` pass it to theirs; a read that fails with an error of its own is still the 500), and
2112
+ the refusal of an aborted request is
2063
2113
  never recorded as busy, on any of them (`search-facets` included).
2064
2114
  Its 200 carries no ETag and `Cache-Control: no-store` (serialized once, with the shared security headers), and so does every other answer of the
2065
2115
  endpoint — its 400, 404 and 429, the router's 405 and 500 for it, and that 503 —, as every answer of the API route that is not a 200 does (**The API
@@ -2118,8 +2168,8 @@ line wraps instead of truncating;
2118
2168
  it comes first in the field's description (before the syntax help), and the field is `aria-invalid` only for an invalid query (busy, unavailable and
2119
2169
  other failures are no input errors).
2120
2170
  The AND / OR toggle and the filter toggle share one look: a 24 px pill with 12 px semibold text, in the field face (OR; the closed filter row), whose
2121
- boundary comes from the text-field token as the field's does, or the filled face (AND; the open filter row), blue-700 under white text (dark blue-600)
2122
- and `Highlight` under `HighlightText` in forced colours (**Text fields** in [Selection and focus appearance](#selection-and-focus-appearance)).
2171
+ boundary comes from the text-field token as the field's does, or the filled face (AND; the open filter row), blue-800 under white text (dark: a light
2172
+ blue-300 fill under slate-950 text) and `Highlight` under `HighlightText` in forced colours (**Text fields** in [Selection and focus appearance](#selection-and-focus-appearance)).
2123
2173
  The faces differ only in colour, so a toggle that changes its face moves nothing. Each toggle stacks its current label and an invisible reserved label
2124
2174
  in one grid cell — the AND / OR toggle the other mode's label, the filter toggle its label with the largest number of selected values (every filter at
2125
2175
  its limit) —,
@@ -2131,14 +2181,17 @@ and that a match found through a reading is underlined with dots.
2131
2181
  While a search request runs or after it failed, the views keep the list they showed; an answer replaces the list in its order (a report the provider
2132
2182
  marked removed is left out), and the header shows the hit count. While an answer shows, the rows read their details by id in batches (`reports?ids=`)
2133
2183
  instead of by business date, and an answer goes into the per-report cache only, never into a business date's cached list.
2134
- A row's id joins the waiting ids at the first animation frame after rows stop entering and leaving, so a fast scroll or a held key requests nothing
2135
- for the rows it passes, and a window that stops waits at most one frame.
2136
- The batches are decided when they are sent, by the scheduler the search marks use: from the rows still waiting, newest first, up to 100 ids a request
2184
+ Each view leads its rows' inputs: it keeps its rendered rows and 24–40 rows beyond each rendered edge subscribed to the details and the marks, in
2185
+ chunks of 8 rows (32 on each side after a jump; **Highlights** below), so the rows a held key mounts find their details already loaded.
2186
+ An id joins the waiting ids at the first animation frame after rows stop entering and leaving, so a fast scroll requests nothing for the rows it
2187
+ passes and the lead let go, and a window that stops waits at most one frame.
2188
+ The batches are decided when they are sent, by the scheduler the search marks use: from the ids still waiting, newest first, up to 100 ids a request
2137
2189
  and `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` (2) requests at once per API base path (an aborted request keeps its slot until its fetch settles), each
2138
2190
  sent `cache: "no-cache"` with a signal of its own.
2139
- A row that unmounts leaves its load, so an id no row waits for is not sent, a request none of whose rows waits any more is aborted, and a row that
2140
- comes back asks again. A report a 2xx answer lacks reads as `null` (not visible, or deleted), and another status, a body that does not match the
2141
- schema or a network failure is the rows' error (`[daily-report] reports answered HTTP <status>` for a status).
2191
+ A row that unmounts leaves its load, and the details of a report are loaded once for all of its readers (the rows, the lead, the side pane), so an
2192
+ id nothing waits for is not sent, a request none of whose ids is still waited for is aborted, and a row that comes back asks again. A report a 2xx answer lacks reads as `null` (not visible, or deleted), and another status, a body that does not match the
2193
+ schema or a network failure is the rows' error (`[daily-report] reports answered HTTP <status>` for a status), except over a report a row already
2194
+ shows: a failed refetch of an expired report leaves it in place (the shared detail record under `useDailyReportDetail` below).
2142
2195
  A 429 whose `Retry-After` is at most 5 s (it covers a full admission gate's `Retry-After: 1` and stays below the rate bucket's 60 s window) keeps the
2143
2196
  rows loading, with no error and no `null`, and asks once more after that delay for the rows still waiting; a second 429, or one without such a
2144
2197
  `Retry-After`, is the error.
@@ -2244,7 +2297,7 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
2244
2297
  fill continues under the `…` while its underline ends at the last character shown (a cut at a mark's start shows a filled `…` alone).
2245
2298
  An `interviewerWithAffiliation` template that does not insert the name and the affiliation verbatim (it rewrites them, uses one twice, or words
2246
2299
  itself by their values) draws the pill plain. `LIKE` counts an occurrence inside a grapheme cluster, so such a match marks the whole cluster.
2247
- - **Look**: one token: yellow-200 under slate-900 (dark yellow-800 under yellow-50; 15.33:1 and 6.61:1 on any surface, since the mark sets its own
2300
+ - **Look**: one token: yellow-200 under slate-900 (dark yellow-900 under yellow-50; 15.33:1 and 8.38:1 on any surface, since the mark sets its own
2248
2301
  text colour), a solid underline for a word and a dotted one for a reading (a cue that does not depend on colour), and the system `Mark` / `MarkText`
2249
2302
  in forced colours.
2250
2303
  It writes `margin`, `border` and `padding` 0 and inherits the font, so a host's `mark` rule cannot change it and a mark changes no width, no line
@@ -2265,34 +2318,46 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
2265
2318
  - **Arrival**: the screen holds one marks store per shown answer (inside the client bundle), only while a view draws an answer that has words: without
2266
2319
  `config.search`, before the first answer is drawn, for a filters-only answer (it has no words, so nothing is marked and no match indicator shows),
2267
2320
  after the release and for a view rendered on its own there is none, and the DOM is the plain one.
2268
- A row wraps its subtree in the package's marks scope, which subscribes to the report's marks while the row is mounted and hands them to its leaves
2269
- through one row context (the marks, their revalidation, the row's tally and the indicator's settings) that only the scope provides:
2321
+ A row hands its marks scope the marks of the unit that drew its card: the row reads the report's details and marks through its input source from
2322
+ the moment it mounts as a shell (the first subscriber of a report asks for its marks) and draws them with its units (**Rows mount in two passes**
2323
+ under [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)), so a card whose marks are known is drawn with them in its first
2324
+ commit, never plain first, and marks that arrive later are drawn by one unit of the row, which keeps the row's box (the row stays skippable).
2325
+ The desktop side pane reads the marks with a deferred reader instead: its first render after a change of report draws the text plain, and the marks
2326
+ follow in a deferred render.
2327
+ The scope subscribes to nothing: it hands the marks to its leaves through one row context (the marks, their revalidation, the row's tally and the
2328
+ indicator's settings) that only the scope provides, and settles the row's tally in its own layout effect, after its leaves registered;
2270
2329
  a leaf, a match indicator or a List card's match description drawn outside a scope while the screen holds a store stops with an error naming the
2271
2330
  package, so no leaf draws marks that no tally counts.
2272
- The row body reads no marks, so an arrival re-renders only the scope, the marked leaves and the match indicator — never the row's body, header,
2273
- attachment list or comment section, and never another row; a newly mounted row draws its text plain first and its marks in a deferred render.
2331
+ The card body reads no marks, so an arrival re-renders only the row's container and frame once (one unit) or the side pane's marks scope, then the
2332
+ scope, the marked leaves and the match indicator — never the row's body, header, attachment list or comment section, and never another row.
2274
2333
  A leaf registers in its row's tally only once the row's marks have arrived (the indicator and the description say nothing about a row without
2275
- marks), so a row that mounts during a held key adds no tally work before its marks, and the tally builds its summary when it is read and tells its
2276
- readers once, at the end of the task that changed it, however many leaves registered in it.
2334
+ marks), in one layout effect that revalidates, registers and returns its unregistration (an opaque token that removes exactly that registration),
2335
+ so a row that mounts during a held key adds no tally work before its marks, and the tally builds its summary when it is read and tells its readers
2336
+ once, at the end of the task that changed it, however many leaves registered in it: a card's match description is complete by the end of the task
2337
+ that committed the card.
2338
+ While an answer is shown, each view also keeps the rows beyond its rendered range subscribed (the input lead: 24–40 rows beyond each rendered edge,
2339
+ 8 more on the side it travels towards whenever fewer than 24 remain, 32 on each side after a jump), so a row that mounts during a held key finds its
2340
+ marks and details in the stores.
2277
2341
  A report's first subscription reaches the requests through a batching window of 240 ms: outside a window it is handed on at once, together with the
2278
2342
  subscriptions of the same task, and opens a window; the subscriptions made inside the window are handed on together at its end, which opens the next
2279
- window while rows keep mounting, and a row that leaves inside the window asks for nothing.
2280
- A held key mounts one row per key at the overscan's edge, 15 rows and about 0.5 s of key repeats before the row shows, so a request at the window's
2281
- end is answered and drawn before its rows are visible, while a hold sends about one request per window (4–5 in each 1.2 s hold of the perf harness).
2343
+ window while first subscriptions keep coming, and a report whose subscribers all left inside the window asks for nothing.
2344
+ During a held key the windows are opened by the lead's chunks of 8 rows, one chunk every 8 keys (about 240 ms at a 30 ms repeat), so a chunk never
2345
+ waits for the window the previous chunk opened, and a 40-key hold sends 4–5 requests per store; a row is the first subscriber of its report only
2346
+ when the lead does not hold it yet (the view's first render, after a jump, or a scroll faster than the lead).
2282
2347
  The epoch's renewal, a field's revalidation, a 429's retry and the deferred ids do not wait for the window.
2283
2348
  The requests are decided at send time: a subscribed report waits in one queue, and each free request slot — two in flight at most — takes the newest
2284
- waiting reports that a row still subscribes to, at most 100 a request, never cached. A report whose rows left while it waited is dropped, so a
2285
- scroll or a held key sends nothing for the rows it passed, and the window it stops on goes out in the first free slot.
2286
- A request whose rows have all left is aborted (the server then stops computing its marks; **Requests** above), and a row that leaves before its
2349
+ waiting reports that are still subscribed (by a row or the lead), at most 100 a request, never cached. A report whose subscribers left while it waited
2350
+ is dropped, so a scroll sends nothing for the rows it passed and the lead let go, and the window it stops on goes out in the first free slot.
2351
+ A request none of whose reports is still subscribed is aborted (the server then stops computing its marks; **Requests** above), and a row that leaves before its
2287
2352
  marks were sent, or whose request was aborted, asks again when it returns.
2288
2353
  An aborted request keeps its slot until its fetch settles, so the requests in flight, aborted ones included, never exceed the two the server's
2289
2354
  per-viewer wait queue is sized for while it still winds the aborted ones down.
2290
2355
  The store keeps one record per report (its marks, the send order that brought them, the epoch it was asked in, its 429 retry and its revalidations)
2291
- and forgets the oldest records of reports no row subscribes to beyond 400 (2 × the two requests in flight × 100: the answers still in flight and as
2292
- many rows just left), so its memory follows the drawn window, not the distance scrolled; a forgotten report asks again when a row subscribes to it.
2356
+ and forgets the oldest records of reports nothing subscribes to beyond 400 (2 × the two requests in flight × 100: the answers still in flight and as
2357
+ many reports just let go), so its memory follows the drawn rows and their lead, not the distance scrolled; a forgotten report asks again when it is
2358
+ subscribed again.
2293
2359
  The ids an answer defers (`deferred`, past the server's budget) are asked again through the same scheduler, once the answer arrived in the epoch it
2294
- was sent and carried at least one report: an id a row still subscribes to is queued again, any other is given back, so a row that returns asks
2295
- again.
2360
+ was sent and carried at least one report: an id still subscribed is queued again, any other is given back, so a row that returns asks again.
2296
2361
  The server always answers a request's first report, so every re-ask makes progress and the re-asks end with the ids asked in the epoch; a deferral
2297
2362
  without any report breaks the contract and is not asked again (those rows stay plain for the epoch), and a previous epoch's deferral is not queued
2298
2363
  (the epoch's renewal asked again for every subscribed report).
@@ -2304,7 +2369,7 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
2304
2369
  painted and the field asks once for fresh marks. An answer whose `wordsDigest` differs from the shown answer's words is dropped; a quiet re-query of
2305
2370
  the same search makes the marks stale and the mounted rows ask again;
2306
2371
  401 and 403 call `onSessionExpired`; a 429 whose `Retry-After` is at most 60 s (one window of the server's rate bucket) is asked again once, after
2307
- that delay, for the reports a row still subscribes to then (one whose rows left meanwhile asks again when a row returns), and a 429 without a
2372
+ that delay, for the reports still subscribed then (one whose subscribers left meanwhile asks again when it is subscribed again), and a 429 without a
2308
2373
  `Retry-After`, or with a longer one, sets no timer and leaves its reports plain for the answer's epoch (a delay of 2^31 ms or more would overflow
2309
2374
  the timer and ask again at once);
2310
2375
  any other failure of the request — a network failure, an abort, another status, a body that is not JSON — leaves those rows plain for as long as the
@@ -2345,9 +2410,13 @@ narrow for the author and the slot the slot drops to a 16 px line of its own ins
2345
2410
  A DetailList row's description (`aria-describedby`) ends with that element while the screen holds a marks store, so moving through the rows with the
2346
2411
  arrow keys reads the sentences each row shows.
2347
2412
  - A List card shows no slot. While the screen holds a marks store, the description of its row and of its primary button ends, after the preview,
2348
- with a `hidden` element inside the card's marks scope (`SearchMatchDescription`, `data-daily-report-search-match-description`) that holds the marked
2349
- fields' sentence alone (empty while the card draws no match; the List does not announce a hidden match), so the active row's announcement says what
2350
- matched and whether only a reading did. Without a store the card's description is unchanged.
2413
+ with a `hidden` element inside the card's marks scope (`SearchMatchDescription`, `data-daily-report-search-match-description`) that holds the same
2414
+ sentences the DetailList row's indicator holds for the same report under the same answer, built by one rule: the matched keywords, the marked fields
2415
+ and the hidden match (a card whose leaves draw none of the report's matches — a report matched only in the body beyond the preview, for example —
2416
+ says the hidden match in the List, while the DetailList, which draws the whole body, names the content field). The element has no box, so the
2417
+ sentences never change the row's height, and each description reads them once; they are empty until the marks arrive. So the active row's
2418
+ announcement says how many keywords matched, what matched, whether only a reading did and whether the match lies outside the card. Without a store
2419
+ the card's description is unchanged.
2351
2420
 
2352
2421
  ## Client wiring
2353
2422
 
@@ -2407,14 +2476,29 @@ render (`[daily-report] config.headingLevel must be an integer from 2 to 5 …;
2407
2476
  `DailyReportPage` is layout-agnostic (header rendering via `renderHeader` slot, error boundaries/footer control managed by the app). For fine-grained
2408
2477
  usage, `DailyReportActionProvider`, `DailyReportResolvedContent`, `useDailyReportDetail`, etc. can be imported individually.
2409
2478
  Every field `useDailyReportDetail(reportHubId, businessDate)` returns — `report`, `error`, `isLoading` and `isRefetching` — belongs to the id it is
2410
- called with: the hook keeps them as one record keyed by the id. On the first render after the id changes it already returns that id's starting state:
2411
- its cached report (one definition, also used by the loading effect: the report cache, expired entries included, then the business date's cached list),
2412
- `isLoading` only when nothing is cached, no error and no refetch yet — never the previous id's report, error or loading flags.
2413
- 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
2414
- 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).
2415
- 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
2416
- 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
2417
- only scroll past. Loads of reports of one business date share one request.
2479
+ called with. The details of a report live in one record per report id that every reader of the screen shares — the views' rows, the views' input
2480
+ lead while a search answer is shown, the List's side pane and the hook — so one report has one record and one load whoever reads it: its first
2481
+ reader's subscription starts the load and its last reader's leaving stops it (an answer that arrives after that is dropped), and a cache write
2482
+ reaches the readers of its own report only, once per answer. The hook copies the record into state at default priority. On the first render after
2483
+ the id changes it already returns that id's starting state: its cached report (one definition, also used by the load: the report cache, expired
2484
+ entries included, then the business date's cached list), `isLoading` only when nothing is cached, no error and no refetch yet — never the previous
2485
+ id's report, error or loading flags. An expired cached report is therefore drawn at once, never preceded by `null`, and refetched behind it. An id of
2486
+ 0 or less fetches nothing and returns no report (0 also returns an `Invalid ID` error). A row and the side pane that show one report share its
2487
+ record, so they never run two loads of it at once, and readers waiting on one load of a report not shown yet receive its failure once each. A
2488
+ settled report is shared with every later reader while its cache entry is fresh. Once the entry has expired, a reader that subscribes to the record
2489
+ — the side pane, which is rebuilt for every report it shows, a row that mounts, an id that enters the input lead's band — re-validates it after
2490
+ 120 ms (the same wait as the refetch below), even while a row keeps the record open: the record keeps the expired report meanwhile (never a loading
2491
+ state), every reader that subscribes before the answer shares the one request, and the answer reaches every reader once; a report selected again
2492
+ inside its TTL is not fetched. A refetch of a shown report that fails (the re-validation, or the first reader's refetch of an expired cached report)
2493
+ leaves the report in place with no error and only logs a warning on `[DailyReportDetailLoader]`, so the next new reader re-validates again. A settled
2494
+ load error is not shared either: a reader that subscribes to a record holding an error — a row that mounts, an id that enters the input lead's band,
2495
+ the side pane — asks again, and the answer replaces the error. Only a new subscription asks again or re-validates (a re-render or a second
2496
+ subscription of the same listener does not, so a row that keeps showing a failure or an expired report never loops), and a refusal of the viewer's
2497
+ session (401 or 403, which cannot succeed before `onSessionExpired` has run) is shared like an answer: it becomes the record's error even over a
2498
+ shown report, and no new reader asks again or re-validates.
2499
+ The first load of a report that is not cached starts inside its first reader's subscription, with no task between the row's mount and its request,
2500
+ 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
2501
+ absorb rows that only scroll past. Loads of reports of one business date share one request.
2418
2502
 
2419
2503
  Mutation failures (save / publish / delete / comment) are surfaced through an error seam: inject `config.onError(info: DailyReportErrorInfo)` to route
2420
2504
  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
@@ -2700,8 +2784,8 @@ view, and no script computes or writes a height.
2700
2784
  text anyway.
2701
2785
  - **Empty list**: an empty view shows exactly one message, `VirtualScroll`'s own empty state with the engine label `labels.noItems` (en "No items", ja
2702
2786
  「項目がありません」), which `VirtualScroll` draws over the top of its content, out of the flow, so an empty view keeps the same box. The views render no
2703
- message of their own; they only theme the engine's (on `VirtualScroll`'s root: 16 px above and below, slate-500 in light and slate-400 in dark:
2704
- 4.55:1 and 7.66:1 against the page, where the engine's default grey reaches only 4.17:1 in dark).
2787
+ message of their own; they only theme the engine's (on `VirtualScroll`'s root: 16 px above and below, in the quiet text colour: 7.39:1 and 9.75:1
2788
+ against the page in light and dark, where the engine's default grey reaches only 4.17:1 in dark).
2705
2789
  Because the views pass an active row (`activeItemIndex`, `null` while the list is empty), `VirtualScroll` draws the message outside the composite —
2706
2790
  in a layer over the composite's box, with the same pixels and the same `.aqvs-no-items-container` / `.aqvs-no-items-text` hooks — and names it first
2707
2791
  in the composite's `aria-describedby`, before the key help: the `treegrid` owns nothing but rows, and a screen reader entering an empty view (a
@@ -2761,7 +2845,8 @@ the resize handle and the side pane lie outside the list.
2761
2845
  leaving the view and unmounting end it without applying it.
2762
2846
  On a slow device the keys therefore never queue up, and the selection stops when the key is released. `Ctrl+Home` / `Ctrl+End` are never
2763
2847
  accumulated. While a stream lasts, the desktop side pane keeps showing the report the stream started from (see **List side pane** below), and rows
2764
- far from the active row stay shells (see **Rows mount in two passes** below).
2848
+ get their bodies only in the frames without a key, far rows behind the travel direction after the key is released (see **Rows mount in two
2849
+ passes** below).
2765
2850
  - **Ignored keys**: `Alt` / `Ctrl` / `Meta` / `Shift` combinations other than `Ctrl+Home` / `Ctrl+End` alone (so `Ctrl+PageDown` and `Shift+End` keep
2766
2851
  their browser meaning); an event whose default is already prevented; IME composition (`isComposing` or `keyCode` 229);
2767
2852
  keys whose target is neither the view's composite nor a control inside one of its rows (the scroll bar, the side pane, the mobile overlay); the
@@ -2852,35 +2937,86 @@ the resize handle and the side pane lie outside the list.
2852
2937
  renderer do not render, and no context value changes per key.
2853
2938
  The app's keyboard harness counts the style and layout work that starts inside a keydown dispatch or inside a frame callback the key caused: 0 in
2854
2939
  every run of its nine conditions.
2855
- - **Rows mount in two passes.** A row that a commit mounts outside the visible range first renders as a **shell**: its row frame, with its role,
2856
- id, position, set size, level and selection, named by the report's ISO business date and `aria-busy="true"`, and an empty surface
2857
- (`data-daily-report-row-shell`) that fills the box `VirtualScroll` gives the row, so nothing moves when the body arrives.
2858
- One scheduler per view then advances the rows one at a time — a shell to its body, a body that still draws an older report to the latest loaded one
2859
- (a report that arrived by a load's answer or an SSE update) — each unit applied with `flushSync` as one commit of its own, in a slot that runs right
2860
- after a frame's rendering (a `MessageChannel` task armed from the scheduler's own task by `requestAnimationFrame`, never from a keydown, a commit or a
2861
- held key's frame).
2862
- The order is by zone — visible, then the active row's reach, then near, then far (**Far rows skip rendering** below) — and within a zone by the
2863
- distance from the active row, the side the keys travel towards first. A slot applies one unit, and a second when the next pending unit is visible or
2864
- in reach.
2865
- A row that becomes visible with a pending unit renders its body inside the commit that made it visible, so a visible row is never a shell. A newer
2866
- loaded report for a row that is already visible is applied inside the commit that brings it, outside a key hold, so such a row never shows an outdated
2867
- rendering of a loaded report while no key is held. A commit that brings a pending row into the reach or the near band wakes a sleeping scheduler, so
2868
- the rows a key can reveal get their bodies in a slot before that key.
2869
- While a key is held, the scheduler keeps its slots out of the frames that render the keys:
2870
- - far units wait: far rows stay shells and take no arriving card until the key is released;
2871
- - a slot applies one unit, and a second only when both rows have boxes of a fixed size (the List's slots): two measured DetailList bodies do not fit
2872
- one key-free frame;
2873
- - a slot runs only when the frame that renders its commit starts before the next repeat keydown can arrive. The next keydown is predicted from the
2874
- hold's own keydowns (the median interval of the latest 8 and their earliest phase; `predictNextHoldKey`), and the frame interval is the shortest
2875
- gap between the scheduler's consecutive frame callbacks, measured again for each hold. The decision is taken in the frame callback that would arm
2876
- the slot, and taken again when the slot runs (a key that arrived after the frame began, or a frame that ran long, holds the unit back to the next
2877
- frame); while the cadence or the frame interval is still unknown (the first one or two frames of a hold), no slot runs;
2878
- - a newer loaded report for a visible row waits for the next slot that runs, ranked first.
2879
-
2880
- So during a hold the units go to the frames without a key commit, and a row the keys reveal before a slot reached it renders its body in the key's
2881
- own commit (counted).
2882
- Decisions never wait for the drawing: the safety valve that removes a row whose report no longer exists, and a DetailList body's draft and
2883
- ownership, its opening of the viewer's own draft in the editor and its edit button's action, read the loaded report itself.
2940
+ - **Rows mount in two passes.** A row that a commit mounts outside the visible range first renders as a **shell**: its row frame, with its role, id,
2941
+ position, set size, level and selection, and a surface (`data-daily-report-row-shell`) that fills the box `VirtualScroll` gives the row, so nothing
2942
+ moves when the body arrives. The surface is the card's surface without content (the card surface token: its radius, border, colours and shadow),
2943
+ so the selection ring and the focus outline around a shell are concentric with it as around a card, and the resting surface does not change when
2944
+ the card lands. While the report's details are still loading, the shell is named by the report's ISO business date and is
2945
+ `aria-busy="true"`. Once the details are in the store, the shell is a **named shell**: it holds, hidden, the card's own name and the parts of the
2946
+ card's description that do not depend on drawing (the row state, the attachment marker with its count, the source badge's text, the List preview and
2947
+ the matched-keywords sentence), made by the same functions as the card's, so the active descendant announces the row as it will read as a card. Only
2948
+ the matched-fields and outside-view sentences, which the drawn leaves report, arrive with the card. The settled DOM never contains a shell.
2949
+ One scheduler per view then advances the rows one at a time — a shell to its body, a body that still draws older inputs to the latest ones (a report
2950
+ that arrived by a load's answer or an SSE update, or the report's search marks) — each unit applied with `flushSync` as one commit of its own.
2951
+ A row reads its report's details and search marks through one input source from the moment it mounts, and a unit draws what the source holds when it
2952
+ is applied, so an arriving answer never re-renders a row by itself: a row renders only through its units and its own state (such as its edit form).
2953
+ - **Units commit only in key-free frames.** The scheduler applies units only inside its own frame callback, and only when nothing of a key happened
2954
+ since its previous callback: no `keydown` or `keyup` was dispatched in the view's window (two passive capture listeners that only count, so they
2955
+ read nothing), the held key's stream applied no step in a frame callback, and the stream holds no accumulated step. A unit the scheduler applies
2956
+ in its own frame callback therefore never renders in the frame that renders a key, a single press included; a unit commits elsewhere only in the
2957
+ two cases named below (a row that becomes visible, and a newer input of a visible row outside a key hold). The first callback after the scheduler
2958
+ wakes only calibrates. The scheduler arms itself
2959
+ with a `MessageChannel` message and calls `requestAnimationFrame` only from its own message and callbacks — never from a keydown, a commit or the
2960
+ held key's frame — with one exception: when a key hold starts (in its first repeat keydown, before the hold's first step) in a view that has a
2961
+ measured row (each mounted row declares how its box is sized, and the DetailList's rows are measured), and the scheduler has no frame requested and
2962
+ has not classified the hold yet, it requests one bare listening frame there (it applies no unit, writes no DOM and reads no clock in the keydown),
2963
+ and its `hold-start` event says so (`listening`), so a harness that counts frames requested in a keydown as key work can tell the scheduler's frame
2964
+ apart. A view whose rows all have fixed boxes (the List) never listens, since the key frames' evidence below classifies measured rows only: during
2965
+ a hold its scheduler sleeps whenever no unit can run.
2966
+ - **A frame's share.** A key-free frame applies its first unit, then more units only while their measured costs (the p90 of the latest 32 per kind
2967
+ of unit, known after 8; kinds are a box sizing and a change: a body over a shell, a change that can change the box, the marks alone) fit half the
2968
+ frame interval and the time left in the frame. At today's costs a DetailList card takes one frame, and the List packs 1–3 rows. A frame whose
2969
+ callbacks started late (a GC or a long task ran before them) skips its first unit when that unit no longer fits (a 2 ms margin); a unit of a
2970
+ visible or reach row is skipped by at most one late frame in a row.
2971
+ - **Order.** By zone — visible, then the active row's reach, then near, then far (**Far rows skip rendering** below) — and within a zone by the
2972
+ distance from the active row, the side the keys travel towards first; equal ranks keep the order the units were added in. The travel direction is
2973
+ the active row's move, read at every frame callback and, during a hold, whenever units are ranked, so a hold takes its direction from its first
2974
+ key step (a hold upwards draws the far rows above from its first key) and never from a jump before it. While a key is held, far rows ahead in the
2975
+ travel direction are drawn after the near ones, so the rows a hold brings into the reach already have their bodies; far rows behind wait for the
2976
+ key's release. While the active row is pinned outside the rendered range or absent (a wheel or a fling), the rows are ranked by the pane instead:
2977
+ those ahead of the pane's travel first, by their distance from the visible range, and while the pane moves, the units of one viewport ahead of it
2978
+ are never skipped as late, so the rows a fling passes are drawn before they become visible.
2979
+ - **Visible rows are never stale.** A row that becomes visible with a pending unit renders its body inside the commit that made it visible, so a
2980
+ visible row is never a shell and never shows outdated data or marks (counted), with one exception on a device where a card exceeds a frame (below). Outside a key hold, a newer input of a row that is already visible
2981
+ is drawn at once: in the commit that brings it, or, when it arrives as a store notification, in one commit at the end of the notifying task.
2982
+ - **Search answers lead the rows' inputs.** While a search answer is shown, each view keeps its rendered rows and 24–40 rows beyond each rendered
2983
+ edge subscribed to the details and the marks (8 more rows on the side it travels towards whenever fewer than 24 remain, 32 on each side after a
2984
+ jump), from its range report, which `VirtualScroll` delivers in a frame callback, never inside a keydown. A row that mounts during a held key finds
2985
+ its details and marks already loaded, so every unit of a hold draws one complete card with its marks, and the requests leave in chunks (4–5 per
2986
+ store in a 40-key hold; after a jump one request per store carries the rendered window and 2 × 32 rows, at most 96 ids). The lead holds its
2987
+ subscriptions per store and releases only the swapped store's: a new answer (a new marks store) re-leases the marks and keeps the details and their
2988
+ request in flight, so it never asks for the same ids twice; a new details store also forgets the edges and leases as after a jump. The by-date
2989
+ views keep the by-date prefetch.
2990
+ - **Heights settle in the frame that lays the body out.** The DetailList's row-height delivery — one `ResizeObserver` per view, made from the
2991
+ view's own window where its scroll container attaches, beside the scheduler, and disconnected when the container detaches — commits each delivery
2992
+ in one synchronous commit:
2993
+ the row's height, `VirtualScroll`'s positions (with the compensation above the visible start) and the row's settled state land in the frame that
2994
+ laid the body out. A row that this commit reveals, or a body whose box it changes, is measured before the delivery ends and observed at the
2995
+ view's next frame callback, so no observation starts inside a delivery and the browser never reports "ResizeObserver loop completed with
2996
+ undelivered notifications".
2997
+ With a 30 ms key repeat at 60 Hz, a hold has about 0.8 key-free frames per key and brings about one row per key into the reach, so a 40-key hold
2998
+ keeps several bodied rows ahead of the active row; holds longer than about 60 keys draw the bodies they reveal inside the key's own commit (one
2999
+ complete card each, counted).
3000
+ On a device where a DetailList card exceeds a frame, a held key draws no card in its frames. The scheduler classifies the device from what it
3001
+ measures, with no device constant: a kind of unit exceeds the frame when its measured p50 exceeds the class limit, the display's frame interval
3002
+ floored at the 60 Hz interval, less 2 ms. That is 14.67 ms on a 60 Hz display and on a faster one alike: an interval more than 1 ms below 16.67 ms
3003
+ (a display above about 64 Hz) is judged as 16.67 ms, because the frame targets are written for 60 Hz frames, so the class follows the device's CPU
3004
+ and not the display's rate (a 120 Hz display's own interval less 2 ms, 6.33 ms, would classify every DetailList hold at 1×). The interval is the
3005
+ display's, read from the frames that were not pushed back, so a slow device does not stretch it. During a hold and for the DetailList's measured
3006
+ cards, a kind also exceeds the frame when the median of the hold's key frames does (from 3 samples: the key frames' own weight, measured even
3007
+ while the scheduler draws nothing, from their busy time, or from their cadence when a frame's work outlasted the class limit and missed the vsync
3008
+ after it, which a gap of 1.5 display intervals past the vsync before the one nearest the limit's end shows: 1.5 intervals at 60 Hz, 2.5 at 120 Hz
3009
+ and 144 Hz). The end-of-frame message of the frame after which the scheduler slept adds no sample: it waits behind input and rendering, so its time
3010
+ measures that wait, not the frame. On such a device the rows that enter
3011
+ the visible range during the hold stay named shells, the scheduler sleeps, and after the key's release it draws the active row's card first and then
3012
+ the visible shells in their visible order. At 1× no kind ever exceeds the frame on a 60, 120 or 144 Hz display (a DetailList card's p50 is
3013
+ 7.3–8.4 ms and the key frames' median at most about 13.6 ms), so every entering row is complete at its entry. With the
3014
+ CPU slowed 4× before a 40-key hold, the hold classifies itself by its 6th or 7th key, 23–26 rows enter as named shells, and the next frame's p95 is
3015
+ 27.4–45.1 ms (median 33.4) against the 33 ms target. The List's rows fit a frame even then, so the List keeps every row complete at its entry.
3016
+ Decisions never wait for the drawing: the card and the error surface are both drawn from the unit's snapshot, and only the safety valve that
3017
+ removes a row whose report no longer exists, the busy state of a row whose card or error waits for its unit (a load error that arrived but is not
3018
+ drawn yet keeps the row's skeleton, never a "not found" surface), and a DetailList body's draft and ownership, its opening of the viewer's own draft
3019
+ in the editor and its edit button's action read the row's current details.
2884
3020
  - **Far rows skip rendering.** Every mounted row lies in one zone, computed in the render that positions the rows from that render's own scroll
2885
3021
  position (half-open intervals in content pixels, V the viewport's size, s the scroll position): **visible** when it intersects [s, s + V);
2886
3022
  **reach** when it intersects the active row's reach, from V above the row before the active one to V below the row after it — every row that a
@@ -2999,7 +3135,14 @@ the resize handle and the side pane lie outside the list.
2999
3135
  (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
3000
3136
  the last row's height).
3001
3137
  - **Scrolls `VirtualScroll` makes for the user** (wheel, drag, the scroll bar, inertia) are recorded each time the view reports its visible range,
3002
- 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
3138
+ once per frame. During a fling far beyond the rendered window the rows are ranked by the pane (those ahead of its travel first) and the rows of
3139
+ one viewport ahead of a moving pane are drawn before they become visible (**Rows mount in two passes**). Over rows of one height (the List)
3140
+ wheeling back by the same distance lands where the fling started. Over measured rows (the DetailList) a fling is not yet symmetric: the rows it
3141
+ passes before they are drawn are laid out at the measured estimate (below) and change to their own heights when they are drawn above the pane, and
3142
+ `VirtualScroll`'s compensation keeps the visible rows in place, so a plain 20,000 px fling and back lands 156–548 px away from where it started
3143
+ (measured in Chromium with React's development build, where a card takes 10–26 ms to draw, so the rows a fling passes are measured late); the
3144
+ visible rows never jump, and content that was drawn and measured never moves: wheeling there and back in steps that each wait until the rows they
3145
+ mount are drawn lands on the start report exactly. A list change in the frame before that report first advances the anchor by the distance scrolled since it was recorded and
3003
3146
  restores from there: it neither undoes the user's scroll nor shifts the content by a row.
3004
3147
  Over rows of one height (the List) the new anchor follows from the distance by arithmetic; over measured rows (the DetailList) it walks the
3005
3148
  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
@@ -3058,7 +3201,12 @@ the resize handle and the side pane lie outside the list.
3058
3201
  rows are the whole set; the views' `VirtualScroll` always counts the rows given.
3059
3202
  The markers are the attachment marker (`<labels.attachments>: <count>`) and the source badge (its full `name` when the configuration gives one,
3060
3203
  otherwise its label); a description references only the markers that are painted. A shell, and a row without content (loading, failed, missing,
3061
- editing), is named by its ISO business date, with `aria-busy="true"` while its report or its body is still to come. ★, 既読, 編集 and 削除 carry
3204
+ editing), is named by its ISO business date, with `aria-busy="true"` while its report or its body is still to come, and is described by the state
3205
+ it shows, so the active descendant's announcement never reduces a visible state to a date: a failed row by its error message
3206
+ (`labels.reportSummaryLoadFailed` in the List, `labels.reportLoadFailed` in the DetailList), a List row the server did not find by
3207
+ `labels.reportNotFound`, and a DetailList row that shows its edit form — the viewer's own draft opens there by itself — by a visually hidden
3208
+ `labels.rowEditingState({ isDraft })` (en "Editing draft" / "Editing", ja 「下書きを編集中」 / 「編集中」). A shell and a loading skeleton have no
3209
+ description. ★, 既読, 編集 and 削除 carry
3062
3210
  `aria-label` equal to their `title` and no `aria-pressed`; no element of a view carries `aria-current`.
3063
3211
  The active row is never skipped, so its name and description are always complete; a far row that skips rendering is absent from the accessibility
3064
3212
  tree (**Far rows skip rendering** above). The package's tests compute the active row's name and description in both views; how real screen readers
@@ -3119,6 +3267,9 @@ the resize handle and the side pane lie outside the list.
3119
3267
  the comment form: see [External Source Badge Configuration](#external-source-badge-configuration-sourcetypeconfigs)).
3120
3268
  The comment field is named by that heading (`aria-labelledby`; `labels.comments`), which renders whenever the form does, so the field has a name of
3121
3269
  its own in the DetailList row, the side pane and the mobile overlay; its placeholder (`labels.commentPlaceholder`) stays a hint, not its name.
3270
+ While a post is sent, the field keeps the draft, read-only, with the submit button disabled, and empties only when the post is accepted; a refused
3271
+ post (401, 403, a 5xx) leaves the draft, its text and its caret untouched, so a viewer whose session ended sends it again after signing in
3272
+ (the package's part of WCAG 2.2.5; keeping a draft across a navigation or a reload is the host's).
3122
3273
  A report of a source that takes no comments and has none shows no empty section, and deleting the last comment of such a report removes the section,
3123
3274
  so focus goes to the report's anchor above.
3124
3275
  - **Mobile detail overlay** (List, single-column layout): the platform's modal dialog, a `<dialog data-daily-report-mobile-overlay>` (implicit
@@ -3176,6 +3327,7 @@ and their versions, and per step either a pass or the text announced.
3176
3327
  | Empty and no-category views | A search that matches nothing, and a viewer who may see no category: Tab into the view | The composite is announced by its title, described by the empty text (「項目がありません」) and the keyboard help, with no row; for the viewer without a category, the page's notice (「閲覧できる日報の区分がありません。…」) is reached in browse mode before the view |
3177
3328
  | Overlay close | Phone layout, List: open a report's overlay with Enter on the composite and close it with Escape; open it with a tap and close it with the close button | After the keyboard close, focus is on the composite and the same row is announced as active; after the tap close, focus is on the card's primary button, announced with its position first |
3178
3329
  | Touch hand-back in the DetailList | Touch reader, DetailList: in the active row of your own report from a source that takes no comments, delete its last comment (the trash button, then the confirmation) | The comment section leaves and focus lands on the row's name heading, announced as the report's date, author and subject (not on the list as a whole) |
3330
+ | Held key on a slow device | Desktop, DetailList with a search answer shown, Chrome's CPU throttling at 4× (DevTools, Performance): hold ArrowDown for about 40 keys, then release | While the key is held, each active row is announced with its name and its description (state, attachments, source, the matched-keywords sentence), even when its card has not been drawn; after the release, the active row's card arrives first and its description gains the matched-fields sentence; nothing is announced twice |
3179
3331
  | Far rows | Browse mode (NVDA, JAWS, Narrator) or swiping (VoiceOver on iOS, TalkBack) past the visible rows | The rows within one viewport of the visible range are reached; rows farther away are reached only once the list has scrolled near them (**Far rows skip rendering**); the active row is always reached |
3180
3332
  | Switch Access | Android, Switch Access with Chrome, List on the phone layout: scan to a card and select it, then close the overlay with its close button | The card is reachable by scanning and opens the overlay; after the close, focus (which Switch Access follows) is on that card's primary button, or on the composite when the page recorded no pointer input for the action (a hand-back after no recorded input lands on the composite): record which, and that scanning continues from there |
3181
3333
  | Forced colours | Windows contrast theme (forced colours) with the keyboard: move the active row with the arrows, wheel it out of view, then press a key | The selected row shows the system's `Highlight` outline, the active row's keyboard outline shows in the system colour while it is visible, and the composite's 2 px pane ring shows while the active row is out of view |
@@ -3252,8 +3404,9 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3252
3404
  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
3253
3405
  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
3254
3406
  reveal.
3255
- A row that has not been measured yet is laid out at an estimate of 352 px (88 u = 44 × 8), also on the lattice at every ratio of the domain, so the
3256
- 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
3407
+ A row that has not been measured yet is laid out at the view's measured estimate — the median of the heights the view has measured, rounded up to
3408
+ 8 px (2 u), and 440 px (110 u) until it has measured one; the view applies a changed estimate only at rest, with the first visible report kept in
3409
+ place — 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
3257
3410
  [Attachment display](#attachment-display)), so the rows below an image grid stay on whole device pixels.
3258
3411
  - **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
3259
3412
  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
@@ -3399,16 +3552,19 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3399
3552
 
3400
3553
  - **Text fields**: the comment input, the edit form's `Input` and `Textarea` and the search field, and the outline of the search band's toggles in
3401
3554
  their field face (the OR toggle and the closed filter toggle), take their boundary and placeholder from one token: a 1 px slate-500 border in both
3402
- schemes and a slate-500 placeholder (dark slate-400).
3555
+ schemes and a placeholder in the quiet text colour (below).
3403
3556
  Each field declares its own `border border-solid` beside its inset and its own package fill (white, the comment input slate-50 turning white on
3404
3557
  focus; dark slate-900, the comment input slate-800 turning slate-900); none reads the host's `--input`, `--muted-foreground`, `--primary` or
3405
3558
  `--destructive`.
3406
3559
  The boundary reaches the 3:1 of WCAG 1.4.11 against every surface a field sits on and against the field's own fill, its focus fill included (light:
3407
- 4.76 on white and 4.55 on slate-50; dark: 3.74 on slate-900, 3.07 on slate-800 and 4.23 on slate-950), and the placeholder reaches 4.5:1 on each
3408
- field's fill.
3409
- The toggles' filled face (AND, and the open filter toggle) is a blue-700 fill (dark blue-600), whose edge is its boundary (6.52:1 on the page, dark
3410
- 3.84:1), under white text (6.82:1, dark 5.25:1); it stands 6.82:1 (dark 3.40:1) from the field face's fill, so open and closed differ in more than
3411
- hue, and forced colours draw it as `Highlight` under `HighlightText` while the field face keeps the forced button colours.
3560
+ 4.76 on white and 4.55 on slate-50; dark: 3.74 on slate-900, 3.07 on slate-800 and 4.23 on slate-950), and the placeholder reaches 7:1 on each
3561
+ field's fill (light: 7.73 on white, 7.39 on the comment input's slate-50; dark: 8.62 on slate-900, 7.07 on the comment input's slate-800).
3562
+ The toggles' filled face (AND, and the open filter toggle) is a blue-800 fill under white text in light and a light blue-300 fill under slate-950
3563
+ text in dark, whose edge is its boundary (8.43:1 on the page, dark 11.12:1), with its text at 8.82:1 (dark 11.12:1); it stands 8.82:1 (dark 9.83:1)
3564
+ from the field face's fill, so open and closed differ in more than hue, and the blue-400 focus outline, drawn 2 px outside the face, stands 1.45:1
3565
+ from the dark fill. No dark fill under white text can meet both the 7:1 of the text and the 3:1 of the fill against the page, since the two ratios
3566
+ multiply to white against the slate-950 page, 20.16 < 7 × 3. Forced colours draw it as `Highlight` under `HighlightText` while the field face keeps
3567
+ the forced button colours.
3412
3568
  `src/client/ui/ui-state-contrast.spec.ts` computes these pairs for every site from the site's own classes (so a site that drops the token fails),
3413
3569
  and its host-token guard covers the token, the primitives and the sites.
3414
3570
 
@@ -3622,8 +3778,25 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3622
3778
  are exactly the token's, and the search box's spec renders every status message in both locales and checks that the line carries the token and no
3623
3779
  truncating class.
3624
3780
  - **Type and contrast**: no text is smaller than 12 px (`text-xs`; a scan of every module under `src/client` rejects a smaller arbitrary size,
3625
- `src/client/components/report-views.spec.tsx`); text reaches 4.5:1 and the indicators 3:1 in both themes (section labels slate-500 / slate-400: 4.77
3626
- / 7.09). The floating tap-scroll circle hides while the view root carries `data-daily-report-keyboard-focus` (through the class both views pass in
3781
+ `src/client/components/report-views.spec.tsx`); every text colour reaches 7:1 (WCAG 1.4.6, AAA) against every surface it can sit on, in both themes
3782
+ and every state (hover, active, `aria-*`, `data-*`, translucent surfaces composited over theirs), and the indicators 3:1:
3783
+ `src/client/ui/aaa-text-contrast.spec.ts` scans every class literal of the shipped client (and the comment authors' colours) that sets a text colour,
3784
+ refuses one without a registered surface, and measures each against its surfaces. The quiet text (section labels, metadata, placeholders, the empty
3785
+ view, inactive tabs) is one token, `--aqdr-text-quiet` (light `oklch(44% 0.046 257.417)`, dark `oklch(77% 0.040 256.788)`: slate-500's and
3786
+ slate-400's hue and chroma), whose lightness `src/client/ui/aaa-quiet-text.spec.ts` solves from the surfaces it sits on (light 7.06–7.73, dark
3787
+ 7.07–9.75). A source badge's colours are the host's (**External Source Badge Configuration**). Targets stay at WCAG 2.5.8's 24 px, except the
3788
+ List's resize handle with its 10 px hit area and the 8 px scroll bar beside it (**Side pane** above); 2.5.5's 44 px is not claimed.
3789
+ - **WCAG 2.2 AAA beyond contrast**: met are 1.4.9 (no images of text), 2.1.3 (every function by the keyboard, the List splitter's arrow keys
3790
+ included), 2.2.3 (nothing on a timer; **The built-in notice**), 2.3.2 (no flashes), 2.3.3 (motion follows `prefers-reduced-motion`), 2.4.8 (the page
3791
+ and the report headings tell where the viewer is), 2.4.9 (links name their purpose), 2.4.10 (sections carry headings), 2.4.12 and 2.4.13 (the focus
3792
+ is never obscured, and its outline is 2 px at 3:1), 3.1.3, 3.1.4 (abbreviated source badges, **External Source Badge Configuration**), 3.2.5 and
3793
+ 3.3.6. Not met, by decision: 2.2.4 (Interruptions): live updates (SSE) apply as they arrive, with no pause, and
3794
+ the scroll anchoring keeps the rows on screen in place. Not met at AA: 2.5.8 for the List's resize handle and the scroll bar beside it, and 2.5.7 for
3795
+ the List's splitter (**Side pane** above): a path without dragging belongs to `@aiquants/resize-panels`, and larger hit areas for the handle and the
3796
+ bar wait for the same layout pass as 2.5.5. Not claimed yet: 2.5.5 (44 px targets), 1.4.8 (a line measure and the List preview's
3797
+ leading) and 3.3.5 (a help control for the search syntax), which need a layout pass, and 3.1.6 (readings of names), which needs readings from the
3798
+ host's directory. 2.2.5 is met by the package for a refused comment post (**One comment section rule** above); keeping work across a
3799
+ re-authentication elsewhere is the host's, as is 2.2.6 (telling users how long a session lasts). The floating tap-scroll circle hides while the view root carries `data-daily-report-keyboard-focus` (through the class both views pass in
3627
3800
  `VirtualScroll`'s tap-scroll circle options).
3628
3801
  - **Action buttons and icons**: the List card's star and read toggles, the star, read, edit and delete buttons of the DetailList header and the side
3629
3802
  pane, and the trash button of the viewer's comments share one 24 px round target that never shrinks, with a 16 px SVG icon centred in it (4 px on
@@ -3905,7 +4078,7 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
3905
4078
  | `[data-daily-report-row="<id>"]` | Row frame of both views (`role="row"` with `id`, `aria-rowindex`, `aria-posinset`, `aria-setsize`, `aria-level` and an explicit `aria-selected`; the set size is the declared total while the ids stream delivers and `-1` before it is declared, see **Screen readers**), in every load state |
3906
4079
  | `[data-daily-report-active]` | The active row's frame (the row the composite's `aria-activedescendant` names) |
3907
4080
  | `[data-daily-report-row-surface]` | Surface of a row frame (`role="gridcell"`): its direct child that paints the selection ring and the focus outline, in every load state (opaque and never animated; a loading pulse runs inside it) |
3908
- | `[data-daily-report-row-shell]` | The empty surface of a shell: a row mounted outside the visible range whose body is still to come (**Rows mount in two passes**) |
4081
+ | `[data-daily-report-row-shell]` | The surface of a shell, the card's surface without content: a row mounted outside the visible range whose body is still to come (**Rows mount in two passes**) |
3909
4082
  | `[data-daily-report-card]` | List card surface (present while the card is shown) |
3910
4083
  | `button[data-daily-report-button="<id>"]` | List card primary button (the click target that selects, and the pointer hand-back target; never a Tab stop, `tabIndex=-1`) |
3911
4084
  | `[data-daily-report-row-focus]` | A row's own focus target, where a pointer or touch hand-back lands: the List card's primary button, or the DetailList card's visually hidden name heading (`tabIndex=-1`; never a Tab stop) |
@@ -3932,17 +4105,30 @@ Only the package writes `data-daily-report-keyboard-focus`, `data-daily-report-s
3932
4105
  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
3933
4106
  a key's render the frame already names the new selection while the content still shows the previous report.
3934
4107
 
4108
+ A host's test build instruments the views by providing `createDailyReportTestInstrumentation()` (testing entry) through
4109
+ `DailyReportInstrumentationContext` (client entry) around the page; production provides none, so no view carries a test handle or reports a row-pass
4110
+ event there, and none of the testing entry's code is in the client bundle. A view reads the context once, when it mounts. Instrumented,
3935
4111
  `[data-testid="daily-report-list"]` and `[data-testid="daily-report-detail-list"]` carry a `__virtualScroll` accessor for end-to-end tests, with the
3936
4112
  same shape in both views. It is the read-only part of the current `VirtualScroll` handle — `getViewportSize()`, `getScrollPosition()`,
3937
4113
  `getScrollAnchor()`, `getRange()` and `getFenwickSize()` — plus `findReportIndex(id)`, `getReportItem(index)` and `getReportIds(limit = 20)` over the
3938
- view's committed list, and one way to move the position, `revealIndex(index, options)`.
3939
- The client entry exports the contract as types, so a host's tests import it instead of restating it (type-only, no bundle bytes):
3940
- `DailyReportViewTestHandle` is the accessor, `DailyReportViewTestReadHandle` its read-only part, and `DailyReportRevealOptions` the options of
3941
- `revealIndex`, which are the second argument of `VirtualScroll`'s `scrollToIndex` (its alignment and offset, as the peer defines them).
4114
+ view's committed list, one way to move the position, `revealIndex(index, options)`, and one read-only view of the view's row pass:
4115
+ `rowPass.counters()`, the counts of its events since the view mounted, read when it is called — `syncReleases` (units drawn synchronously, in a
4116
+ commit or a notification's microtask commit), `deferredVisible` (units of a frame-exceeding kind kept pending when their row became visible during a
4117
+ hold), `oversizeFrames` (key-free frames whose first unit alone exceeded the frame's share), `lateFrames` (late key-free frames the pass skipped),
4118
+ `lateForcedFrames` (late key-free frames it applied a unit in regardless) and `unitFrames` (frames that applied units) — and
4119
+ `rowPass.unitKinds()`, the latest `p50`, `p90` and `budget` (the class limit: the frame interval, floored at the 60 Hz interval on a faster display,
4120
+ less the late-frame margin) of each unit kind its unit frames
4121
+ reported — and one read-only view of the view's re-estimates: `estimate.counters()` — `reEstimates` (position changes `VirtualScroll` made for a
4122
+ new row-height estimate, the cause `re-estimate`) and `reEstimatesInMotion` (those made while the view moved: during a key hold or while it
4123
+ scrolled; 0 by design, since the view applies an estimate at rest only). A performance probe reads them before and after a key hold and reports the
4124
+ differences, so it copies none of the pass's constants or rules (**Rows mount in two passes**).
4125
+ The testing entry exports the contract as types, so a host's tests import it instead of restating it (type-only):
4126
+ `DailyReportViewTestHandle` is the accessor, `DailyReportViewTestReadHandle` its read-only part, and `DailyReportRevealOptions` (client entry) the
4127
+ options of `revealIndex`, which are the second argument of `VirtualScroll`'s `scrollToIndex` (its alignment and offset, as the peer defines them).
3942
4128
  `revealIndex` goes through the view's scroller (the `VirtualScroll` alignment of `scrollToIndex`, then the anchor's record, like a key move; see
3943
4129
  **Host selections and list changes**), so a list change right after it keeps the revealed report in place. The accessor has none of the handle's
3944
- functions that move the position (`scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel`, `updateItemSize`) and no selection backdoor: select through
3945
- the UI (a click, the keys). Read it on every use; it is `undefined` while the list is not mounted.
4130
+ functions that move the position (`scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel`, `updateItemSize`, `setItemHeightEstimate`) and no
4131
+ selection backdoor: select through the UI (a click, the keys). Read it on every use; it is `undefined` while the list is not mounted.
3946
4132
 
3947
4133
  ### Localization (`locale` / `labels`)
3948
4134
 
@@ -3960,9 +4146,9 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
3960
4146
  />
3961
4147
  ```
3962
4148
 
3963
- - **One surface for all wording.** The catalog covers the 11 engine chrome keys of `@aiquants/virtualscroll` plus 110 own keys: field headings, the
3964
- page title (`title`, passed to `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state and the List
3965
- card's position read to screen readers, tabs, buttons, placeholders, the `confirm()` text, stream badges, the search box, its filter row and the
4149
+ - **One surface for all wording.** The catalog covers the 11 engine chrome keys of `@aiquants/virtualscroll` plus 111 own keys: field headings, the
4150
+ page title (`title`, passed to `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row states (read and
4151
+ starred, editing) and the List card's position read to screen readers, tabs, buttons, placeholders, the `confirm()` text, stream badges, the search box, its filter row and the
3966
4152
  search's match indicator, the status screen and the page's status region and the mutation-failure message with its repeat count. The engine part of
3967
4153
  each catalog is `@aiquants/virtualscroll`'s own catalog of the same locale, reused by value, so the scroll arrows, the names of the scroll bar and
3968
4154
  its thumb, and the "No items" text always speak the same language as the rest.
@@ -3970,12 +4156,12 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
3970
4156
  and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
3971
4157
  its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
3972
4158
  re-render the memoized list subtree.
3973
- - **Formatter keys.** Twenty-one keys take arguments and are functions: `totalCount(count)`,
4159
+ - **Formatter keys.** Twenty-two keys take arguments and are functions: `totalCount(count)`,
3974
4160
  `debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
3975
4161
  `streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
3976
4162
  `interviewerWithAffiliation(name, affiliation)`, `operationFailed(operation)` (`operation` is a
3977
4163
  `DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
3978
- `toggleStar` / `toggleRead`), `noticeRepeated(count)`, `rowState({ isRead, isStarred })`, `listRowPosition(position, total)`,
4164
+ `toggleStar` / `toggleRead`), `noticeRepeated(count)`, `rowState({ isRead, isStarred })`, `rowEditingState({ isDraft })`, `listRowPosition(position, total)`,
3979
4165
  `listRowPositionInUnknownTotal(position)`,
3980
4166
  `searchHitCount(count)`, `searchFailed(failure)` (`failure` is `invalid` / `busy` / `unavailable` / `failed`),
3981
4167
  `searchMatchedKeywords(matched, total)`, `searchMatchedKeywordsDescription(matched, total)`, `searchFieldMatched(field)` and
@@ -3986,7 +4172,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
3986
4172
  locale built once (the row positions and counts re-render on every stream publish); a host override receives
3987
4173
  the raw number and formats it itself.
3988
4174
  - **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
3989
- `RangeError` at render. So do an unknown `labels` key (a key error that lists the 121 keys), a string
4175
+ `RangeError` at render. So do an unknown `labels` key (a key error that lists the 122 keys), a string
3990
4176
  key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
3991
4177
  function. An `undefined` value keeps the catalog value.
3992
4178
  - **`config` has a closed key set**: `apiBasePath`, `ssePath`, `locale`, `labels`,
@@ -4024,7 +4210,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
4024
4210
  grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
4025
4211
  that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
4026
4212
 
4027
- Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 121 keys, the 11 engine keys
4213
+ Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 122 keys, the 11 engine keys
4028
4214
  first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
4029
4215
  (the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
4030
4216
  `locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
@@ -4130,6 +4316,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
4130
4316
  | `operationFailed` | the error notice / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
4131
4317
  | `noticeRepeated` | the error notice, beside its message when failures with the same message follow one another (`count` is how many came in a row, from 2; part of the notice's name) | `(2)` → (2 times) | `(2)` → (2 回目) |
4132
4318
  | `rowState` | visually hidden row state, the first description of a card row in both views and the second of the List card's primary button | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
4319
+ | `rowEditingState` | visually hidden state of a DetailList row that shows its edit form in place of the card (the viewer's own draft opens there by itself), the row's description | `({ isDraft: true })` → Editing draft; `({ isDraft: false })` → Editing | `({ isDraft: true })` → 下書きを編集中; `({ isDraft: false })` → 編集中 |
4133
4320
  | `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 件目 |
4134
4321
  | `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`; it also ends the description of a card row in both views while the size is unknown | `(3)` → Item 3 | `(3)` → 3 件目 |
4135
4322
  | `searchFieldMatched` | match sentences of a row (the DetailList row's and the side pane's hidden indicator sentences, the List card's hidden match description): a field heading the row's leaves marked with a typed word's mark (`field` is the heading's resolved label) | `("Author")` → Author matched | `("作成者")` → 作成者で一致 |
@@ -4145,23 +4332,29 @@ prevents the package from hardcoding specific third-party service names or desig
4145
4332
  sourceTypeConfigs: {
4146
4333
  Internal: {
4147
4334
  label: "Native",
4148
- badgeClassName: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
4335
+ badgeClassName: "bg-emerald-100 text-emerald-900 dark:bg-emerald-900/30 dark:text-emerald-400",
4149
4336
  },
4150
4337
  ExternalCRM: {
4151
4338
  label: "External CRM",
4152
- badgeClassName: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
4339
+ badgeClassName: "bg-red-100 text-red-900 dark:bg-red-900/30 dark:text-red-300",
4153
4340
  },
4154
4341
  LegacyPortal: {
4155
4342
  label: "LP",
4156
4343
  name: "Legacy Portal",
4157
- badgeClassName: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
4344
+ badgeClassName: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
4158
4345
  },
4159
4346
  }
4160
4347
  ```
4161
4348
 
4162
- - `label` is the text on the badge. When it is an abbreviation, give the full name in `name` (WCAG 3.1.4): the badge then renders the label as
4163
- `<abbr title="<name>">` (pointer users see the expansion), hides the abbreviation from assistive technology and reads the name once instead. Without
4164
- `name` the badge shows its label and nothing is substituted.
4349
+ - The badge's colours are the host's: the package's 7:1 guard reads only the package's own classes, so a `badgeClassName` must keep its text at 7:1
4350
+ (WCAG 1.4.6) itself, against its fill composited over the card and over the overlay in both schemes (light: white under both; dark: `slate-900/70`
4351
+ over `slate-950` under the card, `slate-900` under the overlay). The pairs above do, by the package's contrast math at the lower of the two
4352
+ surfaces: 8.47:1 light and 7.93:1 dark for emerald (the built-in `Internal` badge's own pair), 8.22:1 and 8.54:1 for red, 7.23:1 and 8.63:1 for
4353
+ blue. A `-800` text on a green or red `-100` fill falls short in light (6.45:1 and 6.86:1), and a red or blue `-400` text on a `-900/30` fill falls
4354
+ short in dark (5.68:1 and 5.94:1 over the overlay).
4355
+ - `label` is the text on the badge. When it is an abbreviation, give the full name in `name` (WCAG 3.1.4): a badge whose label differs from its `name`
4356
+ renders the label as `<abbr title="<name>">` (pointer users see the expansion), hides the abbreviation from assistive technology and reads the name
4357
+ once instead. Without `name`, or with a `name` equal to the label (which abbreviates nothing), the badge shows its label and nothing is substituted.
4165
4358
  A `name` that is given must be a non-blank string; anything else (`""`, `" "`, `null`, a number) throws a `RangeError` at render that names the
4166
4359
  source type.
4167
4360
  - The badge is part of the row description (see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)): the List card's
@@ -4278,7 +4471,9 @@ Documenting a name here is no reason to export it, and the values that tune the
4278
4471
  `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` (2, the batched reads the package's client keeps in flight per API base path, from which the server sizes
4279
4472
  a viewer's share of the `reports` and `search-marks` admissions), and the answer of `search-marks`: `DailyReportSearchMarksAnswer`
4280
4473
  (`{ wordsDigest, reports, deferred }`) of `DailyReportSearchReportMarks` (a report's id, `matchedSets` and `fields`) and
4281
- `DailyReportSearchFieldMarks` (a field's `fingerprint`, with its `ranges` or `dense: true`).
4474
+ `DailyReportSearchFieldMarks` (a field's `fingerprint`, with its `ranges` or `dense: true`), and the answer of `reports`:
4475
+ `DailyReportSearchDetailsAnswer` (`{ outcomes }`, one `DailyReportSearchDetailOutcome` per requested id in request order: `ok` with its `report`,
4476
+ `not_found` or `failed`).
4282
4477
  - **client** (`@aiquants/daily-report/client`, React):
4283
4478
  - Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the
4284
4479
  providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider` (required above the action provider:
@@ -4307,6 +4502,8 @@ Documenting a name here is no reason to export it, and the values that tune the
4307
4502
  session's client is not public. The route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
4308
4503
  - Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` /
4309
4504
  `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
4505
+ - The source-type fold `foldDailyReportSourceTypeToken`, the same function as the shared entry's, for the host's route modules that ship to the
4506
+ browser (the shared entry is not split, so a route module that imports the fold from it carries a second copy of shared modules into its chunk).
4310
4507
  - The search's filter controls: `DAILY_REPORT_SEARCH_FILTER_FACETS` with its type `DailyReportSearchFilterFacet` (the filters in the filter row's
4311
4508
  order, `author`, `sourceType` and `customer`), and the contract of the control a host renders in each filter cell:
4312
4509
  `DailyReportSearchFilterSelectRenderer` (what `config.search.renderFilterSelect` is) /
@@ -4317,20 +4514,30 @@ Documenting a name here is no reason to export it, and the values that tune the
4317
4514
  slot P**), so rows are located by `[data-daily-report-row]` or the test handle.
4318
4515
  - The rows' shape: `DailyReportRows` (`Pick<ReadonlyArray<DailyReportItem>, "length" | "at"> & Iterable<DailyReportItem>`: what
4319
4516
  `DailyReportActionState.items` is and both views' `dailyReportItems` take; a plain array is one too).
4517
+ - The selection's phase: `DailyReportSelectionPhase` (`"held"` for each key of a held-key repeat stream, `"settled"` for every other selection and
4518
+ once at a stream's end: the second argument of both views' `onSelectItem`). Every way a stream ends (the key's release, another key, a pointer
4519
+ press, a wheel, focus leaving the view, the view unmounting) passes the stream's last selection once as `"settled"`, unless the host settled
4520
+ another selection during the stream or no key of the stream selected. A host may record `"held"` selections without rendering them; rendering
4521
+ them is also correct, at the cost of a render per key.
4320
4522
  - Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `DailyReportClientConfig` (the resolved configuration
4321
4523
  `useDailyReportConfig` returns) / `DailyReportHeaderProps` (what `renderHeader` receives) / `DailyReportSearchSettingInput` /
4322
4524
  `DailyReportSearchSetting` (`config.search` as passed and as resolved) / `DailyReportSourceTypeConfig` (one entry of `sourceTypeConfigs`) /
4323
4525
  `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` /
4324
4526
  `DailyReportSseConnectionStatus`,
4325
- and the end-to-end test handle's contract `DailyReportViewTestHandle` / `DailyReportViewTestReadHandle` / `DailyReportRevealOptions` /
4326
- `DailyReportListNavigationHandle` (the read-only getters of the views' `VirtualScroll` handle that `DailyReportViewTestReadHandle` builds on) (see
4327
- [Test hooks](#test-hooks)).
4527
+ the options of the test handle's reveal `DailyReportRevealOptions` and `DailyReportListNavigationHandle` (the read-only getters of the views'
4528
+ `VirtualScroll` handle that the testing entry's `DailyReportViewTestReadHandle` builds on) (see [Test hooks](#test-hooks)).
4529
+ - Test instrumentation: `DailyReportInstrumentationContext` (null by default; a host's test build provides the testing entry's instrumentation
4530
+ through it, and production provides none) with its value's contract `DailyReportInstrumentation` / `DailyReportInstrumentedView` /
4531
+ `DailyReportViewInstrumentation` (see [Test hooks](#test-hooks)).
4328
4532
  - **server** (`@aiquants/daily-report/server`, Node.js):
4329
4533
  - Factories: `createDailyReportServer` (whose `knownSourceTypes` lists the package's own source type, `Internal`, then each `externalSources`
4330
4534
  adapter's, frozen) / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`;
4331
4535
  `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`;
4332
4536
  and for the search `searchLimits`, `null` without the `search` block, `searchFilterSourceTypes` (the source types a filter may name: frozen, the
4333
- very array of `knownSourceTypes`), `searchDailyReports` (it takes `DailyReportSearchCriteria`), `getDailyReportDetailsByIdsByExternalId`,
4537
+ very array of `knownSourceTypes`), `searchDailyReports` (it takes `DailyReportSearchCriteria`), `getDailyReportDetailsByIdsByExternalId` (it
4538
+ answers one `DailyReportSearchDetailOutcome` per requested id, in request order, and takes the request's `{ signal }` like
4539
+ `markDailyReportSearchHits`: validated before any query, its reason thrown at the next check after an abort, before a query or after a yield
4540
+ between two reports' copies),
4334
4541
  `markDailyReportSearchHits` (it takes the request's `{ signal }`, validated before any query, and throws its reason at its next check after an
4335
4542
  abort, before a read or after a yield between two slices of the marks; its answer holds the reports within the marks budget and defers the rest),
4336
4543
  `listDailyReportSearchFacets`, `reconcileSearchIndex`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `defineDailyReportSearchSchema`
@@ -4344,7 +4551,7 @@ Documenting a name here is no reason to export it, and the values that tune the
4344
4551
  decode-work and walk bounds, for a host's conformance checks) / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` /
4345
4552
  `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
4346
4553
  - SSE and caching: `buildDailyReportSseStreamKey(streamKey)` (the Redis stream key the service writes and reads the SSE entries of, the base key
4347
- with the wire version appended, `<streamKey>:v3`, for a host that reads or writes the stream itself) / `DailyReportSseReader` /
4554
+ with the wire version appended, `<streamKey>:v4`, for a host that reads or writes the stream itself) / `DailyReportSseReader` /
4348
4555
  `readDailyReportSseStreamEntry(raw, logger)` (reads one Redis stream entry, `{ id, message }` as the client returns it, once: `{ entry, json }`,
4349
4556
  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
4350
4557
  contract) /
@@ -4369,7 +4576,10 @@ Documenting a name here is no reason to export it, and the values that tune the
4369
4576
  the ports' own types `DailyReportResolveUserId` / `DailyReportEncodeUserId` / `DailyReportResolveVisibleSourceTypes` (with
4370
4577
  `DailyReportVisibleSourceTypes`, `DailyReportVisibilityDimensions`, `DailyReportVisibilityOptOut` (the explicit opt-out
4371
4578
  `{ unrestricted: <reason> }`), `DailyReportVisibilityResolveContext` and `DailyReportVisibilityResolveReason`) / `DailyReportRedisClient` / `DailyReportRedisBlockingClient` / `DailyReportRedisStreamMessage` /
4372
- `DailyReportSseEntryMessage` (an entry's message read once) / `DailyReportExternalSource` (an `externalSources` adapter),
4579
+ `DailyReportSseEntryMessage` (an entry's message read once: `parsed`, a `DailyReportSseStreamMessage` — the message as the service writes it to
4580
+ the stream, read by `dailyReportSseStreamMessageSchema`: the types that may be addressed carry the server-only `recipientRawUserId`, and the types
4581
+ that embed a report also the server-only `actorRawUserId`, which the delivery replaces with each viewer's `viewerIsActor` — and `published`, the JSON
4582
+ value as written, which only the recipient filter reads; every frame is built from `parsed`) / `DailyReportExternalSource` (an `externalSources` adapter),
4373
4583
  the injected tables `DailyReportHubTable` / `DailyReportInternalTable` / `DailyReportCommentTable` / `DailyReportLabelTable` /
4374
4584
  `DailyReportHubLabelTable` / `DailyReportUserStatusTable` / `DailyReportUserTable` / `DailyReportAttachmentTable` (the members of
4375
4585
  `DailyReportTables`), the service's rows `DailyReportUserStatusRow` / `DailyReportIdsSource`,
@@ -4390,6 +4600,13 @@ Documenting a name here is no reason to export it, and the values that tune the
4390
4600
  `DailyReportSearchCriteria` (what `searchDailyReports` takes: `{ plan, filters }`, the plan `null` for a filters-only search) /
4391
4601
  `DailyReportSearchFilters` (the parsed filters, each `null` when unset: the source types as a lower-case set, the authors and customers as
4392
4602
  `DailyReportSearchNameFilter`, a non-empty tuple) / `DailyReportSearchFacets` (the answer of `search-facets`).
4603
+ - **testing** (`@aiquants/daily-report/testing`, a host's tests):
4604
+ - `createDailyReportTestInstrumentation()`, the value a host's test build provides through `DailyReportInstrumentationContext` (see
4605
+ [Test hooks](#test-hooks)).
4606
+ - Types: `DailyReportViewTestHandle` (the views' test handle) / `DailyReportViewTestReadHandle` (its read-only part) /
4607
+ `DailyReportRowPassCounters` / `DailyReportRowPassKindReading` (what its `rowPass` reads) and the row pass's events it counts,
4608
+ `DailyReportRowPassEvent` / `DailyReportRowPassUnitFrameEvent` / `DailyReportRowPassUnitKind` / `DailyReportRowBoxSizing` /
4609
+ `DailyReportRowUnitChange`, and `DailyReportReEstimateCounters` (what its `estimate` reads: the re-estimates and those made in motion).
4393
4610
 
4394
4611
  ## License
4395
4612