@aiquants/daily-report 0.44.1 → 0.45.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 CHANGED
@@ -2,7 +2,34 @@
2
2
 
3
3
  All notable changes to `@aiquants/daily-report` are documented here.
4
4
 
5
- ## Unreleased
5
+ ## 0.45.0 (2026-10-16)
6
+
7
+ 版番号は 0.45.0 (minor) とする: ピアの `@aiquants/swipe-overlay` の下限を上げる (下の Breaking)。
8
+
9
+ ### Changed
10
+
11
+ - **`dailyReportShouldRevalidate` は `@aiquants/route-revalidation` で組む** (依存に加えた): 決め方は変わらない。パッケージ自身が結果を当てはめる操作の送信と、送信でない URL の変わらない再検証では
12
+ ローダーを走らせず、URL の変わる遷移 (ハッシュを外すものを含む) とほかの送信 (`clearCache` と操作の無い送信) は React Router の判断を保つ。型は `@aiquants/route-revalidation` の
13
+ `RouteShouldRevalidate` で、React Router の `ShouldRevalidateFunctionArgs` をそのまま受け取る (`ShouldRevalidateFunction` へ代入できる)。
14
+ - **Escape の IME の変換の見送りは `@aiquants/swipe-overlay` 1.2.6 の受け口に任せる**: 1.2.6 の受け口は IME の変換を取り消す Escape を見送り、
15
+ オーバーレイの要素の文書 (iframe の文書でも) の Escape で閉じる。モバイルのオーバーレイが自分で積んでいた Escape の登録とダイアログの keydown で閉じる分岐は外し、
16
+ コメントの削除の確認と誤りの知らせが積みへ載せる受け口は、渡された Escape で必ず閉じる (変換の確かめは受け口が持つ)。受け口は文書の捕獲段にあるので、
17
+ ホストが捕獲段で先に既定動作を止めた Escape でも、モバイルのオーバーレイ・削除の確認・知らせは閉じる (0.44.1 までは閉じなかった)。
18
+
19
+ ### Breaking
20
+
21
+ - **ピアの `@aiquants/swipe-overlay` は 1.2.6 以上**: モバイルのオーバーレイ・コメントの削除の確認・誤りの知らせは、IME の変換を取り消す Escape の見送りを
22
+ swipe-overlay 1.2.6 の受け口に任せる (それより古い版では、変換中の Escape でも閉じる)。
23
+ Migration: `@aiquants/swipe-overlay` を 1.2.6 以上へ上げる。
24
+
25
+ ### Documentation
26
+
27
+ - **README を今の振る舞いに合わせた** (コードは変えない): `clearCache` が消すのは自分のプロセスのキャッシュと ID の一覧の epoch だけであること、
28
+ 添付の原本と縮小画像の 503 の文面と中断の答えの時機、キー操作の注記 (Page キーの見せる段は両方のビュー・`Shift+Tab`・押しの記録を捨てる時機)、
29
+ 文字と面の色のコントラストの値 (7:1)、ラベルの表に無かった検索の 18 キー、検索の索引の表の `indexed_at` に既定値が要ること、
30
+ ID の一覧の速い先頭ページの行数 (30)、テストの口 (`[data-skeleton]`・`daily-report-panel-group`) などを直した。
31
+
32
+ ## 0.44.1 (2026-10-16)
6
33
 
7
34
  版番号は 0.44.1 (patch) とする: クラスを 1 つ足すだけで、公開の型と振る舞いは変えない。
8
35
 
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
@@ -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
 
@@ -769,7 +775,8 @@ createDailyReportServer({
769
775
  readViewerKey: async (request, { reason }) => (reason === "initial" ? yourAuth.viewerIdOf(request) : yourSession.viewerIdOf(request)),
770
776
  resolve: async (request, { reason }) => {
771
777
  const grants = await yourAuthz.grantsFor(request, { skipCache: reason !== "initial" })
772
- return grants.canReadLegacy ? ["legacy"] : []
778
+ // `Internal` is the package's own reports: every author needs its read, or creating a report answers 403.
779
+ return grants.canReadLegacy ? ["Internal", "legacy"] : ["Internal"]
773
780
  },
774
781
  }),
775
782
  })
@@ -831,9 +838,9 @@ reason is the audit trail, so `grep -rn "unrestricted:"` in the host lists every
831
838
  - **How it reaches the service**: the resolved set is wrapped in a `DailyReportViewerScope` and passed as the **first** argument of every gated
832
839
  service method, the create and the three author mutations included.
833
840
  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.
841
+ type errors — the only ways to obtain one are `DailyReportViewerScope.restrictTo(tokens)` — which accepts `readonly string[]` or the per-dimension
842
+ `{ read, comment? }` (`DailyReportVisibilityDimensions`) only, so it cannot be handed a nullish value and quietly widen — and
843
+ `DailyReportViewerScope.unrestricted(auditReason)`, which the handlers call only for the port's explicit opt-out, with its reason.
837
844
  Because a bare `null` is not spellable as a visibility value, `grep -rn "DailyReportViewerScope.unrestricted("` enumerates every unfiltered read in
838
845
  a codebase, each carrying a written reason.
839
846
  - **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 +981,7 @@ answers 404 (it never falls back to the original).
974
981
  | `attachments.read` | required in the block | Reads the original bytes (`DailyReportReadAttachment`, **Read port** below). |
975
982
  | `attachments.maxBytes` | 32 MiB | Upper bound for an original, for every delivery. Keep it below the read port's wire limit. Integer ≥ 1. |
976
983
  | `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. |
984
+ | `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
985
  | `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
986
  | `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
987
  | `attachments.thumbnails.concurrency` | 2 | Simultaneous thumbnail generations (read + render), with a wait queue. Integer ≥ 1. |
@@ -1060,7 +1067,7 @@ type DailyReportReadAttachment = (
1060
1067
  | `invalid_path` | A scheme or a path the port does not accept | 404 | 404 |
1061
1068
  | `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
1069
  | `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` |
1070
+ | `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
1071
  | `deadline` | A storage call passed the port's own deadline (a per-call deadline, a stalled stream) | 504 | 504 |
1065
1072
 
1066
1073
  `busy`, `deadline` and `unavailable` are transient: a thumbnail generation concludes nothing from them, caches nothing and marks nothing missing.
@@ -1107,7 +1114,8 @@ type DailyReportReadAttachment = (
1107
1114
  - 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
1115
  (`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
1116
  `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
1117
+ The original download logs `503 attachment=<id> viewer=<id> reason=aborted` and answers `Attachment request aborted` without `Retry-After`; the
1118
+ thumbnail generation also caches nothing and does not render. No
1111
1119
  disconnected client receives either 503. An original request that is already aborted when it arrives gets the same 503 without reading.
1112
1120
  - 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
1121
  the object is not marked missing.
@@ -1252,8 +1260,9 @@ type DailyReportAttachmentThumbnailRenderer = {
1252
1260
  A render still running at its deadline is answered with 502 (`reason=render_timeout`) without waiting for it. A renderer that cannot stop
1253
1261
  (in-process libvips) is still valid: the response is bounded by the deadline, but the generation slot stays held until the render settles
1254
1262
  (`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
1263
+ 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
1264
+ every request waiting for that content has gone (the abort reaches only the renderer, through `signal`), when the render settles or the deadline
1265
+ passes, whichever comes first (503 `reason=aborted`, which no client receives); when the render then settles with a
1257
1266
  thumbnail that passes the output checks below, or with `unsupported`, and the generation's read matched the row's size (**verified**, see **Read
1258
1267
  port** above), the outcome is cached before the slot is released.
1259
1268
  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 +1604,7 @@ Both deliveries log at fixed levels, with fixed line formats:
1595
1604
  | --- | --- |
1596
1605
  | `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
1606
  | `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 |
1607
+ | `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
1608
 
1600
1609
  `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
1610
 
@@ -1637,8 +1646,10 @@ and without the retry the victim would answer 500 and lose what it wrote.
1637
1646
  connections). `createDailyReportServer` creates it; `createDailyReportHandlers` takes the reader as `sseReader` typed by the port
1638
1647
  `DailyReportSseReaderPort` — `ready()` and `subscribe(onEntry, onError)`, with the contract under **`connected` frame** below — which is the type a
1639
1648
  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
1649
+ The reader reads each live entry once per process and hands every subscriber the same value (`DailyReportSseStreamEntry`:
1650
+ `{ id, message }`, frozen one level deep — the entry and its `message` object; an entry whose `message` is `null` is not frozen, and `parsed` and
1651
+ `published` are not frozen either): `message` is the entry's `data` field parsed as JSON and validated by the stream form
1652
+ `dailyReportSseStreamMessageSchema` (the
1642
1653
  only form that declares the server-only fields: the six types that may be addressed — status-update, comment-add, comment-delete, report-create,
1643
1654
  report-update and report-publish — carry the recipient's internal id `recipientRawUserId`, and the three that embed a report also the writer's,
1644
1655
  `actorRawUserId`) — the validated message (`parsed`) and the JSON value as published (`published`, which only the recipient filter reads) — or
@@ -1923,8 +1934,11 @@ source types. The filters' candidates (`search-facets`, under **Requests**) are
1923
1934
  #### The index
1924
1935
 
1925
1936
  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
1937
+ version, `row_version` and the two filter values) and `DailyReportSearchComment` (one row per searchable comment, with its author id: a comment whose
1938
+ normalized text is empty gets no row, and the ordinals count only the kept comments), their normalized text
1927
1939
  `nvarchar(max)` in `Latin1_General_100_BIN2` so that `LIKE` compares it as it is, and both deleted with their report (`ON DELETE CASCADE`).
1940
+ 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
1941
+ the host's table must give it a default (such as `DEFAULT (sysutcdatetime())`).
1928
1942
  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
1943
  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
1944
  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 +1965,8 @@ the comment paths run in a transaction even without the `search` block, which a
1951
1965
 
1952
1966
  Writes outside the package (a sync, an import) leave the index stale until
1953
1967
  `service.reconcileSearchIndex({ batchSize, hubIds?, signal?, onProgress? })` runs.
1968
+ An aborted `signal` stops it between batches by rejecting with the signal's reason, so an aborted run returns no counters (`onProgress` has had
1969
+ those of each finished batch); a run that ends returns the counters and the elapsed time.
1954
1970
  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
1971
  save, and rewrites only the reports whose digest or normalizer version differ.
1956
1972
  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 +2194,9 @@ edges; with the default labels the field starts and ends on the 4 px lattice.
2178
2194
  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
2195
  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
2196
  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=`)
2197
+ 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
2198
+ 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
2199
+ the hit count. While an answer shows, the rows read their details by id in batches (`reports?ids=`)
2183
2200
  instead of by business date, and an answer goes into the per-report cache only, never into a business date's cached list.
2184
2201
  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
2202
  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 +2332,8 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
2315
2332
  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
2333
  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
2334
  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
2335
+ - **Arrival**: the screen holds one marks store per answered search (inside the client bundle; a new answer whose text, combine mode and words equal
2336
+ the shown one's keeps the store, its filters aside), only while a view draws an answer that has words: without
2319
2337
  `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
2338
  after the release and for a view rendered on its own there is none, and the DOM is the plain one.
2321
2339
  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 +2401,9 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
2383
2401
  #### Match indicator
2384
2402
 
2385
2403
  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
2404
+ 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
2405
+ 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
2406
+ 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
2407
  nothing reflows when the marks arrive.
2390
2408
  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
2409
  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 +2479,8 @@ one definition of a draft (`draftLabelNames` in [DI ports](#di-ports)), so `conf
2461
2479
  unknown key.
2462
2480
  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
2481
  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
2482
+ 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,
2483
+ the filters' candidates (`search-facets`) or the marks (`search-marks`) answer 401 / 403 — it
2465
2484
  calls `onSessionExpired()` exactly once per occurrence and stops. A typical host reloads the route (`navigate(0)`) so its own loader redirects to the
2466
2485
  login page.
2467
2486
  `DailyReportConfigProvider` takes the same required `config`, and every component and hook of the package that reads the configuration needs it above:
@@ -2471,7 +2490,7 @@ configuration renders silently.
2471
2490
  `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
2491
  level lower, stay within `h6`. Pick the level that continues your page's outline (see
2473
2492
  [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`).
2493
+ render (`[daily-report] config.headingLevel must be one of 2, 3, 4, 5 (the level of each report's heading); got 6`).
2475
2494
 
2476
2495
  `DailyReportPage` is layout-agnostic (header rendering via `renderHeader` slot, error boundaries/footer control managed by the app). For fine-grained
2477
2496
  usage, `DailyReportActionProvider`, `DailyReportResolvedContent`, `useDailyReportDetail`, etc. can be imported individually.
@@ -2481,7 +2500,9 @@ lead while a search answer is shown, the List's side pane and the hook — so on
2481
2500
  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
2501
  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
2502
  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
2503
+ entries included, then the business date's cached list; when another reader already subscribes to the id, that first render returns its record, and
2504
+ otherwise it reads the report cache alone, so a report only the list holds arrives once the hook subscribes), `isLoading` only when nothing is
2505
+ cached, no error and no refetch yet — never the previous
2485
2506
  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
2507
  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
2508
  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 +2608,11 @@ package does nothing silently outside its provider.
2587
2608
 
2588
2609
  The provider takes no items: it derives its rows from the module-resident ids stream session (below), so establish the session first —
2589
2610
  `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
2611
+ and mount one provider per user (`key={userId ?? "anonymous"}`), which also starts the new user with an empty editing store (**Editing** in
2591
2612
  [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)).
2592
2613
  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`)
2614
+ only when the `user` prop's object changes (keep `user` referentially stable) — and hands out its state apart: the rows (`items`, a persistent
2615
+ list read by its length, the report at a position (`at`)
2594
2616
  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
2617
  (`isSseEnabled`, `sseStatus`).
2596
2618
  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 +2660,16 @@ the failure count.
2638
2660
  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
2661
  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
2662
  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.
2663
+ 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
2664
+ 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
2665
+ 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
2666
  `createDailyReportClientLoader` warms an existing session (`primeDailyReportIdsStreamSession`) and then bootstraps
2645
2667
  (`bootstrapDailyReportIdsStreamSession`): when no session exists it is **created at loader time** so the stream fetch runs in parallel with hydration
2646
2668
  (the dominant cold-load optimization); when one exists, the obfuscated user id is reconciled (a different user destroys and recreates the session
2647
2669
  before render).
2648
2670
  If your app overrides `config.apiBasePath`, pass the same value to `createDailyReportClientLoader({ apiBasePath })` — forgetting it costs one
2649
2671
  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
2672
+ `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
2673
  full query, so first paint does not wait for the full id scan).
2652
2674
 
2653
2675
  - **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 +2829,7 @@ the resize handle and the side pane lie outside the list.
2807
2829
  | --- | --- | --- | --- |
2808
2830
  | `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
2831
  | `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 |
2832
+ | `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
2833
  | `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
2834
  | `Enter` / `Space` on a control of the active row | That control's own action | The same | Native |
2813
2835
  | `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 +2869,8 @@ the resize handle and the side pane lie outside the list.
2847
2869
  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
2870
  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
2871
  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);
2872
+ - **Ignored keys**: `Alt` / `Ctrl` / `Meta` / `Shift` combinations other than `Ctrl+Home` / `Ctrl+End` alone and `Shift+Tab` (so `Ctrl+PageDown`
2873
+ and `Shift+End` keep their browser meaning); an event whose default is already prevented; IME composition (`isComposing` or `keyCode` 229);
2852
2874
  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
2875
  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
2876
  buttons included) or an editable element (`input`, `textarea`, `select`, `contenteditable` other than `"false"`, `audio[controls]`,
@@ -2929,8 +2951,9 @@ the resize handle and the side pane lie outside the list.
2929
2951
  control before or after the list.
2930
2952
  - **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
2953
  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.
2954
+ same commit, which renders it, and `VirtualScroll` names it in the same render. A movement key on the composite calls no `focus()`
2955
+ (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`
2956
+ 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
2957
  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
2958
  (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
2959
  controlled selection (the List's `selectedReportHubId`, the DetailList's `selectedItemId`); the view itself, report rows, card bodies and the row
@@ -3046,7 +3069,8 @@ the resize handle and the side pane lie outside the list.
3046
3069
  | 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
3070
  | The selected report is deleted from the side pane or the mobile overlay | The report now at the deleted report's index |
3048
3071
 
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
3072
+ 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
3073
+ press outside the view, by every `focusin` in the view and by every handled key, so a press whose
3050
3074
  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
3075
  click moves from the clicked row and stays on the key's destination.
3052
3076
  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 +3424,8 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3400
3424
  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
3425
  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
3426
  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).
3427
+ Engines without `calc-size()` accept the declaration when parsing it (it holds a `var()`), find it invalid at computed-value time and give
3428
+ `height` its initial `auto`, the content height (see the browser floor table).
3404
3429
  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
3430
  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
3431
  reveal.
@@ -3445,7 +3470,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3445
3470
  on it.
3446
3471
  - **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
3472
  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
3473
+ 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
3474
  classes and fails on either form.
3450
3475
  - **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
3476
  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 +3546,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3521
3546
  `--primary`, `--background` or `--muted`.
3522
3547
  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
3548
  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).
3549
+ its bottom edge, the bar being the cue that does not depend on colour. Unselected labels are in the quiet text colour (below).
3525
3550
  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
3551
  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
3552
  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 +3554,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3529
3554
  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
3555
  `Highlight`, painted as declared (`forced-color-adjust: none` on the bar), so the state survives the colour override.
3531
3556
  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.
3557
+ 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
3558
  `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
3559
  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
3560
  white, dark slate-900):
@@ -3543,12 +3568,13 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3543
3568
  | Selected tab bar / selected tab | 5.25 | 7.64 | 3 |
3544
3569
  | Selected tab bar / the side pane's tab track | 4.79 | 5.54 | 3 |
3545
3570
  | 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 |
3571
+ | Unselected label / the side pane's tab track | 7.06 | 7.07 | 7 |
3572
+ | Unselected label / toolbar surface | 7.19 | 9.13 | 7 |
3573
+ | Selected label / selected tab | 17.83 | 18.40 | 7 |
3574
+ | Empty view's message / page surface | 7.39 | 9.75 | 7 |
3575
+ | Error text / card surface (the desktop side pane) | 8.36 | 9.70 | 7 |
3576
+ | Error text / overlay panel (the side pane's content on a phone) | 8.36 | 9.29 | 7 |
3577
+ | Error text / the load-error panel (the card surface over the page frame) | 8.36 | 9.70 | 7 |
3552
3578
 
3553
3579
  - **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
3580
  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 +3602,13 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3576
3602
  so the content edge is 16 px from the visible edge and its corner concentric with the surface's) and `shadow-sm`.
3577
3603
  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
3604
  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;
3605
+ 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
3606
  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
3607
  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
3608
  `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
3609
  stands for, so a report of up to three lines keeps the panel's height when it loads, at every root font size).
3584
3610
  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.
3611
+ ends). `src/client/ui/border-style.guard.spec.ts` checks these rules, a source scan included.
3586
3612
  - **Motion**: everything that moves animates only transform or opacity under `motion-safe` — the hover lift, the thumbnail fade, the loading pulse (on
3587
3613
  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
3614
  entrance of the comment delete confirmation and of the error notice, the mobile overlay's slide and its backdrop.
@@ -3592,7 +3618,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3592
3618
  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
3619
  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
3620
  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
3621
+ 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
3622
  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
3623
  `infinite`, or `animation-iteration-count: infinite`), so a new animation that never ends fails until it is listed with a reason.
3598
3624
  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 +3654,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3628
3654
  (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
3655
  scroll-to-edge buttons and their overlay), so the circle follows a drag without easing and the other parts change state at once.
3630
3656
  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
3657
+ `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
3658
  inertia option other than one chosen by `inertiaOptionsFor` is written outside `src/client/ui/motion.ts`,
3633
3659
  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
3660
  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 +3687,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3661
3687
  px inset makes them concentric with their 8 px tabs (12 − 4 = 8).
3662
3688
  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
3689
  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
3690
+ 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
3691
  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
3692
  - **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
3693
  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 +4017,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3991
4017
  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
4018
  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
4019
  - **`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,
4020
+ `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
4021
  and the label breaks its lines by **Line breaks of wrapping labels** in [Selection and focus appearance](#selection-and-focus-appearance)). The
3996
4022
  loader's internal phases are not exposed.
3997
4023
  - **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 +4063,8 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
4037
4063
  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
4064
  the page forgets that the URL loaded, counts one failed attempt, and the frame goes on with the retry wait.
4039
4065
  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.
4066
+ 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
4067
+ its failure count and its retry time across remounts.
4041
4068
  - **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
4069
  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
4070
  value makes the browser drop the loaded image and fetch it again.
@@ -4077,7 +4104,7 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
4077
4104
  | `[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
4105
  | `[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
4106
  | `[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) |
4107
+ | `[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
4108
  | `[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
4109
  | `[data-daily-report-card]` | List card surface (present while the card is shown) |
4083
4110
  | `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 +4126,8 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
4099
4126
  | `[data-daily-report-stream-status]` | The header's ids-stream badge container (persistent; empty while there is nothing to show) |
4100
4127
  | `[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
4128
  | `[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 |
4129
+ | `[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) |
4130
+ | `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
4131
 
4104
4132
  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
4133
  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 +4333,19 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
4305
4333
  | `searchFiltersLoading` | search filter row: the status line while the candidates load | Loading filters... | 絞り込みの候補を読み込み中... |
4306
4334
  | `searchFiltersTooMany` | search filter row: the status line after a selection over the filter limits was refused | No more filter values can be selected | これ以上は絞り込みの値を選べません |
4307
4335
  | `searchFilterUnavailable` | search filter row: a cell whose host control threw while it rendered | This filter cannot be shown | この絞り込みを表示できません |
4336
+ | `searchInputLabel` | search box: the text field's name (`aria-label`) | Search reports | 日報を検索 |
4337
+ | `searchPlaceholder` | search box: the text field's placeholder | Search | 検索 |
4338
+ | `searchCombineAnd` | search box: the combine toggle's label while it asks for every keyword | AND | AND |
4339
+ | `searchCombineOr` | search box: the combine toggle's label while it asks for any keyword | OR | OR |
4340
+ | `searchCombineAndTitle` | search box: the combine toggle's tooltip (`title`) and description in the AND mode | Show the reports that contain every keyword | すべてのキーワードを含む日報を出す |
4341
+ | `searchCombineOrTitle` | search box: the same in the OR mode | Show the reports that contain any keyword | どれかのキーワードを含む日報を出す |
4342
+ | `searchClear` | search box: the clear button's name (`aria-label`) and tooltip | Clear the search | 検索を消す |
4343
+ | `searching` | search box: the status while a search request runs | Searching... | 検索中... |
4344
+ | `searchNoHits` | search box: the status of an answer without a hit | No report matches | 当たる日報はありません |
4345
+ | `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 で消去します。 |
4346
+ | `searchMatchOutsideView` | match indicator: the text of a row that matched in fields the view does not draw | Hidden match | 表示外で一致 |
4347
+ | `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 | この画面に出していない項目 (ラベル・明細など) で一致しました |
4348
+ | `searchReadingMarkDescription` | marked text: the description of a mark found only through a romaji reading | Matched by its romaji reading | ローマ字の読みで一致 |
4308
4349
  | `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
4350
  | `debounce` | header annotation | `(300)` → Debounce: 300 ms | `(300)` → デバウンス: 300 ms |
4310
4351
  | `streamProgressCount` | stream badge before the total is known | `(1234)` → Loading 1,234 | `(1234)` → 読み込み中 1,234 件 |
@@ -4322,6 +4363,11 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
4322
4363
  | `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
4364
  | `searchFieldMatchedByReading` | the same, for a heading whose marks matched only through a romaji reading | `("Author")` → Author matched by its romaji reading | `("作成者")` → 作成者がローマ字の読みで一致 |
4324
4365
  | `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) |
4366
+ | `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 件ヒット |
4367
+ | `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")` → 混み合っています。少し待ってからもう一度検索してください |
4368
+ | `searchMatchedKeywords` | match indicator: how many of an OR answer's keywords the row matched | `(2, 3)` → Match 2/3 | `(2, 3)` → 一致 2/3 |
4369
+ | `searchMatchedKeywordsDescription` | match indicator: the hidden sentence of the same count | `(2, 3)` → Matches 2 of 3 keywords | `(2, 3)` → キーワード 3 個のうち 2 個に一致 |
4370
+ | `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
4371
 
4326
4372
  ### External Source Badge Configuration (`sourceTypeConfigs`)
4327
4373