@aiquants/daily-report 0.44.1 → 0.46.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
@@ -105,6 +105,9 @@ label catalog reuses its 11 engine keys, the names of the scroll bar and its thu
105
105
  `DailyReportRevealOptions`, the options of the test handle's `revealIndex`, is `@aiquants/virtualscroll`'s `scrollToIndex` options type, which takes
106
106
  `align: "nearest"` since 3.11.0, and DetailList rows stay at the tree's tops and heights after a batch of re-measurements whose deltas cancel only
107
107
  because its row memo follows the tree revision (3.11.0).
108
+ The peer floor of `@aiquants/swipe-overlay` is **1.2.6**: install 1.2.6 or later.
109
+ The mobile overlay, the comment delete confirmation and the built-in error notice leave `Escape` to its `Escape` stack, which lets an `Escape` that
110
+ cancels an IME composition pass; the mobile overlay's entry listens on the document of the overlay's element, an iframe's document included (1.2.6).
108
111
  `@aiquants/sse` (the SSE wire contract, server response helpers and the reopening client) is a regular **dependency**: it arrives transitively, so
109
112
  consumers do not declare it.
110
113
  The server entry needs **Node.js 20.3 or later** (`engines.node` `>=20.3.0`): the thumbnail stage deadlines combine the generation's signal with a
@@ -126,7 +129,7 @@ of the floor, and the row says what those engines draw:
126
129
  | `:has()` | Chrome 105, Firefox 121, Safari 15.4 | The keyboard-focus outline of a List card whose primary button holds keyboard focus (the card's surface reads its direct-child button). The selection ring, its forced-colours outline and the active row's outline come from the row frame's own `aria-selected` and `data-daily-report-active` and do not use `:has()` | A List card whose primary button holds keyboard focus (after a pointer hand-back, a key on the button) shows no focus outline; the selection ring and the active row's outline still show, and the DetailList is unaffected |
127
130
  | Container size queries and container query units (`cqi`) | Chrome 105, Firefox 110, Safari 16 | The attachment grid's column count and its whole-pixel width (both read from the grid's own size container); the tile frame's height and the tile's lattice pad (`100cqi` of the tile root, an inline-size container as wide as its track); the view-mode toolbar's end inset in the single-column List | One attachment column at every width; frames sized by `aspect-ratio` alone and tiles without the pad (below); the toolbar's end edge overhangs the List's scroll bar by 8 px |
128
131
  | CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The attachment grid's whole-pixel width `calc(round(down, 100% − 16 (n − 1), n px) + 16 (n − 1))` per column count, which makes every track a whole pixel, the tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` and the tile's lattice pad `round(up, h, var(--aqdr-lattice-block)) − h` under its last line (see [Attachment display](#attachment-display)); the views' column origin on the lattice (see **Origin on the lattice**) | Chrome 121–124, inside the floor, drop those declarations: the grid fills its container with the fluid tracks (W − 16 (n − 1)) / n (every breakpoint keeps its column count, which no `round()` declaration sets), so tile edges can fall between pixels and the names are fitted less than 1 px narrower than the track; the frame's `aspect-ratio: 480 / 320`, always declared beside its height, sizes the frame at the exact fractional 2t / 3, so the frames of one grid can paint one device pixel apart; a tile stays its frame's fractional height + 48 px tall, so the rows below an image grid can start between device pixels; and the column keeps the `mx-auto` centre |
129
- | CSS `calc-size()` | Chrome 129 | The DetailList row bodies', the error notice's and the view-mode toolbar band's height, their content height rounded up to the block quantum of the device-pixel lattice (`calc-size(auto, round(up, size, var(--aqdr-lattice-block)))`: 4 px, 8 px at the odd multiples of 1/8; see **Row slots on the lattice** and **Origin on the lattice**) | Chrome 121–128, and any engine that does not implement it, drop the declaration: a body keeps its content height, which is on the 4 px lattice at a 16 px root and can leave it at other roots and at the odd multiples of 1/8, so the DetailList rows below can start between device pixels; the toolbar's band keeps its content height, so at the odd multiples of 1/8 the views start half a device pixel off whenever that height is not a multiple of 8 px (the toolbar alone is 32) |
132
+ | CSS `calc-size()` | Chrome 129 | The DetailList row bodies', the error notice's and the view-mode toolbar band's height, their content height rounded up to the block quantum of the device-pixel lattice (`calc-size(auto, round(up, size, var(--aqdr-lattice-block)))`: 4 px, 8 px at the odd multiples of 1/8; see **Row slots on the lattice** and **Origin on the lattice**) | Chrome 121–128, and any engine that does not implement it, find the declaration invalid at computed-value time (it holds a `var()`) and give `height` its initial `auto`: a body keeps its content height, which is on the 4 px lattice at a 16 px root and can leave it at other roots and at the odd multiples of 1/8, so the DetailList rows below can start between device pixels; the toolbar's band keeps its content height, so at the odd multiples of 1/8 the views start half a device pixel off whenever that height is not a multiple of 8 px (the toolbar alone is 32) |
130
133
  | `scrollbar-gutter: stable` | Chrome 94, Firefox 97, Safari 18.2 | The side pane's tab panels keep their scroll bar's width whatever their content's height | Safari 17.4–18.1, inside the floor, ignore it; their default scroll bars overlay the content and take no width |
131
134
  | `scrollbar-color` | Chrome 121, Firefox 64 (not in Safari 17.4) | The desktop side pane's native scroll bar wears the views' resting thumb and track (**Scroll bars** in [Selection and focus appearance](#selection-and-focus-appearance)); the mobile overlay keeps the browser's own bar | Safari, inside the floor, ignores it and draws its default scroll bar there; the bar's width does not change |
132
135
  | `text-wrap: balance` | Chrome 114, Firefox 121, Safari 17.5 | The short labels that can wrap (the side pane's selection prompt, an attachment tile's unavailable label, the search box's failure line) keep their lines about equally long | Safari 17.4, inside the floor, drops the value and wraps them greedily, so a wrapped label can end with a short last line |
@@ -536,7 +539,8 @@ its payload — with one strict parser each, after authentication and before any
536
539
  read by one encoder and one strict reader inside the package, so a renamed parameter fails their round trip instead of turning every resume into a
537
540
  full replay) and the pattern of the echo id (`DAILY_REPORT_CLIENT_TEMP_ID_PATTERN`).
538
541
 
539
- Every answer carries its `intent` (`deleteComment`'s too), the echo id and the report's id as a JSON number, the same id domain every SSE event uses
542
+ Every answer carries its `intent` (`deleteComment`'s too) and, but for the development-only `clearCache` (`{"status":"OK","intent":"clearCache"}`),
543
+ the echo id and the report's id as a JSON number, the same id domain every SSE event uses
540
544
  (an integer from 1 to 2^53 − 1), beside the intent's own values (the created or published report, the toggled statuses, the posted comment, the
541
545
  deleted comment's id, also a JSON number). The server builds each 200 answer from its command alone, so it cannot answer one intent with another's
542
546
  shape.
@@ -581,9 +585,9 @@ its payload — with one strict parser each, after authentication and before any
581
585
  since sources may call their drafts by different names). `createDailyReportService` copies and freezes the list, and throws
582
586
  `[daily-report] draftLabelNames must be one or more non-blank label names; got <value>` for anything but one or more non-blank strings without an
583
587
  exact duplicate, an omitted key and `[]` included: the names decide who may see a report, so there is no default name to fall back on.
584
- A report is a draft while it carries a label of one of these names, and every decision about a draft reads the names inside SQL at the moment it is
585
- made (the label table's collation ignores case, width and trailing spaces, so a name is never compared in JavaScript, and the process keeps no label
586
- id, which would go stale when a label is re-created):
588
+ A report is a draft while it carries a label of one of these names, and every decision about a draft label reads the names inside SQL at the moment
589
+ it is made (the label table's collation ignores case, width and trailing spaces, so a label's name is never compared in JavaScript, and the process
590
+ keeps no label id, which would go stale when a label is re-created):
587
591
  every read shows a draft to its author only (one correlated `NOT EXISTS` over the report-label relation joined to the labels by name, so a report
588
592
  with several draft labels still reads as one row), and the audience of a draft's SSE events — its content on `report-create`, `report-update` and
589
593
  `report-publish`, and its comments' `comment-add` and `comment-delete` — is its author alone while it is a draft (one indexed one-row read with the
@@ -592,7 +596,8 @@ its payload — with one strict parser each, after authentication and before any
592
596
  fails, writes nothing and names the label (a report without it would be visible to every viewer). `publishDailyReport` removes every draft-named
593
597
  label of the report, by name and inside its transaction, and addresses its `report-publish` event to the author alone when the report is still a
594
598
  draft after the transaction. `getDraftLabelId()` reads the canonical label's id by its name on every call (`null` when no label has that name).
595
- A report whose creation category (`creationCategory`) is one of the names shows the canonical label in its labels, with that label's own id, read by
599
+ A report whose creation category (`creationCategory`) is one of the names (compared in JavaScript, as an exact string: case, width and trailing
600
+ spaces count) shows the canonical label in its labels, with that label's own id, read by
596
601
  name in the same read and only when a report of the read needs it (none when no label has the name); the read rule reads labels alone, so such a
597
602
  report without a draft label is not hidden.
598
603
  Every report detail the server sends — the `report`, `business-date` and `reports` answers, the action's answers that carry a report and the SSE
@@ -600,7 +605,7 @@ its payload — with one strict parser each, after authentication and before any
600
605
  shared draft-label test, beside the hub row) or true when its creation category is one of the names; a row whose flag reads as anything but 1 or 0
601
606
  throws.
602
607
  The client compares no name: the views open the viewer's own draft in the editor by `isDraft` alone, so a draft named by any of the names, or by a
603
- variant the collation equates, behaves as one ([Client wiring](#client-wiring)). The SSE stream's key carries the wire version (`<streamKey>:v4`; [Realtime architecture](#realtime-architecture)),
608
+ variant the collation equates, behaves as one ([Client wiring](#client-wiring)). The SSE stream's key carries the wire version (`<redisKeyPrefix>:sse-stream:v4`; [Realtime architecture](#realtime-architecture)),
604
609
  so an entry written before `isDraft` existed is never read.
605
610
  - `resolveVisibleSourceTypes(request, { reason })` — **Required row-level authorization port.** Returns the `Hub.source_type` values this request may
606
611
  view, or the explicit opt-out `{ unrestricted: "<reason>" }` of a deployment that does not restrict by source type. Applies uniformly to every server
@@ -611,9 +616,10 @@ its payload — with one strict parser each, after authentication and before any
611
616
  - `loginRedirectPath` — **Required**: the path of the host's sign-in route, where the page loader (`index.loader`) redirects a reader who is not
612
617
  signed in. `createDailyReportServer` and `createDailyReportHandlers` stop with a `RangeError` for anything but a non-blank text
613
618
  (`[daily-report] loginRedirectPath must be the non-blank path of the host's sign-in route; got <value>`): the package names no host route of its own.
614
- - `enableDevCacheClear` — Gates the dev-only `POST /action` `intent=clearCache` (flush every worker's cache). Default `false` → the action answers
619
+ - `enableDevCacheClear` — Gates the dev-only `POST /action` `intent=clearCache` (flush this process's SQL result and user-id caches and, with Redis,
620
+ advance only the epoch of the id lists, so other workers keep their business-date and detail caches). Default `false` → the action answers
615
621
  `400` (`Invalid intent`) and never calls `service.clearCache()`. Wire `import.meta.env.DEV` to enable it only in development (any authenticated user
616
- could otherwise flush all caches without limit).
622
+ could otherwise flush these caches without limit).
617
623
  - `attachments` — Optional attachment delivery block (`DailyReportAttachmentsConfig`), owned by the service: the id codec and the read port it always
618
624
  carries, the size limit and the original route's tuning, and the optional `thumbnails` with the renderer port and its tuning. See
619
625
  [Attachments](#attachments).
@@ -638,7 +644,7 @@ its payload — with one strict parser each, after authentication and before any
638
644
  | `[DailyReportReads]` | `warn` | the 503 of **Cache misses per viewer** in [The API route](#the-api-route) |
639
645
  | `[DailyReportSearch]` | `warn` | the 429 of the `search`, `reports`, `search-marks` and `search-facets` admissions ([Requests](#requests)) |
640
646
  | `[DailyReportServer]` | `warn` | `createDailyReportServer` without a `redis` provider |
641
- | `[DailyReportService]` | `info` | the service: SSE publishing (an event over the size bound, a slow or failed publish), a failed read of the stream anchor, the development cache clear |
647
+ | `[DailyReportService]` | `info` | the service: SSE publishing (no Redis client, an event over the size bound, a slow or failed publish), a failed read of the stream anchor, the create's `info` trace (`createDailyReport called`, `Starting transaction`, `Hub created` with the inserted hub row, `Internal created`, `Label assigned`) and its `error` (`Error in createDailyReport`), an id list read out of stream order (`ids array is not in stream order; re-sorting …` at `warn`), a report whose own assembly failed and its suppressed repeats (`<read>: report <id> passed the visibility predicate …`, `failed_report_suppressed …`; [Requests](#requests)), the failed presence writes (`markAttachmentMissing failed`, `markAttachmentPresent failed`), the development cache clear |
642
648
  | `[DailyReportSseReader]` | `info` | the shared SSE reader's Redis errors |
643
649
  | `[DailyReportExternalSource]` | `info` | `transformJsonArray`'s malformed JSON columns of an external source |
644
650
 
@@ -659,11 +665,18 @@ its payload — with one strict parser each, after authentication and before any
659
665
  The JSON answers themselves write nothing, and no line carries a request header's value or traces an answer given before the endpoint (the 401, 404
660
666
  and 405), so a client decides neither the number nor the size of the lines.
661
667
  - Primary tuning parameters: `idsTtlMs` (180 s) / `businessDateTtlMs` (300 s) / `cacheDefaultTtlMs` (60 s) / `cacheMaxBuckets` (4,096) /
662
- `cacheMaxBytes` (512 MiB per process; both **The SQL result cache** in [The API route](#the-api-route)) / `streamKey` / `streamMaxLen` (10,000
668
+ `cacheMaxBytes` (512 MiB per process; both **The SQL result cache** in [The API route](#the-api-route)) / `streamMaxLen` (10,000
663
669
  entries); the attachment tuning lives in the `attachments` block ([Attachments](#attachments)).
664
670
  Every number among them is an integer of at least 1, and only an omitted one takes its default: `0`, a negative number, `NaN`, a fraction or 2^53
665
671
  stops `createDailyReportService` and `createDailyReportServer` with a `RangeError` that names the key
666
672
  (`[daily-report] idsTtlMs must be an integer >= 1; got 0`).
673
+ - `redisKeyPrefix` (`daily-report` by default) — the prefix of every Redis key of the service: the SSE stream `<prefix>:sse-stream:v4` and the
674
+ cross-worker epochs `<prefix>:ids:epoch`, `<prefix>:date-epoch:<business date>` and `<prefix>:detail-epoch:<report id>`. Environments that share
675
+ one Redis (development and production, say) each pass their own prefix, so that neither reads the other's notifications nor drops the other's
676
+ cached reads. The prefix is one or more characters without whitespace or control characters, neither
677
+ starting nor ending with `:`, and only an omitted one takes the default: any other value stops `createDailyReportService` and
678
+ `createDailyReportServer` with a `RangeError` (`[daily-report] redisKeyPrefix must be a Redis key prefix of …; got ""`), and a configuration that
679
+ carries `streamKey` stops with a `TypeError` that names `redisKeyPrefix` instead.
667
680
 
668
681
  #### Configuration errors
669
682
 
@@ -769,7 +782,8 @@ createDailyReportServer({
769
782
  readViewerKey: async (request, { reason }) => (reason === "initial" ? yourAuth.viewerIdOf(request) : yourSession.viewerIdOf(request)),
770
783
  resolve: async (request, { reason }) => {
771
784
  const grants = await yourAuthz.grantsFor(request, { skipCache: reason !== "initial" })
772
- return grants.canReadLegacy ? ["legacy"] : []
785
+ // `Internal` is the package's own reports: every author needs its read, or creating a report answers 403.
786
+ return grants.canReadLegacy ? ["Internal", "legacy"] : ["Internal"]
773
787
  },
774
788
  }),
775
789
  })
@@ -831,9 +845,9 @@ reason is the audit trail, so `grep -rn "unrestricted:"` in the host lists every
831
845
  - **How it reaches the service**: the resolved set is wrapped in a `DailyReportViewerScope` and passed as the **first** argument of every gated
832
846
  service method, the create and the three author mutations included.
833
847
  The class is nominal (it holds a private field), so `{ visible: null }`, `{ ...scope, visible: null }` and `new DailyReportViewerScope(...)` are all
834
- type errors — the only ways to obtain one are `DailyReportViewerScope.restrictTo(tokens)` — which accepts `readonly string[]` only, so it cannot be
835
- handed a nullish value and quietly widen — and `DailyReportViewerScope.unrestricted(auditReason)`, which the handlers call only for the port's
836
- explicit opt-out, with its reason.
848
+ type errors — the only ways to obtain one are `DailyReportViewerScope.restrictTo(tokens)` — which accepts `readonly string[]` or the per-dimension
849
+ `{ read, comment? }` (`DailyReportVisibilityDimensions`) only, so it cannot be handed a nullish value and quietly widen — and
850
+ `DailyReportViewerScope.unrestricted(auditReason)`, which the handlers call only for the port's explicit opt-out, with its reason.
837
851
  Because a bare `null` is not spellable as a visibility value, `grep -rn "DailyReportViewerScope.unrestricted("` enumerates every unfiltered read in
838
852
  a codebase, each carrying a written reason.
839
853
  - **Failure**: a port that throws — an authorization-store outage, or a viewer who is gone (signed out or disabled) — or answers outside the contract
@@ -974,7 +988,7 @@ answers 404 (it never falls back to the original).
974
988
  | `attachments.read` | required in the block | Reads the original bytes (`DailyReportReadAttachment`, **Read port** below). |
975
989
  | `attachments.maxBytes` | 32 MiB | Upper bound for an original, for every delivery. Keep it below the read port's wire limit. Integer ≥ 1. |
976
990
  | `attachments.rateLimitPerMinute` | 60 | Original downloads per user per minute; a token is spent before authorization. Integer ≥ 1. |
977
- | `attachments.concurrency` | 4 | Simultaneous original reads. A slot is held while the original is referenced (below: until its body has been handed over, the client cancels it, or the client stops reading for 60 s), and one viewer holds at most half of the slots, rounded up (⌈n / 2⌉: 2 of the default 4). A request that finds no free slot, or whose viewer already holds that share, fails fast with 503 (`reason=concurrency`, `Retry-After: 5`). Integer ≥ 1. |
991
+ | `attachments.concurrency` | 4 | Simultaneous original reads. A slot is held while the original is referenced (below: until its body has been handed over, the client cancels it, or the client stops reading for 60 s), and one viewer holds at most half of the slots, rounded up (⌈n / 2⌉: 2 of the default 4). A request that finds no free slot, or whose viewer already holds that share, fails fast with 503 `Too many concurrent downloads` (`reason=concurrency`, `Retry-After: 5`). Integer ≥ 1. |
978
992
  | `attachments.thumbnails.renderer` | required in `thumbnails` | Renders the preview (`DailyReportAttachmentThumbnailRenderer`: `{ id, render }`; the package's sharp implementation is `createSharpThumbnailRenderer`). Validated when the service is created (**Thumbnail renderer port** below). |
979
993
  | `attachments.thumbnails.rateLimitPerMinute` | 120 | Thumbnail generations per user per minute, in a bucket separate from originals; revalidations (requests with `If-None-Match`) pay from a second bucket of ten times this value. Both admit before authorization, and an empty bucket answers 429 at once; a revalidation that does not end in a 304 also pays a generation token, right after authorization (see **Rate admission** below). Integer ≥ 1. |
980
994
  | `attachments.thumbnails.concurrency` | 2 | Simultaneous thumbnail generations (read + render), with a wait queue. Integer ≥ 1. |
@@ -1060,7 +1074,7 @@ type DailyReportReadAttachment = (
1060
1074
  | `invalid_path` | A scheme or a path the port does not accept | 404 | 404 |
1061
1075
  | `too_large` | The object is over `maxBytes`; only the object's size may decide it, never a transient refusal | 413 | 404, cached only when the row's recorded size backs it (below) |
1062
1076
  | `unavailable` | The storage cannot be reached, a read ended early, or the package's `signal` aborted the read | 502 | 502 |
1063
- | `busy` | The storage refuses for now (a load limit, such as a per-connection stream limit that refuses at once) | 503 with `Retry-After: 5` | 503 with `Retry-After: 5` |
1077
+ | `busy` | The storage refuses for now (a load limit, such as a per-connection stream limit that refuses at once) | 503 `Attachment storage busy` with `Retry-After: 5` | 503 `Thumbnail temporarily unavailable` with `Retry-After: 5` |
1064
1078
  | `deadline` | A storage call passed the port's own deadline (a per-call deadline, a stalled stream) | 504 | 504 |
1065
1079
 
1066
1080
  `busy`, `deadline` and `unavailable` are transient: a thumbnail generation concludes nothing from them, caches nothing and marks nothing missing.
@@ -1107,7 +1121,8 @@ type DailyReportReadAttachment = (
1107
1121
  - A read that has already been aborted when it settles is discarded whatever its kind, for an original download and a thumbnail generation alike: 503
1108
1122
  (`reason=aborted`) and no missing- or present-object record, so an aborted `unavailable` is never logged as a storage outage (502) and a racing
1109
1123
  `not_found` never marks the object missing.
1110
- The original download logs `503 attachment=<id> viewer=<id> reason=aborted`; the thumbnail generation also caches nothing and does not render. No
1124
+ The original download logs `503 attachment=<id> viewer=<id> reason=aborted` and answers `Attachment request aborted` without `Retry-After`; the
1125
+ thumbnail generation also caches nothing and does not render. No
1111
1126
  disconnected client receives either 503. An original request that is already aborted when it arrives gets the same 503 without reading.
1112
1127
  - A thumbnail read still running at its deadline (`readMs`) is answered with 504 (`reason=read_timeout`) without waiting for it; nothing is cached and
1113
1128
  the object is not marked missing.
@@ -1252,8 +1267,9 @@ type DailyReportAttachmentThumbnailRenderer = {
1252
1267
  A render still running at its deadline is answered with 502 (`reason=render_timeout`) without waiting for it. A renderer that cannot stop
1253
1268
  (in-process libvips) is still valid: the response is bounded by the deadline, but the generation slot stays held until the render settles
1254
1269
  (`render_overrun ms=<elapsed>`; a render that has still not settled at twice its budget is logged once as `render_stuck ms=<elapsed>`).
1255
- What a render has paid for is kept, whoever still waits for it. The generation stops waiting for its render at the deadline (the 502 above) or as
1256
- soon as every request waiting for that content has gone (503 `reason=aborted`, which no client receives); when the render then settles with a
1270
+ What a render has paid for is kept, whoever still waits for it. The generation stops waiting for its render at the deadline (the 502 above) or, once
1271
+ every request waiting for that content has gone (the abort reaches only the renderer, through `signal`), when the render settles or the deadline
1272
+ passes, whichever comes first (503 `reason=aborted`, which no client receives); when the render then settles with a
1257
1273
  thumbnail that passes the output checks below, or with `unsupported`, and the generation's read matched the row's size (**verified**, see **Read
1258
1274
  port** above), the outcome is cached before the slot is released.
1259
1275
  A result that settles after the generation stopped waiting is classified by the same rule as one that settles in time, so only those conclusions are
@@ -1595,7 +1611,7 @@ Both deliveries log at fixed levels, with fixed line formats:
1595
1611
  | --- | --- |
1596
1612
  | `info` | 200, 304, the 503 nobody receives (`reason=aborted`), 404 `not_eligible`, and the content-determined 404s (`signature`, `unsupported`, `too_large`; cached when verified, `too_large size=unrecorded` on a row without a recorded size) |
1597
1613
  | `warn` | 404 `not_visible` (a sign of token enumeration), the storage 404s (`not_found`, `denied`, `invalid_path`), 413 (`db_size`, and the port's `too_large` on an original), the first 429 of a viewer's window per bucket and the window's `rate_limit_suppressed` / `revalidation_rate_limit_suppressed` line, the busy 503s (`queue`, `concurrency`, the storage's `busy`), every 502, the 504 of a storage read past a deadline (`deadline`, `read_timeout`), port settlements after a deadline (`read_overrun`, `render_overrun`), ports still unsettled at twice their stage budget (`read_stuck`, `render_stuck`) and thumbnail reads that do not match the row's size (`size_mismatch`: another length, or a `too_large` on a row whose size is known) |
1598
- | `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` and an original's `HEAD` that declares a size over it (`code=over_max_bytes`), an original's `GET` read whose declared `size` is not the length of its bytes (`code=size_mismatch`) and a read failure reason outside the union (`code=failure_reason`), the loader's `unexpected`, a route without the `token` parameter among them) and failed `markAttachmentMissing` / `markAttachmentPresent` writes |
1614
+ | `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` and an original's `HEAD` that declares a size over it (`code=over_max_bytes`), an original's `GET` read whose declared `size` is not the length of its bytes (`code=size_mismatch`) and a read failure reason outside the union (`code=failure_reason`), the loader's `unexpected`, a route without the `token` parameter among them) and a `markAttachmentMissing` / `markAttachmentPresent` call that rejects (`<method> failed attachment=<id> message=<msg>`); a failed database write inside those calls is caught by the service and logged on `[DailyReportService]`, not here |
1599
1615
 
1600
1616
  `error` is left to failures of the server itself, so an alert on `error` does not fire on user traffic or on upstream storage states.
1601
1617
 
@@ -1628,17 +1644,19 @@ and without the retry the victim would answer 500 and lose what it wrote.
1628
1644
 
1629
1645
  ### Realtime architecture
1630
1646
 
1631
- 1. Mutation services publish to Redis Stream (`<streamKey>:v4`, `daily-report:sse-stream:v4` by default: the key carries the wire version of the
1647
+ 1. Mutation services publish to Redis Stream (`<redisKeyPrefix>:sse-stream:v4`, `daily-report:sse-stream:v4` by default: the key carries the wire version of the
1632
1648
  stream's messages and their embedded report, so a deployment that changes a required key of them reads no entry an older version wrote, and the stream of the previous version
1633
1649
  stops growing and can be deleted by hand; a host that reads or writes the stream itself, such as an integration test, builds the key with the
1634
- server entry's `buildDailyReportSseStreamKey(streamKey)` instead of restating the version) in sequence:
1650
+ server entry's `buildDailyReportRedisKeys(redisKeyPrefix).sseStream` instead of restating the version) in sequence:
1635
1651
  `invalidate -> epoch increment -> SSE publish`.
1636
1652
  2. `DailyReportSseReader` uses a single blocking `xRead` loop to fan-out events to all SSE connections (eliminating per-connection Redis TCP
1637
1653
  connections). `createDailyReportServer` creates it; `createDailyReportHandlers` takes the reader as `sseReader` typed by the port
1638
1654
  `DailyReportSseReaderPort` — `ready()` and `subscribe(onEntry, onError)`, with the contract under **`connected` frame** below — which is the type a
1639
1655
  host's own reader implements: a plain object with the two methods is accepted, while the class type, which has private fields, would refuse one.
1640
- The reader reads each live entry once per process and hands every subscriber the same frozen value (`DailyReportSseStreamEntry`:
1641
- `{ id, message }`): `message` is the entry's `data` field parsed as JSON and validated by the stream form `dailyReportSseStreamMessageSchema` (the
1656
+ The reader reads each live entry once per process and hands every subscriber the same value (`DailyReportSseStreamEntry`:
1657
+ `{ id, message }`, frozen one level deep — the entry and its `message` object; an entry whose `message` is `null` is not frozen, and `parsed` and
1658
+ `published` are not frozen either): `message` is the entry's `data` field parsed as JSON and validated by the stream form
1659
+ `dailyReportSseStreamMessageSchema` (the
1642
1660
  only form that declares the server-only fields: the six types that may be addressed — status-update, comment-add, comment-delete, report-create,
1643
1661
  report-update and report-publish — carry the recipient's internal id `recipientRawUserId`, and the three that embed a report also the writer's,
1644
1662
  `actorRawUserId`) — the validated message (`parsed`) and the JSON value as published (`published`, which only the recipient filter reads) — or
@@ -1923,8 +1941,11 @@ source types. The filters' candidates (`search-facets`, under **Requests**) are
1923
1941
  #### The index
1924
1942
 
1925
1943
  Two derived tables per database, `DailyReportSearchDocument` (one row per report: the normalized text, the digest of the raw source, the normalizer
1926
- version, `row_version` and the two filter values) and `DailyReportSearchComment` (one row per comment, with its author id), their normalized text
1944
+ version, `row_version` and the two filter values) and `DailyReportSearchComment` (one row per searchable comment, with its author id: a comment whose
1945
+ normalized text is empty gets no row, and the ordinals count only the kept comments), their normalized text
1927
1946
  `nvarchar(max)` in `Latin1_General_100_BIN2` so that `LIKE` compares it as it is, and both deleted with their report (`ON DELETE CASCADE`).
1947
+ The document's `indexed_at` (`datetime2`, `NOT NULL`) is written by a rewrite (`SYSUTCDATETIME()`) but left out of the insert of a new document, so
1948
+ the host's table must give it a default (such as `DEFAULT (sysutcdatetime())`).
1928
1949
  The filter values are two nullable columns of the document, `author_name` and `customer_name`: `nvarchar(200)` in `Latin1_General_100_BIN2`, so that
1929
1950
  the filters and their candidates compare values exactly, each with an index of its own (the candidates' `GROUP BY` and the filters' `IN` read them).
1930
1951
  They hold the author name the list shows (`employeeName`) and the customer name the detail shows, both masked like the detail, in their canonical form
@@ -1951,6 +1972,8 @@ the comment paths run in a transaction even without the `search` block, which a
1951
1972
 
1952
1973
  Writes outside the package (a sync, an import) leave the index stale until
1953
1974
  `service.reconcileSearchIndex({ batchSize, hubIds?, signal?, onProgress? })` runs.
1975
+ An aborted `signal` stops it between batches by rejecting with the signal's reason, so an aborted run returns no counters (`onProgress` has had
1976
+ those of each finished batch); a run that ends returns the counters and the elapsed time.
1954
1977
  It walks every report (or the given ones) in batches of 1 to 500, one transaction per batch at the low deadlock priority, takes the same locks as a
1955
1978
  save, and rewrites only the reports whose digest or normalizer version differ.
1956
1979
  Run it from a command-line process after such writes, for the initial build, and after deploying a build whose normalizer version changed (the version
@@ -2178,8 +2201,9 @@ edges; with the default labels the field starts and ends on the 4 px lattice.
2178
2201
  The reserved label is the generated content of a hidden element, so it is in neither the toggle's accessible name nor its text.
2179
2202
  The field's description (`labels.searchSyntaxHelp`, also its tooltip) gives the syntax, says that a word in Latin letters also finds its kana reading
2180
2203
  and that a match found through a reading is underlined with dots.
2181
- While a search request runs or after it failed, the views keep the list they showed; an answer replaces the list in its order (a report the provider
2182
- marked removed is left out), and the header shows the hit count. While an answer shows, the rows read their details by id in batches (`reports?ids=`)
2204
+ While a search request runs or after it failed, the views keep the list they showed; an answer replaces the list in its order (a report only the
2205
+ answer holds is left out when the provider marked it removed, while a report the stream holds shows as the stream holds it), and the header shows
2206
+ the hit count. While an answer shows, the rows read their details by id in batches (`reports?ids=`)
2183
2207
  instead of by business date, and an answer goes into the per-report cache only, never into a business date's cached list.
2184
2208
  Each view leads its rows' inputs: it keeps its rendered rows and 24–40 rows beyond each rendered edge subscribed to the details and the marks, in
2185
2209
  chunks of 8 rows (32 on each side after a jump; **Highlights** below), so the rows a held key mounts find their details already loaded.
@@ -2315,7 +2339,8 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
2315
2339
  The sentences are built from what the row's leaves registered, each heading being the field's own label (the header's and the metadata's author are
2316
2340
  one `author`, a customer name is `customer`, every interviewer, attachment and comment key its section's heading), so they name only what the view
2317
2341
  draws; **Match indicator** below says where each view puts them.
2318
- - **Arrival**: the screen holds one marks store per shown answer (inside the client bundle), only while a view draws an answer that has words: without
2342
+ - **Arrival**: the screen holds one marks store per answered search (inside the client bundle; a new answer whose text, combine mode and words equal
2343
+ the shown one's keeps the store, its filters aside), only while a view draws an answer that has words: without
2319
2344
  `config.search`, before the first answer is drawn, for a filters-only answer (it has no words, so nothing is marked and no match indicator shows),
2320
2345
  after the release and for a view rendered on its own there is none, and the DOM is the plain one.
2321
2346
  A row hands its marks scope the marks of the unit that drew its card: the row reads the report's details and marks through its input source from
@@ -2383,9 +2408,9 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
2383
2408
  #### Match indicator
2384
2409
 
2385
2410
  There is no score: an answer can tell only how many of its keywords a report matched, and only an OR of two or more keywords varies it. While the
2386
- marks store exists, the side pane's author line (after the source badge) and the end of a DetailList row's header hold a slot in the contact map's
2387
- score colours (the page's text face at the package's 12 px floor with tabular figures, on a 16 px line;
2388
- slate-600 / dark slate-300, 7.56:1 on white and 12.02:1 on slate-900), as wide as the longest text the answer can show (an invisible placeholder), so
2411
+ marks store exists, the side pane's author line (after the source badge) and the end of a DetailList row's header hold a slot in the quiet text
2412
+ colour (the page's text face at the package's 12 px floor with tabular figures, on a 16 px line; 7.73:1 on the white card and overlay, 9.01:1
2413
+ on the dark card and 8.62:1 on the dark overlay's slate-900), as wide as the longest text the answer can show (an invisible placeholder), so
2389
2414
  nothing reflows when the marks arrive.
2390
2415
  The side pane's author line wraps, its parts 8 px apart along a line and its wrapped lines 4 px apart (`gap-x-2 gap-y-1`), the label kept whole on one
2391
2416
  line; only the gap spaces the parts (the source badge has no margin of its own), so a part that wraps starts at the line's start, and in a pane too
@@ -2461,7 +2486,8 @@ one definition of a draft (`draftLabelNames` in [DI ports](#di-ports)), so `conf
2461
2486
  unknown key.
2462
2487
  An optimistic report the viewer creates is a draft with no labels until the server's answer brings its labels (the draft label among them); an
2463
2488
  optimistic publish clears `isDraft` at once and keeps the labels until the answer, and a failed publish restores `isDraft`.
2464
- The package never navigates by itself: when the session is gone — the SSE stream ends with `auth-required`, or the ids stream answers 401 / 403 — it
2489
+ The package never navigates by itself: when the session is gone — the SSE stream ends with `auth-required`, or the ids stream, a `search` request,
2490
+ the filters' candidates (`search-facets`) or the marks (`search-marks`) answer 401 / 403 — it
2465
2491
  calls `onSessionExpired()` exactly once per occurrence and stops. A typical host reloads the route (`navigate(0)`) so its own loader redirects to the
2466
2492
  login page.
2467
2493
  `DailyReportConfigProvider` takes the same required `config`, and every component and hook of the package that reads the configuration needs it above:
@@ -2471,7 +2497,7 @@ configuration renders silently.
2471
2497
  `config.headingLevel` is the level of each report's heading: an integer from 2 to 5 (default 3), so that the section headings inside a report, one
2472
2498
  level lower, stay within `h6`. Pick the level that continues your page's outline (see
2473
2499
  [Document structure](#keyboard-focus-and-selection-list--detaillist)); any other value (`1`, `6`, `3.5`, `"3"`, `null`) throws a `RangeError` at
2474
- render (`[daily-report] config.headingLevel must be an integer from 2 to 5 …; got 6`).
2500
+ render (`[daily-report] config.headingLevel must be one of 2, 3, 4, 5 (the level of each report's heading); got 6`).
2475
2501
 
2476
2502
  `DailyReportPage` is layout-agnostic (header rendering via `renderHeader` slot, error boundaries/footer control managed by the app). For fine-grained
2477
2503
  usage, `DailyReportActionProvider`, `DailyReportResolvedContent`, `useDailyReportDetail`, etc. can be imported individually.
@@ -2481,7 +2507,9 @@ lead while a search answer is shown, the List's side pane and the hook — so on
2481
2507
  reader's subscription starts the load and its last reader's leaving stops it (an answer that arrives after that is dropped), and a cache write
2482
2508
  reaches the readers of its own report only, once per answer. The hook copies the record into state at default priority. On the first render after
2483
2509
  the id changes it already returns that id's starting state: its cached report (one definition, also used by the load: the report cache, expired
2484
- entries included, then the business date's cached list), `isLoading` only when nothing is cached, no error and no refetch yet — never the previous
2510
+ entries included, then the business date's cached list; when another reader already subscribes to the id, that first render returns its record, and
2511
+ otherwise it reads the report cache alone, so a report only the list holds arrives once the hook subscribes), `isLoading` only when nothing is
2512
+ cached, no error and no refetch yet — never the previous
2485
2513
  id's report, error or loading flags. An expired cached report is therefore drawn at once, never preceded by `null`, and refetched behind it. An id of
2486
2514
  0 or less fetches nothing and returns no report (0 also returns an `Invalid ID` error). A row and the side pane that show one report share its
2487
2515
  record, so they never run two loads of it at once, and readers waiting on one load of a report not shown yet receive its failure once each. A
@@ -2587,10 +2615,11 @@ package does nothing silently outside its provider.
2587
2615
 
2588
2616
  The provider takes no items: it derives its rows from the module-resident ids stream session (below), so establish the session first —
2589
2617
  `createDailyReportClientLoader` in the route, or `ensureDailyReportIdsStreamSession({ apiBasePath, userKey })` on mount, as `DailyReportPage` does —
2590
- and mount one provider per user (`key={userId}`), which also starts the new user with an empty editing store (**Editing** in
2618
+ and mount one provider per user (`key={userId ?? "anonymous"}`), which also starts the new user with an empty editing store (**Editing** in
2591
2619
  [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)).
2592
2620
  The provider builds its actions once — the functions, the ledgers of the optimistic state and the viewing user, in one value whose identity changes
2593
- only with the viewing user — and hands out its state apart: the rows (`items`, a persistent list read by its length, the report at a position (`at`)
2621
+ only when the `user` prop's object changes (keep `user` referentially stable) — and hands out its state apart: the rows (`items`, a persistent
2622
+ list read by its length, the report at a position (`at`)
2594
2623
  and iteration, not an array: **Rows follow the stream by its changes** below), the version that announces a change of the ledgers, and the SSE state
2595
2624
  (`isSseEnabled`, `sseStatus`).
2596
2625
  Each function calls the implementation of the last committed render, so a function kept from an earlier render does what the current one does.
@@ -2638,16 +2667,16 @@ the failure count.
2638
2667
  A line refused on every attempt ends a first scan in `failed` (a revalidation gives up with its error and keeps its data), never in `complete`, so the
2639
2668
  completion's pruning never runs after a refused line. A line whose `kind` is not `"chunk"` or `"error"`, a line without `kind` included, is skipped
2640
2669
  with a warning as a line of an unknown kind, so the wire can grow by new kinds.
2641
- The client splits the stream into lines as the chunks arrive, examining each decoded character once (a chunk of a long line does not re-read the part
2642
- of the line already received), and reads a final line without a newline at the end of the stream; a line cut short there is not valid JSON, so it ends
2643
- the attempt like any refused line, and the next attempt resumes from the last accepted line's cursor.
2670
+ The client splits the stream into lines as the chunks arrive, examining each byte once (a chunk of a long line does not re-read the part
2671
+ of the line already received, and a line is decoded once, when it is complete), and reads a final line without a newline at the end of the stream; a
2672
+ line cut short there is not valid JSON, so it ends the attempt like any refused line, and the next attempt resumes from the last accepted line's cursor.
2644
2673
  `createDailyReportClientLoader` warms an existing session (`primeDailyReportIdsStreamSession`) and then bootstraps
2645
2674
  (`bootstrapDailyReportIdsStreamSession`): when no session exists it is **created at loader time** so the stream fetch runs in parallel with hydration
2646
2675
  (the dominant cold-load optimization); when one exists, the obfuscated user id is reconciled (a different user destroys and recreates the session
2647
2676
  before render).
2648
2677
  If your app overrides `config.apiBasePath`, pass the same value to `createDailyReportClientLoader({ apiBasePath })` — forgetting it costs one
2649
2678
  wrong-path request on the very first load, self-healed by the page-mount `ensureDailyReportIdsStreamSession`.
2650
- `DailyReportPage` renders the list as soon as the first chunk arrives (on cache misses the server races a fast `TOP 200` first page against the cached
2679
+ `DailyReportPage` renders the list as soon as the first chunk arrives (on cache misses the server races a fast `TOP 30` first page against the cached
2651
2680
  full query, so first paint does not wait for the full id scan).
2652
2681
 
2653
2682
  - **An items publish re-renders only what it changes, behind the keys.** The package's own components read the session through narrow views
@@ -2807,7 +2836,7 @@ the resize handle and the side pane lie outside the list.
2807
2836
  | --- | --- | --- | --- |
2808
2837
  | `ArrowUp` / `ArrowDown` | Makes the previous / next report active and selected, and reveals it by the nearer edge | Makes the previous / next report active and selected, and aligns it to the top (the reading start) | Yes, also at the ends (no wrap-around) |
2809
2838
  | `Home` / `End` | First / last report, aligned to the top / bottom | First / last report, aligned to the top / bottom (to the top when the last row is taller than the viewport) | Yes |
2810
- | `PageDown` / `PageUp` | The farthest row that still fits in the viewport together with the active row, revealed by the nearer edge | The same; an active row taller than the viewport is first scrolled by min(overflow, ⌊0.875 × viewport⌋) without changing the selection | Yes |
2839
+ | `PageDown` / `PageUp` | The farthest row that still fits in the viewport together with the active row, revealed by the nearer edge; an active row that reaches past the viewport's bottom (`PageDown`) or top (`PageUp`) is first scrolled by min(overflow, ⌊0.875 × viewport⌋) without changing the selection | The same | Yes |
2811
2840
  | `Enter` / `Space` on the composite | Activates the active row like a press of its card's primary button: selects it (on a mobile layout it also opens the detail overlay) | Selects the active row without scrolling | Yes, also without an active row (the page does not scroll) |
2812
2841
  | `Enter` / `Space` on a control of the active row | That control's own action | The same | Native |
2813
2842
  | `Escape` on a control of the active row, a field or button of an in-row form included | Focus returns to the composite; the active row does not change (below: where `Escape` keeps its own meaning) | The same | Yes |
@@ -2847,8 +2876,8 @@ the resize handle and the side pane lie outside the list.
2847
2876
  accumulated. While a stream lasts, the desktop side pane keeps showing the report the stream started from (see **List side pane** below), and rows
2848
2877
  get their bodies only in the frames without a key, far rows behind the travel direction after the key is released (see **Rows mount in two
2849
2878
  passes** below).
2850
- - **Ignored keys**: `Alt` / `Ctrl` / `Meta` / `Shift` combinations other than `Ctrl+Home` / `Ctrl+End` alone (so `Ctrl+PageDown` and `Shift+End` keep
2851
- their browser meaning); an event whose default is already prevented; IME composition (`isComposing` or `keyCode` 229);
2879
+ - **Ignored keys**: `Alt` / `Ctrl` / `Meta` / `Shift` combinations other than `Ctrl+Home` / `Ctrl+End` alone and `Shift+Tab` (so `Ctrl+PageDown`
2880
+ and `Shift+End` keep their browser meaning); an event whose default is already prevented; IME composition (`isComposing` or `keyCode` 229);
2852
2881
  keys whose target is neither the view's composite nor a control inside one of its rows (the scroll bar, the side pane, the mobile overlay); the
2853
2882
  movement keys and the exits from an input region — a `form` (every control of the in-row edit form and of the comment form, their
2854
2883
  buttons included) or an editable element (`input`, `textarea`, `select`, `contenteditable` other than `"false"`, `audio[controls]`,
@@ -2929,8 +2958,9 @@ the resize handle and the side pane lie outside the list.
2929
2958
  control before or after the list.
2930
2959
  - **A key commits once and forces no layout.** A key move sets the active row, the selection, the scroll and the remembered selection in one
2931
2960
  synchronous commit (`flushSync`) inside its keydown: a destination outside the rendered window gets the alignment scroll (`scrollToIndex`) in that
2932
- same commit, which renders it, and `VirtualScroll` names it in the same render. No key calls `focus()` (DOM focus is already on the composite), and
2933
- no key reads the layout, so a key task forces no style recalculation and no layout of its own.
2961
+ same commit, which renders it, and `VirtualScroll` names it in the same render. A movement key on the composite calls no `focus()`
2962
+ (DOM focus is already there; one pressed from a row's control hands focus back to the composite once, and Escape and the `Ctrl+Home` / `Ctrl+End`
2963
+ exits move focus by design) and reads no layout (the exits and Tab's reveal do), so its task forces no style recalculation and no layout of its own.
2934
2964
  The commit renders the two row frames whose state changed (the old and the new active row, and the old and the new selection), `VirtualScroll` once
2935
2965
  (the active index and, when the key scrolled, the new window) through the view's `VirtualScroll` wrapper, and the host component that owns the
2936
2966
  controlled selection (the List's `selectedReportHubId`, the DetailList's `selectedItemId`); the view itself, report rows, card bodies and the row
@@ -3046,7 +3076,8 @@ the resize handle and the side pane lie outside the list.
3046
3076
  | The active report leaves the list (a delete, an SSE removal, a stale removal, a search answer that replaces the list) | The report now at its index, clamped to the new length; none in an empty list |
3047
3077
  | The selected report is deleted from the side pane or the mobile overlay | The report now at the deleted report's index |
3048
3078
 
3049
- The row under a press is recorded at the press (mouse and pen) and dropped by every `focusin` in the view and every handled key, so a press whose
3079
+ The row under a press is recorded at the press (mouse and pen) and dropped when the press ends (`pointerup` / `pointercancel` on the document), by a
3080
+ press outside the view, by every `focusin` in the view and by every handled key, so a press whose
3050
3081
  focus never entered the composite — a List card's primary button takes the click's focus — cannot decide a later entry: a far key after a card
3051
3082
  click moves from the clicked row and stays on the key's destination.
3052
3083
  A press in the List's row gutter moves no focus, so it changes nothing; a touch scroll fires no click and no focus, so it changes nothing either.
@@ -3400,7 +3431,8 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3400
3431
  error — rounds its content height up to q (`height: calc-size(auto, round(up, size, var(--aqdr-lattice-block)))`), so rem line boxes at a root other
3401
3432
  than 16 px (a 25 px line at a 20 px root) and a body of an odd multiple of 4 px at an odd eighth leave the next row on whole device pixels; at a 16
3402
3433
  px root and a quarter ratio the content is already on the lattice.
3403
- Engines without `calc-size()` drop the declaration and keep the content height (see the browser floor table).
3434
+ Engines without `calc-size()` accept the declaration when parsing it (it holds a `var()`), find it invalid at computed-value time and give
3435
+ `height` its initial `auto`, the content height (see the browser floor table).
3404
3436
  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
3405
3437
  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
3406
3438
  reveal.
@@ -3445,7 +3477,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3445
3477
  on it.
3446
3478
  - **A key press restyles only what paints the change**: every selector that depends on another element's state ends in the styled element's own class
3447
3479
  or attribute, and `:has()` sits only on the styled element itself. A featureless subject (`*:`, `group-*`) or an ancestor's `:has(:focus-visible)`
3448
- would make the browser restyle whole rows or the whole view on each key; `src/client/ui/tailwind-selector-scope.spec.ts` compiles the package's
3480
+ would make the browser restyle whole rows or the whole view on each key; `src/client/ui/tailwind-selector-scope.guard.spec.ts` compiles the package's
3449
3481
  classes and fails on either form.
3450
3482
  - **Every row is a relayout boundary**: the row frames of both views are `contain: size layout style` (one token), so a change inside one row — a held
3451
3483
  body rendered after the key's commit, a load that completes, the selection and focus indicators — lays out that row alone and restyles no other.
@@ -3521,7 +3553,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3521
3553
  `--primary`, `--background` or `--muted`.
3522
3554
  The switch track is slate-500 when off and blue-600 when on (dark: slate-400 / blue-400) under a white thumb (dark: slate-950). The selected tab is
3523
3555
  a white surface (dark: slate-950) with slate-900 text (dark: slate-100) and a straight 2 px blue-600 bar (dark: blue-400) along the straight part of
3524
- its bottom edge, the bar being the cue that does not depend on colour. Unselected labels are slate-600 (dark: slate-300).
3556
+ its bottom edge, the bar being the cue that does not depend on colour. Unselected labels are in the quiet text colour (below).
3525
3557
  The bar is an `::after` box at the tab's bottom edge, inset on each side by the tab's own corner radius (`--radius-lg`), so it never runs into the
3526
3558
  rounded corners: it is 2 px thick along its whole length at every device pixel ratio (a bottom border on a rounded box thins and rises along both
3527
3559
  corners), and it stays on the straight part for any host `--radius`. It is out of the flow and takes no space, so selecting a tab moves nothing and
@@ -3529,7 +3561,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3529
3561
  In forced colours the thumb is `ButtonText`, the track has a `ButtonText` border and the on track a `Highlight` fill, and the selected bar is
3530
3562
  `Highlight`, painted as declared (`forced-color-adjust: none` on the bar), so the state survives the colour override.
3531
3563
  The package's error messages — the status panel's load-error message and its badge in the host's header, and the side pane's report-load error —
3532
- take their colour from one token (red-700, dark red-400); the badge sits on the host's header, whose colour is the host's, so it has no pair below.
3564
+ take their colour from one token (red-800, dark red-300); the badge sits on the host's header, whose colour is the host's, so it has no pair below.
3533
3565
  `src/client/ui/ui-state-contrast.spec.ts` computes every pair below from the compiled CSS and the palette (the toolbar surface is slate-100 at 60 %
3534
3566
  over the slate-50 page, dark slate-900 at 60 % over slate-950; the card surface is white, dark slate-900 at 70 % over the page; the overlay panel is
3535
3567
  white, dark slate-900):
@@ -3543,12 +3575,13 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3543
3575
  | Selected tab bar / selected tab | 5.25 | 7.64 | 3 |
3544
3576
  | Selected tab bar / the side pane's tab track | 4.79 | 5.54 | 3 |
3545
3577
  | Selected tab bar / toolbar surface | 4.88 | 7.16 | 3 |
3546
- | Unselected label / the side pane's tab track | 6.90 | 9.83 | 4.5 |
3547
- | Unselected label / toolbar surface | 7.03 | 12.71 | 4.5 |
3548
- | Selected label / selected tab | 17.83 | 18.40 | 4.5 |
3549
- | Error text / card surface (the desktop side pane) | 6.42 | 6.45 | 4.5 |
3550
- | Error text / overlay panel (the side pane's content on a phone) | 6.42 | 6.17 | 4.5 |
3551
- | Error text / the load-error panel (the card surface over the page frame) | 6.42 | 6.45 | 4.5 |
3578
+ | Unselected label / the side pane's tab track | 7.06 | 7.07 | 7 |
3579
+ | Unselected label / toolbar surface | 7.19 | 9.13 | 7 |
3580
+ | Selected label / selected tab | 17.83 | 18.40 | 7 |
3581
+ | Empty view's message / page surface | 7.39 | 9.75 | 7 |
3582
+ | Error text / card surface (the desktop side pane) | 8.36 | 9.70 | 7 |
3583
+ | Error text / overlay panel (the side pane's content on a phone) | 8.36 | 9.29 | 7 |
3584
+ | Error text / the load-error panel (the card surface over the page frame) | 8.36 | 9.70 | 7 |
3552
3585
 
3553
3586
  - **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
3554
3587
  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
@@ -3576,13 +3609,13 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3576
3609
  so the content edge is 16 px from the visible edge and its corner concentric with the surface's) and `shadow-sm`.
3577
3610
  Two more surfaces have one token each, and no other place writes their classes: the placeholder panel (the side pane's selection prompt and its
3578
3611
  loading and empty tabs; a dashed 1 px border, the 12 px corner of an inner panel and the 12 px inset of every such panel (1 + 11), slate-50 / dark
3579
- slate-900 at 40 %, slate-600 / dark slate-300 text) and the reading panel (a report's content in the side pane and in the DetailList card;
3612
+ slate-900 at 40 %, text in the quiet text colour) and the reading panel (a report's content in the side pane and in the DetailList card;
3580
3613
  a 12 px corner and a concentric 12 px inset, slate-50 / dark slate-800 at 60 %, slate-700 / dark slate-200 text on 24 px lines;
3581
3614
  in the side pane it reserves three reading lines while its report loads, `min-h-24`, the inset twice and three line boxes of its own `p-3` and
3582
3615
  `leading-6`: 12 + 3 × 24 + 12 = 96 px at the 16 px root, and its skeleton is three 24 px line boxes, each centring a 16 px bar on the text line it
3583
3616
  stands for, so a report of up to three lines keeps the panel's height when it loads, at every root font size).
3584
3617
  The scroll bar's business-day bubble is the bordered pill (24 px tall, its text 12 px in = 1 px border + 11 px padding, the radius of its round
3585
- ends). `src/client/ui/border-style.spec.ts` checks these rules, a source scan included.
3618
+ ends). `src/client/ui/border-style.guard.spec.ts` checks these rules, a source scan included.
3586
3619
  - **Motion**: everything that moves animates only transform or opacity under `motion-safe` — the hover lift, the thumbnail fade, the loading pulse (on
3587
3620
  a thumbnail frame's skin, only while the frame may show its load activity, see [Attachment display](#attachment-display)), the switch's thumb, the
3588
3621
  entrance of the comment delete confirmation and of the error notice, the mobile overlay's slide and its backdrop.
@@ -3592,7 +3625,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3592
3625
  So only four run until their state ends, each only while something loads or reconnects and none behind a measured key hold: the spinning icon of the
3593
3626
  stream's retrying and revalidating badges, the List row's loading skeleton, the side pane's loading skeleton (its author and article lines) and the
3594
3627
  waiting pulse of a visible thumbnail frame's skin.
3595
- The progress pill's dot stands still: the count and the percentage beside it change on every publish. `src/client/ui/motion-policy.spec.ts` allows
3628
+ The progress pill's dot stands still: the count and the percentage beside it change on every publish. `src/client/ui/motion-policy.guard.spec.ts` allows
3596
3629
  exactly those places, each with its reason and its count of class positions, in the sources and in the shipped CSS (an `--animate-*` value with
3597
3630
  `infinite`, or `animation-iteration-count: infinite`), so a new animation that never ends fails until it is listed with a reason.
3598
3631
  The one exception is the mobile overlay layer: its right edge follows the host's pinned-chrome offset (`--aqdr-overlay-right`) over 0.5 s, also
@@ -3628,7 +3661,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3628
3661
  (the circle and every element inside it, overriding the visual's inline transitions; the bar's circle wrapper; the arrow buttons; the thumb; the
3629
3662
  scroll-to-edge buttons and their overlay), so the circle follows a drag without easing and the other parts change state at once.
3630
3663
  While the list scrolls (`data-daily-report-scrolling` on the view's scroll container) the hover lift and its shadow are held still.
3631
- `src/client/ui/motion-policy.spec.ts` checks the shipped CSS, and scans the sources so that no smooth scroll behaviour, no `animate()` call and no
3664
+ `src/client/ui/motion-policy.guard.spec.ts` checks the shipped CSS, and scans the sources so that no smooth scroll behaviour, no `animate()` call and no
3632
3665
  inertia option other than one chosen by `inertiaOptionsFor` is written outside `src/client/ui/motion.ts`,
3633
3666
  and so that only the files of an allowlist with a written reason ask for an animation frame: the header total's easing, which must read the
3634
3667
  reduced-motion preference, and two uses that move nothing (the held key's one move per frame and the attachment grid's track width handed to the
@@ -3661,7 +3694,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3661
3694
  px inset makes them concentric with their 8 px tabs (12 − 4 = 8).
3662
3695
  The only other exceptions are the visually hidden text (Tailwind's `sr-only`, and inside virtualized rows and for every attachment file name the
3663
3696
  package's transparent 1 px box, which clips nothing and contains its own layout) and the 48 px the search field keeps free at its end for the
3664
- searching indicator and the clear button inside its box. `src/client/ui/spacing-ladder.spec.ts` checks every class the package writes, that every
3697
+ searching indicator and the clear button inside its box. `src/client/ui/spacing-ladder.guard.spec.ts` checks every class the package writes, that every
3665
3698
  bordered box's border + padding is on the ladder or equals its corner, and that every 12 px-corner panel's inset is 12.
3666
3699
  - **Origin on the lattice**: the views' centred column (at most 72 rem, `max-w-6xl`, centred with `mx-auto`) starts at the centring offset rounded
3667
3700
  down to a multiple of the block quantum q (`ml-[max(0px,round(down,calc((100%-72rem)/2),var(--aqdr-lattice-block)))]`, on the column that carries q
@@ -3991,7 +4024,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3991
4024
  frame's own style never changes after mount — its classes are its box alone (`relative block w-full`: no variant, no paint, no animation) and its
3992
4025
  inline style is that frame style — and every state is drawn inside it, by the skin and the image, which read the frame's attributes.
3993
4026
  - **`data-thumbnail-state`** on the frame is `pending` (waiting or loading), `loaded` (the image fades in) or `unavailable` (an icon and
3994
- `labels.attachmentThumbnailUnavailable` inside the skin; the icon reaches 4.35:1 / 5.56:1 and the label 6.90:1 / 5.58:1 in the light / dark theme,
4027
+ `labels.attachmentThumbnailUnavailable` inside the skin; the icon reaches 4.35:1 / 5.56:1 and the label 7.06:1 / 7.07:1 in the light / dark theme,
3995
4028
  and the label breaks its lines by **Line breaks of wrapping labels** in [Selection and focus appearance](#selection-and-focus-appearance)). The
3996
4029
  loader's internal phases are not exposed.
3997
4030
  - **The waiting pulse runs only where the tile can be seen**: a `pending` frame's skin pulses (under `prefers-reduced-motion: no-preference`) only
@@ -4037,7 +4070,8 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
4037
4070
  unchanged, after authorization); the revalidation waits for no settle time and takes no slot, and at most one runs per mounted frame. If it fails,
4038
4071
  the page forgets that the URL loaded, counts one failed attempt, and the frame goes on with the retry wait.
4039
4072
  A URL whose attempts are all used up shows `unavailable` at once, without a request, for 60 s after its last failure (one rate-limit window); after
4040
- that, one fresh attempt starts. A URL between attempts keeps its failure count and its retry time across remounts.
4073
+ that, the next frame that starts on the URL forgets them and runs a new series of up to the same number of attempts. A URL between attempts keeps
4074
+ its failure count and its retry time across remounts.
4041
4075
  - **One writer of `src`**: React never renders the image's `src`. A layout effect projects it from the frame's state before paint on every commit (the
4042
4076
  URL while a load is granted and once it has loaded, none while waiting) and writes the attribute only when it differs, because rewriting the same
4043
4077
  value makes the browser drop the loaded image and fetch it again.
@@ -4077,7 +4111,7 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
4077
4111
  | `[role="treegrid"]` | Each view's composite: `VirtualScroll`'s content element, the view's one Tab stop and the element that holds DOM focus while the keys move; its `id` prefixes the row ids, `aria-activedescendant` names the active row's `id` (written by `@aiquants/virtualscroll`), `aria-rowcount` is the set's size, and `data-aqvs-active-item` is `visible` or `outside` while there is an active row |
4078
4112
  | `[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 |
4079
4113
  | `[data-daily-report-active]` | The active row's frame (the row the composite's `aria-activedescendant` names) |
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) |
4114
+ | `[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; the List's loading skeleton pulses inside it, the DetailList's does not pulse) |
4081
4115
  | `[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**) |
4082
4116
  | `[data-daily-report-card]` | List card surface (present while the card is shown) |
4083
4117
  | `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`) |
@@ -4099,7 +4133,8 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
4099
4133
  | `[data-daily-report-stream-status]` | The header's ids-stream badge container (persistent; empty while there is nothing to show) |
4100
4134
  | `[data-daily-report-status-panel]` | The status screen's panel (also `data-testid="daily-report-status-panel"`), the last of the page's focus successors |
4101
4135
  | `[data-daily-report-view-tabs]` | The view-mode tab list (its selected tab is the focus successor of a list without rows) |
4102
- | `data-testid` | `daily-report-root`, `daily-report-list`, `daily-report-detail-list`, `daily-report-side-pane`, `daily-report-error-banner` (the error notice), `daily-report-status-panel` (the status screen's panel, `data-phase` `loading` / `failed`), `daily-report-load-error-retry`, `daily-report-stream-announcer` (the page's status region, never focusable), `daily-report-ids-stream-status` and `daily-report-ids-stream-retry` (the badge's content and its retry button), `daily-report-ids-stream-status-announcer` (the badge's own live regions, rendered only while it announces its phases), and the attachment ids above |
4136
+ | `[data-skeleton]` | A loading placeholder: the List's skeleton card (the surface of a List row whose report is still loading) and the List side pane's placeholder lines (the DetailList's skeleton carries none) |
4137
+ | `data-testid` | `daily-report-root`, `daily-report-list`, `daily-report-detail-list`, `daily-report-panel-group` (the desktop List's panel group: the list and the side pane), `daily-report-side-pane`, `daily-report-error-banner` (the error notice), `daily-report-status-panel` (the status screen's panel, `data-phase` `loading` / `failed`), `daily-report-load-error-retry`, `daily-report-stream-announcer` (the page's status region, never focusable), `daily-report-ids-stream-status` and `daily-report-ids-stream-retry` (the badge's content and its retry button), `daily-report-ids-stream-status-announcer` (the badge's own live regions, rendered only while it announces its phases), and the attachment ids above |
4103
4138
 
4104
4139
  Only the package writes `data-daily-report-keyboard-focus`, `data-daily-report-scrolling`, `data-daily-report-active`, `data-daily-report-row-shell` and `data-daily-report-row-focus`. Hosts may select on them — in tests, or in CSS to adapt
4105
4140
  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
@@ -4305,6 +4340,19 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
4305
4340
  | `searchFiltersLoading` | search filter row: the status line while the candidates load | Loading filters... | 絞り込みの候補を読み込み中... |
4306
4341
  | `searchFiltersTooMany` | search filter row: the status line after a selection over the filter limits was refused | No more filter values can be selected | これ以上は絞り込みの値を選べません |
4307
4342
  | `searchFilterUnavailable` | search filter row: a cell whose host control threw while it rendered | This filter cannot be shown | この絞り込みを表示できません |
4343
+ | `searchInputLabel` | search box: the text field's name (`aria-label`) | Search reports | 日報を検索 |
4344
+ | `searchPlaceholder` | search box: the text field's placeholder | Search | 検索 |
4345
+ | `searchCombineAnd` | search box: the combine toggle's label while it asks for every keyword | AND | AND |
4346
+ | `searchCombineOr` | search box: the combine toggle's label while it asks for any keyword | OR | OR |
4347
+ | `searchCombineAndTitle` | search box: the combine toggle's tooltip (`title`) and description in the AND mode | Show the reports that contain every keyword | すべてのキーワードを含む日報を出す |
4348
+ | `searchCombineOrTitle` | search box: the same in the OR mode | Show the reports that contain any keyword | どれかのキーワードを含む日報を出す |
4349
+ | `searchClear` | search box: the clear button's name (`aria-label`) and tooltip | Clear the search | 検索を消す |
4350
+ | `searching` | search box: the status while a search request runs | Searching... | 検索中... |
4351
+ | `searchNoHits` | search box: the status of an answer without a hit | No report matches | 当たる日報はありません |
4352
+ | `searchSyntaxHelp` | search box: the text field's description and tooltip (`title`) | Separate keywords with spaces. Words in double quotes form one keyword that needs every word. A word in Latin letters also finds its kana reading (denpun finds でんぷん); text found through a reading is underlined with dots. Enter searches, Esc clears. | キーワードは空白で区切ります。二重引用符で囲んだ語は、すべての語を含む 1 つのキーワードになります。英字の語はローマ字の読みでも仮名を探します (denpun → でんぷん)。読みで当たった所は点線の下線で示します。Enter で検索、Esc で消去します。 |
4353
+ | `searchMatchOutsideView` | match indicator: the text of a row that matched in fields the view does not draw | Hidden match | 表示外で一致 |
4354
+ | `searchMatchOutsideViewDescription` | match indicator: that text's tooltip (`title`) and its sentence in the row's hidden match description | Matched in fields this view does not show, such as labels or details | この画面に出していない項目 (ラベル・明細など) で一致しました |
4355
+ | `searchReadingMarkDescription` | marked text: the description of a mark found only through a romaji reading | Matched by its romaji reading | ローマ字の読みで一致 |
4308
4356
  | `totalCount` | header annotation: the size of the views' set (the declared total while the ids stream delivers, the rows once it completes) | `(1234)` → Total: 1,234 | `(1234)` → 総件数: 1,234 件 |
4309
4357
  | `debounce` | header annotation | `(300)` → Debounce: 300 ms | `(300)` → デバウンス: 300 ms |
4310
4358
  | `streamProgressCount` | stream badge before the total is known | `(1234)` → Loading 1,234 | `(1234)` → 読み込み中 1,234 件 |
@@ -4322,6 +4370,11 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
4322
4370
  | `searchFieldMatched` | match sentences of a row (the DetailList row's and the side pane's hidden indicator sentences, the List card's hidden match description): a field heading the row's leaves marked with a typed word's mark (`field` is the heading's resolved label) | `("Author")` → Author matched | `("作成者")` → 作成者で一致 |
4323
4371
  | `searchFieldMatchedByReading` | the same, for a heading whose marks matched only through a romaji reading | `("Author")` → Author matched by its romaji reading | `("作成者")` → 作成者がローマ字の読みで一致 |
4324
4372
  | `searchFiltersToggle` | search box: the filter toggle, with the number of selected filter values (no number at 0; digit grouping) | `(0)` → Filters; `(3)` → Filters (3) | `(0)` → 絞り込み; `(3)` → 絞り込み (3) |
4373
+ | `searchHitCount` | header annotation: the number of the answer's hits, and the search box's status of an answer with hits (digit grouping) | `(1)` → 1 hit; `(1234)` → 1,234 hits | `(1234)` → 1,234 件ヒット |
4374
+ | `searchFailed` | search box: the status of a failed search (`failure` is `invalid` / `busy` / `unavailable` / `failed`) | `("busy")` → The server is busy. Please search again in a moment | `("busy")` → 混み合っています。少し待ってからもう一度検索してください |
4375
+ | `searchMatchedKeywords` | match indicator: how many of an OR answer's keywords the row matched | `(2, 3)` → Match 2/3 | `(2, 3)` → 一致 2/3 |
4376
+ | `searchMatchedKeywordsDescription` | match indicator: the hidden sentence of the same count | `(2, 3)` → Matches 2 of 3 keywords | `(2, 3)` → キーワード 3 個のうち 2 個に一致 |
4377
+ | `searchFiltersFailed` | search filter row: the status line when the candidates could not be loaded (`failure` is `busy` / `unavailable` / `failed`) | `("unavailable")` → Filter options are not available | `("unavailable")` → 絞り込みの候補を使えません |
4325
4378
 
4326
4379
  ### External Source Badge Configuration (`sourceTypeConfigs`)
4327
4380
 
@@ -4550,8 +4603,9 @@ Documenting a name here is no reason to export it, and the values that tune the
4550
4603
  `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_WORK_MODEL` (the render deadline `renderMs` and the
4551
4604
  decode-work and walk bounds, for a host's conformance checks) / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` /
4552
4605
  `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
4553
- - SSE and caching: `buildDailyReportSseStreamKey(streamKey)` (the Redis stream key the service writes and reads the SSE entries of, the base key
4554
- with the wire version appended, `<streamKey>:v4`, for a host that reads or writes the stream itself) / `DailyReportSseReader` /
4606
+ - SSE and caching: `buildDailyReportRedisKeys(redisKeyPrefix)` (`DailyReportRedisKeys`: the keys the service builds under the prefix, the SSE
4607
+ stream `sseStream` with the wire version, `<prefix>:sse-stream:v4`, and the epoch keys `idsEpoch`, `dateEpochPrefix` and `detailEpochPrefix`,
4608
+ for a host that reads or writes the stream or the epochs itself) / `DailyReportSseReader` /
4555
4609
  `readDailyReportSseStreamEntry(raw, logger)` (reads one Redis stream entry, `{ id, message }` as the client returns it, once: `{ entry, json }`,
4556
4610
  the frozen `StreamEntry` the shared reader hands every subscriber and the entry's parsed JSON value; for a host whose own reader keeps the same
4557
4611
  contract) /