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)・`
+
+ {/* The cost has to be readable before the box is ticked: enabling this
+ replaces the baseline once. Same warning as the README section. */}
+
+