Skip to content

feat(render): prefers-reduced-motion のエミュレーションをプロジェクト単位で追加する - #21

Open
sousuke0422 wants to merge 3 commits into
mainfrom
feat/reduced-motion-emulation
Open

feat(render): prefers-reduced-motion のエミュレーションをプロジェクト単位で追加する#21
sousuke0422 wants to merge 3 commits into
mainfrom
feat/reduced-motion-emulation

Conversation

@sousuke0422

Copy link
Copy Markdown
Contributor

storybook モードの撮影で prefers-reduced-motion: reduce をエミュレートする設定を、プロジェクト単位で追加する。
既定は無効。

背景

PR #19 で入れた静止機構は getAnimations() に載るものを止める。
CSS アニメーション、トランジション、Web Animations API がこれにあたる。
実装がメディアクエリを見ているかどうかに依存しないのが、この機構の強みになっている。

一方で getAnimations() に載らないものには原理的に届かない。
requestAnimationFrame で毎フレーム描き直すものや、canvas の中の描画がこれにあたる。
次に何が描かれるかは実行するまで決まらないため、終端も初期状態も定義できない。

prefers-reduced-motion のエミュレーションは、この範囲に届きうる唯一の手段になる。
メディアクエリを見て動きを止める実装であれば、rAF のループ自体が回らなくなるため。

効く範囲が狭いことについて

この機構は、メディアクエリを尊重する実装にしか効かない。
無視する実装には何も起きない。
ただしそちらは PR #19 の静止機構が既に止めているため、二つは補完の関係にある。

アニメーション GIF、APNG、<video>、SVG の SMIL には効かない。
ブラウザ自身が動かすものであり、実装の行儀という概念が当てはまらないため。

したがってこの設定を入れても、flaky な差分が広く消えるわけではない。
効くのは rAF や canvas を使い、かつメディアクエリを尊重する実装という交差だけになる。
README にもこの範囲を明記した。

実装

プロジェクトに emulate_reduced_motion 列を追加した。
既定は false。
有効にすると撮影される絵が変わるため、そのプロジェクトの baseline は一度入れ替わることになる。

エミュレーションはナビゲーションの前に適用している。
撮影の直前ではない。
初期化時に一度だけ matchMedia を読む実装が、この層の主な対象になるため。
ナビゲーション後に設定しても、そうした実装には何も効かない。

適用されたかどうかは、撮影の直前に実測している。
constructed stylesheet の @media (prefers-reduced-motion: reduce) が効いたかを見るプローブと、matchMedia().matches の二つを両方確認する。
どちらかが不成立なら撮影せず失敗させる。
matchMedia が polyfill やテストダブルで差し替わっていて reduce を返さない場合も、ここで落ちる。

検知できない経路について

二つある。
どちらも実装の前に洗い出し、browser.rs のモジュール冒頭にある失敗経路の表へ記録した。

ページが両方の観測を偽装した場合は検知できない。
検証はページの JS realm で走り、CDP にはエミュレート中のメディア状態を読み戻す API が存在しないため。
このリポジトリの脅威モデルは一貫して事故であり悪意ではない。
壊れた polyfill やテストダブルは捕まえるが、意図的な偽装は対象外になる。

設定が有効なのに呼び出し自体が漏れる経路も、実行時には検知できない。
呼ばれなかったことを観測する層は、それ自身も呼ばれないため。
これは構造で塞いだ。
適用箇所を render_story の一箇所だけに置き、プロジェクト列からの配線を単体テストで固定し、経路全体を有効時に絵が変わることを見る試験で貫通させている。

検証

有効にして同じストーリーを二回撮り、PNG のバイト列が一致することを確認した。

無効と有効で絵が変わることも確認している。
描画時に一度だけ matchMedia を読む fixture を用意し、無効なら黄、有効なら緑になるようにした。
これが変わらなければ、エミュレーションが効いていないのか fixture が行儀良くないのかを区別できないため。

matchMedia を常に false を返すモックへ差し替えた fixture でも試験した。
CSS 側は成立するが matchMedia 側が不成立になり、撮影せずに失敗する。

変更されていないもの

Page.setBypassCSP は使っていない。
script-src まで外れて inline script が動き、DOM が変わるため。
撮る絵を変えないことがこの機構の前提になっている。

PR #19 の静止機構には手を入れていない。

レビューしてほしい点

適用のタイミングをナビゲーション前にした判断が妥当か。
撮影直前の設定では、初期化時に一度だけメディアクエリを読む実装に効かない。
一方でナビゲーション前に置くと、適用から撮影までの間が長くなる。

失敗経路の表に漏れがないか。
特に「検知できない」と書いた二つについて、実際には検知しうる方法があるなら指摘してほしい。

Assisted-by: multi-agent-shogun-aki-tweak

…lementing it

新しい層は既定で fail-open として生まれる——実装より先に、この層自身が
失敗したときどう倒れるかを三欄(検知できるか/どう倒れるか/検知できぬ
ならなぜか)で数え、層ごとの失敗経路・手当て・分類の三表へ行を足す。
対象は ①CDP エラー ②無応答 ③成功したがメディアクエリ未変化
④matchMedia 差し替え ⑤有効な project なのに呼び出し漏れ。

Assisted-by: multi-agent-shogun-aki-tweak
…osed

プロジェクト単位の 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant