@aiquants/daily-report 0.29.0 → 0.30.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,6 +2,49 @@
2
2
 
3
3
  All notable changes to `@aiquants/daily-report` are documented here.
4
4
 
5
+ ## 0.30.0 (2026-10-06)
6
+
7
+ 0.29.0 の続き: 本体をストリームで返す 2 つの経路のフレームワークのデータの要求をパッケージが断ること (ホストの経路は薄いマウントに戻る)、原本の本文の 1 切れずつの受け渡しと無通信の締め切りと閲覧者ごとのスロットの上限、間隔を単調な時計で測ること、読み取りの予算切れの 504、暦に無い営業日の 400、行とカードのフォーカスのアウトラインをキーボードのフォーカスの属性の下だけで描くこと、整数の装置の画素のホバーの浮き上がり、List のカードの位置の読み上げ、キーの移動の `tabindex` をフォーカスの受け渡しで書くこと、ハンドラーの設定と戻り値の型の公開。どれも README の該当の節が今の契約を記す。
8
+
9
+ ### Security
10
+
11
+ - **ストリームの経路へのフレームワークのデータの要求はパッケージが断る**: ハンドラー工場の要求の隔離の包みが、SSE の経路 (`sse.loader`) へのデータの要求はどれも、API の経路 (`api.loader`) へのものは ID 一覧の NDJSON の分 (`ids-stream.data`。カーソル付きと末尾の斜線の `ids-stream/_.data` も) だけを、GET も HEAD も、認証より前に 404 の JSON (`{"error":{"message":"Not Found"}}`、`Cache-Control: no-store`・`X-Content-Type-Options: nosniff`) で断る (経路の方針の `frameworkDataRefusal`: `SSE_ISOLATION_POLICY`・`API_LOADER_ISOLATION_POLICY`)。断った要求は認証・購読・タイマー・クエリのどれも始めず、拒否の記録にも書かない。JSON のエンドポイント (`report`・`business-date`) と action は今までどおりデータの要求を受ける。
12
+ 以前はホストの経路が断る契約だったので、断りを置き忘れたホストでは `<経路>.data` の要求 1 つが、終わらない SSE の本体か ID 一覧の NDJSON の全量をサーバーのメモリに溜めた。どのホストも版を上げれば守られ、2 つの経路は薄いマウント (`(args) => dailyReportServer.sse.loader(args)` と `(args) => dailyReportServer.api.loader(args)`) でよい。
13
+
14
+ ### Fixed
15
+
16
+ - **原本の本文は 1 切れずつ渡し、止まった読み手はスロットを握り続けない**: GET の原本の本文は、256 KiB の 1 切れの写しを、ホストの書き手が読むたびに 1 つずつ渡す (本文は書き手の先に何も積まない)。同時実行のスロットは原本を参照する間だけ保持し、最後の 1 切れを渡したとき・クライアントが本文を取り消したとき・書き手が 60 秒のあいだ次の 1 切れを求めなかったとき (無通信の締め切り。読みのたびに始め直す) に返す。締め切りでは本文を失敗で終え、原本の残りを捨てる。
17
+ 1 人の閲覧者が同時に保持できる原本のスロットは枠の半分の切り上げ (⌈`attachmentConcurrency` / 2⌉。既定の 4 なら 2) で、それを超える要求は空きがあっても過負荷の 503 (`reason=concurrency`、`Retry-After`) になる。プロセスが溜めうるのは、転送が進む間は「同時実行 × 原本の上限」に応答ごとの渡し済みの 1 切れを足した分、無通信の締め切りを過ぎて止まった応答には 1 切れだけ (README の Attachments の Heap estimate)。
18
+ 以前は原本全体を最初の読みで本文の待ち行列へ渡し、スロットは送り終えるか 120 秒のバックストップまで保持した。読み止めたクライアントはスロットを 120 秒握り、バックストップの後もバイト列は書き手の待ち行列に残ってヒープの見積りの外に溜まり、1 人の閲覧者が読み止めた転送を並べれば枠を全部握ってほかの閲覧者を 503 にできた。
19
+ - **間隔・締め切り・有効期限は単調な時計で測る**: サーバー層のレートのバケットの補充、SQL の結果のキャッシュの有効期限、縮小画像の段の締め切りを過ぎた決着と決着しない読み取りや描画の計時、ハンドラーとサービスの経過時間のログは、プロセスの単調な時計 (`performance.now()` の切り捨て) で測る。壁時計は意味が時刻のもの (記録の `since=`・一時的な ID) だけに使う。
20
+ 以前は壁時計で測ったので、運用の時刻合わせで時計が戻ると、戻った分だけレートのバケットが補充されず (429 が `Retry-After` より長く続く)、キャッシュが有効期限を過ぎても新しいまま残った。
21
+ - **暦に無い営業日は 400**: API の `business-date` は、形の合う日付でも暦に無い日 (`2026-13-40`・`2026-02-29`・`2026/04/31`・月 0) を、クエリより前に 400 (`Invalid business date`) で答える。以前は形だけを見てクエリへ渡し、データベースの変換の失敗が 500 (サーバーの失敗の行) になった。
22
+
23
+ ### Changed
24
+
25
+ - **読み取りの予算切れは 504**: 縮小画像の生成の読み取りの段の締め切り (`readMs`、30 秒) が先に来たときの答えは 504 (`reason=read_timeout`、文面は `Thumbnail storage timed out`) で、ポート自身の締め切り (`deadline`) と同じ状態になった。ストレージの読み取りの締め切りは、どちらが先に切れてもクライアントには同じ 504 で、ログの理由がどちらの締め切りかを残す (どちらも warn)。キャッシュにも実体消失にも書かないのは今までどおり。縮小の段の締め切りは 502 (`render_timeout`) のまま。以前は読み取りの予算切れも 502。
26
+ - **行とカードのフォーカスのアウトラインはキーボードのフォーカスの属性の下でだけ描く**: 行の枠の面と List のカードの面のアウトラインは、`:focus-visible` に加えて、ビューの根要素が `data-daily-report-keyboard-focus` を持つときだけ描く (`ROW_FRAME_STATE_CLASS_NAME`・`CARD_SURFACE_STATE_CLASS_NAME`。祖先は `:where()` で読むので、詳細度は (0,2,0) と (0,3,0) のまま)。アウトラインを描くなら属性があり、属性があればタップスクロールのサークルは隠れるので、2 つが一緒に出ることは作りから無い。操作部品 (ボタン・リンク・タブ・スイッチ・入力欄) の輪は今までどおり `:focus-visible` だけに依る。
27
+ 以前は Chromium がポインターの押下の後の `Shift` や `CapsLock` だけの押下でも `:focus-visible` とするので、ビューがポインターの操作と見なしてサークルを描いている間に、行のアウトラインも描かれた。
28
+ - **ホバーの浮き上がりは整数の装置の画素**: カードのホバーの浮き上がり L は、画素密度 dpr で 2 CSS px 以下の最大の整数の装置の画素 (⌊2·dpr⌋ / dpr): 1・1.5・2・3 では 2 px、1.25 では 1.6 px (2 装置画素)、1.75 では 12/7 px (3 装置画素)。値はカード自身のカスタムプロパティ `--aqdr-hover-lift` に置き、2 つの画素密度だけを `resolution` の媒体条件で上書きし、浮き上がりの `translate` がそれを読む。G = 8 ≥ 輪 2 + 隙間 2 + アウトライン 2 + L は保たれる。
29
+ 以前は常に 2 px で、1.25 と 1.75 ではホバーした面が装置の画素の半分に乗り (2.5 と 3.5 装置画素)、1 px の枠線・輪・アウトラインが隣の画素の行へにじんだ。
30
+ - **キーの移動の `tabindex` はフォーカスの受け渡しで書く**: 行カーソルは移動先の `tabindex="0"` を `focus()` の前ではなく、移動元のフォーカスが外れた所 (移動元の `focusout` の中。どの要素もフォーカスを持たない) で書く。Chromium の `focus()` は要素の間のフォーカスの移動のたびにスタイルを 2 回計算し直し (移動元が失った直後と移動先が得た直後)、書き込みはその 1 回目に相乗りする。
31
+ 以前は `focus()` の前に書いたので、ホストの CSS が `[tabindex]` を見ると `focus()` の入口でもう 1 回計算し直した (Chromium 148 で 8 回のキーの移動が 24 回。今は 16 回)。
32
+ - **DEV の操作箱のアイコンのボタンの的は 24 × 24**: 再読み込みとキャッシュの消去のボタン (`showDevControls`) は、見た目の 16 px のまま、上下左右に 4 px はみ出す擬似要素で 24 × 24 の的を持ち (WCAG 2.5.8)、8 px 離れて並ぶ。以前は 16 × 16 の的が 4 px 離れて並んだ。
33
+ - **開発者向け**: ハンドラーの工場のモジュールを分けた。`src/server/handlers.ts` は設定・ロガー・可視集合の解決・index・JSON のエンドポイントの表・action・要求の隔離の包みを持ち、SSE の配信は `src/server/sse-delivery.ts`、ID 一覧の NDJSON の配信は `src/server/ids-stream-delivery.ts`、本体をストリームで返す 2 つの答えが共有する HEAD の規則は `src/server/stream-body.ts`、時計は `src/server/monotonic-clock.ts` にある。添付のレートのバケット (`createRateLimitBucket`) は制限・閲覧者ごとの拒否の記録・429 を 1 つに持ち、原本と縮小画像の配信が使う (行の書式は変わらない)。
34
+ docstring の歯止め (`scripts/lib/ratchet-docstrings.mjs`) は、関数が返す関数と、オブジェクトのリテラルの関数の値のプロパティも数え (呼び出し・`new`・JSX の属性へ渡すリテラルは数えない)、基準 (`docstring-baseline.json`) はこの規則で 1 回記録し直した。React Router の実物の要求の処理を通す spec は、断った `.data` の要求ごとに loader が 1 回呼ばれて認証が 0 回であること (経路の照合が外れた 404 ではないこと) を確かめ、添付の行は同じ URL の `.data` の無い要求が同じ処理で答えることも確かめる。
35
+
36
+ ### Added
37
+
38
+ - client のラベルのキー `listRowPosition(position, total)`: List のカードの主ボタンの最初の説明になる、一覧の中の位置 (en "3 of 40"、ja 「全 40 件中 3 件目」。数は UI の言語の桁区切り)。フォーカスを受ける主ボタンは `aria-posinset` を持てないので、行の枠の位置を言葉で伝える。窓より前への挿入・削除で描き直すのは行の枠とこの文言だけで、カードの本体は描き直さない。固有のキーは 84 (全体は 95) で、上書きは関数でなければならない。
39
+ - server の型 `DailyReportHandlersConfig`・`DailyReportHandlers` (`createDailyReportHandlers` の設定と戻り値) と `DailyReportServer` (`createDailyReportServer` の戻り値)。ホストと例は `Parameters<>` や `ReturnType<>` で引き直さずに読み込む。型だけの公開なので、バンドルは増えない。
40
+
41
+ ### Breaking
42
+
43
+ - server の値 `DAILY_REPORT_IDS_STREAM_ENDPOINT` を公開しない: フレームワークのデータの要求の断りはパッケージの要求の隔離が決め (上の Security)、ホストの経路はエンドポイントの名前を比べない。
44
+ Migration: ホストの SSE の経路と API の経路から `.data` の断り (`<経路>.data` に 404 を投げる部品) と `DAILY_REPORT_IDS_STREAM_ENDPOINT` の読み込みを外し、`sse.loader` と `api.loader` をそのままマウントする。React Router の実物の要求の処理を通す経路の試験は、そのまま 404 を確かめられる。
45
+ - ピアの `@aiquants/virtualscroll` の下限を 3.11.1 へ上げた (`peer-floors.json`)。両ビューが 3.11.1 の変更に頼る所は無いが、この版の試験はすべて 3.11.1 の上で通したので、公開する範囲 (`^3.11.1`) と文書の下限をそれに合わせた。
46
+ Migration: `@aiquants/virtualscroll` を 3.11.1 以降へ上げる。
47
+
5
48
  ## 0.29.0 (2026-10-06)
6
49
 
7
50
  0.28.0 の続き: 添付の経路のフレームワークのデータの要求 (`<token>.data`) の拒否、本体をストリームで返す 2 つの答え (SSE と ID 一覧) の HEAD、読み取りポートの失敗の理由 `busy` と `deadline`、窓の終わりに数を書く拒否と 429 の記録、行のサイズの裏付けの無い `too_large` をキャッシュしない縮小画像の生成、ポインターで押したフォーカスをキーボードのフォーカスと見なさない判定、枠を越えない折り返す文言と側面ペインのパネルの最小の幅、DetailList のカードの日付の `<time>`。どれも README の該当の節が今の契約を記す。
package/README.md CHANGED
@@ -26,7 +26,7 @@ pnpm add @aiquants/daily-report @aiquants/virtualscroll @aiquants/swipe-overlay
26
26
  ```
27
27
 
28
28
  `@aiquants/virtualscroll` / `@aiquants/swipe-overlay` / `@aiquants/resize-panels` / `react` / `react-dom` / `react-router` / `drizzle-orm` / `zod` are **peer dependencies**. React must be **19 or later** (`react` / `react-dom` `>=19`): the rows register their keyboard focus targets with ref callbacks that return a cleanup.
29
- The peer floor of `@aiquants/virtualscroll` is **3.11.0**: install 3.11.0 or later.
29
+ The peer floor of `@aiquants/virtualscroll` is **3.11.1**: install 3.11.1 or later.
30
30
  Both views pass its scroll-bar option `enableArrowButtonTabStops: false`, since 3.10.0 its one signal that the host scrolls by keyboard itself, under which the scroll bar is pointer-only — hidden from assistive technology, out of the Tab order and never taking focus on a press (the views scroll by keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)) —; its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2;
31
31
  see [Selection and focus appearance](#selection-and-focus-appearance)); and both views re-record their scroll anchor from its `onScrollAdjust` notification and rely on its edge-keeping snap (3.9.0; see **Host selections and list changes** and [View height](#view-height-host-layout)). The label catalog reuses its 11 engine keys, the names of the scroll bar and its thumb among them (3.10.0; see [Localization](#localization-locale--labels)).
32
32
  `DailyReportRevealOptions`, the options of the test handle's `revealIndex`, is `@aiquants/virtualscroll`'s `scrollToIndex` options type, which takes `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 because its row memo follows the tree revision (3.11.0).
@@ -143,7 +143,7 @@ export const dailyReportServer = createDailyReportServer({
143
143
  })
144
144
  ```
145
145
 
146
- Route mounts (React Router v7, flexible file convention):
146
+ Route mounts (React Router v7, flexible file convention). Every resource route is a thin mount: the package's handlers decide the request isolation, React Router's single-fetch data requests and `HEAD` themselves, so a route only hands its arguments over:
147
147
 
148
148
  ```ts illustrative
149
149
  // daily_report._index/loader.server.ts
@@ -152,28 +152,23 @@ export const loader = async (args) => {
152
152
  return data(r.data, { headers: r.headers })
153
153
  }
154
154
  // daily_report.api.$endpoint/route.tsx
155
- export const loader = (args) => {
156
- // only the ids stream: the JSON endpoints answer single fetch as before (Streaming routes, below)
157
- if (args.params.endpoint === DAILY_REPORT_IDS_STREAM_ENDPOINT) refuseSingleFetchDataRequest(args.request)
158
- return dailyReportServer.api.loader(args)
159
- }
155
+ export const loader = (args) => dailyReportServer.api.loader(args)
160
156
  export const action = (args) => dailyReportServer.api.action(args)
161
157
  // sse.daily_report.$endpoint/route.tsx
162
- export const loader = (args) => {
163
- refuseSingleFetchDataRequest(args.request)
164
- return dailyReportServer.sse.loader(args)
165
- }
166
- // refuseSingleFetchDataRequest is the host's own guard: it throws a 404 when new URL(request.url).pathname ends in ".data"
158
+ export const loader = (args) => dailyReportServer.sse.loader(args)
159
+ // daily_report.api.attachment.$token/route.tsx (see Attachments)
160
+ export const loader = (args) => dailyReportServer.attachment.loader(args)
167
161
  ```
168
162
 
169
- **Streaming routes**: the SSE route and the API route's ids stream (`GET {apiBasePath}/ids-stream`; the endpoint's name is exported as `DAILY_REPORT_IDS_STREAM_ENDPOINT` from the server entry) answer with a body that streams until the client leaves.
163
+ **Streaming routes**: the SSE route and the API route's ids stream (`GET {apiBasePath}/ids-stream`) answer with a body that streams until the client leaves. The package keeps that body from starting, or from being held, for a request nobody reads as a stream, so the two routes need no guard of their own:
170
164
 
171
165
  - **`HEAD`**: both answer the status and headers a `GET` would get, and start no work for the body.
172
166
  Everything that decides the status runs as for `GET` — the request isolation, authentication, the internal user, the endpoint, the resume cursor (a malformed ids cursor is still 400, a malformed SSE cursor still the `resync-required` frame) and the viewer's visibility —
173
167
  but the SSE route subscribes to nothing and starts no heartbeat and no visibility refresh timer, and the ids stream runs neither its full query nor its first-page query (so a resumed `GET`, which waits for the full query, can still end in a 500 that a `HEAD` does not see: it answers 200).
174
168
  React Router runs a resource route's loader for `HEAD` and drops the body without reading or cancelling it, and an adapter need not abort the request's signal once the response is sent, so body work started for a `HEAD` would run until the process ends. A `GET` whose signal is already aborted starts no body work either.
175
- - **Single fetch**: React Router answers a single-fetch data request (`<path>.data`) by reading the loader's whole body before it answers, so a `.data` request to either stream would hold every event, or the whole NDJSON, in server memory until the client leaves. The host refuses those requests in the two routes before it calls the package (above): the SSE route every one, the API route only the ids stream's, whose name it compares with `DAILY_REPORT_IDS_STREAM_ENDPOINT` instead of a copied literal.
176
- The attachment route needs no such guard: the package refuses `<token>.data` itself ([Request isolation](#request-isolation)).
169
+ - **Single fetch**: React Router answers a single-fetch data request (`<path>.data`) by running the route's loader and reading its whole body before it answers, so a `.data` request to either stream would hold every event, or the whole NDJSON, in server memory until the client leaves.
170
+ The package's request isolation refuses them before authentication ([Request isolation](#request-isolation)): on the SSE route every one, and on the API route the ids stream's (`ids-stream.data`, with or without a cursor, and its trailing-slash form `ids-stream/_.data`), `GET` and `HEAD` alike, with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`) thrown the way a loader answers early.
171
+ A refused request authenticates nothing, subscribes to nothing, starts no timer and runs no query. The JSON endpoints (`report`, `business-date`) and the action keep serving single fetch, which React Router's own fetchers use.
177
172
  - **The API route's methods**: authentication comes first (401), then the endpoint — an unknown name is 404, an inherited name such as `toString` included — and then the method: one the endpoint does not answer is 405 with `Allow`, `GET` for the JSON endpoints (their ETag is computed from the body, so a `HEAD` would build the body only to discard it) and `GET, HEAD` for the ids stream.
178
173
 
179
174
  ### DI ports
@@ -200,8 +195,16 @@ The three helpers of `src/shared/config-errors.ts` (`configValueError`, `configK
200
195
  Every resource route the handlers serve — `api.loader`, `api.action`, `sse.loader` and `attachment.loader` — is wrapped once, where `createDailyReportHandlers` returns it, in the request isolation, which runs first: before authentication, any rate charge, authorization or service call (`isCrossSiteRequest(request, policy)` in `src/server/request-isolation.ts`). `index.loader`, the document route, is not wrapped.
201
196
  The check is an allow-list: it names what a route serves from another origin, so everything else — frames (`iframe`, `frame`), `fencedframe`, `object`, `embed`, a navigation without `Sec-Fetch-Dest`, and any destination a later specification adds — is refused by construction.
202
197
  Before that check the same wrapper asks the route's policy about React Router's single-fetch data requests (`isFrameworkDataRequest`: the URL's path ends in `.data`, the test React Router dispatches on; a percent-encoded `%2Edata` and a `.data` in the query are not one). React Router answers such a request by running the route's loader, reading the loader's whole body into memory and re-encoding it, and keeps none of the loader's headers but `Set-Cookie`.
203
- The attachment route refuses them (`frameworkDataRefusal` of its policy): a `<token>.data` request — `GET` or `HEAD`, inline, download or thumbnail, from the same origin or from another origin's navigation alike — is answered with the attachment route's 404 (`Attachment not found`, thrown the way a loader answers early), before authentication, any rate charge, authorization or storage read, and it writes no line in the refusal record below (it is not a cross-site refusal).
204
- The 404 carries no attachment content, which is why it is safe after React Router has replaced its headers; a caller of the loader itself still sees the attachment security headers. The API, the action and SSE serve such requests like any other (`NOT_NAVIGABLE`): the host guards its two streams (**Streaming routes** in [Server wiring](#server-wiring-di)).
198
+ The policy's `frameworkDataRefusal` receives the loader's arguments and returns the response the wrapper throws (the way a loader answers early), or `null` to serve the request like any other. A refused request reaches no authentication, rate charge, authorization or service call, and it writes no line in the refusal record below (it is not a cross-site refusal). Each route decides by what its body is:
199
+
200
+ | Route (policy) | Framework data requests |
201
+ | --- | --- |
202
+ | `attachment.loader` (`ATTACHMENT_ISOLATION_POLICY`) | Every `<token>.data` — `GET` or `HEAD`, inline, download or thumbnail, from the same origin or from another origin's navigation alike — is refused with the attachment route's 404 (`Attachment not found`). React Router would read the original into memory and drop every protective header; the 404 carries no attachment content, so it is safe after React Router has replaced its headers, and a caller of the loader itself still sees the attachment security headers |
203
+ | `sse.loader` (`SSE_ISOLATION_POLICY`) | Every one is refused with a 404 JSON (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`; `dataRequestRefusal`): the body never ends, and `EventSource` requests the route itself |
204
+ | `api.loader` (`API_LOADER_ISOLATION_POLICY`) | Refused with the same 404 when `params.endpoint` names the ids stream (the whole NDJSON would be held); served for the JSON endpoints |
205
+ | `api.action` (`NOT_NAVIGABLE`) | Served (React Router's own fetchers write through single fetch) |
206
+
207
+ All four policies also decide the navigations they serve from another origin and build their own 403 (`crossSiteRejection`), so the route, its refusals and their response builders cannot be paired wrongly.
205
208
 
206
209
  | Request | Answer |
207
210
  | --- | --- |
@@ -316,7 +319,7 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
316
319
  | `attachmentThumbnailRenderer` | service | not injected | `readAttachment`, `attachmentIdCodec` | Renders the preview (`DailyReportAttachmentThumbnailRenderer`: `{ id, render }`; the package's sharp implementation is `createSharpThumbnailRenderer`). Validated when the service is created. Without it `hasThumbnail` is always `false` and every thumbnail request answers 404 (it never falls back to the original). |
317
320
  | `attachmentMaxBytes` | service | 32 MiB | `readAttachment` | Upper bound for an original, for every delivery. Keep it below the read port's wire limit. Integer ≥ 1. |
318
321
  | `attachmentRateLimitPerMinute` | handlers | 60 | `readAttachment` | Original downloads per user per minute; a token is spent before authorization. Integer ≥ 1. |
319
- | `attachmentConcurrency` | handlers | 4 | `readAttachment` | Simultaneous original reads; the slot is held until the body is sent, and the request fails fast with 503 when none is free. Integer ≥ 1. |
322
+ | `attachmentConcurrency` | handlers | 4 | `readAttachment` | 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. |
320
323
  | `attachmentThumbnailRateLimitPerMinute` | handlers | 120 | `attachmentThumbnailRenderer` | 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. |
321
324
  | `attachmentThumbnailConcurrency` | handlers | 2 | `attachmentThumbnailRenderer` | Simultaneous thumbnail generations (read + render), with a wait queue. Integer ≥ 1. |
322
325
  | `attachmentThumbnailCacheBytes` | handlers | 8 MiB | `attachmentThumbnailRenderer` | Total size of the thumbnail cache. `0` disables caching. Integer ≥ 0. |
@@ -329,7 +332,10 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
329
332
  - **The six numeric settings are validated once, at creation.** Only an omitted key (`undefined`) takes the default. Any other value that is not a safe integer at or above the minimum — `null`, `NaN`, `1.5`, `Infinity`, `-1` or the string `"2"` — throws a `RangeError` that names the public key, for example `[daily-report] attachmentThumbnailCacheBytes must be an integer >= 0; got -1` or `[daily-report] attachmentConcurrency must be an integer >= 1; got "2"`.
330
333
  `createDailyReportService` checks `attachmentMaxBytes`; the handler factory checks the other five (`createDailyReportServer` runs both). The wiring check runs first, so a numeric key given without its port throws the `TypeError`.
331
334
  - **Handler tuning is per process.** Rate buckets, gates and the cache live in the handler factory's closure and are not shared across workers; the effective container-wide limit is the configured value times the number of workers. `createDailyReportServer` forwards the five tuning keys explicitly.
332
- - **Heap estimate per process**: `attachmentConcurrency × attachmentMaxBytes` for originals, plus `attachmentThumbnailConcurrency × (attachmentMaxBytes + the renderer's decode memory)` for thumbnails, plus `attachmentThumbnailCacheBytes`. The package's sharp renderer decodes only a source whose predicted working set is at most `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` (200,000,000 bytes, server entry), and the prediction is built to stay above the measured one: read that constant in your own estimate instead of restating it (see **The sharp renderer** below).
335
+ - **An original's body is handed over slice by slice.** A `GET` of an original sends copies of 256 KiB slices of the bytes, one per read of the host's writer (the body queues nothing ahead of the writer), and keeps its concurrency slot exactly as long as it references the original: the slot is released when the last slice has been handed over, when the client cancels the body, or when the writer has not asked for the next slice for 60 s.
336
+ That idle deadline restarts on every read, so it bounds the time to send one slice, not the transfer (a reader slower than about 35 kbit/s, or one that stopped reading, cannot keep a slot and the original); when it passes, the body ends with an error and the rest of the original is dropped. A `HEAD` of an original holds its slot only until its answer is built.
337
+ - **Heap estimate per process**: while transfers progress, originals hold at most `attachmentConcurrency × attachmentMaxBytes` plus the one slice each response has handed to the host's writer, and a response stalled past the idle deadline holds only that one slice (its slot and the original are released); one viewer holds at most ⌈`attachmentConcurrency` / 2⌉ of the slots, so with two slots or more a viewer who stops reading several downloads cannot take every slot from the others.
338
+ Thumbnails add `attachmentThumbnailConcurrency × (attachmentMaxBytes + the renderer's decode memory)`, plus `attachmentThumbnailCacheBytes`. The package's sharp renderer decodes only a source whose predicted working set is at most `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` (200,000,000 bytes, server entry), and the prediction is built to stay above the measured one: read that constant in your own estimate instead of restating it (see **The sharp renderer** below).
333
339
 
334
340
  **Read port**
335
341
 
@@ -373,7 +379,9 @@ type DailyReportReadAttachment = (
373
379
  - Once aborted, settle promptly with a typed failure (normally `unavailable`) instead of throwing. A failure that settles after the abort is the abort's doing, not a verdict about the object: report it as `unavailable`, never `not_found` (`not_found` is a verdict that the object is gone, which the package records as a missing object).
374
380
  - 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 (`reason=aborted`) and no missing- or present-object record, so an aborted `unavailable` is never logged as a storage outage (502) and a racing `not_found` never marks the object missing.
375
381
  The original download logs `503 attachment=<id> viewer=<id> reason=aborted`; the thumbnail generation also caches nothing and does not render. No disconnected client receives either 503. An original request that is already aborted when it arrives gets the same 503 without reading.
376
- - A thumbnail read still running at its deadline is answered with 502 (`reason=read_timeout`) without waiting for it; nothing is cached and the object is not marked missing. The generation slot stays held until the port actually settles (releasing it earlier would start the next read while this one still holds the original), and a settlement after the deadline is logged as `read_overrun ms=<elapsed>`.
382
+ - A thumbnail read still running at its deadline (`readMs`) is answered with 504 (`reason=read_timeout`) without waiting for it; nothing is cached and the object is not marked missing.
383
+ A storage read past a deadline is 504 whichever deadline passed first — the package's read stage or the port's own (`deadline`) — so the client sees the same status either way and the log line's reason names the deadline.
384
+ The generation slot stays held until the port actually settles (releasing it earlier would start the next read while this one still holds the original), and a settlement after the deadline is logged as `read_overrun ms=<elapsed>`.
377
385
  A read that has still not settled at twice its budget is logged once, at that moment, as `read_stuck ms=<elapsed>`: such a port keeps its slot for the life of the worker, and once every slot is held every thumbnail request waits `queueWaitMs` and answers 503. A port that honours the signal settles early and hands the slot to the next queued generation. Code that calls a `DailyReportReadAttachment` directly (tests, for example) must pass `signal`.
378
386
 
379
387
  Example — a read port over a host object store (the file is type-checked by `pnpm run typecheck:examples` and not shipped):
@@ -584,7 +592,7 @@ Example — the wiring (the ports are service keys; the tuning keys stay in the
584
592
  * 添付のどのキーも有効になる。結果の `attachment.loader` はホスト自身のバイト列配信ルート (`{apiBasePath}/attachment/{token}`) に
585
593
  * マウントし、サムネイルも同じルートを使う。
586
594
  */
587
- import { createDailyReportServer, type DailyReportIdCodec, type DailyReportServerConfig } from "@aiquants/daily-report/server"
595
+ import { createDailyReportServer, type DailyReportIdCodec, type DailyReportServer, type DailyReportServerConfig } from "@aiquants/daily-report/server"
588
596
  import { createObjectStoreReadAttachment, type ObjectStore, type ObjectStoreReadSettings } from "./read-attachment-port"
589
597
  import { thumbnailRenderer } from "./sharp-thumbnail-renderer"
590
598
 
@@ -606,10 +614,10 @@ export type AttachmentWiring = {
606
614
  * 添付配信とサムネイルを有効にした日報サーバーを作る処理。
607
615
  *
608
616
  * @param wiring Base configuration and the attachment dependencies. 基本設定と添付の依存。
609
- * @returns The server; mount its `attachment.loader` on the attachment route. サーバー (`attachment.loader` を添付のルートへマウントする)。
617
+ * @returns The server (`DailyReportServer`); mount its `attachment.loader` on the attachment route. サーバー (`DailyReportServer`。`attachment.loader` を添付のルートへマウントする)。
610
618
  * @throws {RangeError} When a numeric attachment setting in `base` is not an integer in range. `base` の添付の数値設定が範囲内の整数でないとき。
611
619
  */
612
- export const createDailyReportServerWithAttachments = ({ base, idCodec, storage }: AttachmentWiring): ReturnType<typeof createDailyReportServer> =>
620
+ export const createDailyReportServerWithAttachments = ({ base, idCodec, storage }: AttachmentWiring): DailyReportServer =>
613
621
  createDailyReportServer({
614
622
  ...base,
615
623
  attachmentIdCodec: idCodec,
@@ -643,13 +651,13 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
643
651
  **One decode per content key at a time**: a render that its generation no longer waits for — past its deadline, or after every requester left — keeps its slot until it settles, and while it runs, a new generation of the same identity does not queue for a slot: it first waits for that render to settle and its result to be recorded or dropped, then reads the cache, and only then queues at the gate.
644
652
  The wait for the still-running render and the wait for a slot share one `queueWaitMs` budget, and the generation's abort signal ends both; when the budget runs out first, the request is answered 503 `reason=queue` without a second read or decode. A generation that receives its slot also reads the cache again before it reads the original, so a request that waited at the gate behind a still-running render of the same content is answered from that render's result.
645
653
  The generation has its own abort signal, which fires only when every joined request has gone, and passes it to the gate wait and to both ports. An abandoned generation leaves the wait queue, and a port that honours the signal settles early and hands the slot to the next queued generation. A read that settles after the abort is discarded before its result is looked at (see **Read port** above) and nothing is rendered; a render that is running at the abort is kept as above.
646
- - **Time budget**: one budget bounds a cache miss from the generation gate onward — the wait for a slot, the read, the render and the transfer — so a request that has reached the gate leaves the server within the sum of those stages, and the client waits exactly that sum before counting a load as failed. Each port stage runs under its own deadline and answers 502 at the deadline even when the port ignores the signal; a deadline that passes after every requester has gone ends as 503 `aborted` instead, since nobody receives it.
647
- Two things are outside the budget. Authentication and authorization run before the gate and are bounded only by the host's own timeouts; their time is taken out of the client's sum as well. And the budget cannot end a port that ignores its signal: after the 502 such a port keeps its generation slot, and the server logs `read_stuck` / `render_stuck` once when it has not settled at twice its stage budget.
654
+ - **Time budget**: one budget bounds a cache miss from the generation gate onward — the wait for a slot, the read, the render and the transfer — so a request that has reached the gate leaves the server within the sum of those stages, and the client waits exactly that sum before counting a load as failed. Each port stage runs under its own deadline and answers at that deadline even when the port ignores the signal (the read with 504 `read_timeout`, the render with 502 `render_timeout`); a deadline that passes after every requester has gone ends as 503 `aborted` instead, since nobody receives it.
655
+ Two things are outside the budget. Authentication and authorization run before the gate and are bounded only by the host's own timeouts; their time is taken out of the client's sum as well. And the budget cannot end a port that ignores its signal: after the deadline's answer such a port keeps its generation slot, and the server logs `read_stuck` / `render_stuck` once when it has not settled at twice its stage budget.
648
656
 
649
657
  | Stage | Budget | At the deadline |
650
658
  | --- | --- | --- |
651
659
  | `queueWaitMs` — waiting for a generation slot | 30 s | 503 `reason=queue` (`Retry-After: 5`) |
652
- | `readMs` — reading the original | 30 s | 502 `reason=read_timeout` |
660
+ | `readMs` — reading the original | 30 s | 504 `reason=read_timeout` |
653
661
  | `renderMs` — decoding, resizing, encoding | 10 s | 502 `reason=render_timeout` |
654
662
  | `transferMarginMs` — sending the response (at most 1 MiB) | 5 s | (the client's margin) |
655
663
  | Client load timeout | 75 s (the sum) | the load counts as one failure |
@@ -657,7 +665,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
657
665
  - **Cache**: an LRU bounded by `attachmentThumbnailCacheBytes` and 1,024 entries (each entry weighs its body plus 256 bytes). A preview is stored as an owned copy sized exactly to its body, so a view into a larger buffer never keeps that buffer alive, and later writes to the port's buffer do not change the cached preview.
658
666
  It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch and the port's `unsupported`), including those a render delivers after its generation stopped waiting for it, past its deadline or after every requester left (see **Thumbnail renderer port** above).
659
667
  A `too_large` conclusion is cached only when the row's recorded size is over the limit, which eligibility already answers with the not-eligible 404 before any read; so the endpoint never caches a `too_large`: on a row whose known size is within the limit it is unverified, and on a row without a recorded size it is unrecorded (answered with the 404 with `Cache-Control: no-store`, and read again on the next view; see **Read port** above).
660
- It never stores unverified or unrecorded outcomes, `failed`, the 502 of a timeout, the 503 of an abort, storage errors and transient refusals (`not_found`, `unavailable`, `busy`, `deadline`), queue rejections or exceptions.
668
+ It never stores unverified or unrecorded outcomes, `failed`, the answer of a stage deadline (504 `read_timeout`, 502 `render_timeout`), the 503 of an abort, storage errors and transient refusals (`not_found`, `unavailable`, `busy`, `deadline`), queue rejections or exceptions.
661
669
  - **Presence records**: `not_found` sets the missing mark (`markAttachmentMissing`). A successful read does **not** call `markAttachmentPresent` (it would issue an unconditional UPDATE on every view, and `present` and `unknown` look the same on screen);
662
670
  the client never requests a thumbnail from a summary that already says `absent` (`hasThumbnail: false`). The endpoint itself does not check `state`, though, so a request from an older summary (for example the automatic retry right after a `not_found`) still reads and renders, and a successful read leaves the mark unchanged. Recovery is left to the original download. Cache hits and 304s record nothing.
663
671
 
@@ -670,9 +678,9 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
670
678
  | 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path`, and (before authentication) a React Router single-fetch data request (`<token>.data`) — one identical body for all |
671
679
  | 405 | Any method other than GET (`Allow: GET`) |
672
680
  | 429 | An empty generation bucket (`reason=rate_limit`) or revalidation bucket (`reason=revalidation_rate_limit`), or a revalidation that is not a matching 304 when no generation token is left after authorization (`reason=rate_limit`) (`Retry-After: 60`) |
673
- | 502 | Storage unreachable or a read ended early (`unavailable`), the port's `failed` (`render_failed`), or a stage deadline (`read_timeout`, `render_timeout`) |
681
+ | 502 | Storage unreachable or a read ended early (`unavailable`), the port's `failed` (`render_failed`), or the render stage's deadline (`render_timeout`) |
674
682
  | 503 | Wait queue full or wait timed out (`queue`), the storage busy (`busy`), or the generation abandoned by every waiting request (`aborted`; no one receives it) (`Retry-After: 5`); the message is `Thumbnail temporarily unavailable` for every cause |
675
- | 504 | A storage call past the read port's own deadline (`deadline`); never cached |
683
+ | 504 | A storage read past a deadline: the read port's own (`deadline`) or the read stage's `readMs` (`read_timeout`); never cached |
676
684
  | 500 | Unexpected exception, including one thrown by a port during generation (logged as `500 attachment=<id> viewer=<id> variant=thumbnail cache=miss reason=port_exception message=<msg>`, never cached), or a port contract violation (`port_contract`: a render result outside the contract, logged with `code=result` for a value that is not an object, `code=failure_reason` for a failure whose reason is neither `unsupported` nor `failed`, and for a read failure whose reason is outside `DailyReportAttachmentFailure`, or the output check that failed, `code=` `content_type`, `bytes`, `empty`, `too_large`, `signature` or `dimensions`; or a read over `maxBytes`, `code=over_max_bytes`), or a host route that passes no `token` parameter (a route misconfiguration, below) |
677
685
 
678
686
  Error responses are JSON (`{ "error": { "message": "..." } }`) with `Cache-Control: no-store` (a 404 or 405 without freshness information may otherwise be cached heuristically, `Set-Cookie` included), carry `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin` and `X-Frame-Options: SAMEORIGIN` like every attachment response (**Same-origin loads only** above), and forward `Set-Cookie` (except the isolation's 403, which comes before authentication).
@@ -687,7 +695,7 @@ The package never writes the file path itself; a `port_exception` line includes
687
695
  | Level | Outcomes |
688
696
  | --- | --- |
689
697
  | `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) |
690
- | `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 storage deadline's 504 (`deadline`), 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) |
698
+ | `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) |
691
699
  | `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` (`code=over_max_bytes`) 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 |
692
700
 
693
701
  `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.
@@ -751,7 +759,7 @@ The first load of a report that is not cached starts inside the hook's effect, w
751
759
 
752
760
  Mutation failures (save / publish / delete / comment) are surfaced through an error seam: inject `config.onError(info: DailyReportErrorInfo)` to route them into your own toast/notification system, or omit it to use the package's built-in `role="alert"` banner (auto-dismiss + manual close). Failures on a continuation that resolves after a no-reload user switch are suppressed. If you compose `DailyReportActionProvider` yourself instead of using `DailyReportPage`, wrap it in `DailyReportErrorProvider` (both are exported from `@aiquants/daily-report/client`) so `onError` / the banner work.
753
761
 
754
- The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-stream`, the endpoint `DAILY_REPORT_IDS_STREAM_ENDPOINT` of the server entry; resilient client with cursor resume + exponential backoff) supplies it.
762
+ The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-stream`, the API route's ids stream; resilient client with cursor resume + exponential backoff) supplies it.
755
763
  The client splits the stream into lines as the chunks arrive, examining each decoded character once (a chunk of a 4,000-item line of about 300 KB does not re-read the part 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 is skipped with one warning and the attempt resumes from its cursor, like any interrupted stream.
756
764
  `createDailyReportClientLoader` warms an existing session (`primeDailyReportIdsStreamSession`) and then bootstraps (`bootstrapDailyReportIdsStreamSession`): when no session exists it is **created at loader time** so the stream fetch runs in parallel with hydration (the dominant cold-load optimization); when one exists, the obfuscated user id is reconciled (a different user destroys and recreates the session before render).
757
765
  If your app overrides `config.apiBasePath`, pass the same value to `createDailyReportClientLoader({ apiBasePath })` — forgetting it costs one wrong-path request on the very first load, self-healed by the page-mount `ensureDailyReportIdsStreamSession`.
@@ -828,11 +836,13 @@ The keys are delegated to each view's **list**: the element that holds the view'
828
836
  A request stays pending only on the paths that send focus to a row that may lie outside the rendered window: the focus owner's report leaving the list and the selected report deleted from the side pane or the mobile overlay (the table below), and the List's return to the card whose overlay closed (its scroll runs in an effect after the commit). Such a request settles when its row registers, however long that takes;
829
837
  the next key, a `pointerdown`, `wheel` or `touchstart` in the view, and the destination leaving the list drop it, and a dropped request never settles later.
830
838
  Focus is only ever taken from nowhere — no element, `body`, or an element inside an `inert` subtree such as the mobile overlay while it slides out (such an element cannot keep focus; `isFocusNowhere` in `src/client/keyboard/dom-node.ts`) — or from inside the view; focus that has meanwhile moved to the side pane, the open overlay or a host element stays there and the request is dropped.
831
- - **A key move writes before focus and commits once.** The row cursor owns the `tabindex` of every registered focus target (0 on the Tab-stop row, −1 on every other row; the row components render none): it writes the attribute when a target registers and when the row's Tab stop changes, and never rewrites a value the element already has.
832
- A move first makes the destination current and the Tab stop while focus is still on the origin (the cursor writes the destination's `tabindex="0"` then, without a React commit); then focus moves; then, once focus has left the origin, the selection and the Tab stop settle on the destination (the cursor writes the origin's `-1`). The rows' states, the host's selection and the scroll commit in one synchronous React commit.
833
- No step writes the `tabindex` of the element that holds focus (Chromium recalculates the document's style and layout on the spot when that happens, to check that the element can still take focus), and none reads layout after a write, so every style recalculation a key forces comes from its one `focus()` call (which recomputes style to check that the element can take focus).
834
- The app's keyboard harness counts the style and layout work done inside keydown tasks and, in a run of its own, records the JavaScript stack of each (run `2026-10-05T16-45-08-477Z`, keys held): every recalculation with a stack comes from that `focus()` call, made by the key's focus request before the commit — the DetailList from row 110 at 1× CPU ran 63 recalculations in 21 of its 22 keydown tasks (8.48 ms) and no layout.
835
- At 4× CPU from row 200,000 the keydown tasks also ran two recalculations and two layouts without a stack (DetailList 16.00 ms and 23.00 ms, List 3.76 ms and 2.42 ms), each laid out from virtualscroll's items boundary (`.aqvs-items-boundary`), none from the document root.
839
+ - **A key move writes at the focus hand-over and commits once.** The row cursor owns the `tabindex` of every registered focus target (0 on the Tab-stop row, −1 on every other row; the row components render none): it writes the attribute when a target registers and when the row's Tab stop changes, and never rewrites a value the element already has.
840
+ A move first makes the destination current and the Tab stop while focus is still on the origin, without a React commit and without writing its `tabindex` yet. The cursor writes the destination's `tabindex="0"` at the hand-over of its focus move: inside the origin's `focusout`, while no element holds focus (before the move when no element held focus, right after it when the move hands nothing over, and before any later change of the rows' states when no focus move comes first).
841
+ Then focus arrives on the destination, and the selection and the Tab stop settle on it (the cursor writes the origin's `-1`). The rows' states, the host's selection and the scroll commit in one synchronous React commit.
842
+ Chromium's `focus()` recalculates the document's style twice whenever focus moves between two elements — right after the origin loses focus and right after the destination gains it — and once more at its entry when a style change is still pending there (a `tabindex` written before the call is one whenever the page's CSS has a `[tabindex]` selector). The hand-over write rides on the first of the two.
843
+ No step writes the `tabindex` of the element that holds focus (Chromium recalculates the document's style and layout on the spot when that happens, to check that the element can still take focus), and none reads layout after a write,
844
+ so a key whose destination is rendered forces exactly the two recalculations of its one `focus()` call and no layout (a destination that the key's own commit renders also has the entry recalculation of the rows that commit inserted): in Chromium 148 with a host `[tabindex]` selector, 8 key moves force 16 recalculations (24 when the destination's `tabindex` is written before the call; 16 either way without such a selector).
845
+ The app's keyboard harness counts the style and layout work done inside keydown tasks and, in a run of its own, records the JavaScript stack of each. The entries without a stack (at 4× CPU from row 200,000 in run `2026-10-05T16-45-08-477Z`: two recalculations and two layouts, DetailList 16.00 ms and 23.00 ms, List 3.76 ms and 2.42 ms) are the frame's own rendering update, which Chromium ran in the same task as the keydown; the keydown handler forces none of them, and each laid out from virtualscroll's items boundary (`.aqvs-items-boundary`), none from the document root.
836
846
  When the destination is not rendered yet, focus is still on the origin as the commit starts: the destination renders in that commit, registers and takes focus, and the commit's layout effect then settles the origin, which keeps the Tab stop until then.
837
847
  The commit renders the destination's and the origin's row frames, the view and the host component that owns the controlled selection (the List's `selectedReportHubId`, the DetailList's `selectedItemId`), and `VirtualScroll` only when the key scrolled. Report rows, card bodies and the row renderer do not render, and no context value changes per key.
838
848
  A row that the commit brings into the rendered window outside the rows the key shows — the rows of the viewport after the scroll, plus one on each side, computed from the same row heights `VirtualScroll` uses (overscan rows, in other words) — mounts its row element alone, with an empty surface that fills its slot and `aria-busy="true"` (a DetailList row without its name and description references, whose elements do not exist yet), and renders its body in a transition right after the commit; a held row that the next key's shown rows reach renders its body inside that key's commit.
@@ -856,10 +866,11 @@ The keys are delegated to each view's **list**: the element that holds the view'
856
866
  - **Keyboard focus**: one predicate decides it for the whole view (`isKeyboardFocused` in `src/client/keyboard/keyboard-focus.ts`): the focused element matches `:focus-visible` and the last input of its document was not a pointer press. `:focus-visible` alone is not enough, because browsers match it on a text field (`input`, `textarea`, an editing host) that a click or a tap focused.
857
867
  The last input is recorded per document (the element's `ownerDocument`, so a view in an iframe or a pop-out window reads its own), while a view of that document is mounted (`observeInputModality` in `src/client/keyboard/input-modality.ts`, reference-counted across the views):
858
868
  a capture-phase `pointerdown` records the pointer, and a capture-phase `keydown` records the keyboard unless the key is a modifier alone (`Shift`, `Control`, `Alt`, `Meta` and the other modifier keys of UI Events), so a modifier held during a pointer gesture does not turn it into keyboard input. A `Tab` pressed outside the view counts too.
859
- So a click or a tap into any element of a row, a text field included, is never keyboard focus; a key press inside the view (other than a modifier alone) turns the focus into keyboard focus without moving it. Chromium also matches `:focus-visible` after a bare `Shift` or `CapsLock`, so there the focus outline can show while the view still treats the focus as a pointer's.
869
+ So a click or a tap into any element of a row, a text field included, is never keyboard focus; a key press inside the view (other than a modifier alone) turns the focus into keyboard focus without moving it. The row and card focus outlines follow this predicate through the attribute below, not `:focus-visible` alone, so they never show while the view treats the focus as a pointer's (also where Chromium matches `:focus-visible` after a bare `Shift` or `CapsLock` that follows a pointer press).
860
870
  - **Keyboard focus is revealed**: when an element inside a row receives keyboard focus, the view scrolls the row frame into the visible area by the nearer edge — or, for a row taller than the viewport, the focused element with 8 px around it. Focus from a pointer never scrolls.
861
871
  - **Keyboard-focus attribute**: while keyboard focus is inside a view, the view root carries `data-daily-report-keyboard-focus` (the List's `[data-testid="daily-report-root"]`, the DetailList's scroll container). Every `focusin` and `keydown` inside the view sets it from the focused element by the same predicate (a key press can turn pointer focus into keyboard focus without moving it), and `pointerdown` inside the view or focus leaving it removes it; a click into a text field of a row therefore leaves it off, and the tap-scroll circle stays pressable.
862
872
  It is a plain DOM attribute toggled with `toggleAttribute`, so arrow keys moving between rows never rewrite it and nothing re-renders. The floating tap-scroll circle hides while it is present, because the circle is drawn over the rows.
873
+ The keyboard-focus outlines of the row frames and the List cards paint only under it (**Selection and focus appearance**), so an outline is drawn only while the circle is hidden and the two never show together; the package's controls (buttons, links, tabs, switches, fields) keep their plain `:focus-visible` outlines.
863
874
  - **Pointer selection**: in the List the card's primary `<button>` selects on `click` (a pointer click, Enter or Space; the click that ends a drag is swallowed by the scroll pane), and ★ / 既読 run only their own action. In the DetailList a click on a row selects it without scrolling, except clicks on controls inside the row (`button`, `a[href]`, `input`, `textarea`, `select`, `label`, `[role="button"]`, `contenteditable`). Without `onSelectItem` the DetailList handles neither clicks nor keys.
864
875
  - **Host selections and list changes**: when the host changes the selection, the selection ring follows it and the DetailList scrolls that row to the top (when the selected report is not in the list yet, as soon as it arrives); focus does not move.
865
876
  A change of the list alone (SSE inserts and deletes, a stale removal) keeps what is on screen in place. Rows are keyed by report id in both views, and both views anchor their scroll position on a report, the way CSS scroll anchoring does: the anchor is the first visible report, how many px of it are hidden above the viewport, the last visible row and the scroll position it was taken at, all read from `VirtualScroll`'s handle, whose position is current right after a scroll call.
@@ -885,7 +896,8 @@ The keys are delegated to each view's **list**: the element that holds the view'
885
896
  The mobile overlay shows the report it opened, so its pane carries the same `data-displayed-report-id` and no `aria-busy`.
886
897
  The article tab scrolls to its end (where the comments are) only after the viewer's own comment has been posted; a comment that arrives over SSE, from another user or another tab, never moves what the viewer is reading.
887
898
  - **Screen readers**: each list references `labels.listKeyboardHelp` (visually hidden) through `aria-describedby` — the DetailList only when it handles keys (with `onSelectItem`); there is no live region, announcements come from focus.
888
- The List card's primary button is named by its content ("YYYY-MM-DD creator"), described first by the row state (`labels.rowState`: read or unread, and starred), then by the markers the card paints and then by its preview, and carries `aria-current="true"` when selected. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints.
899
+ The List card's primary button is named by its content ("YYYY-MM-DD creator"), described first by its position in the list (`labels.listRowPosition`, a visually hidden text: en "3 of 40", ja 「全 40 件中 3 件目」; the row frame carries `aria-posinset` / `aria-setsize`, which the focused button cannot carry), then by the row state (`labels.rowState`: read or unread, and starred), then by the markers the card paints and then by its preview, and carries `aria-current="true"` when selected.
900
+ The position text reads the row's place from the same context as the row frame, so an insert or a delete before the rendered window re-renders the frames and these texts, never a card's body. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints.
889
901
  The markers are the attachment marker (`<labels.attachments>: <count>`) and the source badge (its full `name` when the configuration gives one, otherwise its label); a description references only the markers that are painted. A row without content (loading, failed, missing, editing) is named by its ISO business date, with `aria-busy="true"` while loading. At most one element per view carries `aria-current`. ★, 既読, 編集 and 削除 carry `aria-label` equal to their `title` and no `aria-pressed`.
890
902
  - **Document structure**: every report has a heading at `config.headingLevel` (an integer from 2 to 5, default 3; pick the level that continues the host page's outline). A DetailList card starts with a visually hidden heading `<date> <author> <subject>`, which names the row.
891
903
  The side pane and the mobile overlay start with two label / value pairs: `labels.businessDate`, whose value is the report heading (the business date alone), and `labels.author` with the author's name; each label and each value is read once, and the source badge sits beside the pair, outside it. The overlay names its dialog by the heading and the author's value together (`<date> <author>`, the name of the List card).
@@ -918,16 +930,19 @@ Selection and keyboard focus are two separate channels, identical in both views,
918
930
  | State | Channel | Geometry | Light | Dark | Forced colours |
919
931
  | --- | --- | --- | --- | --- | --- |
920
932
  | Selected | box-shadow ring; the surface paints no shadow outside it | 2 px, 0–2 px outside the surface | blue-500 | blue-500 | 2 px `Highlight` outline of the row frame in the ring's band (below) |
921
- | Keyboard focus on a row or card | outline | 2 px at offset 4 (4–6 px outside) | blue-600 | blue-400 | kept (system colour) |
933
+ | Keyboard focus on a row or card (only while the view root carries `data-daily-report-keyboard-focus`) | outline | 2 px at offset 4 (4–6 px outside) | blue-600 | blue-400 | kept (system colour) |
922
934
  | Keyboard focus on a control or link | outline | 2 px at offset 2 | blue-600 | blue-400 | kept |
923
935
  | Pointer focus | none | — | — | — | — |
924
- | Hover (cards) | blue-300 border, `shadow-lg` (a selected card paints no shadow), 2 px lift (motion-safe) | — | — | — | — |
936
+ | Hover (cards) | blue-300 border, `shadow-lg` (a selected card paints no shadow), a lift L of at most 2 px on whole device pixels (motion-safe; **Row gutter G** below) | — | — | — | — |
925
937
 
926
938
  The selected surface sets the shadow colour to transparent (`shadow-transparent`; the selection rules are the only ones that write a shadow colour), and every shadow size the surface can take — the resting `shadow-sm`, the hover `shadow-lg` and the mid-scroll reset `[[data-daily-report-scrolling]_&]:hover:shadow-sm`, whose specificity (0,3,0) beats the selection rules' (0,2,0) — reads its colour from that one variable (`--tw-shadow-color`).
927
939
  Whichever size rule wins the cascade, a selected surface therefore paints nothing outside its ring, at rest, hovered or mid-scroll, and the 2 px separation band below the ring is page colour like the other three edges (any shadow there tints the band: the resting `shadow-sm` shifts its relative luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to 3.12:1 against it).
928
940
 
929
- - **Row gutter G = 8 px** on all four sides of both row frames: ring 2 + separation 2 + outline 2 + hover lift 2. Every term is a CSS pixel quantity and is written in px (`p-[8px]`, the lift `-translate-y-[2px]`), because the ring and the outline do not scale with the root font size and a `rem` term would break the sum at any root other than 16 px; the lengths built on G — the focus reach, the toolbar insets, the frame radius — are px too. A row aligned to either edge of the viewport while hovered, selected and focused is therefore never clipped, at any root font size;
941
+ - **Row gutter G = 8 px** on all four sides of both row frames: G = 8 ≥ ring 2 + separation 2 + outline 2 + hover lift L, with L ≤ 2. Every term is a CSS pixel quantity and is written in px (`p-[8px]`, the lift's values), because the ring and the outline do not scale with the root font size and a `rem` term would break the sum at any root other than 16 px; the lengths built on G — the focus reach, the toolbar insets, the frame radius — are px too. A row aligned to either edge of the viewport while hovered, selected and focused is therefore never clipped, at any root font size;
930
942
  a DetailList row is its measured body plus 2G = 16.
943
+ The lift L is the largest whole number of device pixels within 2 CSS px at the device-pixel ratio dpr, ⌊2·dpr⌋ / dpr: 2 px at 1, 1.5, 2 and 3, 1.6 px (2 device pixels) at 1.25 and 12/7 px (3 device pixels) at 1.75.
944
+ A 2 px lift would be 2.5 and 3.5 device pixels at 1.25 and 1.75, which puts the hovered surface on a half device pixel and blends its 1 px border, the ring and the outline into the next row of device pixels.
945
+ The card sets the value on itself as the custom property `--aqdr-hover-lift` (`2px`, overridden under the media conditions `resolution: 1.25dppx` and `resolution: 1.75dppx`), and the lift reads it (`translate-y-[calc(-1*var(--aqdr-hover-lift))]`); every class is a literal, so a host's Tailwind build and the package's standalone stylesheet compile the same rules.
931
946
  - **List slot P**: the List card's content is sized in rem, so the slot follows the page's root font size R: P(R) = 4 · ⌈(2G + 2 × (1 + 15) (the card's border and padding) + 4 (spare) + 6.75R) / 4⌉ = 4 · ⌈(52 px + 6.75 rem) / 4⌉, the sum rounded up to the 4 px layout lattice (`listRowSlotHeight`, `LAYOUT_LATTICE_PX`), so every row top stays on the lattice.
932
947
  R is read from the root element's computed style and read again whenever a hidden 1 rem probe inside the List (`data-daily-report-root-font-size-probe`) changes size, and the List renders its `VirtualScroll` only once P is known (measured before the first paint).
933
948
  That one value sizes the row frames, `VirtualScroll`'s rows and the keyboard's row geometry, and when it changes the List keeps the first visible row in place by rescaling the scroll position. At the default 16 px root the sum is exactly 160, so P = 160: the card is P − 2G = 144 px, cards are 16 px apart and an edge-aligned card sits 8 px from the edge (exactly 8 when the view's height is a whole number of device pixels, see **G-symmetric frame**). Roots of 12, 20 and 24 px give 136, 188 and 216 (the sums 133, 187 and 214, rounded up), so the card's spare space grows by less than 4 px.
@@ -940,6 +955,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
940
955
  - **Forced colours**: the ring (a box shadow) disappears, so the row frame draws the selection itself: a 2 px solid `Highlight` outline at offset −8 (−G), which puts the line in the ring's band, 0–2 px outside the surface. The frame's corner radius is the surface's radius plus G (`calc(var(--radius-2xl) + 8px)`, 24 at a 16 px root), so the line is concentric with the surface at every root font size.
941
956
  A DetailList row and a List row without a card read their own `aria-current`; a List row with a card reads its card's primary button (`:has(> [data-daily-report-card] > [aria-current=true])`). The selected row differs by shape as well as by colour (WCAG 1.4.1); the surface border stays 1 px in every state, and the focus outline (4–6 px outside) sits beside the selection line without touching it.
942
957
  - **State comes from the row itself**: a row frame styles its surface — the direct child that carries `data-daily-report-row-surface` in every load state — from its own `aria-current` / `:focus-visible`, and the List card surface styles itself from its direct-child primary button (`:has(> …)`). Conditions on an ancestor read only the attributes the package writes itself (`data-daily-report-scrolling`, `data-daily-report-keyboard-focus`), so an ancestor that carries shared attributes such as `aria-current` never lights up a row.
958
+ The focus outline of both surfaces also requires the view root's `data-daily-report-keyboard-focus` (`:where([data-daily-report-keyboard-focus]) <frame>:focus-visible > :where(<surface>)` and `:where([data-daily-report-keyboard-focus]) <card surface>:has(> <primary button>:focus-visible)`): the ancestor sits in `:where()`, so the rules keep their specificity of (0,2,0) and (0,3,0), and the attribute changes only when the input modality does, never per arrow key, so it restyles the surfaces once per change of modality.
943
959
  - **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 or attribute, and `:has()` sits only on the styled element itself. A featureless subject (`*:`, `group-*`) or an ancestor's `:has(:focus-visible)` 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 classes and fails on either form.
944
960
  - **Every row is a relayout boundary**: the row frames of both views are `contain: size layout style` (one token, `ROW_CONTAINMENT_CLASS_NAME`), so a change inside one row — a held 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.
945
961
  A key move that shifts `VirtualScroll`'s rendering window (it mounts and unmounts rows) is laid out from the items wrapper's containing block. In `@aiquants/virtualscroll` 3.9 that block is a flex item, which Chromium does not make a relayout boundary, so such a shift lays out from the document root: in the app's keyboard harness (run `2026-10-03T23-04-07-660Z`, 4× CPU) a key's layout CPU p50 equals its document-rooted layout CPU p50, 3.99 ms in the List and 8.12 ms in the DetailList.
@@ -958,7 +974,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
958
974
  Outside the rows, the side pane's tab panels are the package's own (`role="tabpanel"`, named by their trigger, hidden and empty while not selected) and read no computed style when they mount, and the scroll bar's business-day bubble reads its date and wheel state from a store of its own (`useSyncExternalStore`), so a change of the visible range or a wheel re-renders only the bubble, never the view or `VirtualScroll`.
959
975
  The bubble sits at a constant offset from the thumb overlay's box, 16 px beside the bar, and follows the thumb's centre by `transform` alone, with no transition, and the thumb itself moves by a translate snapped to device pixels (`@aiquants/virtualscroll` 3.9.0); so a scroll step that keeps the rendering window writes no `top` or `left` and adds no layout from the document root while the bubble shows.
960
976
  - **Focus indicators are the package's own**: every focus indicator the package draws is one of the outlines above, including those of its tabs, buttons, switches, inputs and text areas (`src/client/ui`); none uses the host's `--ring` (measured at 2.43:1 in light and 1.22:1 in dark against the tab list), and no element that carries a focus indicator transitions its colours (in Tailwind 4 `transition-colors` also fades the outline in).
961
- No element is scaled: a scaled element scales its outline with it. The switches are drawn at their real size — a 40 × 24 track whose transparent 4 px border (its style declared) frames a 16 px thumb that moves 16 px — so the 24 px track is its own target and its 2 px outline at offset 2 is drawn at full width. `src/client/ui/focus-indicator.spec.ts` checks the shipped CSS, including that no rule scales an element.
977
+ No element is scaled: a scaled element scales its outline with it. The switches are drawn at their real size — a 40 × 24 track whose transparent 4 px border (its style declared) frames a 16 px thumb that moves 16 px — so the 24 px track is its own target and its 2 px outline at offset 2 is drawn at full width. `src/client/ui/focus-indicator.spec.ts` checks the shipped CSS, including that no rule scales an element and that every outline rule of a row or card surface requires `data-daily-report-keyboard-focus` on an ancestor.
962
978
  - **Focus reach**: a control's outline reaches 4 px outside it (offset 2 + width 2 = 4 = u; a row's or a card's outline stays inside the gutter G), and every box that clips its content and holds focusable content keeps that reach inside its clipping edges, so no outline is ever cut.
963
979
  The rows' gutter holds it in both views; the side pane's tab panels — the pane's scroll box, also inside the mobile overlay — carry `FOCUS_RING_REACH_CLASS_NAME` (`-mx-[4px] px-[4px] pb-[4px] scroll-p-[4px]`, in px like the outline it holds): the negative inline margin and the equal padding move the clipping edges 4 px out on both sides without moving or narrowing the content, `pb-[4px]` keeps the last control's outline, `scroll-p-[4px]` keeps the outline of a control that Tab scrolls to an edge, and the panel's 8 px top padding holds the top.
964
980
  The side pane's header boxes (the date and author column and the tab list, which clip to reserve the scroll-bar gutter; **Side pane** below) move their clipping edges 4 px out on all four sides the same way (`-m-[4px] p-[4px]` in `SIDE_PANE_HEADER_BOX_CLASS_NAME`), so the heading's and the tabs' outlines stay whole.
@@ -1093,7 +1109,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
1093
1109
  Under HTTP/2 all tabs of a profile share one connection per origin, and the limit is the server's number of concurrent streams. The value stays 2 because it must be safe wherever HTTP/1.1 remains (development, E2E, TLS-inspecting proxies, deployments without HTTP/2), and it also bounds how many server-side renders one screen starts at once. Module state cannot see other tabs, so the page is the widest unit that can be counted; counting per component or per list would multiply the limit when the side pane and several DetailList cards show thumbnails at once.
1094
1110
  - A frame waiting for a slot keeps being observed: if it leaves view it gives up its place in the queue, and when it comes back it waits the 250 ms again before queuing.
1095
1111
  - A load ends on `load`, on `error`, or after 75 s without either, counted from the moment the slot was granted. 75 s is the sum of the server's time budget, which bounds a request from the generation gate onward, so the client does not give up on a response the server may still produce once the request has reached the gate; authentication and authorization come before the gate and their time is taken out of the same 75 s. The slot is returned then, and a timed-out load has its `src` removed first.
1096
- - A failed or timed-out load is retried once, 5 s later, through the same visible-settle-slot path (an `<img>` cannot see the status code, so a transient 503 / 502 looks the same as a permanent failure). Only when the retry also fails does the frame become `unavailable`.
1112
+ - A failed or timed-out load is retried once, 5 s later, through the same visible-settle-slot path (an `<img>` cannot see the status code, so a transient 503, 502 or 504 looks the same as a permanent failure). Only when the retry also fails does the frame become `unavailable`.
1097
1113
  The 5 s and the 60 s below are the server's own timing, read by both sides from one shared module (`src/shared/attachment-delivery-timing.ts`) because an `<img>` cannot follow a `Retry-After` it receives: 5 s is the `Retry-After` of the server's overload 503, and 60 s is the window of the per-minute rate buckets, the `Retry-After` of their 429.
1098
1114
  - **Page-wide progress**: the page remembers up to 1,024 thumbnail URLs (the least recently used is forgotten first). A URL that loaded before shows its image in the first render of a remounted frame, with no observation, slot or timer — for example after the arrow keys moved away from a report and back.
1099
1115
  Because thumbnails are `private, no-cache`, the browser revalidates such an image with one conditional request (answered 304 without a body when 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, the page forgets that the URL loaded, counts one failed attempt, and the frame goes on with the retry wait.
@@ -1167,8 +1183,8 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1167
1183
  ```
1168
1184
 
1169
1185
  - **One surface for all wording.** The catalog covers the 11 engine chrome keys of
1170
- `@aiquants/virtualscroll` plus 83 own keys: field headings, the page title (`title`, passed to
1171
- `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state read to
1186
+ `@aiquants/virtualscroll` plus 84 own keys: field headings, the page title (`title`, passed to
1187
+ `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state and the List card's position read to
1172
1188
  screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the load-error screen
1173
1189
  and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
1174
1190
  value, so the scroll arrows, the names of the scroll bar and its thumb, and the "No items" text always speak the same language as the rest.
@@ -1176,17 +1192,17 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1176
1192
  and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
1177
1193
  its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
1178
1194
  re-render the memoized list subtree.
1179
- - **Formatter keys.** Ten keys take arguments and are functions: `totalCount(count)`,
1195
+ - **Formatter keys.** Eleven keys take arguments and are functions: `totalCount(count)`,
1180
1196
  `debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
1181
1197
  `streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
1182
1198
  `interviewerWithAffiliation(name, affiliation)`, `operationFailed(operation)` (`operation` is a
1183
1199
  `DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
1184
- `toggleStar` / `toggleRead`) and `rowState({ isRead, isStarred })`. An override of such a key must be a function too.
1200
+ `toggleStar` / `toggleRead`), `rowState({ isRead, isStarred })` and `listRowPosition(position, total)`. An override of such a key must be a function too.
1185
1201
  - **Digit grouping follows the UI locale, not the runtime.** The built-in formatters group numbers with
1186
1202
  the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
1187
1203
  the raw number and formats it itself.
1188
1204
  - **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
1189
- `RangeError` at render. So do an unknown `labels` key (a key error that lists the 94 keys), a string
1205
+ `RangeError` at render. So do an unknown `labels` key (a key error that lists the 95 keys), a string
1190
1206
  key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
1191
1207
  function. An `undefined` value keeps the catalog value.
1192
1208
  - **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
@@ -1223,7 +1239,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
1223
1239
  grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
1224
1240
  that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
1225
1241
 
1226
- Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 94 keys, the 11 engine keys
1242
+ Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 95 keys, the 11 engine keys
1227
1243
  first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
1228
1244
  (the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
1229
1245
  `locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
@@ -1324,7 +1340,8 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
1324
1340
  | `reportSummaryLoadFailed` | list card load failure | `(12345)` → Failed to load the summary of report 12,345. | `(12345)` → 日報 12,345 の概要読み込みに失敗しました。 |
1325
1341
  | `interviewerWithAffiliation` | interviewer chip | `("Sato", "Acme")` → Sato (Acme) | `("佐藤", "山田商事")` → 佐藤 (山田商事) |
1326
1342
  | `operationFailed` | error banner / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
1327
- | `rowState` | visually hidden row state, the first description of the List card's primary button and of a DetailList row | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
1343
+ | `rowState` | visually hidden row state, the first description of a DetailList row and the second of the List card's primary button | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
1344
+ | `listRowPosition` | visually hidden position of a List card in the list, the first description of its primary button (`position` is 1-based) | `(3, 1234)` → 3 of 1,234 | `(3, 1234)` → 全 1,234 件中 3 件目 |
1328
1345
 
1329
1346
  ### External Source Badge Configuration (`sourceTypeConfigs`)
1330
1347
 
@@ -1412,6 +1429,7 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
1412
1429
  | Not authenticated | 200, `retry: 86400000` → `event: auth-required` (`Set-Cookie` forwarded) | terminated; calls `config.onSessionExpired()` once; never reconnects |
1413
1430
  | Authenticated but no internal user | 200, `retry: 86400000` → `event: forbidden` | terminated; no navigation (a reload cannot fix it); visible through `sseStatus` |
1414
1431
  | A request from another origin's page that the isolation refuses ([Request isolation](#request-isolation)) | 403 JSON before authentication (`Cache-Control: no-store`, no `Set-Cookie`), counted in the bounded refusal record | never met: the package's client connects from the page's own origin |
1432
+ | A React Router single-fetch data request (`<path>.data`, `GET` or `HEAD`) | 404 JSON before authentication (`{"error":{"message":"Not Found"}}`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, no `Set-Cookie`), with no subscription or timer and no line in the refusal record ([Request isolation](#request-isolation)) | never sent by the client: `EventSource` requests the route itself |
1415
1433
  | Unknown endpoint | 404 | treated as retryable; backoff up to 30 s |
1416
1434
  | A `HEAD` request | the status and headers a `GET` would get (one of the rows here), with no body work: no subscription, no heartbeat, no visibility refresh timer (**Streaming routes** in [Server wiring](#server-wiring-di)) | never sent by the client |
1417
1435
  | Unexpected error before the stream starts (for example the internal-user lookup fails) | 500 with the body `Internal Server Error` (no error detail), `Set-Cookie` forwarded | treated as retryable; backoff up to 30 s |
@@ -1461,8 +1479,7 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
1461
1479
  - Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
1462
1480
  - Attachments: `createSharpThumbnailRenderer` / `predictSharpThumbnailWorkingSetBytes` / `DAILY_REPORT_SHARP_THUMBNAIL_WORKING_SET_MODEL` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_WORK_MODEL` (the render deadline `renderMs` and the decode-work and walk bounds, for a host's conformance checks) / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
1463
1481
  - SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
1464
- - Routes: `DAILY_REPORT_IDS_STREAM_ENDPOINT` (`"ids-stream"`), the name of the API endpoint that streams the ids NDJSON, which the client's URL and the server's endpoint table both use; a host compares a route's `endpoint` parameter with it to single out that stream (**Streaming routes** in [Server wiring](#server-wiring-di)).
1465
- - Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` / `ExternalReportFields`,
1482
+ - Types: `DailyReportServerConfig` / `DailyReportServer` (what `createDailyReportServer` returns) / `DailyReportHandlersConfig` / `DailyReportHandlers` (what `createDailyReportHandlers` takes and returns) / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` / `ExternalReportFields`,
1466
1483
  and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
1467
1484
 
1468
1485
  MIT
package/dist/client.d.mts CHANGED
@@ -1,9 +1,9 @@
1
1
  import * as react from "react";
2
2
  import { RefObject, ReactNode, Context } from "react";
3
- import { D as DailyReportItem, a as DailyReportUser, b as DailyReportDetail, c as DailyReportSseStreamAnchor } from "./ids-stream-CWuIDrIO.mjs";
3
+ import { D as DailyReportItem, a as DailyReportUser, b as DailyReportDetail } from "./types-DkERw8NE.mjs";
4
4
  import { VirtualScrollLocale, VirtualScrollLabels, VirtualScrollHandle } from "@aiquants/virtualscroll";
5
5
  import { UseReopeningEventSourceResult } from "@aiquants/sse/react";
6
- import { D as DailyReportSseTerminalEvent, a as DailyReportSseMessage, U as UIComment } from "./comment-adapter-lV2ZB_vH.mjs";
6
+ import { D as DailyReportSseTerminalEvent, a as DailyReportSseMessage, U as UIComment, b as DailyReportSseStreamAnchor } from "./ids-stream-DvB2h1dj.mjs";
7
7
  import { ShouldRevalidateFunction } from "react-router";
8
8
  import "zod";
9
9
  type DailyReportAttachmentIndicatorProps = {
@@ -128,6 +128,7 @@ type DailyReportLabels = VirtualScrollLabels & {
128
128
  readonly isRead: boolean;
129
129
  readonly isStarred: boolean;
130
130
  }) => string;
131
+ readonly listRowPosition: (position: number, total: number) => string;
131
132
  };
132
133
  type DailyReportLabelOverrides = {
133
134
  readonly [K in keyof DailyReportLabels]?: DailyReportLabels[K];
@@ -226,7 +227,8 @@ declare const DAILY_REPORT_LABEL_KEYS: readonly [
226
227
  "reportSummaryLoadFailed",
227
228
  "interviewerWithAffiliation",
228
229
  "operationFailed",
229
- "rowState"
230
+ "rowState",
231
+ "listRowPosition"
230
232
  ];
231
233
  declare const DAILY_REPORT_LABEL_CATALOGS: Readonly<Record<DailyReportLocale, DailyReportLabels>>;
232
234
  declare const resolveDailyReportLabels: (locale: DailyReportLocale | undefined, overrides: DailyReportLabelOverrides | undefined) => DailyReportLabels;
package/dist/client.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import * as react from "react";
2
2
  import { RefObject, ReactNode, Context } from "react";
3
- import { D as DailyReportItem, a as DailyReportUser, b as DailyReportDetail, c as DailyReportSseStreamAnchor } from "./ids-stream-CWuIDrIO.js";
3
+ import { D as DailyReportItem, a as DailyReportUser, b as DailyReportDetail } from "./types-DkERw8NE.js";
4
4
  import { VirtualScrollLocale, VirtualScrollLabels, VirtualScrollHandle } from "@aiquants/virtualscroll";
5
5
  import { UseReopeningEventSourceResult } from "@aiquants/sse/react";
6
- import { D as DailyReportSseTerminalEvent, a as DailyReportSseMessage, U as UIComment } from "./comment-adapter-5bAtR4qb.js";
6
+ import { D as DailyReportSseTerminalEvent, a as DailyReportSseMessage, U as UIComment, b as DailyReportSseStreamAnchor } from "./ids-stream-BR5RSj5u.js";
7
7
  import { ShouldRevalidateFunction } from "react-router";
8
8
  import "zod";
9
9
  type DailyReportAttachmentIndicatorProps = {
@@ -128,6 +128,7 @@ type DailyReportLabels = VirtualScrollLabels & {
128
128
  readonly isRead: boolean;
129
129
  readonly isStarred: boolean;
130
130
  }) => string;
131
+ readonly listRowPosition: (position: number, total: number) => string;
131
132
  };
132
133
  type DailyReportLabelOverrides = {
133
134
  readonly [K in keyof DailyReportLabels]?: DailyReportLabels[K];
@@ -226,7 +227,8 @@ declare const DAILY_REPORT_LABEL_KEYS: readonly [
226
227
  "reportSummaryLoadFailed",
227
228
  "interviewerWithAffiliation",
228
229
  "operationFailed",
229
- "rowState"
230
+ "rowState",
231
+ "listRowPosition"
230
232
  ];
231
233
  declare const DAILY_REPORT_LABEL_CATALOGS: Readonly<Record<DailyReportLocale, DailyReportLabels>>;
232
234
  declare const resolveDailyReportLabels: (locale: DailyReportLocale | undefined, overrides: DailyReportLabelOverrides | undefined) => DailyReportLabels;