@aiquants/daily-report 0.43.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
@@ -1698,15 +1703,17 @@ unexpected error, a request that is not the package's client (another origin's p
1698
1703
  | Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
1699
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 |
1700
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 |
1701
- | Empty stream, or a cursor inside the retained window or in the future | 200, `connected` → heartbeat → catch-up (every entry) → live | continues |
1702
- | 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 |
1703
1709
 
1704
1710
  - **Resume cursor**: `readLastEventId` reads the `Last-Event-ID` header first, then the `lastEventId` query parameter
1705
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
1706
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
1707
1713
  `resync-required`. The client URL-encodes the cursor.
1708
1714
  - **`connected` frame**: `data: {"type":"connected"}` with `id:` set to the resume position (the cursor, or the newest entry at connect time when
1709
- 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.
1710
1717
  Without a cursor it is written only after the shared reader has fixed its read position (`DailyReportSseReader.ready()`), so every entry appended
1711
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
1712
1719
  the catch-up and the position fix is lost.
@@ -1727,9 +1734,15 @@ unexpected error, a request that is not the package's client (another origin's p
1727
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
1728
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).
1729
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
1730
- 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
1731
- 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
1732
- 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).
1733
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
1734
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
1735
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
@@ -1741,8 +1754,11 @@ unexpected error, a request that is not the package's client (another origin's p
1741
1754
  provisional line). The service reads it inside the cached fetcher **before** the SQL, so every cache path (bucket hit,
1742
1755
  joining an in-flight fetch, refresh-ahead) returns an array that already reflects every update up to the anchor. The
1743
1756
  SSE hook does not connect until an anchor arrives and then uses it as the first cursor, which closes the gap between
1744
- loading the ids and opening the stream. `null` means "position unknown" (empty stream, no redis, read failure): the
1745
- 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).
1746
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
1747
1763
  `comment-add` is matched by comment id.
1748
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,
@@ -1994,37 +2010,51 @@ The 400s come in order, all before any service call: `q` and `combine` (`Invalid
1994
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
1995
2011
  without one it is still the 400 `empty`.
1996
2012
  A request without filter parameters adds no condition to the SQL: its statement and parameters are those of the text search alone.
1997
- `GET {apiBasePath}/reports?ids=<id,id,...>` (distinct canonical ids, at most 100) answers the visible details among them, `{ reports }`. Neither is
1998
- 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.
1999
2027
  `reports` has an admission of its own from the same factory, never the search's: 2 × the larger of `concurrency` and
2000
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
2001
2029
  half, so a viewer always passes the client's two concurrent reads (a `concurrency` of 1 admits 4 per process),
2002
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
2003
2031
  reads nor spends the searches.
2004
- 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,
2005
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
2006
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
2007
- 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`.
2008
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,
2009
2038
  the rows a view draws) answers `{ wordsDigest, reports, deferred }` (`DailyReportSearchMarksAnswer`): in `reports`, for each requested report the
2010
- 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
2011
2041
  OR of two or more keywords;
2012
2042
  `null` otherwise) and the marks of every non-empty field value the views draw (`fields`, by key: `subject`, `content`, `author`, `employeeName`,
2013
2043
  `updatedBy`, `category`, `creationCategory`, `customerName`, `interviewer:<i>:name|affiliation`, `attachment:<i>` and `comment:<id>:author|content`,
2014
2044
  the comment's id in the list `useDailyReportComments` returns).
2015
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
2016
- `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`.
2017
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
2018
2048
  either `ranges`, flat `[start, end, tier]` triples in UTF-16 offsets of that value,
2019
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
2020
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
2021
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
2022
- 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
2023
- 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).
2024
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
2025
2055
  most one triple per UTF-16 unit of its value.
2026
2056
  An answer has a budget: at most 500,000 ranges and 4,000,000 raw UTF-16 units of the fields it marks.
2027
- 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
2028
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
2029
2059
  not marked and its id goes to `deferred`, for the client to ask again (**Arrival** under **Highlights** below).
2030
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
@@ -2042,7 +2072,9 @@ runs, and its marks equal those of the per-occurrence definition byte for byte.
2042
2072
  work passes 8 ms or 5 ms have passed on the
2043
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
2044
2074
  checks the request's signal between two slices,
2045
- 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
2046
2078
  4,000 units) and one grapheme cluster longer than a
2047
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
2048
2080
  `AB` line with no cut between its pieces,
@@ -2065,17 +2097,19 @@ Its order is the search's and the details': 404 without the `search` block, 400
2065
2097
  400 for the ids, then a viewer without an internal user answered `{ wordsDigest, reports: [], deferred: [] }` with no admission and no dataset query,
2066
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
2067
2099
  whatever the outcome, and the JSON. It is not cached on the server either.
2068
- The service takes the request's signal too (`markDailyReportSearchHits(scope, userId, plan, ids, { signal })`) and checks it before each read and
2069
- after each yield, stopping at the next slice once the client has left, between two slices of one field too.
2070
- 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
2071
- a `TypeError`
2072
- (`[daily-report] markDailyReportSearchHits: options.signal must be the request's AbortSignal (new AbortController().signal for a call that is never aborted)`),
2073
- any other value a `RangeError` ending with `; got <value>`.
2074
- 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
2075
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
2076
2109
  a body, with `Retry-After` and `Cache-Control: no-store` and no ETag, and writes no error line;
2077
- 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
2078
- 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
2079
2113
  never recorded as busy, on any of them (`search-facets` included).
2080
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
2081
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
@@ -2134,8 +2168,8 @@ line wraps instead of truncating;
2134
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
2135
2169
  other failures are no input errors).
2136
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
2137
- 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)
2138
- 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)).
2139
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
2140
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
2141
2175
  its limit) —,
@@ -2263,7 +2297,7 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
2263
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).
2264
2298
  An `interviewerWithAffiliation` template that does not insert the name and the affiliation verbatim (it rewrites them, uses one twice, or words
2265
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.
2266
- - **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
2267
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`
2268
2302
  in forced colours.
2269
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
@@ -2750,8 +2784,8 @@ view, and no script computes or writes a height.
2750
2784
  text anyway.
2751
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
2752
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
2753
- 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:
2754
- 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).
2755
2789
  Because the views pass an active row (`activeItemIndex`, `null` while the list is empty), `VirtualScroll` draws the message outside the composite —
2756
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
2757
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
@@ -2903,9 +2937,15 @@ the resize handle and the side pane lie outside the list.
2903
2937
  renderer do not render, and no context value changes per key.
2904
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
2905
2939
  every run of its nine conditions.
2906
- - **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,
2907
- id, position, set size, level and selection, named by the report's ISO business date and `aria-busy="true"`, and an empty surface
2908
- (`data-daily-report-row-shell`) that fills the box `VirtualScroll` gives the row, so nothing moves when the body arrives.
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.
2909
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
2910
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.
2911
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
@@ -2917,18 +2957,27 @@ the resize handle and the side pane lie outside the list.
2917
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
2918
2958
  wakes only calibrates. The scheduler arms itself
2919
2959
  with a `MessageChannel` message and calls `requestAnimationFrame` only from its own message and callbacks — never from a keydown, a commit or the
2920
- held key's frame — so the harness never counts its work as a key's.
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.
2921
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
2922
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
2923
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
2924
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
2925
2970
  visible or reach row is skipped by at most one late frame in a row.
2926
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
2927
- distance from the active row, the side the keys travel towards first; equal ranks keep the order the units were added in. While a key is held, far
2928
- rows ahead in the travel direction are drawn after the near ones, so the rows a hold brings into the reach already have their bodies; far rows
2929
- behind wait for the key's release.
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.
2930
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
2931
- visible row is never a shell and never shows outdated data or marks (counted). Outside a key hold, a newer input of a row that is already visible
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
2932
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.
2933
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
2934
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
@@ -2948,9 +2997,22 @@ the resize handle and the side pane lie outside the list.
2948
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
2949
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
2950
2999
  complete card each, counted).
2951
- On a slow device (the CPU slowed 4×) a held key renders about 9 frames a second — the next frame's p95 is about 109 ms against the 33 ms target —
2952
- because each key frame's own style, layout and paint take that long; every row that enters the visible range is still complete, with its card and
2953
- marks, at its entry.
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.
2954
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
2955
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
2956
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
@@ -3073,10 +3135,14 @@ the resize handle and the side pane lie outside the list.
3073
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
3074
3136
  the last row's height).
3075
3137
  - **Scrolls `VirtualScroll` makes for the user** (wheel, drag, the scroll bar, inertia) are recorded each time the view reports its visible range,
3076
- once per frame. A fling far beyond the DetailList's rendered window is not symmetric: the rows it passes as shells are drawn above the pane
3077
- afterwards and grow from the unmeasured estimate (352 px, below every measured row) to their heights, and `VirtualScroll`'s compensation keeps the
3078
- visible rows in place, so wheeling back by the same distance lands several hundred px (up to about 1,100) away from where the fling started; the
3079
- visible rows never jump. 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
3080
3146
  restores from there: it neither undoes the user's scroll nor shifts the content by a row.
3081
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
3082
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
@@ -3201,6 +3267,9 @@ the resize handle and the side pane lie outside the list.
3201
3267
  the comment form: see [External Source Badge Configuration](#external-source-badge-configuration-sourcetypeconfigs)).
3202
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
3203
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).
3204
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,
3205
3274
  so focus goes to the report's anchor above.
3206
3275
  - **Mobile detail overlay** (List, single-column layout): the platform's modal dialog, a `<dialog data-daily-report-mobile-overlay>` (implicit
@@ -3258,6 +3327,7 @@ and their versions, and per step either a pass or the text announced.
3258
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 |
3259
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 |
3260
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 |
3261
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 |
3262
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 |
3263
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 |
@@ -3334,8 +3404,9 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3334
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
3335
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
3336
3406
  reveal.
3337
- 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
3338
- 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
3339
3410
  [Attachment display](#attachment-display)), so the rows below an image grid stay on whole device pixels.
3340
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
3341
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
@@ -3481,16 +3552,19 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3481
3552
 
3482
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
3483
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
3484
- schemes and a slate-500 placeholder (dark slate-400).
3555
+ schemes and a placeholder in the quiet text colour (below).
3485
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
3486
3557
  focus; dark slate-900, the comment input slate-800 turning slate-900); none reads the host's `--input`, `--muted-foreground`, `--primary` or
3487
3558
  `--destructive`.
3488
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:
3489
- 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
3490
- field's fill.
3491
- 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
3492
- 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
3493
- 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.
3494
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),
3495
3569
  and its host-token guard covers the token, the primitives and the sites.
3496
3570
 
@@ -3704,8 +3778,25 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3704
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
3705
3779
  truncating class.
3706
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,
3707
- `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
3708
- / 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
3709
3800
  `VirtualScroll`'s tap-scroll circle options).
3710
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
3711
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
@@ -3987,7 +4078,7 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
3987
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 |
3988
4079
  | `[data-daily-report-active]` | The active row's frame (the row the composite's `aria-activedescendant` names) |
3989
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) |
3990
- | `[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**) |
3991
4082
  | `[data-daily-report-card]` | List card surface (present while the card is shown) |
3992
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`) |
3993
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) |
@@ -4014,21 +4105,30 @@ Only the package writes `data-daily-report-keyboard-focus`, `data-daily-report-s
4014
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
4015
4106
  a key's render the frame already names the new selection while the content still shows the previous report.
4016
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,
4017
4111
  `[data-testid="daily-report-list"]` and `[data-testid="daily-report-detail-list"]` carry a `__virtualScroll` accessor for end-to-end tests, with the
4018
4112
  same shape in both views. It is the read-only part of the current `VirtualScroll` handle — `getViewportSize()`, `getScrollPosition()`,
4019
4113
  `getScrollAnchor()`, `getRange()` and `getFenwickSize()` — plus `findReportIndex(id)`, `getReportItem(index)` and `getReportIds(limit = 20)` over the
4020
- view's committed list, one way to move the position, `revealIndex(index, options)`, and one read-only view of the view's row pass,
4021
- `rowPass.counters()`: the counts since the view mounted, read when it is called — `syncReleases` (units drawn synchronously, in a commit or a
4022
- notification's microtask commit), `oversizeFrames` (key-free frames whose first unit alone exceeded the frame's share), `lateFrames` (late key-free
4023
- frames the pass skipped) and `lateForcedFrames` (late key-free frames it applied a unit in regardless). A performance probe reads them before and
4024
- after a key hold and reports the differences, so it copies none of the pass's constants or rules (**Rows mount in two passes**).
4025
- The client entry exports the contract as types, so a host's tests import it instead of restating it (type-only, no bundle bytes):
4026
- `DailyReportViewTestHandle` is the accessor, `DailyReportViewTestReadHandle` its read-only part, and `DailyReportRevealOptions` the options of
4027
- `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).
4028
4128
  `revealIndex` goes through the view's scroller (the `VirtualScroll` alignment of `scrollToIndex`, then the anchor's record, like a key move; see
4029
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
4030
- functions that move the position (`scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel`, `updateItemSize`) and no selection backdoor: select through
4031
- 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.
4032
4132
 
4033
4133
  ### Localization (`locale` / `labels`)
4034
4134
 
@@ -4232,23 +4332,29 @@ prevents the package from hardcoding specific third-party service names or desig
4232
4332
  sourceTypeConfigs: {
4233
4333
  Internal: {
4234
4334
  label: "Native",
4235
- 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",
4236
4336
  },
4237
4337
  ExternalCRM: {
4238
4338
  label: "External CRM",
4239
- 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",
4240
4340
  },
4241
4341
  LegacyPortal: {
4242
4342
  label: "LP",
4243
4343
  name: "Legacy Portal",
4244
- 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",
4245
4345
  },
4246
4346
  }
4247
4347
  ```
4248
4348
 
4249
- - `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
4250
- `<abbr title="<name>">` (pointer users see the expansion), hides the abbreviation from assistive technology and reads the name once instead. Without
4251
- `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.
4252
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
4253
4359
  source type.
4254
4360
  - The badge is part of the row description (see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)): the List card's
@@ -4365,7 +4471,9 @@ Documenting a name here is no reason to export it, and the values that tune the
4365
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
4366
4472
  a viewer's share of the `reports` and `search-marks` admissions), and the answer of `search-marks`: `DailyReportSearchMarksAnswer`
4367
4473
  (`{ wordsDigest, reports, deferred }`) of `DailyReportSearchReportMarks` (a report's id, `matchedSets` and `fields`) and
4368
- `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`).
4369
4477
  - **client** (`@aiquants/daily-report/client`, React):
4370
4478
  - Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the
4371
4479
  providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider` (required above the action provider:
@@ -4406,20 +4514,30 @@ Documenting a name here is no reason to export it, and the values that tune the
4406
4514
  slot P**), so rows are located by `[data-daily-report-row]` or the test handle.
4407
4515
  - The rows' shape: `DailyReportRows` (`Pick<ReadonlyArray<DailyReportItem>, "length" | "at"> & Iterable<DailyReportItem>`: what
4408
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.
4409
4522
  - Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `DailyReportClientConfig` (the resolved configuration
4410
4523
  `useDailyReportConfig` returns) / `DailyReportHeaderProps` (what `renderHeader` receives) / `DailyReportSearchSettingInput` /
4411
4524
  `DailyReportSearchSetting` (`config.search` as passed and as resolved) / `DailyReportSourceTypeConfig` (one entry of `sourceTypeConfigs`) /
4412
4525
  `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` /
4413
4526
  `DailyReportSseConnectionStatus`,
4414
- and the end-to-end test handle's contract `DailyReportViewTestHandle` / `DailyReportViewTestReadHandle` / `DailyReportRevealOptions` /
4415
- `DailyReportListNavigationHandle` (the read-only getters of the views' `VirtualScroll` handle that `DailyReportViewTestReadHandle` builds on) (see
4416
- [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)).
4417
4532
  - **server** (`@aiquants/daily-report/server`, Node.js):
4418
4533
  - Factories: `createDailyReportServer` (whose `knownSourceTypes` lists the package's own source type, `Internal`, then each `externalSources`
4419
4534
  adapter's, frozen) / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor, snapshot }`;
4420
4535
  `readSseStreamAnchor`; `attachmentDelivery`, `null` without the `attachments` block; `titleMaxLength`;
4421
4536
  and for the search `searchLimits`, `null` without the `search` block, `searchFilterSourceTypes` (the source types a filter may name: frozen, the
4422
- 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),
4423
4541
  `markDailyReportSearchHits` (it takes the request's `{ signal }`, validated before any query, and throws its reason at its next check after an
4424
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),
4425
4543
  `listDailyReportSearchFacets`, `reconcileSearchIndex`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `defineDailyReportSearchSchema`
@@ -4482,6 +4600,13 @@ Documenting a name here is no reason to export it, and the values that tune the
4482
4600
  `DailyReportSearchCriteria` (what `searchDailyReports` takes: `{ plan, filters }`, the plan `null` for a filters-only search) /
4483
4601
  `DailyReportSearchFilters` (the parsed filters, each `null` when unset: the source types as a lower-case set, the authors and customers as
4484
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).
4485
4610
 
4486
4611
  ## License
4487
4612