@aiquants/daily-report 0.25.0 → 0.26.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 -2
- package/README.md +77 -36
- package/dist/client.js +4 -4
- package/dist/client.mjs +4 -4
- package/dist/server.d.mts +12 -3
- package/dist/server.d.ts +12 -3
- package/dist/server.js +4 -4
- package/dist/server.mjs +4 -4
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@aiquants/daily-report` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.26.0 (2026-10-04)
|
|
6
|
+
|
|
7
|
+
0.25.0 の続き: 位置の変化ごとの錨の記録、行と面の 4 px の格子、読み込みの画面の枠とエラーの文言の色、コメントの節の 1 つの規則、添付の配信とサムネイルの生成の締め付け、共有 SSE リーダーの位置の固定。どれも README の該当の節が今の契約を記す。
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **錨は位置のどの変化の後にも記録する**: DetailList の行の測り直しはスクロールの口の `resizeRow` (VirtualScroll の `updateItemSize` の直後に錨を記録する) を通る。`updateItemSize` は可視の先頭より上の行の高さが変わるとその差だけ位置を自分でずらす (レイアウトのずれの補正) ので、直後の記録は補正の後の状態を読む。
|
|
12
|
+
VirtualScroll が自分で位置を動かしたとき (測り直した行の補正・描画の後の高さの照合・保留中の揃えの留め直し・クランプされた補正の再発行) は、`@aiquants/virtualscroll` 3.9.0 の `onScrollAdjust` で同期に知らされ、両ビューは錨をその場で記録し直す。
|
|
13
|
+
以前は次のフレームの可視範囲の知らせまで錨が古いままで、その間に確定した一覧の変化 (SSE の挿入・削除) が、先頭に見えている日報を補正の差だけずらすか隣の日報に置き換えた。
|
|
14
|
+
キーボード操作とビューが受け取るハンドルは読み取りの関数だけの型 (`ListNavigationHandle`) になり、位置を動かす関数 (`scrollToIndex`・`scrollBy`・`scrollTo`・`applyWheel`・`updateItemSize`) を口の外で呼ぶと型検査で落ちる。利用者のスクロールの後の錨の進め方は、一様な高さの行 (List) では算術で 1 回、実測の高さの行 (DetailList) では通り過ぎた行だけをたどる。
|
|
15
|
+
- **ビューの高さはどの値も `ResizeObserver` から**: 最初の値も観測の最初の配達から受け取り、その配達は `flushSync` で描く前に確定させる (配達まではビューの中身を描かないので、推測の高さでは描かない)。0.25.0 は最初の値を計算済みの `block-size` から読み、有効数字 6 桁の文字列 (743.65625 が 743.656) のために端数のある高さを 2 回確定させた。
|
|
16
|
+
箱を持たない根要素は、Chromium では最初の配達の 0、仕様どおり 0 × 0 から観測を始める描画エンジンでは箱を持つまで値が無い (VirtualScroll を描かない)。
|
|
17
|
+
- **行の枠と面は 4 px の格子の上**: List の行の枠の高さ P は 52 px + 6.75 rem を 4 px の倍数へ切り上げる (根 16 px は 160 のまま。根 12・20・24 px は 133・187・214 から 136・188・216 になり、カードの余裕が 4 px 未満だけ増える)。DetailList の測る前の行の見積もりは 352 px (以前の 350 は、窓より上の測っていない行 1 つごとに下の行を 2 px ずらした)。
|
|
18
|
+
DetailList の行の本体 (カード・スケルトン・編集の枠・エラー) は中身の高さを 4 px へ切り上げる (`calc-size(auto, round(up, size, 4px))`。根 16 px では変わらない。持たない描画エンジンは中身の高さのまま)。
|
|
19
|
+
画像の添付のタイルは最後の行の下に 0〜3 px の寄せ (`round(up, h, 4px) − h`) を持ち、どのタイルも ⌈h / 4⌉ · 4 + 48 px になる (枠の高さ h(t) = round(2t / 3) と 3 : 2 の画像の埋まり方は変えない)。タイルの `100cqi` の容器はタイルの根になり、枠の包みは容器でなくなった。
|
|
20
|
+
以前は k 段のタイルを持つ面が k · h(t) (mod 4) だけ格子を外れ、その下の DetailList の行は画素密度 1.25 で装置の画素の半分ずれて、枠線・選択の輪・アウトラインがにじんだ。
|
|
21
|
+
- **端に揃えた面は G より端へ寄らない**: `@aiquants/virtualscroll` 3.9.0 の装置の画素への揃えが揃えた端を守るので、下端・上端に揃えた行の面は端から G = 8 px より近づかず (足されるのは 1 装置画素未満)、ビューの高さと行の枠が装置の画素の整数ならちょうど G になる。以前はビューの高さが端数だと、下端に揃えた行の余白が最大で装置の画素の半分削られた (比 1 で高さ 705.5 px のビューの最後の行の余白が 7.5 px)。
|
|
22
|
+
- **読み込み中と読み込みの失敗の画面も同じページの枠と列**: どの画面もページの枠 (`VIEW_PAGE_FRAME_CLASS_NAME`。明 slate-50・暗 slate-950) と中央の列 (`VIEW_COLUMN_CLASS_NAME`。4 px の格子の原点を含む) を使う。以前の読み込み中と失敗の画面は明るい slate-50 の枠を直に書いたので、暗色では読み込みが終わるまで明るいページで、終わると切り替わった。
|
|
23
|
+
エラーの文言の色は 1 つのトークン (`ERROR_TEXT_CLASS_NAME`。明 red-700・暗 red-400) で、載るパッケージの面のどれにも明暗とも 6.17:1 以上。以前は失敗の画面の文言 (red-600) が暗色の面の上で 1.42:1、側面ペインの日報の読み込みの失敗 (red-500) が白の上で 3.81:1 だった。
|
|
24
|
+
- **コメントの節は 1 つの規則**: DetailList の行・側面ペイン・モバイルのオーバーレイは、コメントがあるか投稿できる間だけ、見出しごとコメントの節を描く。以前の側面ペインとオーバーレイは常に描き、投稿できない種別でコメントの無い日報に空の見出しを残した。
|
|
25
|
+
削除を確定した後のフォーカスの行き先は残る構造から決める: 次のゴミ箱、前のゴミ箱、節が残るなら節の見出し、残らなければ日報自身の目印 (DetailList は行、側面ペインとオーバーレイはペインの日報の見出し)。以前の DetailList では、投稿できない種別の最後のコメントを消すと、フォーカスを移した見出しが節ごと消え、フォーカスが `body` へ落ちた。
|
|
26
|
+
- **営業日の吹き出しは `transform` で付いていく**: スクロールバーのつまみの営業日の吹き出しは一定の位置に置き、つまみの中心へは `transform` だけで動かす (遷移は付けない)。以前は `top` / `left` を書いたので、吹き出しを見せている間のスクロールの 1 段ごとに文書の根からのレイアウトを 1 回足した。
|
|
27
|
+
- **sharp を起動する前のたどりに歩数の上限**: JPEG の区切りと走査の読み飛ばしと WebP のチャンクのたどりは同期でイベントループを止めるので、訪れる 0xFF 1 つと WebP のチャンク 1 つを 1 歩と数え、`maxWalkSteps` = ⌊16 ms × 10⁶ ÷ (50 ns × 2)⌋ = 160,000 歩を超える原本は sharp を起動せずに `unsupported` (内容で決まる失敗として記録) にする。符号化済みのデータの 0xFF の連なりは 1 つの JS のループで読む。
|
|
28
|
+
以前は 32 MiB の原本で、符号化済みのデータが 0xFF の連なりならイベントループを 0.97〜1.11 秒、`FF 00` や再同期の連なりなら 0.50〜0.59 秒止め、そのうえ sharp を起動した。上限までのたどりは、測ったホストで最も重い形 (`FF 00` の組を 3〜209 バイトおきに散らした原本) でも 5.0〜7.0 ms、ほかの形は 1〜2.4 ms。
|
|
29
|
+
- **添付のどの応答にも `Cross-Origin-Resource-Policy` と `nosniff`**: 失敗の応答 (400・401・403・404・405・413・429・500・502・503。GET と HEAD) も `Cross-Origin-Resource-Policy: same-origin` と `X-Content-Type-Options: nosniff` を持つ。どの応答の経路も 1 つのヘッダーの組を最後に広げる。
|
|
30
|
+
0.25.0 は成功の応答 (原本の 200 と縮小画像の 200・304) にだけ方針を付けたので、同じサイトのページが no-cors の `fetch` で、見える添付と見えない添付をトークンごとに見分けられた。
|
|
31
|
+
- **内容キーごとに展開は 1 つ**: 描画の締め切りを過ぎても続く描画がある間、同じ内容の次の生成は枠を取らずにその決着を待ち、結論が記録されていればキャッシュから答える (締め切りの後の描画の待ちと枠の待ちを合わせて `queueWaitMs` の内。尽きたら展開を重ねずに 503 `queue`)。以前は既定の 2 枠のもう 1 つで、締め切りを越えさせた混雑の中で同じ原本をもう一度読んで展開した。
|
|
32
|
+
描画の結果は締め切りの前に決着しても後に決着しても同じ 1 つの分類で決め、オブジェクトでない結果は 500 (`port_contract` / `code=result`) になる (以前は例外)。
|
|
33
|
+
- **共有 SSE リーダーは `$` から読まない**: 読み取り位置はいつも具体 ID で、ストリームの末尾を読めないときは `ready()` が拒否する (失敗した読取りは残さず、次の呼び出しが読み直す)。同梱の SSE ハンドラはその接続を閉じ (`producer-failed`)、クライアントはカーソルがあればそれで開き直して追いつく。位置を固定できない run は、ほかの失敗した run と同じく全購読者の `onError` を呼ぶ。
|
|
34
|
+
以前は末尾を読めないと `"$"` へ縮退し、2 回の XREAD の合間に足されたエントリを誰にも届けない窓が残った (`"$"` は XREAD のたびにその時点の末尾へ解決される)。
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- server の `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_WORK_MODEL`: 描画の段の締め切り `renderMs` (10,000) と、展開の仕事の上限 (`nanosecondsPerBlockVisit`・`margin`・`maxBlockVisits`) とたどりの上限 (`walkBudgetMs`・`nanosecondsPerWalkStep`・`walkMargin`・`maxWalkSteps`) を持つ凍結した表。ホストの適合の検査は締め切りを書き写さずにここから読む。
|
|
39
|
+
- **開発者向け**: 公開のスクリプトは `pnpm run verify` の直後に `node scripts/check-bundle-size.mjs --exact` を走らせる。測った大きさが基準の項目と 1 バイトでも違えば (大きくても小さくても) 落とし、そのビルドを記録する `--write --accept` のコマンドを示すので、公開する版はどれも自分のビルドの基準を持つ。SQL の結果のキャッシュのキーは 1 つのモジュール (`cache-keys.ts`) が組み、誰も書かないキーを消す無効化の呼び出しを除いた。
|
|
40
|
+
|
|
41
|
+
### Removed
|
|
42
|
+
|
|
43
|
+
- server の `SqlResultCache` の `invalidate` と `flush` (同じ処理の 2 つの名前)。完全一致のキーで消す呼び出しはどこにも無い (詳細のキーは利用者と可視性で区切るので、完全一致のキーは誰も書かないキーを指していた)。消すのは `invalidatePrefix` と `clearAll` の 2 つだけ。
|
|
44
|
+
|
|
45
|
+
### Breaking
|
|
46
|
+
|
|
47
|
+
- `DailyReportSseReader.ready()` はストリームの末尾を読めないとき拒否する (0.24.0 と 0.25.0 は `"$"` へ縮退して解決した)。Migration: 自前の SSE ハンドラで共有リーダーを使うホストは、`ready()` の拒否で接続を閉じてクライアントに開き直させる (同梱のハンドラはそうしている)。リーダーを自前で差し替えるホストの `ready()` も、位置を具体 ID に固定できないときは拒否する。
|
|
48
|
+
- ピアの `@aiquants/virtualscroll` の下限を 3.9.0 へ上げる。両ビューは 3.9.0 の `onScrollAdjust` で錨を記録し直し、揃えた端を守る装置の画素への揃えに頼る。Migration: `@aiquants/virtualscroll` を 3.9.0 以降へ上げる。
|
|
49
|
+
- server の `SqlResultCache` から `invalidate` と `flush` を外した。Migration: 1 つのキーを消していた呼び出しは、そのキーを接頭辞とする `invalidatePrefix(<キー>)` か、全部を消す `clearAll()` へ置き換える。
|
|
50
|
+
|
|
5
51
|
## 0.25.0 (2026-10-03)
|
|
6
52
|
|
|
7
53
|
0.23.0 のキーボード操作とサムネイルの仕上げの続きと、共有 SSE リーダーの取りこぼしの修正。どれも README の該当の節が今の契約を記す。
|
|
@@ -11,7 +57,7 @@ All notable changes to `@aiquants/daily-report` are documented here.
|
|
|
11
57
|
- **スクロールの錨はスクロールの直後に記録する**: ビュー自身のスクロール (キーの移動と長押しのフレームごとの移動・キーボードのフォーカスを見せるスクロール・List のモバイルのオーバーレイを閉じた後の揃えと行の枠の高さ P が変わったときの位置の直し・DetailList の選択の揃えとコメントの投稿の後の見せ方) はどれも 1 つのスクロールの口を通り、口はスクロールの直後に VirtualScroll のハンドルから錨を記録する。以前は次のフレームの可視範囲の知らせで記録したので、キーと次のフレームの間に確定した SSE の挿入・削除が位置をキーの前へ戻した (End・PageDown・長押しが巻き戻り、DetailList ではフォーカスが body へ落ちた)。
|
|
12
58
|
利用者のスクロール (ホイール・ドラッグ・スクロールバー・慣性) は今までどおり可視範囲の知らせで記録し、知らせの前の 1 フレームに一覧が変わったときは、記録してからスクロールした距離だけ錨を前の一覧の行の高さで進めてから戻す (利用者のスクロールを巻き戻さず、1 行もずらさない)。錨が譲るのは、日報の到着を待つ DetailList の選択の見せ方だけ。
|
|
13
59
|
- **キャッシュに無い日報の最初の読み込みはすぐ始める**: `useDailyReportDetail` はキャッシュに無い日報の要求を効果の中で始める。以前は 0 ms のタイマーを挟んだので、キーの長押しが続く間ずっと後回しにされ、キーを離すまで要求が出なかった。間を置く (120 ms) のは期限切れのキャッシュの日報の再取得だけ。id が変わった後とアンマウントの後に届いた結果は捨て、同じ営業日の要求は 1 つにまとまる。
|
|
14
|
-
- **ビューの高さの最初の値も配置の px**: ビューの箱の高さは最初の値も根要素の計算済みの `block-size` (配置の px) から読み、以後の `ResizeObserver` の値と同じ単位になった (以前の最初の値は `getBoundingClientRect` の見た目の px で、祖先が変形・拡大縮小しているときだけ 1 フレーム後の観測で直った)
|
|
60
|
+
- **ビューの高さの最初の値も配置の px**: ビューの箱の高さは最初の値も根要素の計算済みの `block-size` (配置の px) から読み、以後の `ResizeObserver` の値と同じ単位になった (以前の最初の値は `getBoundingClientRect` の見た目の px で、祖先が変形・拡大縮小しているときだけ 1 フレーム後の観測で直った)。祖先の変形・拡大縮小で 2 回目の確定は起きない (ただし端数のある高さは、計算済みのスタイルが長さを有効数字 6 桁の文字列にするので、最初の値 (743.656) と観測の値 (743.65625) が食い違って 2 回確定する)。内部のフックの名前も測るものに合わせた (`useViewBoxHeight`)。
|
|
15
61
|
- **空のビューの文言は 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
62
|
- **両ビューの行の枠は配置の境界**: DetailList の行の枠も List と同じく大きさ・配置・スタイルを閉じ込め (`contain: size layout style`)、VirtualScroll の行の箱を埋める。行の高さは本体 (枠の直下の子) を測って 2G を足した値。行の中の変化 (後回しにした本体の描画・読み込みの完了・選択とフォーカスの表示) の配置はその行から始まり、キーの移動の配置は変わる行の中に留まる (以前の DetailList は、長押しのフレームごとに文書の根から配置し直した)。
|
|
17
63
|
- **側面ペインの見出しと本文は同じ端で終わる**: 営業日と作成者の列とタブの並びは、本文と同じスクロールバーの溝を取る箱に入る (以前は古典的なスクロールバーで、本文の列が見出しより 10 px 手前で終わった)。
|
|
@@ -26,7 +72,7 @@ All notable changes to `@aiquants/daily-report` are documented here.
|
|
|
26
72
|
`@aiquants/virtualscroll` 3.8.2 の配布 CSS が、動きを減らす設定 (`prefers-reduced-motion: reduce`) で、パッケージが動かす部品 (タップスクロールサークルとその中のすべての要素・バーのサークルの器・矢印ボタン・スクロールバーのつまみ・端へ戻るボタンとその覆い) の遷移とアニメーションを止める (3.8.2 をピアの下限にした。Breaking を参照)。
|
|
27
73
|
- **JPEG の展開の仕事の上限**: sharp の描画ポートは、sharp を起動する前に JPEG の展開の仕事 (EOI までの走査ごとに、符号化する成分のブロックの数の和) をフレームヘッダーと同じ 1 回のたどりで見積もり、描画の締め切りから導いた上限 (2,000 万回の訪問) を超える原本と、仕事を見積もれない原本 (算術符号と階層のフレーム、規格の上限を超える走査の数、崩れた区切り) を `unsupported` (内容で決まる失敗として記録) で断る。以前は 0.2 MB で 2,647 走査の 4,096² のプログレッシブの JPEG が、画素数と作業領域の上限を通って描画 1 回に CPU 13〜25 秒をかけ、締め切りを越えても何も記録しないので表示のたびに展開し直した。可逆 (SOF3) のフレームは受け付ける。
|
|
28
74
|
- **締め切りの後の描画の結果を残す**: 描画の締め切り (`render_timeout` の 502) の後に描画ポートが決着したら、出力の検証を通った縮小画像と `unsupported` は、同一性を確かめた生成ならキャッシュに記録してから枠を返す (以前は捨てた)。遅いが正当な原本の超過は 1 回で済み、次の表示はキャッシュから答える。生成は枠を渡されたときにキャッシュを見直すので、超過した描画の後ろで待っていた要求はもう 1 度展開しない。sharp の描画ポートは展開を始めた後に中断されても、展開の結果をそのまま返す (以前は `failed` に変えたので、残す結果が無かった)。
|
|
29
|
-
- **添付の応答は同じオリジンだけが読み込める**: 原本の 200 (インラインとダウンロード、GET と HEAD) とサムネイルの 200・304 は `Cross-Origin-Resource-Policy: same-origin` を持つ。セッションの Cookie は同じサイトの別オリジンからの読み込みにも付くので、無いと同じサイトのページが推測したトークンを画像として読み込み、読めたかどうかから閲覧者の可視性を 1
|
|
75
|
+
- **添付の応答は同じオリジンだけが読み込める**: 原本の 200 (インラインとダウンロード、GET と HEAD) とサムネイルの 200・304 は `Cross-Origin-Resource-Policy: same-origin` を持つ。セッションの Cookie は同じサイトの別オリジンからの読み込みにも付くので、無いと同じサイトのページが推測したトークンを画像として読み込み、読めたかどうかから閲覧者の可視性を 1 件ずつ知りえた。失敗の応答 (404 など) は方針を持たないので、no-cors の `fetch` では、見える添付 (方針を持つ応答をブラウザーが塞ぐ) と見えない添付 (404 が opaque な応答として解決する) をまだ見分けられる。
|
|
30
76
|
- **開発者向け**: バンドルの予算は書かずに導く: `bundle-baseline.json` (パッケージの根) が入口ごとの gzip-9 の大きさと余裕 `headroomPercent` (3) を記録し、予算は ⌈基準 × (100 + 余裕) ÷ 100⌉ (整数の計算)。手で書いた `package.json#bundleBudget` は拒否する。`pnpm run bundle:ratchet` (`--write`) が新しいビルドの大きさを記録し、下げるのと古い項目を消すのは自由、増やすのと項目を足すのは `--write --accept` だけ。
|
|
31
77
|
ビューのスクロールの口を通らないハンドルのスクロールを落とす守り (`view-scroller.spec.ts`)、VirtualScroll の代役と本物を同じ props で比べる適合の spec (空の出力・矢印の Tab の止まり先・ビューが呼ぶハンドル)、`utils/constants.ts` が export するどの定数にも出荷するコードの読み手があることの守り (読み手の無かった `EMPTY_DETAIL` も消した) を加えた。
|
|
32
78
|
|
package/README.md
CHANGED
|
@@ -26,8 +26,9 @@ 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.
|
|
30
|
-
|
|
29
|
+
`@aiquants/virtualscroll` must be **3.9.0 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)), its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2;
|
|
30
|
+
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 7 engine keys (see [Localization](#localization-locale--labels)).
|
|
31
|
+
That floor is declared once, in `peer-floors.json` (`{ "<peer name>": "<X.Y.Z>" }`), 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
32
|
`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
33
|
`@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
34
|
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.
|
|
@@ -35,6 +36,8 @@ Build / test: `pnpm run build` (tsup → dist) / `pnpm run test` (vitest) / `pnp
|
|
|
35
36
|
`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
37
|
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
38
|
`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.
|
|
39
|
+
Every `publish:*` script runs `node scripts/check-bundle-size.mjs --exact` right after `pnpm run verify` (which ends with the build and the bundle check), before the leak check and the version bump: besides the budgets, it fails when any measured size differs from its baseline entry in either direction and prints the `node scripts/check-bundle-size.mjs --write --accept` command that records that build.
|
|
40
|
+
So a release always ships the baseline of its own build: an entry left high after a shrink would loosen every budget derived from it, and one left low would judge the next change against a build nobody shipped. `--exact` cannot be combined with `--write` (exit 2).
|
|
38
41
|
`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.
|
|
39
42
|
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).
|
|
40
43
|
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).
|
|
@@ -44,14 +47,15 @@ The guards are the violations of the workspace's docstring and comment language
|
|
|
44
47
|
A substitution that the specification or a port contract defines is a reviewed exception of the no-fallback guard instead (`EXCEPTIONS` in `scripts/lib/check-no-fallback.mjs`: the file, the exact expression, the number of `occurrences` it covers and the reason), and its matches are not counted; an exception that matches another number of expressions than it declares fails (exit 1: fewer means the expression was fixed or rewritten, more a new copy that needs its own review), and a malformed list is exit 2.
|
|
45
48
|
Every TypeScript or JavaScript code block of this README names its source in its info string: an example file, or a `#region` of one, which `src/docs-examples.spec.ts` compares byte for byte (`ts examples/<file>.ts[#<region>]`), or `illustrative` for a fragment that is not type-checked.
|
|
46
49
|
|
|
47
|
-
**Browser floor**: Chrome 121, Firefox 122, Safari 17.4 (Baseline 2024). The client uses each platform feature below as it is, with no feature detection and no second code path, so on an engine under the floor the listed behaviour fails and nothing else replaces it.
|
|
50
|
+
**Browser floor**: Chrome 121, Firefox 122, Safari 17.4 (Baseline 2024). The client uses each platform feature below as it is, with no feature detection and no second code path, so on an engine under the floor the listed behaviour fails and nothing else replaces it. Three CSS features of the table are newer than parts of the floor, and the row says what those engines draw:
|
|
48
51
|
|
|
49
52
|
| Feature | Supported from | Used for | Below the floor |
|
|
50
53
|
| --- | --- | --- | --- |
|
|
51
54
|
| `Element.checkVisibility({ visibilityProperty: true })` | Chrome 121, Firefox 122, Safari 17.4 (the method alone: Chrome 105, Firefox 106) | Finding Tab stops for the `Ctrl+Home` / `Ctrl+End` exits and the mobile overlay's Tab wrap | Without the method (Safari before 17.4) those key handlers throw a `TypeError`: the exit keys keep their browser meaning, and at the overlay's ends Tab moves to the browser's own interface instead of wrapping (the page behind the dialog stays inert). With the method but not the option (Chrome 105–120, Firefox 106–121) an element hidden by `visibility: hidden` counts as a stop, so an exit key whose nearest stop is such an element is consumed while focus stays where it was |
|
|
52
55
|
| `:has()` | Chrome 105, Firefox 121, Safari 15.4 | The List card's selection ring and keyboard focus outline, and the forced-colours selection outline of a List row with a card (all read from the card's primary button) | List cards show neither the selection nor keyboard focus (the DetailList is unaffected) |
|
|
53
|
-
| Container size queries and container query units (`cqi`) | Chrome 105, Firefox 110, Safari 16 | The attachment grid's column count; the tile frame's height (`100cqi` of the
|
|
54
|
-
| CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` (see [Attachment display](#attachment-display)) | Chrome 121–124, inside the floor, drop
|
|
56
|
+
| Container size queries and container query units (`cqi`) | Chrome 105, Firefox 110, Safari 16 | The attachment grid's column count; the tile frame's height and the tile's lattice pad (`100cqi` of the tile root, an inline-size container as wide as its track); the view-mode toolbar's end inset in the single-column List | One attachment column at every width; frames sized by `aspect-ratio` alone and tiles without the pad (below); the toolbar's end edge overhangs the List's scroll bar by 8 px |
|
|
57
|
+
| CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` and the tile's lattice pad `round(up, h, 4px) − h` under its last line (see [Attachment display](#attachment-display)); the views' column origin on the 4 px lattice (see **Origin on the 4 px lattice**) | Chrome 121–124, inside the floor, drop those declarations: the frame's `aspect-ratio: 480 / 320`, always declared beside its height, sizes the frame at the exact fractional 2t / 3, so the frames of one grid can paint one device pixel apart; a tile stays its frame's fractional height + 48 px tall, so the rows below an image grid can start between device pixels; and the column keeps the `mx-auto` centre |
|
|
58
|
+
| CSS `calc-size()` | Chrome 129 | The DetailList row bodies' height, their content height rounded up to the 4 px lattice (`calc-size(auto, round(up, size, 4px))`, see **Row slots on the lattice**) | Chrome 121–128, and any engine that does not implement it, drop the declaration: a body keeps its content height, which is on the lattice at a 16 px root and can leave it at other roots, so the DetailList rows below can start between device pixels |
|
|
55
59
|
| `scrollbar-gutter: stable` | Chrome 94, Firefox 97, Safari 18.2 | The side pane's tab panels keep their scroll bar's width whatever their content's height | Safari 17.4–18.1, inside the floor, ignore it; their default scroll bars overlay the content and take no width |
|
|
56
60
|
| `<dialog>` with `showModal()` | Chrome 37, Firefox 98, Safari 15.4 | The mobile detail overlay (a modal dialog in the top layer) | Opening the overlay throws a `TypeError` from a layout effect, which React hands to the nearest error boundary |
|
|
57
61
|
|
|
@@ -242,7 +246,8 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
|
|
|
242
246
|
- **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).
|
|
243
247
|
- **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.
|
|
244
248
|
- **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
|
|
249
|
+
- **Same-origin loads only**: every attachment response, success or failure — the original's 200 (inline and download, GET and HEAD), the thumbnail's 200 and 304, and every error status the loader answers (400, 401, 403, 404, 405, 413, 429, 500, 502 and 503, for GET and HEAD alike) — carries `Cross-Origin-Resource-Policy: same-origin` and `X-Content-Type-Options: nosniff`, so the browser lets only pages of the host's own origin load it. Both headers come from one set that every path building an attachment response spreads last, so no status can lose them.
|
|
250
|
+
The session cookie also accompanies requests from other origins of the same site, so a response without the policy would let a same-site page learn, token by token, whether the viewer can see each attachment: an image load succeeds or fails, and a no-cors `fetch` resolves with an opaque response for a response without the policy while the browser blocks one that carries it. With the policy on every status, every cross-origin load fails alike.
|
|
246
251
|
|
|
247
252
|
**Configuration**
|
|
248
253
|
|
|
@@ -406,7 +411,8 @@ type DailyReportAttachmentThumbnailRenderer = {
|
|
|
406
411
|
- `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.
|
|
407
412
|
- `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
413
|
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
|
|
414
|
+
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 late result is classified by the same rule as one that settles in time, so only those conclusions are kept: a late `failed`, a late exception, a late output that fails the checks and a late value outside the contract are not cached.
|
|
415
|
+
Until such a render settles, a new generation of the same content waits for it instead of reading and decoding the same source again beside it (**Generation** below), so a content key has at most one decode at a time: a slow but legitimate source costs one overrun, and the next view is answered from the cache.
|
|
410
416
|
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.
|
|
411
417
|
- `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.
|
|
412
418
|
- `unsupported` means the content itself cannot be downscaled (deterministic); `failed` means a transient failure (retry may succeed).
|
|
@@ -430,6 +436,11 @@ Values for implementing the port:
|
|
|
430
436
|
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
437
|
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
438
|
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.
|
|
439
|
+
- bounds the reading itself: the walks that read a source before sharp starts (the JPEG marker walk with its skip over each scan's coded data, and the WebP chunk walk) are synchronous and block the worker's event loop, so they count steps — one per 0xFF byte the walk visits (a marker, a fill byte before it, a stuffed `FF 00`, a restart marker or a fill byte inside coded data) and one per WebP chunk; coded data between them is skipped by the native byte search.
|
|
440
|
+
A walk that needs more than `maxWalkSteps` = ⌊`walkBudgetMs` 16 ms × 10⁶ ÷ (50 ns per step × a margin of 2)⌋ = 160,000 steps stops there, and the source is `unsupported` (content-determined, cached) without starting sharp, so neither a hostile file nor an abort during the walk can make the stall repeat. The walk's time is then bounded whatever the content: at most 160,000 steps plus the byte search, which grows with the file (2.4–2.8 ms for 32 MiB).
|
|
441
|
+
Measured on Node 24 over 32 MiB sources built to stall the walk (a run of 0xFF, `FF 00` pairs, restart markers inside a scan or between segments, fill bytes, only comment segments, a WebP of zero-size chunks): 1–2.4 ms each, and 5.0–7.0 ms for the costliest shape, `FF 00` pairs spread every 3 to 209 bytes so that every visit restarts the search, within the 8 ms the margin leaves.
|
|
442
|
+
Legitimate sources stay far inside the bound: a baseline JPEG stops at its first scan, the coded data of a noisy photo that sharp encoded at quality 98 holds about 2,800 0xFF bytes per MiB (about 90,000 steps at 32 MiB), and noisy progressive photos above the size limit took 147,010 steps (64 MB) and 155,365 steps (58 MB).
|
|
443
|
+
The model is the frozen `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_WORK_MODEL` (server entry): `renderMs` (the render stage's deadline, 10,000 ms), `nanosecondsPerBlockVisit`, `margin` and `maxBlockVisits` above, and `walkBudgetMs`, `nanosecondsPerWalkStep`, `walkMargin` and `maxWalkSteps`. A host that proves its own decoders against the bound reads `renderMs` from it instead of restating the deadline.
|
|
433
444
|
- 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):
|
|
434
445
|
|
|
435
446
|
| Layout | Whole-image buffer | Largest square admitted |
|
|
@@ -548,7 +559,8 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
548
559
|
- **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.
|
|
549
560
|
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.
|
|
550
561
|
- **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
|
-
|
|
562
|
+
**One decode per content key at a time**: a render that passed its deadline 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.
|
|
563
|
+
The wait for the late 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 an overrunning render of the same content is answered from that render's late result.
|
|
552
564
|
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).
|
|
553
565
|
- **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.
|
|
554
566
|
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.
|
|
@@ -569,7 +581,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
569
581
|
| Status | When |
|
|
570
582
|
| --- | --- |
|
|
571
583
|
| 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`) |
|
|
584
|
+
| 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `Set-Cookie`) |
|
|
573
585
|
| 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
|
|
574
586
|
| 401 / 403 | Not authenticated / no internal user |
|
|
575
587
|
| 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path` — one identical body for all |
|
|
@@ -577,9 +589,9 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
577
589
|
| 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`) |
|
|
578
590
|
| 502 | Storage unreachable (`unavailable`), the port's `failed` (`render_failed`), or a stage deadline (`read_timeout`, `render_timeout`) |
|
|
579
591
|
| 503 | Wait queue full or wait timed out (`queue`), or the generation abandoned by every waiting request (`aborted`; no one receives it) (`Retry-After: 5`) |
|
|
580
|
-
| 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=` `content_type`, `bytes`, `empty`, `too_large`, `signature` or `dimensions
|
|
592
|
+
| 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`, 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) |
|
|
581
593
|
|
|
582
|
-
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), and forward `Set-Cookie`. Log lines carry `attachment=<id> viewer=<id> variant=thumbnail`, plus `cache=hit|miss|revalidated` once the cache stage is reached; the 200 and 404 lines of an unverified outcome end with `identity=unverified`.
|
|
594
|
+
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` and `Cross-Origin-Resource-Policy: same-origin` like every attachment response (**Same-origin loads only** above), and forward `Set-Cookie`. Log lines carry `attachment=<id> viewer=<id> variant=thumbnail`, plus `cache=hit|miss|revalidated` once the cache stage is reached; the 200 and 404 lines of an unverified outcome end with `identity=unverified`.
|
|
583
595
|
Unexpected exceptions outside generation go to the loader's shared catch and are logged as `500 attachment=? viewer=? reason=unexpected message=<msg>` (no `variant`).
|
|
584
596
|
A route that mounts `attachment.loader` without a `token` route parameter is the host's configuration error, on both deliveries: right after the port check the loader throws `[daily-report] params.token must be passed by the route that mounts attachment.loader; declare a "token" route parameter ({apiBasePath}/attachment/{token})`, which that catch logs and answers 500, without decoding an empty token or querying the database.
|
|
585
597
|
Rejections up to the renderer-port check (401 / 400 / 405 / 404 for a missing port / 403) are not logged.
|
|
@@ -667,14 +679,17 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
|
|
|
667
679
|
- **Host contract**: render `DailyReportPage` (or `DailyReportResolvedContent`) as the content of a column flex container (`display: flex; flex-direction: column`) whose height is bounded — a definite height, or the growing item of a column with a minimum height, such as a `min-height: 100dvh` shell whose footer follows the content. The header that `renderHeader` returns and the page box are items of that container, and the page box takes the rest of it. Nothing is bound and no host variable is read.
|
|
668
680
|
Outside such a container the view has no height to fill: its size is contained (below), so its content cannot size it, and it is 0 px tall.
|
|
669
681
|
- **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.
|
|
682
|
+
- **One page frame and one column for every screen**: the loading screen, the load-error screen and the loaded screen share the page box (`VIEW_PAGE_FRAME_CLASS_NAME`: the fill rule and the page surface, slate-50 / dark slate-950) and its centred column (`VIEW_COLUMN_CLASS_NAME`: the fill rule, at most `max-w-6xl`, 4 px from the frame's top and side edges, starting on the 4 px lattice). So the page keeps its colour in both schemes while loading ends (a dark page never shows the light surface first) and the content does not move sideways between the screens.
|
|
670
683
|
- **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.
|
|
671
684
|
Layout and paint are not contained, so the mobile overlay, a fixed-position descendant, keeps the viewport as its containing block.
|
|
672
|
-
- **One measurement, in one unit**: `VirtualScroll` needs its viewport size as a number (`viewportSize`), so each view
|
|
673
|
-
The
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
Every change is followed, a sub-pixel one included,
|
|
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
|
|
685
|
+
- **One measurement, in one unit**: `VirtualScroll` needs its viewport size as a number (`viewportSize`), so each view takes its root's border-box block size in layout px from one `ResizeObserver` of the root's own window, observing the root alone (`box: "border-box"`; `useViewBoxHeight`). Every value, the first included, is the delivery's `borderBoxSize[0].blockSize`: the layout effect only starts the observation, and no code reads a size from the DOM.
|
|
686
|
+
The value is `null` until the first delivery, and the view renders no body until then, so no row is ever drawn at a guessed height (the List also waits for its row slot, below). The platform delivers the first observation in the rendering update after the observation starts, after layout and before paint, and that one delivery is committed at once (`flushSync`), so the view's content is drawn before the same frame paints; later deliveries only set React state, which React renders after the delivery.
|
|
687
|
+
A delivery reads and writes nothing in the DOM, and a value equal to the last one written is not written again.
|
|
688
|
+
The observer reports the layout px value itself — the exact `LayoutUnit`, before any ancestor transform or zoom — so the same layout gives the same height whatever the order of the measurements, and each height commits once (the computed `block-size` would serialize a fractional height to 6 significant digits, such as 743.656 for 743.65625, and the observer's value would then commit a second time).
|
|
689
|
+
Every change is followed, a sub-pixel one included. A root without a box (not rendered, or detached) reads 0 in Chromium, whose first delivery reports 0 for it; an engine that follows the specification's 0 × 0 starting size reports nothing for such a root, and the value stays `null` until the root has a box. 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.
|
|
690
|
+
- **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 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.
|
|
691
|
+
`VirtualScroll` snaps the layer that moves the rows to whole device pixels away from the edge a row is aligned to (the start at position 0 and after an alignment to the top, the end at the maximum position and after an alignment to the bottom; 3.9.0, "Device-pixel snapping" in its README), so an aligned row's surface never comes closer than G to that edge: what the snap adds is less than one device pixel.
|
|
692
|
+
It adds nothing — the surface sits exactly G from the edge — when the view's height and the row slots are whole numbers of device pixels: the slots are multiples of 4 px (**Row slots on the lattice**), and a host gives the view a height on the 4 px lattice by sizing its own bars on that lattice (for example a window height that is a multiple of 4 under a header and a footer whose heights are rounded up to 4 px).
|
|
678
693
|
- **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).
|
|
679
694
|
|
|
680
695
|
### Keyboard, focus and selection (List / DetailList)
|
|
@@ -752,10 +767,15 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
752
767
|
- **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.
|
|
753
768
|
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
769
|
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
|
|
756
|
-
- **
|
|
757
|
-
|
|
758
|
-
|
|
770
|
+
The anchor is recorded after every change of the position, so it describes the committed position, not the one before the last change:
|
|
771
|
+
- **Position changes 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, and the DetailList's row re-measurements — all go through one scroller (`ListViewScroller`: `toIndex`, `by` and `resizeRow`) that records the anchor right after the call, without waiting for the next visible-range report.
|
|
772
|
+
A re-measurement (`resizeRow`) is `VirtualScroll`'s `updateItemSize`, which moves the position by the height change when the row lies above the first visible row (its layout-shift compensation); the update, the compensation and the position are all complete when the call returns, so the record reads the compensated state and never mistakes the compensation for a scroll of the user.
|
|
773
|
+
An insert or a delete that commits between such a change and the next frame therefore keeps the position: `End`, `PageDown` and a held key are never reverted, focus stays on the key's destination, and a re-measured row shifts no report.
|
|
774
|
+
The single path is a type: the keyboard navigation and the views receive only a read-only handle (`ListNavigationHandle`, the getters they read), so a view that calls `scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel` or `updateItemSize` fails the type check, however the call is spelled; only the anchor's scroller and the end-to-end test handle hold the full handle, and `src/client/components/view-scroller.spec.ts` also scans the sources for such a call outside the scroller.
|
|
775
|
+
- **Position changes `VirtualScroll` makes on its own** — the layout-shift compensation of a row re-measured above the first visible row, the reconciliation with a new row height after a render, the re-pinning of a pending alignment after a size change, and the second stage of a compensation the pane had clamped — are reported synchronously, once each change is complete, through its `onScrollAdjust` (3.9.0, the peer floor).
|
|
776
|
+
Both views pass the anchor's `handleScrollAdjust` there, which records the anchor from the handle at once, so every position change `VirtualScroll` makes is in the anchor before a list change can commit.
|
|
777
|
+
- **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 and restores from there: it neither undoes the user's scroll nor shifts the content by a row.
|
|
778
|
+
Over rows of one height (the List) the new anchor follows from the distance by arithmetic; over measured rows (the DetailList) it walks the previous list's row heights from the anchor to the report now at the top, so the walk is bounded by the rows passed since the record (one frame's scroll), whatever the list's length or the position.
|
|
759
779
|
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;
|
|
760
780
|
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.
|
|
761
781
|
- **List side pane**: keys select through `onSelectItem` directly, so on a mobile layout they never open the detail overlay.
|
|
@@ -779,7 +799,9 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
779
799
|
A draft that opens in the editor by itself does not move focus. The editor is a real `<form>` whose submission is cancelled: Save, Publish and Cancel are `type="button"`, and an implicit submission (Enter in the title) or a `requestSubmit()` neither navigates nor saves nor publishes, so what was typed stays. As an input region the form keeps the list keys out (above).
|
|
780
800
|
- **Deleting a comment**: the trash button of the viewer's own comment is a disclosure (`aria-expanded`; while open, `aria-controls` names the confirmation pill it shows under itself). The confirmation has no time limit (WCAG 2.2.1): it stays open until it is confirmed or cancelled — by pressing the trash button again, by Escape (the comment list takes it before the mobile overlay while the confirmation is open; an Escape that belongs to an IME composition is left alone), by focus leaving the trash button and the confirmation, or by a pointer press outside them.
|
|
781
801
|
A cancel by the trash button or by Escape returns focus to the trash button when focus was on the confirmation.
|
|
782
|
-
Confirming moves focus before the comment is hidden
|
|
802
|
+
Confirming moves focus before the comment is hidden, to a destination computed from what remains: the next remaining trash button, else the previous one, else the comment section's heading (`tabIndex=-1`) when the section stays, else the report's own anchor — the row in the DetailList (through the view's focus request), the pane's report heading in the side pane and the mobile overlay — so focus never falls to `body`.
|
|
803
|
+
- **One comment section rule**: the DetailList row, the side pane and the mobile overlay (which shows the side pane's content) render one comment section, its `labels.comments` heading included, only while the report has comments or the viewer may comment on it (the same decision that shows the comment form: see [External Source Badge Configuration](#external-source-badge-configuration-sourcetypeconfigs)).
|
|
804
|
+
A report of a source that takes no comments and has none shows no empty section, and deleting the last comment of such a report removes the section, so focus goes to the report's anchor above.
|
|
783
805
|
- **Mobile detail overlay** (List, single-column layout): the platform's modal dialog, a `<dialog data-daily-report-mobile-overlay>` (implicit `dialog` role, no `aria-modal`) opened with `showModal()`. It is drawn in the top layer, above every host `z-index`, and everything outside it is inert while it is open, host chrome included: no focus, no pointer, nothing in the accessibility tree.
|
|
784
806
|
It is named by the report heading at its top and the author's value under it (`aria-labelledby` lists both: `<date> <author>`). That heading (`tabIndex=-1`) is the dialog's first focusable descendant, so `showModal()`'s own focusing steps move focus to it in the same commit (the overlay focuses it explicitly as well). Tab and Shift+Tab wrap inside it (its stops are counted on every press by the model of **Leaving the list**;
|
|
785
807
|
on Shift+Tab the container of a roving-focus widget around the origin, such as the tabs' `tablist`, is not counted before it, because real Shift+Tab leaves such a widget without stopping at it), and Escape closes it unless the key belongs to an IME composition.
|
|
@@ -807,9 +829,13 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
807
829
|
|
|
808
830
|
- **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;
|
|
809
831
|
a DetailList row is its measured body plus 2G = 16.
|
|
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
|
|
811
|
-
|
|
832
|
+
- **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.
|
|
833
|
+
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).
|
|
834
|
+
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.
|
|
812
835
|
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.
|
|
836
|
+
- **Row slots on the lattice**: every row slot of both views is a multiple of the 4 px layout lattice u, which 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 row top sits on a device-pixel boundary wherever the list is scrolled (`VirtualScroll` snaps only the layer that moves the rows; a row starts on a device pixel when the rows above it add up to whole device pixels).
|
|
837
|
+
The List slot is P above. A DetailList row is its measured body plus 2G, and the body — the frame's direct child in every state: the card, the skeleton, the edit form and the error — rounds its content height up to the lattice (`LATTICE_BLOCK_SIZE_CLASS_NAME`: `height: calc-size(auto, round(up, size, 4px))`), so rem line boxes at a root other than 16 px (a 25 px line at a 20 px root) leave the body on the lattice; at a 16 px root the content is already on it. Engines without `calc-size()` drop the declaration and keep the content height (see the browser floor table).
|
|
838
|
+
A row that has not been measured yet is laid out at an estimate of 352 px (`UNMEASURED_ROW_HEIGHT_ESTIMATE_PX`), also a multiple of 4, so the unmeasured rows above the window move the rows below by whole lattice steps. An image attachment tile is padded to the lattice as well (**Tile** in [Attachment display](#attachment-display)), so the rows below an image grid stay on it.
|
|
813
839
|
- **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.
|
|
814
840
|
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).
|
|
815
841
|
- **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.
|
|
@@ -817,7 +843,8 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
817
843
|
- **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.
|
|
818
844
|
- **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.
|
|
819
845
|
- **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.
|
|
846
|
+
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.
|
|
847
|
+
The DetailList therefore measures each row's body — the frame's direct child, as tall as its content rounded up to the 4 px lattice (**Row slots on the lattice**) — and adds 2G, handing the height to `VirtualScroll` through the scroller's `resizeRow` (see **Host selections and list changes**); 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
848
|
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
849
|
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
850
|
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.
|
|
@@ -826,6 +853,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
826
853
|
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
854
|
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.
|
|
828
855
|
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`.
|
|
856
|
+
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.
|
|
829
857
|
- **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).
|
|
830
858
|
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.
|
|
831
859
|
- **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.
|
|
@@ -836,7 +864,9 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
836
864
|
- **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`.
|
|
837
865
|
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
866
|
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.
|
|
867
|
+
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.
|
|
868
|
+
The package's error messages — the load-error screen's message and its badge in the host's header, and the side pane's report-load error — take their colour from one token (`ERROR_TEXT_CLASS_NAME`: red-700, dark red-400); the badge sits on the host's header, whose colour is the host's, so it has no pair below.
|
|
869
|
+
`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; the card surface is white, dark slate-900 at 70 % over the page; the overlay panel is white, dark slate-900):
|
|
840
870
|
|
|
841
871
|
| Pair | Light | Dark | Minimum |
|
|
842
872
|
| --- | --- | --- | --- |
|
|
@@ -850,6 +880,9 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
850
880
|
| Unselected label / the side pane's tab track | 6.90 | 9.83 | 4.5 |
|
|
851
881
|
| Unselected label / toolbar surface | 7.03 | 12.71 | 4.5 |
|
|
852
882
|
| Selected label / selected tab | 17.83 | 18.40 | 4.5 |
|
|
883
|
+
| Error text / card surface (the desktop side pane) | 6.42 | 6.45 | 4.5 |
|
|
884
|
+
| Error text / overlay panel (the side pane's content on a phone) | 6.42 | 6.17 | 4.5 |
|
|
885
|
+
| Error text / the load-error panel (the card surface over the page frame) | 6.42 | 6.45 | 4.5 |
|
|
853
886
|
|
|
854
887
|
- **Borders declare their style**: every border the package draws sets `border-solid` itself, so a host whose base layer resets the border style of every element (for example `* { border: none }` in Tailwind v4, which turns `--tw-border-style` into `none`) cannot erase it; the hover border and the switch track depend on it.
|
|
855
888
|
The card-like surfaces — the List card and its skeleton, the desktop side pane, the DetailList card and its skeleton — share one token (`CARD_SURFACE_CLASS_NAME`): a 16 px corner, a 1 px slate-200 / dark slate-700 border, white / dark slate-900 at 70 %, a 16 px inset that counts the border (1 px border + 15 px padding, so the content edge is 16 px from the visible edge and its corner concentric with the surface's) and `shadow-sm`.
|
|
@@ -861,21 +894,24 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
861
894
|
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"`).
|
|
862
895
|
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.
|
|
863
896
|
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
|
|
897
|
+
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 (since 3.8.2; the peer floor is 3.9.0) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
|
|
865
898
|
(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.
|
|
866
899
|
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
900
|
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.
|
|
868
901
|
- **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.
|
|
869
902
|
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**).
|
|
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)).
|
|
903
|
+
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)).
|
|
871
904
|
The frame height is not snapped to 4 px: snapping moves the frame's shape more than 0.01 away from 3 : 2 (196 → 132 gives |t / h − 3 / 2| = 0.0152, 171 → 116 gives 0.026) and letterboxes a 3 : 2 image, which fills an unsnapped frame exactly.
|
|
905
|
+
The tile around the frame is on the lattice anyway: its last line carries the pad δ(t) = ⌈h / 4⌉ · 4 − h (0–3 px) as a bottom margin, so every tile is ⌈h / 4⌉ · 4 + 48 px tall, every tile row top and every grid's height are multiples of 4 px, and what follows a grid stays on the lattice (**Tile** in [Attachment display](#attachment-display)).
|
|
872
906
|
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.
|
|
873
907
|
**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).
|
|
874
908
|
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))]
|
|
909
|
+
- **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))]`, part of `VIEW_COLUMN_CLASS_NAME`, so the loading and load-error screens start there too), less than 4 px left of the exact centre.
|
|
910
|
+
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
911
|
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
912
|
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
|
|
913
|
+
The block-axis origin and the view's height are the host's: the views start and end where the host's layout puts them.
|
|
914
|
+
A host that wants the same whole-pixel strokes on the top and bottom edges at fractional ratios puts both edges on the lattice too — for example with a header and a footer whose heights are their content rounded up to 4 px (`height: calc-size(auto, round(up, size, 4px))`) in a window whose height is a multiple of 4, since a bar sized by its font metrics alone ends on a fraction (such as 30.4375 px); the bottom-aligned surface then sits exactly G from the view's end (**G-symmetric frame** in [View height](#view-height-host-layout)).
|
|
879
915
|
- **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
916
|
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
917
|
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.
|
|
@@ -902,6 +938,9 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
902
938
|
Every native scroll container that holds a grid reserves its scroll-bar gutter (the side pane's scroll box; the views' rows sit beside `VirtualScroll`'s own scroll bar of fixed width), so a grid's track width never depends on whether its container overflows.
|
|
903
939
|
Every length of the grid — the breakpoints, the cap and the gap — is written in px, because the bounds are CSS-pixel quantities: in rem they would grow with the root font size (a 20 px root would make the tracks 300 px wide, upscaled 1.25× at a device pixel ratio of 2).
|
|
904
940
|
- **Tile**: one link to the original wraps the thumbnail frame and the file name; below it one line holds the size and the download link. The frame is decorative (`aria-hidden="true"`, no link of its own), so the tile link is named by the file name and a tile has two Tab stops (the preview, then the download). The download link has a 24 px hit area, and its accessible name is the download label followed by the file name.
|
|
941
|
+
The tile root (the grid item) is the tile's one inline-size container, exactly as wide as its track, so `100cqi` inside the tile is t. A tile is the frame (h, below), the 8 px gap, the 20 px name, the 4 px gap and the 16 px last line, h + 48 px in all, and h need not be a multiple of 4.
|
|
942
|
+
The last line therefore carries a bottom margin, the lattice pad δ(t) = ⌈h / 4⌉ · 4 − h (`ATTACHMENT_TILE_LATTICE_PAD_STYLE`: `margin-block-end: calc(round(up, h, 4px) - h)`, where h is the frame height's own expression, resolved against the same container), which puts the remainder of 0–3 px under the last line and keeps the gaps between the frame, the name and the last line.
|
|
943
|
+
Every tile is then ⌈h / 4⌉ · 4 + 48 px tall, so every tile row top and the grid's height stay on the 4 px lattice, and so does what follows the grid (the DetailList rows below an image grid start on device pixels); for the tracks below, t = 196 gives h = 131 and δ = 1, 171 gives 114 and 2, 231⅓ gives 154 and 2, and 234⅔ gives 156 and 0. On Chrome 121–124, which lack `round()`, the margin declaration is dropped with the frame height, and a tile stays its frame's height + 48 px tall.
|
|
905
944
|
- **File name**: a long name is shortened in its stem and keeps its extension visible, on one line. In a tile grid the name is fitted exactly to the track: the longest start of the stem that still fits, an ellipsis that touches the extension, then the extension, drawn as one run clipped to the track (so the ellipsis never floats a glyph's width away from the extension, and a sub-pixel misfit is clipped instead of adding a second ellipsis).
|
|
906
945
|
The extension is kept whole up to half the track; a longer one keeps the start that fits in half the track plus an ellipsis. The names are cut between code points of the NFC-normalized name, not between graphemes, so a combining sequence or a joined emoji at the cut can be split (`Intl.Segmenter` is above the browser floor).
|
|
907
946
|
One `ResizeObserver` per document watches every grid in it (created from the document's own window, disconnected when its last grid stops) and supplies the track width, computed from the grid's content width with the column formula above, not by measuring tiles.
|
|
@@ -915,14 +954,15 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
915
954
|
- **Tab order inside list rows**: the attachment links follow the row rule of [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist): inside a DetailList row they are Tab stops only in the view's Tab-stop row (`tabIndex=-1` in every other row); in the side pane and the mobile overlay they keep their natural order.
|
|
916
955
|
- **Marker**: the List card and the DetailList header show `DailyReportAttachmentIndicator` when a report has attachments: a 24 px tall box without padding or radius (its first ink starts at the content edge) holding a 16 px paperclip icon and, from 2 attachments on, the count in 12 px type on a 16 px line; it is named `<labels.attachments>: <count>` (`role="img"`).
|
|
917
956
|
Its required `id` prop lands on that `role="img"` element, so a row's description can reference the marker (pass an id unique in the document, for example from `useId()`); with no attachment nothing is rendered, and the description leaves it out.
|
|
918
|
-
- **Frame**: as wide as its track and h(t) = round(2t / 3) whole CSS px tall — the ratio of the variant box (480 × 320 for `tile`), rounded to the nearest pixel. CSS computes it, in the same layout that sizes the track: each frame sits in
|
|
957
|
+
- **Frame**: as wide as its track and h(t) = round(2t / 3) whole CSS px tall — the ratio of the variant box (480 × 320 for `tile`), rounded to the nearest pixel. CSS computes it, in the same layout that sizes the track: each frame sits in its tile, whose root is the inline-size container exactly as wide as its track (so `100cqi` is t; the frame's parent is a plain block wrapper, not a container), and has the style `aspect-ratio: 480 / 320; height: round(nearest, 100cqi * 320 / 480, 1px); contain: strict`, the sizes built from the variant box (`ATTACHMENT_TILE_FRAME_STYLE`).
|
|
919
958
|
No script measures or writes a frame, so its height is right from the first frame, a width change resizes the frames in the layout that changes the width, and every row of a grid draws its frames with the same number of device pixels at an integer device pixel ratio (a fractional height such as 130.33 px would give rows of 130 and 131; at a fractional ratio such as 1.25 or 1.5 the product can still be fractional).
|
|
920
|
-
The rounding keeps the frame within |t / h − 3 / 2| ≤ 0.75 / h, under 0.01 for every track of a grid with two or more columns (t ≥ 112, h ≥ 75); for the widths above, 196 → 131, 234⅔ → 156, 231⅓ → 154, 171 → 114 and 166 → 111. On Chrome 121–124, which lack `round()`, the height declaration is dropped and the aspect ratio alone gives the fractional 2t / 3 (see the browser floor table).
|
|
959
|
+
The rounding keeps the frame within |t / h − 3 / 2| ≤ 0.75 / h, under 0.01 for every track of a grid with two or more columns (t ≥ 112, h ≥ 75); for the widths above, 196 → 131, 234⅔ → 156, 231⅓ → 154, 171 → 114 and 166 → 111 (each tile adds the lattice pad under its last line, **Tile** above). On Chrome 121–124, which lack `round()`, the height declaration is dropped and the aspect ratio alone gives the fractional 2t / 3 (see the browser floor table).
|
|
921
960
|
The height depends only on the track and stays the same before, during and after loading and after a failure (virtual-scroll row heights do not shift).
|
|
922
961
|
The frame paints nothing itself: its only child, the **skin** (`data-testid="daily-report-attachment-thumbnail-skin"`), is an absolutely positioned box that fills it and paints every state — the background, the waiting pulse, the rounded clip, the inner rim — and holds the image or the failure message.
|
|
923
962
|
The image keeps its aspect ratio inside the skin and is not enlarged; it has no corner radius of its own, and the skin's rounded clip (`overflow: hidden`) alone makes the corners, so an image narrower or shorter than the frame shows no notches of the background where it meets the straight edges (the frame does not clip the corners as well: a second clip at the same edge anti-aliases the corner pixels twice and lightens them).
|
|
924
963
|
The skin's inner rim turns blue while the tile link (`data-daily-report-attachment-preview`) is hovered, on devices that can hover, and a deeper blue while it is pressed; the background never animates.
|
|
925
|
-
The frame is a relayout boundary: its strict containment (size, layout, paint, style) changes neither its size, which comes from its own style alone (the container's full width and the height above, or the aspect ratio), nor what is painted, since paint containment clips at the frame's edge and the skin lies inside it;
|
|
964
|
+
The frame is a relayout boundary: its strict containment (size, layout, paint, style) changes neither its size, which comes from its own style alone (the container's full width and the height above, or the aspect ratio), nor what is painted, since paint containment clips at the frame's edge and the skin lies inside it;
|
|
965
|
+
and as a positioned box that is not a flex or grid item (its parent is a block wrapper inside the tile link), Chromium lays out what changes inside the frame — the image's intrinsic size arriving on load, a style change of the skin — inside the frame alone, not from the document root.
|
|
926
966
|
Only a change inside the frame stops there: a boundary whose own computed style changes is laid out by its parent, from the document root. So the frame's own style never changes after mount — its classes are its box alone (`relative block w-full`: no variant, no paint, no animation) and its inline style is `ATTACHMENT_TILE_FRAME_STYLE` — and every state is drawn inside it, by the skin and the image, which read the frame's attributes.
|
|
927
967
|
- **`data-thumbnail-state`** on the frame is `pending` (waiting or loading), `loaded` (the image fades in) or `unavailable` (an icon and `labels.attachmentThumbnailUnavailable` inside the skin; the icon reaches 4.35:1 / 5.56:1 and the label 6.90:1 / 5.58:1 in the light / dark theme). The loader's internal phases are not exposed.
|
|
928
968
|
- **The waiting pulse runs only where the tile can be seen**: a `pending` frame's skin pulses (under `prefers-reduced-motion: no-preference`) only while the frame also carries the boolean attribute `data-thumbnail-active`. The frame's load controller decides it (`showActivity`) and the attribute is toggled on the frame element, without a React render, so visibility changes and key moves add no commit; only the skin reads it, so a toggle restyles the skin, never the frame (see **Frame**):
|
|
@@ -1251,12 +1291,13 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
|
|
|
1251
1291
|
| Cursor older than the oldest retained entry (the stream is trimmed with `MAXLEN ~ streamMaxLen`) | 200, `connected` → heartbeat → `retry: 86400000` → `event: resync-required` → end | same as above |
|
|
1252
1292
|
| The start of a catch-up page is trimmed away before the page is read | 200, the entries delivered so far → `retry: 86400000` → `event: resync-required` → end | same as above |
|
|
1253
1293
|
| Empty stream, or a cursor inside the retained window or in the future | 200, `connected` → heartbeat → catch-up (every entry) → live | continues |
|
|
1254
|
-
| Redis failure during the catch-up | the body ends (`producer-failed`) | one native reconnect with `Last-Event-ID`, then the utility's own backoff; the cursor is kept |
|
|
1294
|
+
| Redis failure during the catch-up, or the stream tail cannot be read while the shared reader fixes its read position (`ready()` rejects; on the path without a cursor before `connected` is written) | the body ends (`producer-failed`) | one native reconnect with `Last-Event-ID`, then the utility's own backoff; the cursor is kept |
|
|
1255
1295
|
|
|
1256
1296
|
- **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.
|
|
1257
1297
|
- **`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
1298
|
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
|
-
|
|
1299
|
+
The read position is always a concrete stream id: the shared reader fixes it by reading the stream tail (XREVRANGE) before its first run starts reading, and it never issues an XREAD from `$` (which the server resolves to the tail again at every read, so entries appended between two blocking reads would reach nobody).
|
|
1300
|
+
When the tail cannot be read, `ready()` rejects and the failed read is not kept, so the next call reads again: the connection that waited for it ends (`producer-failed`, the row above) and the client reopens it, with its cursor when it has one, so the catch-up covers everything since; and a run that cannot fix its position fails like any other failing run (every subscriber's `onError`). A host with its own SSE handler awaits `ready()` the same way and closes the connection when it rejects.
|
|
1260
1301
|
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.
|
|
1261
1302
|
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).
|
|
1262
1303
|
- **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).
|
|
@@ -1289,7 +1330,7 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
|
|
|
1289
1330
|
- Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`.
|
|
1290
1331
|
- **server** (`@aiquants/daily-report/server`, Node.js):
|
|
1291
1332
|
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
|
1292
|
-
- Attachments: `createSharpThumbnailRenderer` / `predictSharpThumbnailWorkingSetBytes` / `DAILY_REPORT_SHARP_THUMBNAIL_WORKING_SET_MODEL` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
|
|
1333
|
+
- 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`.
|
|
1293
1334
|
- SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
|
|
1294
1335
|
- Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `StreamEntry` / `ExternalReportFields`, and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
|
|
1295
1336
|
|