@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/CHANGELOG.md +167 -0
- package/README.md +211 -86
- package/dist/client.d.mts +11 -25
- package/dist/client.d.ts +11 -25
- package/dist/client.js +5 -5
- package/dist/client.mjs +5 -5
- package/dist/index.d.mts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/instrumentation-CobsTeyw.d.mts +70 -0
- package/dist/instrumentation-Dz7Uh7dT.d.ts +70 -0
- package/dist/{search-marks-wire-BSS2d1t6.d.ts → search-marks-wire-BiImBos8.d.mts} +16 -1
- package/dist/{search-marks-wire-BSS2d1t6.d.mts → search-marks-wire-C_vjmxwf.d.ts} +16 -1
- package/dist/server.d.mts +9 -5
- package/dist/server.d.ts +9 -5
- package/dist/server.js +10 -10
- package/dist/server.mjs +10 -10
- package/dist/{source-types-Bu-_yfcY.d.ts → source-types-Cg7T2dm6.d.ts} +1 -1
- package/dist/{source-types-NkQm-gNO.d.mts → source-types-Hy_znWJl.d.mts} +1 -1
- package/dist/{sse-schema-Br0b278w.d.mts → sse-schema-BcjRPJjN.d.ts} +2 -75
- package/dist/{sse-schema-Br0b278w.d.ts → sse-schema-sKBorzg9.d.mts} +2 -75
- package/dist/styles/daily-report.standalone.css +1 -1
- package/dist/testing.d.mts +39 -0
- package/dist/testing.d.ts +39 -0
- package/dist/testing.js +1 -0
- package/dist/testing.mjs +1 -0
- package/dist/types-BP1kIGMo.d.mts +75 -0
- package/dist/types-BP1kIGMo.d.ts +75 -0
- package/package.json +8 -3
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
|
|
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.
|
|
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`)
|
|
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
|
|
573
|
-
|
|
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
|
-
|
|
|
1702
|
-
|
|
|
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)
|
|
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.
|
|
1731
|
-
|
|
1732
|
-
cursor that is
|
|
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.
|
|
1745
|
-
|
|
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
|
|
1998
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
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
|
|
2078
|
-
|
|
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-
|
|
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-
|
|
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,
|
|
2754
|
-
|
|
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
|
-
|
|
2908
|
-
|
|
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 —
|
|
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.
|
|
2928
|
-
|
|
2929
|
-
|
|
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
|
|
2952
|
-
|
|
2953
|
-
|
|
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.
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
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
|
|
3338
|
-
|
|
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
|
|
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
|
|
3490
|
-
field's fill.
|
|
3491
|
-
The toggles' filled face (AND, and the open filter toggle) is a blue-
|
|
3492
|
-
|
|
3493
|
-
|
|
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
|
|
3708
|
-
|
|
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
|
|
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()
|
|
4022
|
-
notification's microtask commit), `
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
-
|
|
4250
|
-
|
|
4251
|
-
`
|
|
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
|
-
|
|
4415
|
-
`
|
|
4416
|
-
|
|
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
|
|