From 0bc0f497b7fe6a4b0a187dbcf6dc424dd0c3f81c Mon Sep 17 00:00:00 2001 From: sousuke0422 Date: Tue, 11 Aug 2026 02:19:37 +0900 Subject: [PATCH 1/5] docs(render): add the reduced-motion layer's failure paths before implementing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新しい層は既定で fail-open として生まれる——実装より先に、この層自身が 失敗したときどう倒れるかを三欄(検知できるか/どう倒れるか/検知できぬ ならなぜか)で数え、層ごとの失敗経路・手当て・分類の三表へ行を足す。 対象は ①CDP エラー ②無応答 ③成功したがメディアクエリ未変化 ④matchMedia 差し替え ⑤有効な project なのに呼び出し漏れ。 Assisted-by: multi-agent-shogun-aki-tweak --- apps/backend/crates/service/src/render/browser.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/backend/crates/service/src/render/browser.rs b/apps/backend/crates/service/src/render/browser.rs index ef9732d..a9d567d 100644 --- a/apps/backend/crates/service/src/render/browser.rs +++ b/apps/backend/crates/service/src/render/browser.rs @@ -55,9 +55,13 @@ //! | Rust 側の解析([`freeze_verdict`]) | 検知 | `ok === true` と確かめられた場合のみ撮影へ進む。欠落・型違い・parse 失敗はすべて unparseable(fail-closed) | //! | iframe・shadow 走査(`freezeRoot` 再帰) | 部分的 | open shadow root と同一オリジン iframe には到達。closed shadow root は**列挙する API が存在せず検知不能**、静止処理より後から生成される root にも届かない——いずれも README「届かない範囲」でページ側の責務と定めてある | //! | スクリーンショット | 検知 | CDP エラーは Rust 側 `Err`(fail-closed) | +//! | reduced-motion 適用(`Emulation.setEmulatedMedia`) | 検知(CDP エラー・無応答とも) | project 設定で有効なときだけ `new_page` 直後(ナビゲーション前)に一度呼ぶ。CDP エラーは Rust 側 `Err` → [`RenderError::Cdp`](環境分類・即中断。story のスクリプトを待たない一往復で、失敗の原因はブラウザ側——`new_page` と同じ分類)。無応答は chromiumoxide の request timeout(既定 30 秒)が `CdpError::Timeout` を返し、同じ `Cdp` へ倒れる(fail-closed) | +//! | reduced-motion 適用の検証([`REDUCED_MOTION_PROBE`]) | 部分的に検知 | 「呼び出しは成功したが実際にはメディアクエリが変わっていない」を撮影直前に実測する——constructed stylesheet の `@media (prefers-reduced-motion: reduce)` が効いたかのプローブ(`--vrt-reduced-motion`)と `matchMedia().matches` の**両輪**。どちらかが不成立なら `ok: false` → [`RenderError::Story`](fail-closed。reduce を返さない壊れた/モックされた `matchMedia`——polyfill やテストダブルの事故——はここで落ちる)。evaluate の CDP エラーは READY probe と同様 deadline までリトライし期限で [`RenderError::Timeout`]。**ページが両方の観測を偽装する積極的な偽りは原理的に検知不能**——検証はページの JS realm で走り、CDP に emulated media 状態を読み戻す API が無い。脅威モデルは一貫して事故であり悪意ではない(README「検証層自身の失敗も fail-closed である」と同じ契約) | +//! | reduced-motion 有効なのに呼び出し自体が漏れる | 実行時には検知不能 | 検知器の不在そのものがこの失敗であり、実行時観測では塞げない(「呼ばれなかったこと」を観測する層は、それ自身も呼ばれない)。構造で塞ぐ——適用は [`StoryRenderer::render_story`] の単一チョークポイントにだけ置き、分岐は `RenderOptions::emulate_reduced_motion` の一つ、project 列からの配線は `render_build` の単体テストで固定、経路全体は「ON で絵が変わる」positive control テスト(`reduced_motion_emulation_changes_the_picture_and_is_deterministic`)が貫通して固定する | //! //! 残る fail-open は「原理的に観測できない」もの(closed shadow root・ -//! クロスオリジン iframe・後から生成される root)だけであり、これらは +//! クロスオリジン iframe・後から生成される root・reduced-motion 検証の +//! 観測を両輪とも偽装するページ)だけであり、これらは //! 検知不能な理由とともに README の「届かない範囲」で利用者との契約に //! 昇格させてある。観測できるのに判定に使っていない失敗は残さないこと。 //! @@ -78,6 +82,8 @@ //! | FREEZE evaluate | READY 待ちと**共有**の deadline(`started + story_timeout`)の残余。1 story の最悪所要は約 `story_timeout` + `SETTLE_DELAY` に収まる | [`RenderError::Timeout`](時間切れ。READY 側と同じ分類。evaluate の CDP エラーも READY probe と同様 deadline までリトライし、期限で同じ Timeout——即 [`RenderError::Cdp`] へは倒さない) | `raf_suppressed_page_fails_within_the_story_timeout`・`freeze_timeout_shares_the_story_deadline_with_the_ready_wait`・`reloading_page_during_freeze_fails_story_scoped`・`collected_freeze_promise_fails_story_scoped` | //! | FREEZE 結果の解析 | 即時(待ちなし) | [`RenderError::Story`](`freeze_verdict`) | `freeze_verdict_*` 単体群・`garbled_freeze_result_fails_instead_of_silently_succeeding` | //! | スクリーンショット | **なし**——CDP 呼び出しが返らない場合は上位の CI ジョブタイムアウト頼み。JS の promise を待たない一往復コマンドで、ハングの既知経路が無いため保留(欠けと認識した上での判断) | [`RenderError::Cdp`] | `renders_a_story_to_a_png_with_the_requested_viewport` | +//! | reduced-motion 適用 | chromiumoxide の request timeout(既定 30 秒。一往復コマンド共通の機構) | [`RenderError::Cdp`] | `reduced_motion_emulation_changes_the_picture_and_is_deterministic` | +//! | reduced-motion 検証 | evaluate リトライは READY 待ちと共有の deadline 残余。判定自体は即時 | [`RenderError::Story`]([`reduced_motion_verdict`])/ リトライ期限切れは [`RenderError::Timeout`] | `a_page_that_breaks_matchmedia_fails_instead_of_silently_capturing`・`reduced_motion_verdict_*` 単体群 | //! //! ## story 固有の失敗と環境の失敗(隔離の分類・全経路) //! @@ -102,6 +108,9 @@ //! | FREEZE evaluate のエラー・ハング | リトライ→期限で [`RenderError::Timeout`] | story | navigation / reload・rAF 捨ては story のスクリプトの挙動(実測経路は上表) | //! | FREEZE verdict(静止失敗・解析不能) | [`RenderError::Story`] | story | その story のアニメーション・応答の内容に起因 | //! | スクリーンショット | [`RenderError::Cdp`] | 環境(即中断) | JS を待たない一往復の CDP コマンド——失敗はブラウザ側 | +//! | reduced-motion 適用(`setEmulatedMedia`) | [`RenderError::Cdp`] | 環境(即中断) | `new_page` 直後・story のスクリプトを待たない一往復——失敗はブラウザ側 | +//! | reduced-motion 検証の evaluate エラー | リトライ→期限で [`RenderError::Timeout`] | story | READY probe と同じ——ナビゲーション中の一時的な context 差し替えが主因 | +//! | reduced-motion 検証の不成立・解析不能 | [`RenderError::Story`] | story | `matchMedia` の差し替え等、そのページの内容に起因 | //! | スクリーンショット名の規則違反 | `StoryFailure` 直行(`render_build`) | story | story の title / name に起因。全違反を 1 ビルドで列挙する | //! | ストレージ・DB・baseline 流用の失敗 | `anyhow`(`render_build`) | 環境(即中断) | 保存経路の異常は次の story でも再現する | //! | バンドル展開・stories 空 | `anyhow`(`render_build`) | ビルド全体(ループ前に中断) | story 以前の前提が壊れている | From 6fb4c4edd008dde1a02943a7b047e8ba2891fc25 Mon Sep 17 00:00:00 2001 From: sousuke0422 Date: Tue, 11 Aug 2026 02:33:15 +0900 Subject: [PATCH 2/5] feat(render): project-level prefers-reduced-motion emulation, fail-closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit プロジェクト単位の bool(既定 OFF)で、storybook モードの撮影を prefers-reduced-motion: reduce のエミュレーション下で行う。 - 適用はナビゲーション前に Emulation.setEmulatedMedia を一度。撮影直前に 設定すると、初期化時に一度だけ matchMedia を読む実装(この層の主対象で ある rAF / canvas 実装の最頻形)に見えないため、OS で reduce を設定した 実利用者と同じ条件で最初から描画させる - fail-closed: 有効な project では撮影直前に REDUCED_MOTION_PROBE が CSS カスケード(constructed stylesheet の @media プローブ)と matchMedia の両輪で適用を実測し、ok === true と確かめられた場合にだけ 撮影へ進む。reduce を返さないモック matchMedia(polyfill・テスト ダブルの事故)はここで落ちる。CDP エラーは new_page と同じ環境分類、 無応答は chromiumoxide の request timeout、検証 evaluate のエラーは READY probe と同じ deadline リトライ→story 分類の Timeout - 呼び出し漏れ経路は実行時に検知できないため構造で塞ぐ: 配線は render_options_for_project の 1 箇所+単体テスト、経路全体は 「ON で絵が変わる」positive control テストが貫通して固定 - 既定 OFF の理由(有効化で baseline が一度入れ替わる)と効く範囲の 狭さ(行儀の良い rAF / canvas 実装との交差にだけ効く)を README へ - Page.setBypassCSP は使わない(本番と同じ条件で撮る) Assisted-by: multi-agent-shogun-aki-tweak --- README.md | 45 ++ .../crates/entity/src/_generated/projects.rs | 4 + .../crates/handler/src/handlers/projects.rs | 5 +- apps/backend/crates/job/src/render_build.rs | 64 ++- apps/backend/crates/payload/src/projects.rs | 10 + apps/backend/crates/service/src/projects.rs | 9 + .../crates/service/src/render/browser.rs | 439 ++++++++++++++++++ apps/backend/migration/src/lib.rs | 2 + .../src/m20260811000000_reduced_motion.rs | 29 ++ 9 files changed, 601 insertions(+), 6 deletions(-) create mode 100644 apps/backend/migration/src/m20260811000000_reduced_motion.rs diff --git a/README.md b/README.md index b0adf7c..ce1f6cd 100644 --- a/README.md +++ b/README.md @@ -688,6 +688,51 @@ CSP を `Page.setBypassCSP` で迂回すると本番と異なる絵を撮るこ 比較するだけなので、キャレット隠蔽やアニメーション静止は撮影側で行うこと (Playwright なら `caret: 'hide'` / `animations: 'disabled'` に相当)。 +#### `prefers-reduced-motion` のエミュレーション(プロジェクト単位・既定 OFF) + +プロジェクト設定の `emulate_reduced_motion`(`PATCH /v1/projects/{id}`)を +有効にすると、storybook モードの撮影を `prefers-reduced-motion: reduce` を +エミュレートした状態で行う。ページのナビゲーション前に +`Emulation.setEmulatedMedia` で一度設定するので、CSS メディアクエリにも、 +初期化時に `matchMedia` を読む JS にも、実利用者が OS で reduce を +設定したときと同じ条件で見える。 + +**効く範囲は狭い。** 上の静止機構が `getAnimations()` に載るもの +(CSS animation / transition / Web Animations)を実装の行儀に依存せず +止めるのに対し、reduced-motion が動きを止められるのは +**canvas / `requestAnimationFrame` など JS が毎フレーム描き直す実装のうち、 +`prefers-reduced-motion` を自分で尊重するものだけ**である。 + +- **効くもの**: rAF / canvas 駆動で、かつメディアクエリ(CSS)や + `matchMedia`(JS)を見て動きを抑える実装 +- **効かないもの**: + - メディアクエリを見ない rAF / canvas 実装(尊重しない実装に + ブラウザ側から強制する手段は無い) + - アニメーション画像(GIF / APNG)・`