Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)・`<video>`・SMIL——ブラウザ自身が
進めるもので、そもそも「行儀」の概念が無い
- `getAnimations()` に載るアニメーション——こちらは reduce の尊重に
関係なく上の静止機構が既に止めている

つまり**これを入れても flaky が消えるわけではない**。静止機構が原理的に
届かない領域(rAF / canvas)のうち、行儀の良い実装との狭い交差にだけ効く
追加の手である——自前のコンポーネントに `prefers-reduced-motion` の尊重を
規約として課せる場合に最も価値がある。

**既定が OFF なのは、有効化すると撮る絵が変わるから**である。reduce を
尊重する story の絵が変わり、**そのプロジェクトの baseline が一度
入れ替わる**——有効化後の最初のビルドで出る差分をレビューして承認すること
(静止機構の「移行手順」と同じ一度きりの差分)。

**fail-closed である**: 有効にした project では、撮影直前に「エミュレーションが
実際に効いているか」を CSS カスケード(constructed stylesheet の
`@media` プローブ)と `matchMedia` の両面で実測し、**効いていると確かめられ
なければその story は撮らずにエラーにする**(`reduced-motion emulation was
requested but could not be verified as applied`)。`matchMedia` を reduce を
返さないモックへ差し替えるページ(polyfill やテストダブルの事故)はここで
落ちる。ページが両方の観測を偽装する場合は原理的に検出できない——脅威モデルが
事故であり悪意でないのは静止機構の検証と同じである。失敗経路の全数は
`crates/service/src/render/browser.rs` の「層ごとの失敗経路」を参照。

#### `vrt` CLI で 1 コマンド(推奨)

同梱の CLI(`apps/backend/crates/cli`、バイナリ名 `vrt`)を使うと、ビルド作成 →
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/crates/entity/src/_generated/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ pub struct Model {
/// プロジェクトごとに保持する完了ビルド数の上限。NULL は無制限(既定)。
/// 超過した古い完了ビルドは自動削除される(現行 baseline の参照元は残す)。
pub build_retention_limit: Option<i32>,
/// storybook モードの撮影時に `prefers-reduced-motion: reduce` を
/// エミュレートするか。既定 false——有効にすると撮る絵が変わり、
/// baseline が一度入れ替わるため。
pub emulate_reduced_motion: bool,
/// GitHub App のインストール ID。Phase 6 で `github_installations` への FK にする。
pub github_installation_id: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
Expand Down
5 changes: 4 additions & 1 deletion apps/backend/crates/handler/src/handlers/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ pub async fn get_project(
tag = "Projects",
summary = "プロジェクト設定を更新",
description = "admin 以上が必要。`diff_threshold` / `diff_ratio_fail` は 0.0〜1.0、\
`viewport_width` / `viewport_height` は 64〜10000(storybook モードのレンダリング用)。",
`viewport_width` / `viewport_height` は 64〜10000(storybook モードのレンダリング用)。\
`emulate_reduced_motion` は撮影時に `prefers-reduced-motion: reduce` を\
エミュレートする(既定 false。有効化すると baseline が一度入れ替わる)。",
params(("project_id" = Uuid, Path, description = "プロジェクトID")),
request_body = UpdateProjectRequest,
responses(
Expand Down Expand Up @@ -178,6 +180,7 @@ pub async fn update_project(
viewport_width: payload.viewport_width,
viewport_height: payload.viewport_height,
build_retention_limit: payload.build_retention_limit,
emulate_reduced_motion: payload.emulate_reduced_motion,
},
)
.await?;
Expand Down
64 changes: 59 additions & 5 deletions apps/backend/crates/job/src/render_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,7 @@ async fn run(
let server = StaticServer::start(&bundle.root).await?;
let base_url = server.base_url();

let options = RenderOptions::new(
chromium_path,
project.viewport_width.max(1) as u32,
project.viewport_height.max(1) as u32,
);
let options = render_options_for_project(chromium_path, &project);
let renderer = StoryRenderer::launch(options).await?;

// ブラウザは成功・失敗どちらでも必ず閉じる(`?` で早期 return しない)。
Expand Down Expand Up @@ -279,6 +275,26 @@ async fn run(
Ok(())
}

/// project 設定から [`RenderOptions`] を組む。
///
/// ここが project 列とレンダラをつなぐ**唯一の配線**である。
/// `emulate_reduced_motion` の「設定が有効な project なのに呼び出し自体が
/// 漏れる」経路は実行時には検知できない(検知器の不在そのものがこの失敗)
/// ため、配線を 1 箇所に寄せて単体テストで固定する
/// (`service::render::browser` モジュール先頭の失敗経路表を参照)。
fn render_options_for_project(
chromium_path: String,
project: &entity::projects::Model,
) -> RenderOptions {
let mut options = RenderOptions::new(
chromium_path,
project.viewport_width.max(1) as u32,
project.viewport_height.max(1) as u32,
);
options.emulate_reduced_motion = project.emulate_reduced_motion;
options
}

/// ストーリー 1 件をどう処理するか。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StoryAction {
Expand Down Expand Up @@ -624,6 +640,44 @@ mod tests {
assert_eq!(truncate(&"x".repeat(50), 10).len(), 10);
}

fn project_fixture(emulate_reduced_motion: bool) -> entity::projects::Model {
let now = chrono::Utc::now().fixed_offset();
entity::projects::Model {
id: Uuid::new_v4(),
tenant_id: Uuid::new_v4(),
name: "p".into(),
slug: "p".into(),
default_branch: "main".into(),
diff_threshold: 0.1,
diff_ratio_fail: 0.0,
viewport_width: 1280,
viewport_height: 720,
build_retention_limit: None,
emulate_reduced_motion,
github_installation_id: None,
github_repo: None,
created_at: now,
updated_at: now,
}
}

/// **project 列 `emulate_reduced_motion` がレンダラのオプションへ届く**こと。
///
/// 「設定が有効な project なのに呼び出し自体が漏れる」経路は実行時には
/// 検知できないため、唯一の配線であるこの関数をテストで固定する。
/// 両方向を見る——ON が届くことだけでなく、OFF の project が ON に
/// 化けないことも(既定 OFF の契約)。
#[test]
fn reduced_motion_setting_reaches_the_render_options() {
let on = render_options_for_project("chromium".into(), &project_fixture(true));
assert!(on.emulate_reduced_motion);
let off = render_options_for_project("chromium".into(), &project_fixture(false));
assert!(!off.emulate_reduced_motion);
// 既存の配線が壊れていないこと(viewport・freeze 既定)。
assert_eq!((on.viewport_width, on.viewport_height), (1280, 720));
assert!(on.freeze_before_capture);
}

#[test]
fn queue_name_is_stable() {
// ワーカー名は `{queue}-worker-{uuid}` で組み立てられる(server.rs 参照)。
Expand Down
10 changes: 10 additions & 0 deletions apps/backend/crates/payload/src/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ pub struct ProjectResponse {
/// 保持する完了ビルド数の上限。null は無制限。
#[schema(nullable)]
pub build_retention_limit: Option<i32>,
/// storybook モードの撮影時に `prefers-reduced-motion: reduce` を
/// エミュレートするか。既定 false。有効化すると撮る絵が変わり、
/// baseline が一度入れ替わる。
pub emulate_reduced_motion: bool,
#[schema(nullable)]
pub github_installation_id: Option<i64>,
#[schema(nullable)]
Expand All @@ -64,6 +68,7 @@ impl From<projects::Model> for ProjectResponse {
viewport_width: model.viewport_width,
viewport_height: model.viewport_height,
build_retention_limit: model.build_retention_limit,
emulate_reduced_motion: model.emulate_reduced_motion,
github_installation_id: model.github_installation_id,
github_repo: model.github_repo,
created_at: model.created_at.with_timezone(&Utc),
Expand Down Expand Up @@ -104,4 +109,9 @@ pub struct UpdateProjectRequest {
#[schema(nullable, value_type = Option<i32>)]
#[serde(default, deserialize_with = "double_option")]
pub build_retention_limit: Option<Option<i32>>,
/// storybook モードの撮影時に `prefers-reduced-motion: reduce` を
/// エミュレートするか。省略すると現在値を据え置く。既定は false。
/// **有効化すると撮る絵が変わり、そのプロジェクトの baseline が
/// 一度入れ替わる**——最初のビルドで差分をレビューして承認すること。
pub emulate_reduced_motion: Option<bool>,
}
9 changes: 9 additions & 0 deletions apps/backend/crates/service/src/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ pub struct ProjectSettings {
/// ビルド保持数の上限。外側 `None` は据え置き、`Some(None)` は無制限(NULL)に設定、
/// `Some(Some(n))` は上限を `n` に設定する。
pub build_retention_limit: Option<Option<i32>>,
/// storybook モードの撮影時に `prefers-reduced-motion: reduce` を
/// エミュレートするか。既定 OFF([`create_project`])——有効にすると
/// 撮る絵が変わり、そのプロジェクトの baseline が一度入れ替わるため、
/// 利用者が明示的に選んだときにだけ変える。
pub emulate_reduced_motion: Option<bool>,
}

/// テナント内のプロジェクト一覧(作成順)。
Expand Down Expand Up @@ -159,6 +164,7 @@ pub async fn create_project<C: ConnectionTrait>(
viewport_width: Set(DEFAULT_VIEWPORT_WIDTH),
viewport_height: Set(DEFAULT_VIEWPORT_HEIGHT),
build_retention_limit: Set(None),
emulate_reduced_motion: Set(false),
github_installation_id: Set(None),
github_repo: Set(None),
created_at: Set(now),
Expand Down Expand Up @@ -212,6 +218,9 @@ pub async fn update_project<C: ConnectionTrait>(
if let Some(build_retention_limit) = settings.build_retention_limit {
active.build_retention_limit = Set(build_retention_limit);
}
if let Some(emulate_reduced_motion) = settings.emulate_reduced_motion {
active.emulate_reduced_motion = Set(emulate_reduced_motion);
}
active.updated_at = Set(Utc::now().fixed_offset());
Ok(active.update(db).await?)
}
Expand Down
Loading
Loading