@aiquants/daily-report 0.24.0 → 0.25.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 +48 -0
- package/README.md +71 -34
- package/dist/client.d.mts +2 -6
- package/dist/client.d.ts +2 -6
- package/dist/client.js +4 -4
- package/dist/client.mjs +4 -4
- package/dist/server.js +4 -4
- package/dist/server.mjs +4 -4
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +4 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@aiquants/daily-report` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.25.0 (2026-10-03)
|
|
6
|
+
|
|
7
|
+
0.23.0 のキーボード操作とサムネイルの仕上げの続きと、共有 SSE リーダーの取りこぼしの修正。どれも README の該当の節が今の契約を記す。
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **スクロールの錨はスクロールの直後に記録する**: ビュー自身のスクロール (キーの移動と長押しのフレームごとの移動・キーボードのフォーカスを見せるスクロール・List のモバイルのオーバーレイを閉じた後の揃えと行の枠の高さ P が変わったときの位置の直し・DetailList の選択の揃えとコメントの投稿の後の見せ方) はどれも 1 つのスクロールの口を通り、口はスクロールの直後に VirtualScroll のハンドルから錨を記録する。以前は次のフレームの可視範囲の知らせで記録したので、キーと次のフレームの間に確定した SSE の挿入・削除が位置をキーの前へ戻した (End・PageDown・長押しが巻き戻り、DetailList ではフォーカスが body へ落ちた)。
|
|
12
|
+
利用者のスクロール (ホイール・ドラッグ・スクロールバー・慣性) は今までどおり可視範囲の知らせで記録し、知らせの前の 1 フレームに一覧が変わったときは、記録してからスクロールした距離だけ錨を前の一覧の行の高さで進めてから戻す (利用者のスクロールを巻き戻さず、1 行もずらさない)。錨が譲るのは、日報の到着を待つ DetailList の選択の見せ方だけ。
|
|
13
|
+
- **キャッシュに無い日報の最初の読み込みはすぐ始める**: `useDailyReportDetail` はキャッシュに無い日報の要求を効果の中で始める。以前は 0 ms のタイマーを挟んだので、キーの長押しが続く間ずっと後回しにされ、キーを離すまで要求が出なかった。間を置く (120 ms) のは期限切れのキャッシュの日報の再取得だけ。id が変わった後とアンマウントの後に届いた結果は捨て、同じ営業日の要求は 1 つにまとまる。
|
|
14
|
+
- **ビューの高さの最初の値も配置の px**: ビューの箱の高さは最初の値も根要素の計算済みの `block-size` (配置の px) から読み、以後の `ResizeObserver` の値と同じ単位になった (以前の最初の値は `getBoundingClientRect` の見た目の px で、祖先が変形・拡大縮小しているときだけ 1 フレーム後の観測で直った)。最初の確定から正しい高さで、2 回目の確定は起きない。内部のフックの名前も測るものに合わせた (`useViewBoxHeight`)。
|
|
15
|
+
- **空のビューの文言は 1 つ**: 空の List と DetailList は VirtualScroll の空の文言 (`noItems`。en "No items"・ja 「項目がありません」) だけを描く。以前はパッケージの `listEmpty` の文言と 2 つが一覧の上端で 8 px 重なった。文言の上下の寄せ 16 px と色 (明 slate-500・暗 slate-400。ページに対して 4.55:1・7.66:1。エンジンの既定の灰は暗色のページで 4.17:1) はビューが与える。
|
|
16
|
+
- **両ビューの行の枠は配置の境界**: DetailList の行の枠も List と同じく大きさ・配置・スタイルを閉じ込め (`contain: size layout style`)、VirtualScroll の行の箱を埋める。行の高さは本体 (枠の直下の子) を測って 2G を足した値。行の中の変化 (後回しにした本体の描画・読み込みの完了・選択とフォーカスの表示) の配置はその行から始まり、キーの移動の配置は変わる行の中に留まる (以前の DetailList は、長押しのフレームごとに文書の根から配置し直した)。
|
|
17
|
+
- **側面ペインの見出しと本文は同じ端で終わる**: 営業日と作成者の列とタブの並びは、本文と同じスクロールバーの溝を取る箱に入る (以前は古典的なスクロールバーで、本文の列が見出しより 10 px 手前で終わった)。
|
|
18
|
+
- **List のパネルの終わりに G**: 一覧のパネルはスクロールバーの後に G = 8 px を取り、側面ペインとの間は 2G = 16 px の溝になる。境界の中央に置かれるリサイズの取っ手 (当たり 10 px・目印 6 px) はスクロールバーにもペインの縁にも掛からない (以前は目印がスクロールバーのトラックの 3 px を覆った)。
|
|
19
|
+
- **選ばれたタブの印はまっすぐな 2 px の線**: 下の枠線をやめ、トリガーの角の半径 (`--radius-lg`) だけ左右を内側へ入れた `::after` の棒にした。角の丸い箱の下の枠線は丸みに沿って細りながら持ち上がり、画素密度 3 では中ほどで 6 装置画素の三日月だった。棒は流れの外なので場所を取らず、文言は 24 px のトリガーの中央に来る (以前は透明な枠線の分だけ 1 px 上)。強制配色では `Highlight` の棒。
|
|
20
|
+
- **ビューの列の原点は 4 px の格子の上**: 中央に寄せた列の始まりを 4 px の倍数へ切り下げる (`round()` を持たないエンジンは `mx-auto` の中央のまま)。画素密度 1.25・1.5・1.75 でも、左右の縁の 2 px の輪・隙間・アウトラインがどれも整数の装置の画素で描かれ、枠線は自分の色で描かれる (以前は列が 0.5 px に来ることがあり、1.25 で線と枠線がにじんだ)。
|
|
21
|
+
- **スクロールを伴うキーの行の描き直しは VirtualScroll の窓のずらしのもの**: 描画済みの行を描き直すのは、VirtualScroll が描画の窓をずらす段だけ (このパッケージの変更は要らない)。
|
|
22
|
+
行をマウントしない段はラッパーの `transform` だけを書き、どの行も描き直さない。
|
|
23
|
+
行をマウントする段ではラッパーの層 (`will-change: transform`) が描き直しを要するので、Chromium が描く範囲 (表示領域を 4000px 広げた範囲) をヒステリシス無しで今の位置へ置き直し (`CullRectUpdater::ShouldProactivelyUpdate`)、中身が自分のはみ出しを切る残る行 (`kMayBeClippedByCullRect`) を描き直す。両ビューの行はどちらもこれに当たる。
|
|
24
|
+
`@aiquants/virtualscroll` の計測 (Chromium 148・1280×800・比 1・overscan 15。20 段 × 3 回の中央値) では、1 行ずらす段が 160px の行で 36 層 (Paint 0.99ms、Paint・PrePaint・Layerize の和 2.6ms、ラスター 3.7ms)、448px の行で 26 層 (0.66ms・1.9ms・2.6ms)。
|
|
25
|
+
描く範囲を外させる `perspective` は層を 6 に減らすが、行を 2D の平行移動でない変換の下で合成させ、比 1.25 の明色で選択の輪とフォーカスの輪の間の帯を混ぜるので、virtualscroll は使わない (ラッパーは 3.8.1 と同じ層)。
|
|
26
|
+
`@aiquants/virtualscroll` 3.8.2 の配布 CSS が、動きを減らす設定 (`prefers-reduced-motion: reduce`) で、パッケージが動かす部品 (タップスクロールサークルとその中のすべての要素・バーのサークルの器・矢印ボタン・スクロールバーのつまみ・端へ戻るボタンとその覆い) の遷移とアニメーションを止める (3.8.2 をピアの下限にした。Breaking を参照)。
|
|
27
|
+
- **JPEG の展開の仕事の上限**: sharp の描画ポートは、sharp を起動する前に JPEG の展開の仕事 (EOI までの走査ごとに、符号化する成分のブロックの数の和) をフレームヘッダーと同じ 1 回のたどりで見積もり、描画の締め切りから導いた上限 (2,000 万回の訪問) を超える原本と、仕事を見積もれない原本 (算術符号と階層のフレーム、規格の上限を超える走査の数、崩れた区切り) を `unsupported` (内容で決まる失敗として記録) で断る。以前は 0.2 MB で 2,647 走査の 4,096² のプログレッシブの JPEG が、画素数と作業領域の上限を通って描画 1 回に CPU 13〜25 秒をかけ、締め切りを越えても何も記録しないので表示のたびに展開し直した。可逆 (SOF3) のフレームは受け付ける。
|
|
28
|
+
- **締め切りの後の描画の結果を残す**: 描画の締め切り (`render_timeout` の 502) の後に描画ポートが決着したら、出力の検証を通った縮小画像と `unsupported` は、同一性を確かめた生成ならキャッシュに記録してから枠を返す (以前は捨てた)。遅いが正当な原本の超過は 1 回で済み、次の表示はキャッシュから答える。生成は枠を渡されたときにキャッシュを見直すので、超過した描画の後ろで待っていた要求はもう 1 度展開しない。sharp の描画ポートは展開を始めた後に中断されても、展開の結果をそのまま返す (以前は `failed` に変えたので、残す結果が無かった)。
|
|
29
|
+
- **添付の応答は同じオリジンだけが読み込める**: 原本の 200 (インラインとダウンロード、GET と HEAD) とサムネイルの 200・304 は `Cross-Origin-Resource-Policy: same-origin` を持つ。セッションの Cookie は同じサイトの別オリジンからの読み込みにも付くので、無いと同じサイトのページが推測したトークンを画像として読み込み、読めたかどうかから閲覧者の可視性を 1 件ずつ知りえた。
|
|
30
|
+
- **開発者向け**: バンドルの予算は書かずに導く: `bundle-baseline.json` (パッケージの根) が入口ごとの gzip-9 の大きさと余裕 `headroomPercent` (3) を記録し、予算は ⌈基準 × (100 + 余裕) ÷ 100⌉ (整数の計算)。手で書いた `package.json#bundleBudget` は拒否する。`pnpm run bundle:ratchet` (`--write`) が新しいビルドの大きさを記録し、下げるのと古い項目を消すのは自由、増やすのと項目を足すのは `--write --accept` だけ。
|
|
31
|
+
ビューのスクロールの口を通らないハンドルのスクロールを落とす守り (`view-scroller.spec.ts`)、VirtualScroll の代役と本物を同じ props で比べる適合の spec (空の出力・矢印の Tab の止まり先・ビューが呼ぶハンドル)、`utils/constants.ts` が export するどの定数にも出荷するコードの読み手があることの守り (読み手の無かった `EMPTY_DETAIL` も消した) を加えた。
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **末尾を読めなかった共有リーダーの取りこぼし**: プロセス最初の run でストリームの末尾を読めないと、リーダーは `"$"` で読み始める。
|
|
36
|
+
その後に別の接続の `ready()` が末尾を読めて位置を固定しても、動いている run は `"$"` のまま再武装を続け、BLOCK の合間に足されたエントリを
|
|
37
|
+
読まなかった (その位置を見て `connected` を受け取った接続にも届かない)。run は XREAD のたびに、まだ `"$"` なら固定された位置へ移る。
|
|
38
|
+
|
|
39
|
+
### Removed
|
|
40
|
+
|
|
41
|
+
- client の `DEFAULT_ITEM_HEIGHT` と `TAP_SCROLL_CIRCLE_OPTIONS` (消費側が読み込まない)。公開名を足すのは消費側 (ホストのアプリ・examples) が読み込むときだけで、README に書くことは公開の理由にならない。
|
|
42
|
+
- ラベル `listEmpty` (固有キーは 83、全 90 キー)。
|
|
43
|
+
|
|
44
|
+
### Breaking
|
|
45
|
+
|
|
46
|
+
- `DEFAULT_ITEM_HEIGHT` と `TAP_SCROLL_CIRCLE_OPTIONS` を外した。行の幾何はホストとの契約ではない: List は根の文字サイズから行の枠の高さ P = 52px + 6.75rem を求めるので、P は根が 16 px のときにしか 160 にならない。0.23.0 の Breaking の Migration の「行の計算は `DEFAULT_ITEM_HEIGHT` で行う」は、この項で置き換える。
|
|
47
|
+
Migration: 行は `[data-daily-report-row="<id>"]` か、テスト用のハンドル (`__virtualScroll` の `findReportIndex` / `getReportItem`) で探し、index × 行の高さで求めない。タップスクロールサークルの設定はホストが自分で持つ。
|
|
48
|
+
- ラベル `listEmpty` を外した (上書きに渡すと、未知のキーとして `RangeError`)。空の一覧の文言は `noItems` の 1 つ。Migration: `labels` から `listEmpty` を外し、空の一覧を「データがありません」/ "No data" で待っていたテストは `noItems` の文言 (「項目がありません」/ "No items") で待つ。
|
|
49
|
+
- 描画ポートの契約: 中断で打ち切った描画は `unsupported` を返してはならない (締め切りの後の `unsupported` は内容で決まる失敗として記録する)。Migration: 自前の描画ポートは、中断で打ち切った描画を `{ ok: false, reason: "failed" }` で返す。
|
|
50
|
+
- ピアの `@aiquants/virtualscroll` の下限を 3.8.2 へ上げた (`peer-floors.json`)。3.8.2 の配布 CSS が、動きを減らす設定で VirtualScroll 自身の部品の遷移とアニメーションを止める (3.8.0 と 3.8.1 ではタップスクロールサークルが動く)。
|
|
51
|
+
Migration: `@aiquants/virtualscroll` を 3.8.2 以降へ上げる。
|
|
52
|
+
|
|
5
53
|
## 0.24.0 (2026-10-03)
|
|
6
54
|
|
|
7
55
|
共有 SSE リーダーを `@aiquants/sse` の `createSharedSource` へ載せ替え、接続の直後に足されたエントリが配られないことのある順序の誤りを直す。
|
package/README.md
CHANGED
|
@@ -26,12 +26,15 @@ 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
|
-
`@aiquants/virtualscroll` must be **3.8.
|
|
30
|
-
That floor is declared once, in `peer-floors.json` (`{ "@aiquants/virtualscroll": "3.8.
|
|
29
|
+
`@aiquants/virtualscroll` must be **3.8.2 or later**: both views pass its scroll-bar option `enableArrowButtonTabStops: false` (3.8.0), which takes the scroll bar's arrow buttons out of the Tab order (the views scroll by keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)), and its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2; see [Selection and focus appearance](#selection-and-focus-appearance)). The label catalog reuses its 7 engine keys (see [Localization](#localization-locale--labels)).
|
|
30
|
+
That floor is declared once, in `peer-floors.json` (`{ "@aiquants/virtualscroll": "3.8.2" }`), and every publish path runs `scripts/check-peer-floors.mjs` (`pnpm run check:peer-floors`) right after the leak check: each `publish:*` script before its version bump (so a refusal leaves no bumped version behind), and `prepublishOnly` before every `pnpm publish`, a bare one included (for example a re-run after a `publish:*` whose registry step failed).
|
|
31
31
|
`workspace:^` publishes `^<version>` of the linked workspace package (`node_modules/@aiquants/virtualscroll/package.json`), so while that version is below the floor the check exits 1 and the publish stops (exit 2 for a configuration error, such as a floor that names no `workspace:` peer).
|
|
32
32
|
`@aiquants/sse` (the SSE wire contract, server response helpers and the reopening client) is a regular **dependency**: it arrives transitively, so consumers do not declare it.
|
|
33
33
|
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 timer through `AbortSignal.any`. `src/server/node-engine-floor.spec.ts` reads the server-side modules' syntax tree and fails when one of them uses a listed runtime API newer than the declared floor.
|
|
34
|
-
Build / test: `pnpm run build` (tsup → dist) / `pnpm run test` (vitest) / `pnpm run typecheck:examples` (the host examples under `examples/`, which are not shipped)
|
|
34
|
+
Build / test: `pnpm run build` (tsup → dist) / `pnpm run test` (vitest) / `pnpm run typecheck:examples` (the host examples under `examples/`, which are not shipped).
|
|
35
|
+
`pnpm run check:bundle` checks the gzip size at level 9 of every shipped ESM entry and the standalone stylesheet — `dist/client.mjs`, `dist/index.mjs`, `dist/server.mjs`, `dist/styles/daily-report.standalone.css` — against a budget that is derived, never written: `bundle-baseline.json` at the package root records each file's gzip-9 size and one `headroomPercent` (3), and each budget is ⌈baseline × (100 + headroomPercent) ÷ 100⌉ in integer arithmetic.
|
|
36
|
+
An `.mjs` or `.css` target of `exports` without an entry, an entry for a file that is no longer a target and a hand-written `bundleBudget` in `package.json` fail the check.
|
|
37
|
+
`pnpm run bundle:ratchet` (`--write`) records the sizes of a fresh build: an entry goes down freely and a stale one is removed, while a larger size or a new target is recorded only with `--write --accept` (a reviewed growth; without `--accept` the entry keeps its size and the growth is judged against the old budget). `headroomPercent` is policy and is never written by the script.
|
|
35
38
|
`pnpm run verify` runs the type checks, Biome (`src/` and `scripts/`), markdownlint, the two count ratchets (docstrings, then the no-fallback guard), the unit tests with per-file coverage floors, the coverage ratchet, the examples, the build and, last, the bundle check.
|
|
36
39
|
Every source file under `src` (without specs, tests, declarations and test helpers) and every gate script under `scripts` (`scripts/**/*.mjs`) has a committed floor of branch and function coverage in `coverage-floors.json`: Vitest fails a file below its floor, `pnpm run check:coverage` (`scripts/ratchet-coverage.mjs`) fails a file without an entry or an entry without a file, and `pnpm run coverage:ratchet` raises each floor to the measured percentage rounded down after a whole-suite coverage run (a file at 100 % stays at 100; no floor is ever lowered by the script).
|
|
37
40
|
Each gate script is a two-statement command-line entry around the `main({ packageRoot, argv })` of its module under `scripts/lib/` (the shared exit codes, JSON readers and error descriptions are `scripts/lib/cli.mjs`); the specs run `main` in the test process against temporary packages and keep one subprocess test of each entry, so the scripts' logic is measured by the same floors as `src` (all at 100).
|
|
@@ -239,6 +242,7 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
|
|
|
239
242
|
- **Parsing is strict.** `thumbnail` must be one known variant name, `download` must be exactly `1`, the two cannot be combined, and neither may repeat. Anything else — `?thumbnail=1`, `?thumbnail=true`, `?download=true`, `?download=yes`, `?thumbnail=tile&download=1` — answers **400** `{"error":{"message":"Invalid attachment request"}}` right after authentication, before any port or database query runs, and writes no log line. Other parameters are ignored, and names are case-sensitive (`?Download=1` is an unknown parameter, so the request stays inline).
|
|
240
243
|
- **The original's type**: the declared type is the row's `file_type` when it is a valid media type, otherwise the type the read port reported. An inline-safe declared type (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `application/pdf`, `text/plain`) is sent as itself, inline (as an attachment for a download); any other declared type, and a missing one, is sent as `application/octet-stream` with `Content-Disposition: attachment`, because the declared type comes from outside the package and an inline `text/html` or `image/svg+xml` would run script in the host's origin.
|
|
241
244
|
- **Methods**: the original (inline and download) answers `GET` and `HEAD` (a HEAD reads metadata only and reports the same `Content-Length` as GET); any other method answers **405** with `Allow: GET, HEAD`. The thumbnail answers `GET` only (see **Thumbnail endpoint** below). Both 405s are checked right after the query, before the ports and the token.
|
|
245
|
+
- **Same-origin loads only**: every attachment response with a body or a validator — the original's 200 (inline and download, GET and HEAD) and the thumbnail's 200 and 304 — carries `Cross-Origin-Resource-Policy: same-origin`, so the browser lets only pages of the host's own origin load it. The session cookie also accompanies requests from other origins of the same site, so without the header a same-site page could load guessed tokens as images and learn, token by token, whether the viewer can see each attachment (a load or an error); with it every cross-origin load fails alike.
|
|
242
246
|
|
|
243
247
|
**Configuration**
|
|
244
248
|
|
|
@@ -400,8 +404,10 @@ type DailyReportAttachmentThumbnailRenderer = {
|
|
|
400
404
|
- `createDailyReportService` / `createDailyReportServer` check the port when they are created, in this order, by the convention under **Configuration errors**: a port that is not an object (`null` included)
|
|
401
405
|
throws `RangeError` (`[daily-report] attachmentThumbnailRenderer must be an object with id and render; got null`), a missing `render` throws `TypeError` (`[daily-report] attachmentThumbnailRenderer.render must be a function`), a `render` that is given but is not a function throws `RangeError` (`…render must be a function; got "sharp"`), and an `id` that is not a string with non-whitespace content throws `RangeError` (`[daily-report] attachmentThumbnailRenderer.id must be a non-blank string naming the rendering pipeline version; got ""`).
|
|
402
406
|
- `render`: fit the image inside `maxWidth` × `maxHeight` — the box of the requested variant — keeping the aspect ratio (never enlarge), apply the EXIF orientation, drop metadata, flatten transparency onto white, bound decode memory before decoding (a pixel count alone does not: a 16-bit sample takes twice the bytes of an 8-bit one), start no decoder other than the one for `format`, and never throw.
|
|
403
|
-
- `signal` aborts when every request waiting for that content has gone or when the render stage's deadline passes (`renderMs`). A renderer that can stop (a remote image service, for example) should forward it and settle with `{ ok: false, reason: "failed" }`.
|
|
404
|
-
A render still running at its deadline is answered with 502 (`reason=render_timeout`)
|
|
407
|
+
- `signal` aborts when every request waiting for that content has gone or when the render stage's deadline passes (`renderMs`). A renderer that can stop (a remote image service, for example) should forward it and settle with `{ ok: false, reason: "failed" }`. A render cut short by the signal must never answer `unsupported`: a late `unsupported` is recorded as a content-determined outcome (below).
|
|
408
|
+
A render still running at its deadline is answered with 502 (`reason=render_timeout`) without waiting for it. A renderer that cannot stop (in-process libvips) is still valid: the response is bounded by the deadline, but the generation slot stays held until the render settles (`render_overrun ms=<elapsed>`; a render that has still not settled at twice its budget is logged once as `render_stuck ms=<elapsed>`).
|
|
409
|
+
What such a render has paid for is kept: when it settles after the deadline with a thumbnail that passes the output checks below, or with `unsupported`, and the generation's read matched the row's size (**verified**, see **Read port** above), the outcome is cached before the slot is released. A slow but legitimate source therefore costs one overrun, and the next view is answered from the cache. A late `failed`, a late exception and a late output that fails the checks are not cached.
|
|
410
|
+
A generation that every waiting request left before its render settled or reached the deadline ends as 503 (`reason=aborted`) and keeps nothing, whatever the render's result.
|
|
405
411
|
- `format` is detected by the package from the leading bytes (PNG, JPEG, GIF87a / GIF89a, RIFF…WEBP), not taken from the declared type. Bytes with any other signature (SVG, TIFF, HEIF, BMP, ...) are never handed to the port.
|
|
406
412
|
- `unsupported` means the content itself cannot be downscaled (deterministic); `failed` means a transient failure (retry may succeed).
|
|
407
413
|
- The result's `contentType` is typed by the closed set (`DailyReportAttachmentThumbnailOutputMediaType`, derived from `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES`) and must have 1 byte to `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`. Both are also checked at run time, for hosts written in JavaScript.
|
|
@@ -420,6 +426,10 @@ Values for implementing the port:
|
|
|
420
426
|
- fits the image into the box without enlarging it, applies the EXIF orientation, flattens transparency onto white and encodes WebP at quality 75 and effort 4 (metadata is dropped). It reads only the first frame of an animation.
|
|
421
427
|
- reads the source's layout from its bytes before it starts sharp — for a JPEG its frame header (the first `SOFn` and `SOS`, read the way libjpeg reads them: one scan or several, and the sampling factors), for a PNG the interlace method in `IHDR`, for a WebP its chunks (lossy, lossy with an `ALPH` alpha plane, lossless or animated); a source whose layout cannot be read is `unsupported` without starting sharp.
|
|
422
428
|
It then reads the image header (`metadata()`, on the same pipeline with the same options) and decodes only an image within both bounds: at most 50,000,000 pixels (sharp's `limitInputPixels`) and a predicted working set of at most `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` (200,000,000 bytes). An image over either bound, a sample format outside the list below or a header without numeric dimensions is `unsupported` (content-determined, cached) without decoding.
|
|
429
|
+
- bounds the decode **work** of a JPEG too, in the same pass over the bytes that reads its frame header, before sharp starts. A progressive JPEG visits every block once per scan, so a small file with a valid progression of thousands of scans passes the pixel and memory bounds and still decodes for many seconds (a 0.2 MB, 4,096² file with 2,647 scans took 13–25 s of CPU).
|
|
430
|
+
The work is counted in block visits: the sum, over the scans up to EOI, of the 8 × 8 blocks of every component a scan codes (counted as libjpeg lays them out, rounded up to whole MCUs). The walk skips each scan's coded data without decoding it and stops at EOI or as soon as the sum passes the bound; a single-scan (baseline) file stops at its first scan, which is all libjpeg decodes.
|
|
431
|
+
The bound comes from the render deadline: ⌊`renderMs` × 10⁶ ÷ (125 ns per visit × a margin of 4)⌋ = 20,000,000 visits, so a source at the bound decodes in about a quarter of the 10 s deadline on the host it was measured on (sharp 0.35.5 / libvips 8.18.7 at one thread; 125 ns is a visit of the costliest scan, a refinement of the whole 1–63 band; single-coefficient scans cost 13–27 ns).
|
|
432
|
+
A JPEG over the bound is `unsupported` without starting sharp, and so is one whose work cannot be predicted: more scans than the JPEG standard allows (one per component in a sequential frame, 896 per component in a progressive one), an arithmetic-coded (SOF9–SOF15) or hierarchical (SOF5–SOF7) frame, or a malformed marker stream. The default progressions libjpeg writes stay well inside the bound even at the 7,071² pixel limit (about 6.3 M visits at 4:2:0, 10.9 M at 4:4:4 and 18.8 M for CMYK), and lossless (SOF3) frames, one scan per component like a sequential frame, are admitted.
|
|
423
433
|
- predicts the working set of one render with `predictSharpThumbnailWorkingSetBytes(source, format, header)` from one frozen table, `DAILY_REPORT_SHARP_THUMBNAIL_WORKING_SET_MODEL` (both in the server entry): the buffer the layout's decoder holds for the whole image before it can shrink, plus `pipelineRows` = 2,048 full-width rows of decoded pixels (width × bands × bytes per sample; `uchar` / `char` 1, `ushort` / `short` 2, `uint` / `int` / `float` 4, `complex` / `double` 8) that libvips holds, plus `fixedBytes` = 4 MiB (the WebP encoder of the 480 × 320 output and libvips' own structures):
|
|
424
434
|
|
|
425
435
|
| Layout | Whole-image buffer | Largest square admitted |
|
|
@@ -437,7 +447,7 @@ Values for implementing the port:
|
|
|
437
447
|
- changes sharp's settings for the whole process when it is created: libvips' operation cache is turned off (the package caches the outcomes), libvips uses one thread per image (`sharp.concurrency(1)`: the model's rows were measured at one thread, and libvips' line caches grow with the thread count — a 7,071² 8-bit RGBA PNG took 42.2 MB at 1 thread, 64.5 MB at 4 and 89.2 MB at 8; the package's generation gate decides how many renders run at once), and every loader is blocked except one per format the package hands over (PNG, JPEG, GIF, WebP). An Ultra HDR JPEG is a valid JPEG:
|
|
438
448
|
the JPEG loader decodes its SDR base image, and the 480 × 320 WebP is byte-identical to the one the Ultra HDR loader made. A host that uses sharp for anything else gets the same settings there, so create the renderer once.
|
|
439
449
|
- answers `failed` (transient, retried by the next request) only when the error message reports exhausted resources (memory, threads, open files, disk space; for example `webpsave: picture memory error` or `Error creating thread: Resource temporarily unavailable`). Every other failure is `unsupported` and cached: treating an unknown message as transient would read and decode a broken attachment again on every view.
|
|
440
|
-
- checks the abort signal before it starts
|
|
450
|
+
- checks the abort signal before it starts and after the header read, before decoding, and answers `failed` when it is aborted by then. libvips cannot be stopped midway, so a decode that has started returns its own result (the thumbnail, `unsupported` or a resource `failed`) even when the signal aborts meanwhile; the package decides what to keep (a late verified result is cached, see **Thumbnail renderer port** above).
|
|
441
451
|
- is named `sharp-<sharp version>/vips-<libvips version>/webp-q75-e4/flatten-#ffffff/v1`, from `sharp.versions` at run time.
|
|
442
452
|
|
|
443
453
|
Example — the wiring; the host imports its own sharp (type-checked, not shipped):
|
|
@@ -538,6 +548,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
538
548
|
- **Revalidation**: a verified preview carries `Cache-Control: private, no-cache` and the ETag. `If-None-Match` is compared weakly (`W/` stripped, lists accepted); `*` never matches. A match answers 304 without touching the gate, storage or renderer. Re-mounted thumbnails cost one 304 round trip, and a logout or a visibility change takes effect on the next revalidation.
|
|
539
549
|
An unverified preview is sent with `Cache-Control: no-store` and no `ETag`: the browser neither keeps nor revalidates it, so a later `If-None-Match` can never pin it through 304s, and the next view generates again.
|
|
540
550
|
- **Generation**: a process-local gate with `attachmentThumbnailConcurrency` slots and a FIFO wait queue (at most 64 waiting, 8 per user, waiting at most `queueWaitMs`). A slot is held while the original is read and rendered. Concurrent requests for the same identity join one generation; the joined requests share one read made with the first requester's principal. A request that disconnects withdraws from the shared generation, but its handler still settles with the shared outcome (which no client receives).
|
|
551
|
+
A generation that receives its slot reads the cache again before it reads the original, so a request that waited behind an overrunning render of the same content is answered from that render's late result instead of decoding it again.
|
|
541
552
|
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; a port that honours the signal settles early and hands the slot to the next queued generation; and a stage that settles after the abort is discarded before its result is looked at (see **Read port** above).
|
|
542
553
|
- **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.
|
|
543
554
|
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.
|
|
@@ -551,14 +562,14 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
551
562
|
| Client load timeout | 75 s (the sum) | the load counts as one failure |
|
|
552
563
|
|
|
553
564
|
- **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.
|
|
554
|
-
It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch, the port's `unsupported`, and the read port's `too_large` for a row whose size is unknown — on a row with a known size, which eligibility keeps within `attachmentMaxBytes`, a `too_large` is always unverified). It never stores unverified outcomes, `failed`,
|
|
565
|
+
It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch, the port's `unsupported`, and the read port's `too_large` for a row whose size is unknown — on a row with a known size, which eligibility keeps within `attachmentMaxBytes`, a `too_large` is always unverified), including those a render delivers after its deadline (see **Thumbnail renderer port** above). It never stores unverified outcomes, `failed`, the 502 of a timeout, storage errors (including `not_found`), queue rejections, aborts or exceptions.
|
|
555
566
|
- **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);
|
|
556
567
|
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.
|
|
557
568
|
|
|
558
569
|
| Status | When |
|
|
559
570
|
| --- | --- |
|
|
560
|
-
| 200 | Preview. Headers: the port's `Content-Type`, `Content-Length`, `Cache-Control: private, no-cache` and `ETag` (an unverified preview: `Cache-Control: no-store` and no `ETag`), `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`, `X-Frame-Options: SAMEORIGIN`, forwarded `Set-Cookie` |
|
|
561
|
-
| 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `Set-Cookie`) |
|
|
571
|
+
| 200 | Preview. Headers: the port's `Content-Type`, `Content-Length`, `Cache-Control: private, no-cache` and `ETag` (an unverified preview: `Cache-Control: no-store` and no `ETag`), `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`, `X-Frame-Options: SAMEORIGIN`, `Cross-Origin-Resource-Policy: same-origin`, forwarded `Set-Cookie` |
|
|
572
|
+
| 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `Cross-Origin-Resource-Policy: same-origin`, `Set-Cookie`) |
|
|
562
573
|
| 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
|
|
563
574
|
| 401 / 403 | Not authenticated / no internal user |
|
|
564
575
|
| 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path` — one identical body for all |
|
|
@@ -638,7 +649,8 @@ The package never navigates by itself: when the session is gone — the SSE stre
|
|
|
638
649
|
|
|
639
650
|
`DailyReportPage` is layout-agnostic (header rendering via `renderHeader` slot, error boundaries/footer control managed by the app). For fine-grained usage, `DailyReportActionProvider`, `DailyReportResolvedContent`, `useDailyReportDetail`, etc. can be imported individually.
|
|
640
651
|
Every field `useDailyReportDetail(reportHubId, businessDate)` returns — `report`, `error`, `isLoading` and `isRefetching` — belongs to the id it is called with: the hook keeps them as one record keyed by the id. On the first render after the id changes it already returns that id's starting state: its cached report (one definition, also used by the loading effect: the report cache, expired entries included, then the business date's cached list), `isLoading` only when nothing is cached, no error and no refetch yet — never the previous id's report, error or loading flags.
|
|
641
|
-
An expired cached report is therefore drawn at once, never preceded by `null`, and refetched behind it; an answer that arrives for an earlier id is dropped. An id of 0 or less fetches nothing and returns no report (0 also returns an `Invalid ID` error).
|
|
652
|
+
An expired cached report is therefore drawn at once, never preceded by `null`, and refetched behind it; an answer that arrives for an earlier id, or after the component unmounted, is dropped. An id of 0 or less fetches nothing and returns no report (0 also returns an `Invalid ID` error).
|
|
653
|
+
The first load of a report that is not cached starts inside the hook's effect, with no task between the row's mount and its request, so a held arrow key's stream of keydowns cannot postpone it until the key is released; only the refetch of an expired cached report waits 120 ms, to absorb rows that only scroll past. Loads of reports of one business date share one request.
|
|
642
654
|
|
|
643
655
|
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.
|
|
644
656
|
|
|
@@ -657,10 +669,13 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
|
|
|
657
669
|
- **The fill chain**: every box from the page box down to a view's root — the page box, its centred width-capped wrapper, the view-mode tabs and the active tab panel — fills the rest of its parent column (`flex: 1 1 0%; min-height: 0`, `FILL_COLUMN_CLASS_NAME`) and declares no height. The wrapper's padding is 4 px at the top and the sides and none at the bottom, so the view's box ends exactly at the page's bottom edge.
|
|
658
670
|
- **The view's root contains its size** (`VIEW_ROOT_CLASS_NAME`: the fill rule, `contain: size` and `overflow: clip`): its box is sized as if it were empty, so what the view renders — a `VirtualScroll` given the measured height — never feeds back into the root's size, into the intrinsic size of an ancestor (a host column with a minimum height does not grow) or into the document's scroll height. The clip keeps rows drawn for the previous height from painting outside the box during the one frame before the view renders the new height.
|
|
659
671
|
Layout and paint are not contained, so the mobile overlay, a fixed-position descendant, keeps the viewport as its containing block.
|
|
660
|
-
- **One measurement**: `VirtualScroll` needs its viewport size as a number (`viewportSize`), so
|
|
661
|
-
The
|
|
672
|
+
- **One measurement, in one unit**: `VirtualScroll` needs its viewport size as a number (`viewportSize`), so each view reads its root's border-box block size in layout px (`useViewBoxHeight`).
|
|
673
|
+
The first value is read in a layout effect before the first paint, as the root's computed `block-size` in its own window (the root has no padding or border, so that is its border box; until then the view renders no body, so no row is ever drawn at a guessed height; the List also waits for its row slot, below).
|
|
674
|
+
Every later value comes from one `ResizeObserver` on the root alone (`box: "border-box"`), whose delivery only hands `borderBoxSize[0].blockSize` to React state and reads and writes nothing.
|
|
675
|
+
Both readings are the same layout primitive, taken before any ancestor transform or zoom (`getBoundingClientRect` would give the first value in visual px, corrected one frame later), so the same layout gives the same height whatever the order of the measurements and a scaled or zoomed ancestor causes no second commit.
|
|
676
|
+
Every change is followed, a sub-pixel one included, and 0 stays 0 (a root that is not rendered has no `px` block size and reads 0, the value `ResizeObserver` reports for it). A view whose document has no window throws. The value reaches only `viewportSize`: the list column, the panel group and the scroll container write no height.
|
|
662
677
|
- **G-symmetric frame**: the rows' gutter G is inside the view's box, so the last aligned surface — a List or DetailList row aligned to the bottom, or the List's side pane — sits exactly G = 8 px above the page's bottom edge (where a footer that follows the page starts), the distance from the view-mode toolbar to the first surface at the top.
|
|
663
|
-
- **Empty list**: `
|
|
678
|
+
- **Empty list**: an empty view shows exactly one message, `VirtualScroll`'s own empty state with the engine label `labels.noItems` (en "No items", ja 「項目がありません」), which `VirtualScroll` draws over the top of its content, out of the flow, so an empty view keeps the same box. The views render no message of their own; they only theme the engine's (`VIEW_EMPTY_TEXT_THEME_CLASS_NAME` on `VirtualScroll`'s root: 16 px above and below, slate-500 in light and slate-400 in dark: 4.55:1 and 7.66:1 against the page, where the engine's default grey reaches only 4.17:1 in dark).
|
|
664
679
|
|
|
665
680
|
### Keyboard, focus and selection (List / DetailList)
|
|
666
681
|
|
|
@@ -713,7 +728,7 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
713
728
|
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.
|
|
714
729
|
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.
|
|
715
730
|
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.
|
|
716
|
-
|
|
731
|
+
A row frame always fills the box `VirtualScroll` gives it (the List's slot; the DetailList's measured or estimated height), held or not, and a held surface is not measured, so holding moves neither the other rows nor the scroll anchor.
|
|
717
732
|
- **The row that owns focus keeps it.** Each view has at most one focus owner, the row it gives focus back to. One rule decides it, a pure transition over these events (`nextFocusOwner` in `src/client/keyboard/focus-ownership.ts`; the view only translates DOM events into them): outside the list a pointer press ends ownership, and inside the list only focus transitions change it.
|
|
718
733
|
|
|
719
734
|
| Event | Focus owner after it |
|
|
@@ -735,9 +750,14 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
735
750
|
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.
|
|
736
751
|
- **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.
|
|
737
752
|
- **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.
|
|
738
|
-
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:
|
|
753
|
+
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.
|
|
754
|
+
When the list changes, the view finds that report by id and restores the same offset with one scroll, in a layout effect before paint (only when that report or a row of the visible window changed).
|
|
755
|
+
The anchor is recorded after every scroll, so it describes the committed position, not the one before the last scroll:
|
|
756
|
+
- **Scrolls the view makes itself** — key moves (each frame of a held key included), the reveal of keyboard focus, the List's alignment of the card whose mobile overlay closed and its rescale when the slot P changes, the DetailList's selection alignment and its reveal after the viewer's comment — all go through one scroller that records the anchor right after scrolling, without waiting for the next visible-range report.
|
|
757
|
+
An insert or a delete that commits between such a scroll and the next frame therefore keeps the scrolled position: `End`, `PageDown` and a held key are never reverted, and focus stays on the key's destination. No other code of the views scrolls the handle (`src/client/components/view-scroller.spec.ts` fails on a direct call).
|
|
758
|
+
- **Scrolls `VirtualScroll` makes for the user** (wheel, drag, the scroll bar, inertia) are recorded each time the view reports its visible range, once per frame. A list change in the frame before that report first advances the anchor by the distance scrolled since it was recorded, walking the previous list's row heights from the anchor to the report now at the top, and restores from there: it neither undoes the user's scroll nor shifts the content by a row. The walk covers only the rows scrolled past in that frame, whatever the list's length or the position.
|
|
739
759
|
An insert or a delete before or inside the rendered window therefore leaves the first visible report — and the focus inside the rows — where it was. At the start of the list (scroll position 0) no anchor is kept, so reports that arrive at the top are shown;
|
|
740
|
-
a pending selection reveal (a DetailList selection waiting for its report
|
|
760
|
+
the anchor yields only to a pending selection reveal (a DetailList selection waiting for its report; a key move needs no precedence, since its scroll, its destination's render and the focus all end inside its own commit); and when the anchor report itself is removed, the first remaining report that followed it on screen is shown at its offset.
|
|
741
761
|
- **List side pane**: keys select through `onSelectItem` directly, so on a mobile layout they never open the detail overlay.
|
|
742
762
|
On the desktop layout the pane follows the selection in a deferred render: its frame (`[data-testid="daily-report-side-pane"]`) takes the new `data-report-id` in the key's own render and carries `aria-busy="true"` until the content catches up, and the content (`data-displayed-report-id`) renders from `useDeferredValue` of the selection, so React draws it when the main thread is free and drops an unfinished catch-up for a newer key.
|
|
743
763
|
While a held key repeats, the content keeps the report the repeat stream started from and does not render at all (the frame keeps following the selection, with `aria-busy="true"`); it catches up once, when the stream ends.
|
|
@@ -788,28 +808,35 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
788
808
|
- **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;
|
|
789
809
|
a DetailList row is its measured body plus 2G = 16.
|
|
790
810
|
- **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) = 2G + 2 × (1 + 15) (the card's border and padding) + 4 (spare) + 6.75R = 52 px + 6.75 rem, rounded up to a whole CSS pixel (`listRowSlotHeight`), so every row top stays on a whole pixel. 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).
|
|
791
|
-
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 P = 160
|
|
811
|
+
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 P = 160: the card is P − 2G = 144 px, cards are 16 px apart and an edge-aligned card sits exactly 8 px from the edge.
|
|
812
|
+
P is not a host contract: it follows the host's root font size, so a host or a test locates a row by `[data-daily-report-row="<id>"]` or through the test handle (below), never by its index times a slot height.
|
|
792
813
|
- **List card**: a wrapping row of the primary button and the action row (markers and toggles), with the preview always on the next line. The primary button takes the remaining width but never less than 96 px, enough for the business-date pill, and the action row keeps to the card's end; on a card too narrow for both (a phone with a pinned host menu), the action row wraps under the button instead of squeezing it to nothing, and the card clips the preview lines that no longer fit.
|
|
793
814
|
The content of a full card is 6.75 rem: the pills' line 1.5 rem + 0.5 + three one-line preview paragraphs of 1.25 rem, 0.5 rem apart (24 + 8 + 3 × 20 + 2 × 8 = 108 px at a 16 px root), inside a 1 + 15 inset at the top and the bottom (the inset counts the border, below), so 1 + 15 + 6.75R + 15 + 1 ≤ P − 2G with at least 4 px to spare at every root font size (140 ≤ 144 at 16 px).
|
|
794
815
|
- **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.
|
|
795
816
|
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.
|
|
796
817
|
- **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.
|
|
797
818
|
- **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.
|
|
798
|
-
- **
|
|
819
|
+
- **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; the layout of a key move stays inside the rows it changes, wherever the list is scrolled.
|
|
820
|
+
Size containment makes the frame's size independent of its content: its width is the row box `VirtualScroll` lays out, and its height is the slot P the List writes on the frame or, in the DetailList, that row box itself (`h-full`), whose height `VirtualScroll` takes from the measured row. The DetailList therefore measures each row's body — the frame's direct child, as tall as its content — and adds 2G; measuring the frame would read back the box it fills. A held body is not measured, and a body that is replaced (another load state, a released hold) is observed in its place.
|
|
821
|
+
A frame with layout and size containment that is neither a flex nor a grid item is a relayout boundary in Chromium. Neither frame contains paint, because the hover `shadow-lg` of an unselected surface (22 px below, 12 px to the sides) reaches beyond the 8 px gutter G that paint containment would clip (the resting `shadow-sm`, 4 px below, stays inside G; a selected surface paints no shadow).
|
|
822
|
+
Scrolling repaints rows only where `VirtualScroll` shifts its rendering window. A scroll step that mounts no row writes only the items wrapper's `transform` and repaints no row; a step that mounts one makes Chromium re-centre the area it paints the wrapper's layer in (`will-change: transform`), and every kept row whose content clips its own overflow repaints, which the rows of both views do. `@aiquants/virtualscroll`'s README ("What a scroll step paints") gives the cost of a one-row shift in Chromium 148: 36 layers with 160 px rows and 26 with 448 px rows, Paint 0.99 and 0.66 ms.
|
|
823
|
+
Containing paint would not avoid it (an overflow clip inside a row still marks the row as clipped by that area) and would clip the hover shadow; a `perspective` on the wrapper would avoid it, but it composites the rows under a non-2D transform that Chromium resamples at some device-pixel ratios (at 1.25 the band between the ring and the outline blends). Neither is used, so the strokes below stay whole device pixels.
|
|
799
824
|
Both side-pane tab panels (article and relations) are one scroll box, `contain: strict`: its size comes from the pane's column and, as a scroll container, it already clips, so its content never restyles or repaints the pane around it and cannot change the panel's size.
|
|
800
|
-
|
|
801
|
-
The panel reserves its scroll bar's width whatever the content's height (`scrollbar-gutter: stable`), so the width its content gets — and with it the attachment grid's tracks — never depends on whether the article overflows, and it keeps the focus reach (below) inside its clipping edges.
|
|
802
|
-
The tokens are `
|
|
825
|
+
The panel is not a relayout boundary in Chromium (it is a flex item), so a layout inside it still starts at the document root and walks the dirty chain of its ancestors; the tile frames inside the panel and the rows are boundaries of their own whose own style never changes, so an image's size arriving on load and the waiting pulse starting or stopping lay out inside the frame alone (**Frame** in [Attachment display](#attachment-display)).
|
|
826
|
+
The panel reserves its scroll bar's width whatever the content's height (`scrollbar-gutter: stable`, the token `SCROLLBAR_GUTTER_CLASS_NAME` that the pane's header shares, see **Side pane** below), so the width its content gets — and with it the attachment grid's tracks — never depends on whether the article overflows, and it keeps the focus reach (below) inside its clipping edges.
|
|
827
|
+
The tokens are `ROW_CONTAINMENT_CLASS_NAME` and `SIDE_PANE_ARTICLE_CONTAINMENT_CLASS_NAME`; `src/client/ui/row-containment.spec.ts` compiles them and measures the two shadows against G, and `src/client/components/report-views.spec.tsx` checks that both views' frames carry the boundary and are neither flex nor grid items.
|
|
803
828
|
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`.
|
|
804
829
|
- **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).
|
|
805
830
|
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.
|
|
806
831
|
- **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.
|
|
807
832
|
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.
|
|
833
|
+
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.
|
|
808
834
|
The view-mode toolbar clips nothing (it wraps instead, below).
|
|
809
835
|
`src/client/ui/focus-indicator.spec.ts` reads the sources and fails on a class constant that scrolls or contains paint (`overflow-*-auto` / `-scroll`, `contain-strict` / `-content` / `-paint`) without the reach token after following the constants it is built from, and on such a class written outside a constant; its one allowed constant, the containment token, is only ever composed into the side pane's scroll box.
|
|
810
836
|
- **State colours are the package's own too**: the switches and the tabs take every state colour from package tokens (`SWITCH_TRACK_COLOR_CLASS_NAME`, `SWITCH_THUMB_COLOR_CLASS_NAME`, `SEGMENTED_TRIGGER_CLASS_NAME`), never from the host's `--input`, `--primary`, `--background` or `--muted`.
|
|
811
|
-
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 a white surface (dark: slate-950) with slate-900 text (dark: slate-100) and a 2 px blue-600
|
|
812
|
-
|
|
837
|
+
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 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 its bottom edge, the bar being the cue that does not depend on colour. Unselected labels are slate-600 (dark: slate-300).
|
|
838
|
+
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 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 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 every label sits in the centre of its 24 px tab.
|
|
839
|
+
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 `Highlight`, painted as declared (`forced-color-adjust: none` on the bar), so the state survives the colour override. `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 % over the slate-50 page, dark slate-900 at 60 % over slate-950):
|
|
813
840
|
|
|
814
841
|
| Pair | Light | Dark | Minimum |
|
|
815
842
|
| --- | --- | --- | --- |
|
|
@@ -817,9 +844,9 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
817
844
|
| Switch thumb / on track | 5.25 | 7.64 | 3 |
|
|
818
845
|
| Off track / toolbar surface | 4.43 | 7.18 | 3 |
|
|
819
846
|
| On track / toolbar surface | 4.88 | 7.16 | 3 |
|
|
820
|
-
| Selected tab
|
|
821
|
-
| Selected tab
|
|
822
|
-
| Selected tab
|
|
847
|
+
| Selected tab bar / selected tab | 5.25 | 7.64 | 3 |
|
|
848
|
+
| Selected tab bar / the side pane's tab track | 4.79 | 5.54 | 3 |
|
|
849
|
+
| Selected tab bar / toolbar surface | 4.88 | 7.16 | 3 |
|
|
823
850
|
| Unselected label / the side pane's tab track | 6.90 | 9.83 | 4.5 |
|
|
824
851
|
| Unselected label / toolbar surface | 7.03 | 12.71 | 4.5 |
|
|
825
852
|
| Selected label / selected tab | 17.83 | 18.40 | 4.5 |
|
|
@@ -834,7 +861,10 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
834
861
|
Selection, focus, colours and borders switch instantly, and with reduced motion nothing moves, script included: the side pane's scroll after the viewer posts a comment is smooth only while the pane's own window does not prefer reduced motion and instant under `prefers-reduced-motion: reduce` (`scrollBehaviorFor` in `src/client/ui/motion.ts` reads the preference on every scroll, because browsers do not apply it to a script's `behavior: "smooth"`).
|
|
835
862
|
A touch or pen flick starts no glide under `prefers-reduced-motion: reduce` either: `VirtualScroll` continues a flick with a scripted `requestAnimationFrame` glide (for up to 10 s with the views' fast-scroll options), so each view reads the preference of its own window (`useReducedMotion`, which follows a change from the next render and is `false` on the server) and passes inertia options whose start threshold no velocity reaches (`inertiaOptionsFor(true)`); the content still follows the finger 1:1 while it drags and stops when it is released.
|
|
836
863
|
Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
|
|
864
|
+
The floating tap-scroll circle and its visual, the scroll bar's thumb and arrow buttons and the scroll-to-edge buttons are `VirtualScroll`'s own: one rule of its stylesheet (3.8.2, the peer floor) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
|
|
865
|
+
(the circle and every element inside it, overriding the visual's inline transitions; the bar's circle wrapper; the arrow buttons; the thumb; the scroll-to-edge buttons and their overlay), so the circle follows a drag without easing and the other parts change state at once.
|
|
837
866
|
While the list scrolls (`data-daily-report-scrolling` on the view's scroll container) the hover lift and its shadow are held still. `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 inertia option other than one chosen by `inertiaOptionsFor` is written outside `src/client/ui/motion.ts`.
|
|
867
|
+
That check is lexical — the stylesheets and the literals of the scripts. What actually moves is checked in the browser by the host app's reduced-motion end-to-end guard: under `prefers-reduced-motion: reduce`, after each step of a pointer and a touch tour of both views (the tap-scroll circle pressed and dragged, a card hovered, a flick, the mobile overlay opened and closed, a thumbnail loaded), no animation or transition of a motion property runs in the view, including what `VirtualScroll` renders.
|
|
838
868
|
- **Spacing**: every spacing the package writes is 4 (within a group), 8 (between parts) or 16 (between items), and every box length it writes sits on the 4 px grid, line boxes included: one-line text such as each List preview line has a 20 px line box (`leading-5`, the preview's lines 8 px apart at a 28 px pitch) and the reading text of the side pane, the DetailList content and the comments a 24 px one (`leading-6`); no line height is proportional to the font size.
|
|
839
869
|
These are the values at the default 16 px root: lengths written in rem (type, line boxes, the card's content, most spacing) scale with the root font size, while the CSS-pixel quantities of the state indicators — G and the lengths built on it — are written in px and keep their values at every root (see **Row gutter G** and **List slot P**).
|
|
840
870
|
Two lengths follow the container instead, by design: the attachment grid's fluid track width t = (W − 16 (n − 1)) / n (horizontal) and the tile frame's height derived from it, h(t) = round(2t / 3) (see [Attachment display](#attachment-display)). A surface that holds k rows of tiles is therefore off the 4 px grid by exactly k · h(t) (mod 4), which the app's visual contract asserts.
|
|
@@ -842,13 +872,19 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
842
872
|
A bordered box counts its border in its inset, so that its content edge sits on the grid measured from the visible edge: border + padding is on the ladder — 16 for the card surfaces, the error panel, the sides of the error banner and the mobile overlay's close button (1 + 15), 8 for the form fields, the comment input and the top and bottom of the error banner (1 + 7), 4 for the bordered tab bar, the development box and the top and bottom of the text input (1 + 3) — or equals a 12 px corner.
|
|
843
873
|
**The 12 px tier**: every panel with a 12 px corner has one inset, 12, equal to its corner, so its content box's corner is concentric with the panel's — the reading panel, the DetailList metadata column and the DetailList skeleton's inner panel (`p-3`, no border), the placeholder panel, a posted comment and the bordered pill (1 + 11); a 24 px pill has the same 12 px padding at its round ends. The tab bars are not in that tier: their 4 px inset makes them concentric with their 8 px tabs (12 − 4 = 8).
|
|
844
874
|
The only other exception is the visually hidden text. `src/client/ui/spacing-ladder.spec.ts` checks every class the package writes, that every bordered box's border + padding is on the ladder or equals its corner, and that every 12 px-corner panel's inset is 12.
|
|
875
|
+
- **Origin on the 4 px lattice**: the views' centred column (at most 72 rem, `max-w-6xl`, centred with `mx-auto`) starts at the centring offset rounded down to a multiple of 4 px (`VIEW_COLUMN_ORIGIN_CLASS_NAME`: `ml-[max(0px,round(down,calc((100%-72rem)/2),4px))]`), less than 4 px left of the exact centre. 4 CSS px is a whole number of device pixels at the ratios 1, 1.25, 1.5, 1.75 and 2 (4, 5, 6, 7 and 8), so every inline edge built on the origin — G, the ladder steps, the card, the row frame — starts on a whole device pixel.
|
|
876
|
+
Plain centring puts the column on a half pixel whenever the space beside it is odd (x = 56.5 in a 1,265 px area), and at a fractional ratio each 2 px stroke then blends into its neighbours.
|
|
877
|
+
On the lattice, each 2 px stroke of the two-channel indicator — the ring, the separation band and the outline — paints ⌊2 × ratio⌋ full device pixels on the left and right edges (2, 3 and 3 at 1.25, 1.5 and 1.75) with no blended pixel on its inner side, and the 1 px border paints its own colour (measured in Chromium at those ratios, light and dark). An engine without CSS `round()` drops the declaration and keeps the `mx-auto` centre.
|
|
878
|
+
The block-axis origin is the host's: the views start where the host's layout puts them. A host that wants the same whole-pixel strokes on the top and bottom edges at fractional ratios starts the views on the lattice too — for example with a header whose height is its content rounded up to 4 px (`height: calc-size(auto, round(up, size, 4px))`), since a header sized by its font metrics alone ends on a fraction (such as 30.4375 px).
|
|
845
879
|
- **Side pane** (List, desktop layout): the panel group fills the view's root, and the list and the side-pane panels both take its height ([View height](#view-height-host-layout)), so a long report scrolls only inside the pane's article tab and never lengthens the page. The pane sits G inside its panel on all four sides, so its top and bottom edges line up with the first and last cards, and its bottom edge sits exactly G above the page's bottom edge, as far as the toolbar is above the first surface.
|
|
880
|
+
The List panel ends G after its scroll bar (`LIST_PANEL_END_GUTTER_CLASS_NAME`, `pr-[8px]`), so a 2G = 16 px channel lies between the scroll bar and the pane, and the resize handle that `@aiquants/resize-panels` centres on the panel boundary (a 10 px hit area around a 6 px grip) covers neither the scroll bar nor the pane's edge.
|
|
881
|
+
The pane's header and its scrolling body end on one edge: the date and author column and the tab list sit in boxes that reserve the same scroll-bar gutter as the article and relations tab panel (`SIDE_PANE_HEADER_BOX_CLASS_NAME` and the panel both compose `SCROLLBAR_GUTTER_CLASS_NAME`, `scrollbar-thin` with `scrollbar-gutter: stable`, which an `overflow: hidden` box reserves too), so with a classic thin scroll bar, a wider one or an overlay one of no width, the header's end and the body's end share one x, in the desktop pane and in the mobile overlay alike.
|
|
846
882
|
- **Tab bars are one segmented control**: the side pane's tabs and the view-mode toolbar share one geometry. The bar has a 12 px corner (`rounded-xl`) and a total inset of 4 px that counts its border (4 px padding without a border, `SEGMENTED_LIST_CLASS_NAME`; 1 px border + 3 px padding with one, `SEGMENTED_BORDERED_LIST_CLASS_NAME`), the same at the top and at the sides.
|
|
847
883
|
The tabs are 24 px tall with 12 px labels and an 8 px corner (`rounded-lg`), so both bars are 4 + 24 + 4 = 32 px tall and every tab is concentric with its bar: 12 − 4 = 8. Because xl − lg = 4 equals the inset both in Tailwind's scale and in a host `--radius` scale (lg = `--radius`, xl = `--radius` + 4 px), the pair stays concentric for any host radius; the toolbar's children at its end corners (the create button, the development box) are `rounded-lg` too.
|
|
848
884
|
- **View-mode toolbar** (`DailyReportResolvedContent`, above every view): its start edge is inset by G and lines up with the cards; its end edge lines up with the active view's content: G in the desktop List, where the side pane ends the row, and G + the scroll-bar width (8 + 8 = 16) in the DetailList and in the single-column List, where the scroll bar ends it. Both insets are written in px (`ml-[8px]`, `mr-[8px]` / `mr-[16px]`), like G and the scroll bar, so they line up at every root font size.
|
|
849
885
|
It never scrolls sideways and hides nothing: when it is narrower than its one-row width its controls wrap onto a second row, every label kept. It clips nothing, so the tabs' outlines, which reach 4 px outside them into the bar's 4 px inset, are never cut (the **Focus reach** rule above). Its tab list is named by `labels.viewTabList`.
|
|
850
886
|
- **Scroll bars**: both views theme VirtualScroll's scroll bar through its root class (`VIEW_SCROLL_BAR_THEME_CLASS_NAME`): the track is slate-100 / dark slate-900 and the thumb slate-500 (slate-600 / 400 on hover, slate-700 / 300 while dragged), at least 3:1 against the track in both themes (lowest 4.35 / 3.74); the arrow glyphs reach 6.90 / 6.14 in light and 6.78 / 5.56 in dark on their resting and hover backgrounds.
|
|
851
|
-
- **Type and contrast**: no text is smaller than 12 px (`text-xs`); text reaches 4.5:1 and the indicators 3:1 in both themes (section labels slate-500 / slate-400: 4.77 / 7.09). The floating tap-scroll circle hides while the view root carries `data-daily-report-keyboard-focus` (`
|
|
887
|
+
- **Type and contrast**: no text is smaller than 12 px (`text-xs`); text reaches 4.5:1 and the indicators 3:1 in both themes (section labels slate-500 / slate-400: 4.77 / 7.09). The floating tap-scroll circle hides while the view root carries `data-daily-report-keyboard-focus` (through the class both views pass in `VirtualScroll`'s tap-scroll circle options).
|
|
852
888
|
- **Action buttons and icons**: the List card's star and read toggles, the star, read, edit and delete buttons of the DetailList header and the side pane, and the trash button of the viewer's comments share one 24 px round target that never shrinks, with a 16 px SVG icon centred in it (4 px on every side), so the icons sit on the 4 px grid without depending on a font. In the List card the markers and toggles stand 4 px apart; the header pills and the source badge are 24 px tall like the targets.
|
|
853
889
|
The star is a regular five-pointed star, outlined when off and filled gold when on (with a darker gold edge in the light theme); read is a check, unread an 8 px dot, edit a pencil, delete a trash can. Every icon state reaches at least 3.59:1 against each background it sits on, the button's hover background included, in both themes; in forced colours the star is drawn in `CanvasText` (off) and `Highlight` (on). The side pane shows them in the order star, read, edit, delete, centred on the first line of the subject.
|
|
854
890
|
Unread is shown by the dot on the read toggle and by the row state in the description; no surface changes its border or its layout for it (a card's border is the same 1 px in every state).
|
|
@@ -969,7 +1005,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
969
1005
|
```
|
|
970
1006
|
|
|
971
1007
|
- **One surface for all wording.** The catalog covers the 7 engine chrome keys of
|
|
972
|
-
`@aiquants/virtualscroll` plus
|
|
1008
|
+
`@aiquants/virtualscroll` plus 83 own keys: field headings, the page title (`title`, passed to
|
|
973
1009
|
`renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state read to
|
|
974
1010
|
screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the load-error screen
|
|
975
1011
|
and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
|
|
@@ -988,7 +1024,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
988
1024
|
the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
|
|
989
1025
|
the raw number and formats it itself.
|
|
990
1026
|
- **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
|
|
991
|
-
`RangeError` at render. So do an unknown `labels` key (a key error that lists the
|
|
1027
|
+
`RangeError` at render. So do an unknown `labels` key (a key error that lists the 90 keys), a string
|
|
992
1028
|
key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
|
|
993
1029
|
function. An `undefined` value keeps the catalog value.
|
|
994
1030
|
- **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
|
|
@@ -1025,7 +1061,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1025
1061
|
grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
|
|
1026
1062
|
that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
|
|
1027
1063
|
|
|
1028
|
-
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all
|
|
1064
|
+
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 90 keys, the 7 engine keys
|
|
1029
1065
|
first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
|
|
1030
1066
|
(the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
|
|
1031
1067
|
`locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
|
|
@@ -1039,7 +1075,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
1039
1075
|
| `scrollRight` | engine: horizontal arrow (not rendered by this package) | Scroll right | 右へスクロール |
|
|
1040
1076
|
| `scrollToTop` | engine: top pill (not enabled by this package) | Top | 先頭へ |
|
|
1041
1077
|
| `scrollToBottom` | engine: bottom pill (not enabled by this package) | Bottom | 末尾へ |
|
|
1042
|
-
| `noItems` | engine: empty list
|
|
1078
|
+
| `noItems` | engine: the one message of an empty List or DetailList, over the top of the list | No items | 項目がありません |
|
|
1043
1079
|
| `id` | DetailList card: id chip | ID | ID |
|
|
1044
1080
|
| `businessDate` | business date heading | Business date | 営業日 |
|
|
1045
1081
|
| `author` | author heading | Author | 作成者 |
|
|
@@ -1085,7 +1121,6 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
1085
1121
|
| `streamFailed` | stream badge (failed) | Failed to load | 読み込みに失敗しました |
|
|
1086
1122
|
| `streamRetry` | stream badge retry button | Retry | 再試行 |
|
|
1087
1123
|
| `streamRevalidating` | stream badge (revalidating) | Refreshing | 最新化中 |
|
|
1088
|
-
| `listEmpty` | package empty state, laid over the top of an empty list | No data | データがありません |
|
|
1089
1124
|
| `selectPrompt` | side pane without a selection | Select a report from the list on the left. | 左側の一覧から日報を選択してください。 |
|
|
1090
1125
|
| `loading` | side pane loading (edit mode) | Loading... | 読み込み中... |
|
|
1091
1126
|
| `reportNotFound` | list card of a vanished report | Report not found | 日報が見つかりません |
|
|
@@ -1220,6 +1255,8 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
|
|
|
1220
1255
|
|
|
1221
1256
|
- **Resume cursor**: `readLastEventId` reads the `Last-Event-ID` header first, then the `lastEventId` query parameter (`DAILY_REPORT_SSE_CURSOR_PARAM`). The browser's own reconnect reuses the URL it opened with (a stale query) but sends a fresh header, so the header is always the newer value. A cursor must be a Redis stream id of the form `<ms>-<seq>` with 1–15 digits per part (`isDailyReportSseStreamId`); anything else is answered with `resync-required`. The client URL-encodes the cursor.
|
|
1222
1257
|
- **`connected` frame**: `data: {"type":"connected"}` with `id:` set to the resume position (the cursor, or the newest entry at connect time when there is no cursor). On the cursor path it is written before any Redis round trip.
|
|
1258
|
+
Without a cursor it is written only after the shared reader has fixed its read position (`DailyReportSseReader.ready()`), so every entry appended after a client sees `connected` reaches that client; on the cursor path the catch-up is read after that point instead, so nothing appended between the catch-up and the position fix is lost.
|
|
1259
|
+
When the stream tail cannot be read, `ready()` still resolves and the reader degrades to `$`, where an entry appended while a read is being re-armed can be missed; a running reader moves to the position as soon as a later `ready()` fixes it.
|
|
1223
1260
|
The same frame is used as an anchor-only frame for entries the viewer's filters drop (addressed to another user, or a source type the viewer cannot see), so the client's cursor keeps advancing without receiving their content.
|
|
1224
1261
|
Without it, a tab whose visible traffic is quiet while other users' read / star updates flow would keep an old cursor, and its next reconnect would fall outside the retained window (`resync-required`, then an ids rescan that bypasses the server cache).
|
|
1225
1262
|
- **Catch-up**: entries after the cursor are read in pages of 1,000 (`xRange` is inclusive, so each page skips its start id) until the stream end; every entry is delivered. When a page does not start with its start id, the oldest retained entry is read again: if it is newer than the start id, the range after it may have been trimmed, so the connection ends with `resync-required` instead of silently skipping it (a cursor that is simply not an entry id inside the retained window continues).
|
|
@@ -1237,7 +1274,7 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
|
|
|
1237
1274
|
|
|
1238
1275
|
## API Surface (Summary)
|
|
1239
1276
|
|
|
1240
|
-
Every entry exports by name (no `export *`), so a module-internal helper never becomes public by accident. `src/public-surface.spec.ts` pins the runtime and the type names of the three entries below and fails when a public name is missing from this section.
|
|
1277
|
+
Every entry exports by name (no `export *`), so a module-internal helper never becomes public by accident. A name is added to an entry only when a consumer — a host app or the examples — imports it: documenting a name here is no reason to export it, and the values that tune the package itself (its row geometry, its scroll settings) stay inside it. `src/public-surface.spec.ts` pins the runtime and the type names of the three entries below and fails when a public name is missing from this section.
|
|
1241
1278
|
|
|
1242
1279
|
- **shared** (`@aiquants/daily-report`, isomorphic):
|
|
1243
1280
|
- Values: `buildDailyReportAttachmentUrl` / `parseDailyReportAttachmentQuery` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_VARIANTS` / `isDailyReportAttachmentThumbnailVariant` (the attachment URL codec and the variant list), `DAILY_REPORT_SSE_TERMINAL_EVENTS` / `DAILY_REPORT_SSE_HEARTBEAT_MS` / `DAILY_REPORT_SSE_CURSOR_PARAM` / `isDailyReportSseStreamId` (the SSE wire constants),
|
|
@@ -1248,7 +1285,7 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
|
|
|
1248
1285
|
- Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider`.
|
|
1249
1286
|
- Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (`sseStatus`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` / `useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `streamAnchor`).
|
|
1250
1287
|
- The ids stream: `DailyReportIdsStreamClient` (`resync()`) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
|
|
1251
|
-
- Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig
|
|
1288
|
+
- Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`. No layout value is public: the row geometry follows the host's root font size (see **List slot P**), so rows are located by `[data-daily-report-row]` or the test handle.
|
|
1252
1289
|
- Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`.
|
|
1253
1290
|
- **server** (`@aiquants/daily-report/server`, Node.js):
|
|
1254
1291
|
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
package/dist/client.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as react from "react";
|
|
2
2
|
import { RefObject, ReactNode, Context } from "react";
|
|
3
3
|
import { D as DailyReportItem, a as DailyReportUser, b as DailyReportDetail } from "./types-DkERw8NE.mjs";
|
|
4
|
-
import { VirtualScrollLocale, VirtualScrollLabels
|
|
4
|
+
import { VirtualScrollLocale, VirtualScrollLabels } from "@aiquants/virtualscroll";
|
|
5
5
|
import { UseReopeningEventSourceResult } from "@aiquants/sse/react";
|
|
6
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";
|
|
@@ -87,7 +87,6 @@ type DailyReportLabels = VirtualScrollLabels & {
|
|
|
87
87
|
readonly streamFailed: string;
|
|
88
88
|
readonly streamRetry: string;
|
|
89
89
|
readonly streamRevalidating: string;
|
|
90
|
-
readonly listEmpty: string;
|
|
91
90
|
readonly selectPrompt: string;
|
|
92
91
|
readonly loading: string;
|
|
93
92
|
readonly reportNotFound: string;
|
|
@@ -186,7 +185,6 @@ declare const DAILY_REPORT_LABEL_KEYS: readonly [
|
|
|
186
185
|
"streamFailed",
|
|
187
186
|
"streamRetry",
|
|
188
187
|
"streamRevalidating",
|
|
189
|
-
"listEmpty",
|
|
190
188
|
"selectPrompt",
|
|
191
189
|
"loading",
|
|
192
190
|
"reportNotFound",
|
|
@@ -452,6 +450,4 @@ declare const bootstrapDailyReportIdsStreamSession: (options: {
|
|
|
452
450
|
declare const primeDailyReportIdsStreamSession: () => void;
|
|
453
451
|
declare const resyncDailyReportIdsStream: () => void;
|
|
454
452
|
declare const useDailyReportIdsStream: () => DailyReportIdsStreamState;
|
|
455
|
-
|
|
456
|
-
declare const TAP_SCROLL_CIRCLE_OPTIONS: ScrollBarTapCircleOptions;
|
|
457
|
-
export { DAILY_REPORT_LABEL_CATALOGS, DAILY_REPORT_LABEL_KEYS, DEFAULT_ITEM_HEIGHT, DailyReportActionProvider, DailyReportAttachmentIndicator, type DailyReportClientConfigDefaults, type DailyReportClientConfigInput, DailyReportConfigProvider, DailyReportDetailList, type DailyReportErrorInfo, DailyReportErrorProvider, DailyReportIdsStreamClient, DailyReportIdsStreamStatus, type DailyReportLabelOverrides, type DailyReportLabels, DailyReportList, type DailyReportLocale, type DailyReportOperation, DailyReportPage, DailyReportResolvedContent, type DailyReportSseConnectionStatus, type SourceTypeConfig, TAP_SCROLL_CIRCLE_OPTIONS, bootstrapDailyReportIdsStreamSession, createDailyReportClientLoader, dailyReportShouldRevalidate, defaultDailyReportClientConfig, ensureDailyReportIdsStreamSession, primeDailyReportIdsStreamSession, resolveDailyReportLabels, resolveSourceTypeConfig, resyncDailyReportIdsStream, useDailyReportActionContext, useDailyReportComments, useDailyReportConfig, useDailyReportDetail, useDailyReportIdsStream, useDailyReportPrefetch, useDailyReportSseConnection };
|
|
453
|
+
export { DAILY_REPORT_LABEL_CATALOGS, DAILY_REPORT_LABEL_KEYS, DailyReportActionProvider, DailyReportAttachmentIndicator, type DailyReportClientConfigDefaults, type DailyReportClientConfigInput, DailyReportConfigProvider, DailyReportDetailList, type DailyReportErrorInfo, DailyReportErrorProvider, DailyReportIdsStreamClient, DailyReportIdsStreamStatus, type DailyReportLabelOverrides, type DailyReportLabels, DailyReportList, type DailyReportLocale, type DailyReportOperation, DailyReportPage, DailyReportResolvedContent, type DailyReportSseConnectionStatus, type SourceTypeConfig, bootstrapDailyReportIdsStreamSession, createDailyReportClientLoader, dailyReportShouldRevalidate, defaultDailyReportClientConfig, ensureDailyReportIdsStreamSession, primeDailyReportIdsStreamSession, resolveDailyReportLabels, resolveSourceTypeConfig, resyncDailyReportIdsStream, useDailyReportActionContext, useDailyReportComments, useDailyReportConfig, useDailyReportDetail, useDailyReportIdsStream, useDailyReportPrefetch, useDailyReportSseConnection };
|
package/dist/client.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as react from "react";
|
|
2
2
|
import { RefObject, ReactNode, Context } from "react";
|
|
3
3
|
import { D as DailyReportItem, a as DailyReportUser, b as DailyReportDetail } from "./types-DkERw8NE.js";
|
|
4
|
-
import { VirtualScrollLocale, VirtualScrollLabels
|
|
4
|
+
import { VirtualScrollLocale, VirtualScrollLabels } from "@aiquants/virtualscroll";
|
|
5
5
|
import { UseReopeningEventSourceResult } from "@aiquants/sse/react";
|
|
6
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";
|
|
@@ -87,7 +87,6 @@ type DailyReportLabels = VirtualScrollLabels & {
|
|
|
87
87
|
readonly streamFailed: string;
|
|
88
88
|
readonly streamRetry: string;
|
|
89
89
|
readonly streamRevalidating: string;
|
|
90
|
-
readonly listEmpty: string;
|
|
91
90
|
readonly selectPrompt: string;
|
|
92
91
|
readonly loading: string;
|
|
93
92
|
readonly reportNotFound: string;
|
|
@@ -186,7 +185,6 @@ declare const DAILY_REPORT_LABEL_KEYS: readonly [
|
|
|
186
185
|
"streamFailed",
|
|
187
186
|
"streamRetry",
|
|
188
187
|
"streamRevalidating",
|
|
189
|
-
"listEmpty",
|
|
190
188
|
"selectPrompt",
|
|
191
189
|
"loading",
|
|
192
190
|
"reportNotFound",
|
|
@@ -452,6 +450,4 @@ declare const bootstrapDailyReportIdsStreamSession: (options: {
|
|
|
452
450
|
declare const primeDailyReportIdsStreamSession: () => void;
|
|
453
451
|
declare const resyncDailyReportIdsStream: () => void;
|
|
454
452
|
declare const useDailyReportIdsStream: () => DailyReportIdsStreamState;
|
|
455
|
-
|
|
456
|
-
declare const TAP_SCROLL_CIRCLE_OPTIONS: ScrollBarTapCircleOptions;
|
|
457
|
-
export { DAILY_REPORT_LABEL_CATALOGS, DAILY_REPORT_LABEL_KEYS, DEFAULT_ITEM_HEIGHT, DailyReportActionProvider, DailyReportAttachmentIndicator, type DailyReportClientConfigDefaults, type DailyReportClientConfigInput, DailyReportConfigProvider, DailyReportDetailList, type DailyReportErrorInfo, DailyReportErrorProvider, DailyReportIdsStreamClient, DailyReportIdsStreamStatus, type DailyReportLabelOverrides, type DailyReportLabels, DailyReportList, type DailyReportLocale, type DailyReportOperation, DailyReportPage, DailyReportResolvedContent, type DailyReportSseConnectionStatus, type SourceTypeConfig, TAP_SCROLL_CIRCLE_OPTIONS, bootstrapDailyReportIdsStreamSession, createDailyReportClientLoader, dailyReportShouldRevalidate, defaultDailyReportClientConfig, ensureDailyReportIdsStreamSession, primeDailyReportIdsStreamSession, resolveDailyReportLabels, resolveSourceTypeConfig, resyncDailyReportIdsStream, useDailyReportActionContext, useDailyReportComments, useDailyReportConfig, useDailyReportDetail, useDailyReportIdsStream, useDailyReportPrefetch, useDailyReportSseConnection };
|
|
453
|
+
export { DAILY_REPORT_LABEL_CATALOGS, DAILY_REPORT_LABEL_KEYS, DailyReportActionProvider, DailyReportAttachmentIndicator, type DailyReportClientConfigDefaults, type DailyReportClientConfigInput, DailyReportConfigProvider, DailyReportDetailList, type DailyReportErrorInfo, DailyReportErrorProvider, DailyReportIdsStreamClient, DailyReportIdsStreamStatus, type DailyReportLabelOverrides, type DailyReportLabels, DailyReportList, type DailyReportLocale, type DailyReportOperation, DailyReportPage, DailyReportResolvedContent, type DailyReportSseConnectionStatus, type SourceTypeConfig, bootstrapDailyReportIdsStreamSession, createDailyReportClientLoader, dailyReportShouldRevalidate, defaultDailyReportClientConfig, ensureDailyReportIdsStreamSession, primeDailyReportIdsStreamSession, resolveDailyReportLabels, resolveSourceTypeConfig, resyncDailyReportIdsStream, useDailyReportActionContext, useDailyReportComments, useDailyReportConfig, useDailyReportDetail, useDailyReportIdsStream, useDailyReportPrefetch, useDailyReportSseConnection };
|