Why Dark Mode Tests Don’t Cover Windows Contrast Themes
A toolbar can look correct in light and dark mode and still become difficult to use with a Windows contrast theme.
The missing check is forced colors: a browser mode that applies a restricted, user-selected palette to the page.
For SVG icons, this raises a different question from ordinary theme support:
When the surrounding interface changes to system colors, does the icon remain recognizable—and does the control still look like a control?
This article explains the documented behavior and a practical way to check it. It does not present results from a completed Windows browser comparison.
Start with the rendered control
A contrast calculation evaluates a foreground against a background. But the colors you authored are not necessarily the colors the browser paints in forced colors mode.
A button might normally use:
- a colored background;
- a white icon;
- a subtle shadow;
- a different background for its selected state.
When the browser applies the user's palette, some of those distinctions can change or disappear.
The useful unit to review is therefore the whole control: its icon, label, boundary, focus indicator, and state.
An SVG can remain present in the DOM while becoming difficult to distinguish visually. A button can remain clickable while losing the visual cues that explain what it does.
What changes in forced colors?
The forced-colors media feature detects whether the browser is enforcing a restricted palette.
@media (forced-colors: active) {
/* Targeted adjustments for the user's palette */
}
MDN documents adjustments to text, backgrounds, borders, and SVG paint properties. It also documents the removal of box shadows and non-URL background images, including CSS gradients.
Two details matter for icons:
A URL-based SVG background is not automatically removed by the rule that removes gradients. Its artwork may still become difficult to see against the changed surface.
SVG has specific color-adjustment behavior. The CSS specification describes preserve-parent-color, which allows embedded SVG to receive its parent's adjusted text color while otherwise preserving artwork colors.
That makes blanket statements such as “forced colors recolors every SVG path” unreliable.
Keep monochrome action icons connected to text
For a monochrome icon inside a button, using currentColor for the appropriate fill or stroke lets the artwork follow the control's text color.
For example:
export function DownloadButton() {
return (
<button className="action" type="button">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
focusable="false"
>
<path
d="M12 3v12m-5-5 5 5 5-5M5 17v4h14v-4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>Download report</span>
</button>
);
}
The SVG does not introduce a separate text color. Its stroke follows the inherited color.
This example uses simple illustrative geometry. When adapting an existing icon, preserve the distinction between fill and stroke. Replacing every paint value indiscriminately can change the artwork.
For a broader explanation of integration methods, see Same SVG, Different Runtime.
The important reminder here is that an external SVG loaded through <img> does not inherit the surrounding button's text color into its internal paths. Adding currentColor inside that file does not create the same inheritance relationship as inline SVG.
Check whether the button boundary survives
An icon is only one part of the control.
If a button depends entirely on a shadow to separate it from its surroundings, removing that shadow can make the boundary unclear.
A transparent border reserves space in the ordinary design. A targeted rule can then give that border a system color:
.action {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
color: #17324d;
background: #edf3f9;
border: 1px solid transparent;
border-radius: 0.375rem;
}
.action svg {
flex: none;
}
.action:focus-visible {
outline: 2px solid currentColor;
outline-offset: 3px;
}
@media (forced-colors: active) {
.action {
border-color: ButtonText;
}
.action:focus-visible {
outline-color: Highlight;
}
}
ButtonText and Highlight refer to the active system palette.
Treat this as a starting pattern to inspect in context. A default button, a disabled button, and a selected toggle have different communication requirements.
Don’t preserve the entire design with an opt-out
This rule may appear to solve unexpected changes:
.application {
forced-color-adjust: none;
}
But it disables automatic color adjustment throughout that subtree.
That can preserve the same color combinations the user enabled a contrast theme to avoid.
Keep any exception narrowly scoped. A color swatch may need to preserve a particular color because that color is the information. An ordinary toolbar usually benefits from following the user's palette.
If an exception is necessary, check both the preserved artwork and its contrast against the surrounding interface.
Test states, not just screenshots of the default theme
A useful review should answer specific questions:
| Check | Question |
| --- | --- |
| Icon | Can its shape still be distinguished from the surface? |
| Label | Is the action understandable if the icon is unclear? |
| Boundary | Can users identify the clickable control? |
| Keyboard focus | Is the focused control visibly indicated? |
| Selected state | Can users distinguish selected from unselected? |
| Disabled state | Is the unavailable state still recognizable? |
For a toggle, exposing aria-pressed communicates its state to assistive technology. Sighted users also need a visible indication that survives the active palette.
Similarly, an accessible name on an icon-only button does not resolve an invisible icon for a sighted user.
Both forms of communication need review.
Use emulation during development, then check Windows
Browser emulation is useful for finding problems early.
You can confirm that the browser reports forced colors with:
window.matchMedia("(forced-colors: active)").matches;
A true result confirms that the media query matches. It does not confirm that the interface is usable.
For a Windows-focused check, use actual Windows contrast themes as well:
1. Open the interface in a browser you support.
2. Enable a contrast theme with a dark canvas.
3. Inspect the controls and navigate with the keyboard.
4. Repeat with a contrast theme with a light canvas.
5. Check selected, disabled, hover, and focus states where applicable.
Record the Windows version, browser version, theme name, and whether the test used OS settings or emulation.
Label screenshots accordingly. An emulated browser capture should not be presented as a capture of an actual Windows contrast theme.
Make an icon comparison reproducible
If you compare integration methods, keep the artwork constant.
Choose a small group of monochrome icons from one family and save:
- the exact SVG files;
- their source URLs;
- the applicable license information;
- any changes made to fill, stroke, or color;
- the component code and test conditions.
Then compare inline SVG, <img>, and URL-based CSS backgrounds using the same geometry.
This separates differences caused by integration from differences introduced while preparing the assets.
A collection exported from SVGicons can provide the source assets, but the exported files are only the beginning. The component that renders them still needs inspection in the target environment.
Add forced colors to the review
Light and dark themes check the palettes your application provides.
Forced colors checks how the interface behaves with a palette chosen by the user.
Before shipping an icon-based control, make sure its meaning, boundary, focus, and state remain available under that condition too.
---
References
- MDN: forced-colors
- CSS Color Adjustment: forced-color-adjust
- Microsoft Edge: preserve-parent-color explainer
- Melanie Richards: CurrentColor SVG in forced colors modes
- SVGicons: Same SVG, Different Runtime
Mentions
I’ve wanted to see native blurred image loading for the web for ages 🤩 Curious though about the performance implications of an additional src. I’d always imagined this as a new img format instead. Also, excited to hear about some of the tradeoffs made in the proposal github.com/MicrosoftEdge/MSEdgeExplainers/blob/ma…
Why Dark Mode Tests Don’t Cover Windows Contrast Themes A toolbar can look correct in light and dark mode and still become difficult to use with a Windows contrast theme. The missing check is forced colors: a browser mode that applies a restricted, user-selected palette to the page. For SVG icons, this raises a different question from ordinary theme support: When the surrounding interface changes to system colors, does the icon remain recognizable—and does the control still look like a control? This article explains the documented behavior and a practical way to check it. It does not present results from a completed Windows browser comparison. Start with the rendered control A contrast calculation evaluates a foreground against a background. But the colors you authored are not necessarily the colors the browser paints in forced colors mode. A button might normally use: - a colored background; - a white icon; - a subtle shadow; - a different background for its selected state. When the browser applies the user's palette, some of those distinctions can change or disappear. The useful unit to review is therefore the whole control: its icon, label, boundary, focus indicator, and state. An SVG can remain present in the DOM while becoming difficult to distinguish visually. A button can remain clickable while losing the visual cues that explain what it does. What changes in forced colors? The forced-colors media feature detects whether the browser is enforcing a restricted palette. @media (forced-colors: active) { /* Targeted adjustments for the user's palette */ } MDN documents adjustments to text, backgrounds, borders, and SVG paint properties. It also documents the removal of box shadows and non-URL background images, including CSS gradients. Two details matter for icons: A URL-based SVG background is not automatically removed by the rule that removes gradients. Its artwork may still become difficult to see against the changed surface. SVG has specific color-adjustment behavior. The CSS specification describes preserve-parent-color, which allows embedded SVG to receive its parent's adjusted text color while otherwise preserving artwork colors. That makes blanket statements such as “forced colors recolors every SVG path” unreliable. Keep monochrome action icons connected to text For a monochrome icon inside a button, using currentColor for the appropriate fill or stroke lets the artwork follow the control's text color. For example: export function DownloadButton() { return ( <button className="action" type="button"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false" > <path d="M12 3v12m-5-5 5 5 5-5M5 17v4h14v-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> </svg> <span>Download report</span> </button> ); } The SVG does not introduce a separate text color. Its stroke follows the inherited color. This example uses simple illustrative geometry. When adapting an existing icon, preserve the distinction between fill and stroke. Replacing every paint value indiscriminately can change the artwork. For a broader explanation of integration methods, see Same SVG, Different Runtime. The important reminder here is that an external SVG loaded through <img> does not inherit the surrounding button's text color into its internal paths. Adding currentColor inside that file does not create the same inheritance relationship as inline SVG. Check whether the button boundary survives An icon is only one part of the control. If a button depends entirely on a shadow to separate it from its surroundings, removing that shadow can make the boundary unclear. A transparent border reserves space in the ordinary design. A targeted rule can then give that border a system color: .action { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; color: #17324d; background: #edf3f9; border: 1px solid transparent; border-radius: 0.375rem; } .action svg { flex: none; } .action:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; } @media (forced-colors: active) { .action { border-color: ButtonText; } .action:focus-visible { outline-color: Highlight; } } ButtonText and Highlight refer to the active system palette. Treat this as a starting pattern to inspect in context. A default button, a disabled button, and a selected toggle have different communication requirements. Don’t preserve the entire design with an opt-out This rule may appear to solve unexpected changes: .application { forced-color-adjust: none; } But it disables automatic color adjustment throughout that subtree. That can preserve the same color combinations the user enabled a contrast theme to avoid. Keep any exception narrowly scoped. A color swatch may need to preserve a particular color because that color is the information. An ordinary toolbar usually benefits from following the user's palette. If an exception is necessary, check both the preserved artwork and its contrast against the surrounding interface. Test states, not just screenshots of the default theme A useful review should answer specific questions: | Check | Question | | --- | --- | | Icon | Can its shape still be distinguished from the surface? | | Label | Is the action understandable if the icon is unclear? | | Boundary | Can users identify the clickable control? | | Keyboard focus | Is the focused control visibly indicated? | | Selected state | Can users distinguish selected from unselected? | | Disabled state | Is the unavailable state still recognizable? | For a toggle, exposing aria-pressed communicates its state to assistive technology. Sighted users also need a visible indication that survives the active palette. Similarly, an accessible name on an icon-only button does not resolve an invisible icon for a sighted user. Both forms of communication need review. Use emulation during development, then check Windows Browser emulation is useful for finding problems early. You can confirm that the browser reports forced colors with: window.matchMedia("(forced-colors: active)").matches; A true result confirms that the media query matches. It does not confirm that the interface is usable. For a Windows-focused check, use actual Windows contrast themes as well: 1. Open the interface in a browser you support. 2. Enable a contrast theme with a dark canvas. 3. Inspect the controls and navigate with the keyboard. 4. Repeat with a contrast theme with a light canvas. 5. Check selected, disabled, hover, and focus states where applicable. Record the Windows version, browser version, theme name, and whether the test used OS settings or emulation. Label screenshots accordingly. An emulated browser capture should not be presented as a capture of an actual Windows contrast theme. Make an icon comparison reproducible If you compare integration methods, keep the artwork constant. Choose a small group of monochrome icons from one family and save: - the exact SVG files; - their source URLs; - the applicable license information; - any changes made to fill, stroke, or color; - the component code and test conditions. Then compare inline SVG, <img>, and URL-based CSS backgrounds using the same geometry. This separates differences caused by integration from differences introduced while preparing the assets. A collection exported from SVGicons can provide the source assets, but the exported files are only the beginning. The component that renders them still needs inspection in the target environment. Add forced colors to the review Light and dark themes check the palettes your application provides. Forced colors checks how the interface behaves with a palette chosen by the user. Before shipping an icon-based control, make sure its meaning, boundary, focus, and state remain available under that condition too. --- References - MDN: forced-colors - CSS Color Adjustment: forced-color-adjust - Microsoft Edge: preserve-parent-color explainer - Melanie Richards: CurrentColor SVG in forced colors modes - SVGicons: Same SVG, Different Runtime
Yes! Added an example to the explainer: github.com/MicrosoftEdge/MSEdgeExplainers/blob/ma…
Would you be interested in any of the following for CSS highlights: animations, opacity, per-range styling? Tell me about your use cases and any thoughts you have on my proposal for these features 👉 github.com/MicrosoftEdge/MSEdgeExplainers/blob/ma…
WebView2 で Web Speech API がエラーになっていた件、どうやらランタイム側の追加機能によるバグだった模様。 一時回避策として、以下のパラメータで追加機能を無効にしたら無事に動作した。 --disable-features=msSpeechRecognitionServiceUseCetoService WebView2Feedback#5724 t.co
パスキーでフィッシング(remoteDesktopClientOverride)する仕組みとその防御について :::note warn - 本検証はすべて筆者のローカル環境で完結していて、防御機構の理解を目的とした解説です。 - remoteDesktopClientOverrideについて既知の問題点を実際に動かしながら解説するものです。 ::: パスキーでもフィッシングできる場合がある 周知のとおり、パスキーはフィッシング耐性がある認証方式です。 フィッシング耐性を実現する機構の1つとして、ブラウザがWebAuthnリクエスト中のrpIdと、ブラウザがアクセスしているオリジンをチェックしています。 これによって、中間者経由でのパスキー認証ができないのでフィッシングされません。 逆に考えると、ブラウザにrpIdとオリジンのチェックを緩めさせることができれば、フィッシングが可能になるということです。 ブラウザの制約を意図的に緩める仕組みがある 背景、リモートブラウザ分離 リモートブラウザ分離でパスキー認証をする場合、これから説明する事情のため、ブラウザによるrpIdとオリジンのチェックを緩める必要性があります。 リモートブラウザ分離は、Webブラウジングを手元の端末ではなく、クラウド上の遠隔ブラウザで実行する仕組みです。 ユーザーから見ると、ローカルブラウザのタブにリモートブラウザがあるマトリョシカのような状態になります。 !FIDO2 (11).png リモートブラウザ環境でパスキー認証するために このリモートブラウザ環境でパスキー認証することを考えます。 下記の環境を前提とします。 !リモートブラウザ.drawio.png RPexample.comがパスキー認証を要求すると、リモートブラウザremote.exampleがWebAuthnリクエストを中継し、ローカルブラウザがrpIdexample.comでのWebAuthnリクエストを受け取ります。 しかし、ローカルブラウザにとってのオリジンはhttps://remote.example(リモートブラウザをホストしている環境)です。 WebAuthnでは、rpIdが呼び出し元オリジンのドメインと一致しない(またはその親ドメインでない)場合、ブラウザは認証器を呼び出す前にSecurityErrorで要求を拒否します。 これはパスキーのフィッシング耐性を支える仕組みですが、この仕組みによってリモートブラウザ環境でパスキー認証ができなくなります。 !パスキー拒否.png フィッシング耐性を緩める仕組み そのため、リモートブラウザ環境でパスキー認証をするためにはブラウザのフィッシング耐性を意図的に緩める必要があります。 標準仕様はまだ確定していませんが、ChromiumではremoteDesktopClientOverrideというWebAuthnクライアント拡張を実装しています。 WebAuthnリクエストのextensionsに次のように指定するものです。 navigator.credentials.get( {publicKey:{ rpId:'example.com', challenge:..., extensions:{ remoteDesktopClientOverride:{ // <--- コレ origin:'https://example.com', sameOriginWithAncestors:true } } }}) この拡張が付いているWebAuthnリクエストを受け取ったChromeブラウザは、指定された値にオリジンを上書きします。 具体例として、ブラウザがアクセスしているオリジンがhttps://remote.exampleの状態で、remoteDesktopClientOverride: https://example.comを受け取ると、オリジンがhttps://example.comに上書きされます。 オリジンが上書きされることによって、ブラウザはドメイン不一致によるSecurity Errorを出さなくなります。 認証器への認証依頼も上書き後のオリジンをベースに行われますので、問題なくパスキー認証を行うことができます。 攻撃者によるremoteDesktopClientOverrideの活用 すでにお気づきかもしれませんが、この拡張を利用できるのはリモートブラウザだけではありません。 正規のユースケースではリモートブラウザがremoteDesktopClientOverrideを付与する一方で、攻撃時は中間者がremoteDesktopClientOverrideを付与することでフィッシングを成立させようとします。 攻撃者がremoteDesktopClientOverrideを活用するリスクはExplainerでも触れられています。 > By design, this extension breaks the WebAuthn security model that guarantees non-phishability of the credentials. 以降では、攻撃者がremoteDesktopClientOverrideを活用することを前提として、Chromiumに備わっている防御機構について説明し、最終的には実際にパスキーのフィッシングを試します。 Chromiumの防御機構 無条件でremoteDesktopClientOverrideを受け入れてしまうと、パスキーのフィッシングが簡単に成立してしまいます。それでは困るので、ChromiumはremoteDesktopClientOverrideを受入れるオリジンを制限しています。 本記事を執筆した2026年10月時点で、下記の条件でremoteDesktopClientOverrideを受入れるようです。Googleのドキュメントと実機で確認しています。 - 組織管理下にあるブラウザを使用していて、組織のユーザーでブラウザにログインしていること。Googleのドキュメントにおけるaffiliated userは上記の状態を指している。 > This policy only applies to affiliated users. - ブラウザのポリシーでWebAuthenticationRemoteDesktopAllowedOriginsを設定していること > Any origin configured in this policy can make WebAuthn requests for Relying Party IDs (RP IDs) that it would normally not allowed to be able to claim. 組織管理下にあるブラウザ、というのはGoogle Workspace等で管理されているブラウザです。 「Google Workspaceで管理」というのはイメージがわかない方も一定数いらっしゃると思うので管理画面のスクリーンショットを載せておきます。(私も今回の検証で初めて使いました、、) ブラウザを組織管理下に置くことで、ブラウザの拡張機能やポリシー等の状態を管理できるようになるようです。 !無題のプレゼンテーション (1).png 上記のような組織管理下にあるブラウザを使っている前提で、さらにポリシーWebAuthenticationRemoteDesktopAllowedOriginsを設定する必要があります。 WebAuthenticationRemoteDesktopAllowedOriginsは現時点ではGoogle Workspaceの管理コンソールから設定できないようですので、ローカルマシン上で設定する必要があります。ローカルマシン上の所定の場所にjsonを置くだけです。 上記3つの条件を満たすと、パスキーに対してもフィッシングが可能になります。 考えられる攻撃 パスキーのフィッシング耐性を部分的に破壊する機能なだけあって、厳重に保護されています。 しかし、現状ではWebAuthenticationRemoteDesktopAllowedOriginsをローカルマシン上で設定する仕様です。 管理コンソールでのみ設定できたほうが安全だと思うのですが、何らかの事情があるのでしょう。 ローカルマシンでWebAuthenticationRemoteDesktopAllowedOriginsを設定する仕様のため、組織管理下にあるブラウザを使用しているユーザーにローカルポリシーを編集させることで、パスキーに対してフィッシングが可能になります。 念のため書きますが、remoteDesktopClientOverrideを利用してフィッシングが可能になることは仕様や実装の不備ではありません。 攻撃者のいいなりになってポリシーを編集してしまうユーザーのリテラシーが原因です。 :::note info navigator.credentials.get()ではrpIdを指定しないことが可能ですが、指定しない場合でも攻撃は成立します。rpIdを指定しない場合には、オリジンの実効ドメインがrpIdとなる仕様であり、remoteDesktopClientOverrideはオリジンを上書きするためです。 ::: 実際にパスキーをフィッシングする 調べたら実際にやってみたくなるのが人の性ですので、ローカル環境で実践してみました。 下記のような構成を用意しました。 中間者evil.localはブラウザとRPの間に入って、すべての通信を中継しつつ、WebAuthnリクエストにremoteDesktopClientOverrideを付与します。 RPにはWebAuthn.ioをidp.service.local上でホストしたものを使用しています。 ブラウザは組織管理下のChromeで、ポリシーWebAuthenticationRemoteDesktopAllowedOriginsでevil.localを許可しています。このポリシーは、攻撃者のいいなりになったユーザーが設定してしまう前提です。 !フィッシング構成.png $ cat /etc/opt/chrome/policies/managed/evil-policy.json { "WebAuthenticationRemoteDesktopAllowedOrigins": [ "https://evil.local" # 中間者を許可リストに入れている ] } パスキーをフィッシングする evil.localにアクセスします。中間者がブラウザとRPidp.service.localとの間に入って通信を中継しているので、直接RPにアクセスした場合と同じ画面が表示されます。 URLバーを見ると、中間者(フィッシングサイト)にアクセスしていると確認できます。 !Phishing_image.png 下記にフィッシングサイトでパスキー認証するGIFを載せます。 ユーザーがアクセスしているサイトはevil.localですが、idp.service.localの鍵でパスキー認証できています。 (GIF中で、「idp.service.localでセキュリティキーを使う」というポップアップが出ています) !GIF.gif 当然ながら、認証器にevil.localの鍵は入っていません。idp.service.localの鍵でパスキー認証しています。 $ ykman fido credentials list | grep -e "idp.service.local" -e "evil.local" Enter your PIN: 73704dea... idp.service.local Phishing_test Phishing_test ブラウザのコンソールからcredentials.get()を確認すると、 中間者によって、credentials.get()のextensionにremoteDesktopClientOverrideが付与されていることが確認できます。 { "publicKey": { "challenge": "...", "extensions": { "remoteDesktopClientOverride": { // <--- コレ "origin": "https://idp.service.local", "sameOriginWithAncestors": true } } }, } ChromeはremoteDesktopClientOverride: https://idp.service.localを受け取ると、オリジンをhttps://idp.service.localとしてパスキー認証します。 これによって、中間者が存在していてもパスキー認証が可能になり、フィッシングが成立します。 まとめと感想 WebAuthnクライアント拡張remoteDesktopClientOverrideを利用すると、パスキーでフィッシングが可能になる理由を説明して、実演しました。 フィッシングが可能になる拡張だけに、remoteDesktopClientOverrideが適用される条件は厳しいものとなっています。Chromeが組織管理下にあって、かつWebAuthenticationRemoteDesktopAllowedOriginsポリシーが設定されている必要があります。 remoteDesktopClientOverrideはChromium独自拡張ですが、標準仕様としてremoteClientDataJSONの策定が進められています。 remoteClientDataJSONを無条件で適用してしまうと、パスキーのフィッシング耐性が弱まってしまうので、remoteClientDataJSONを適用できるオリジンを何らかの方法で制限する必要があるはずです。 Chromiumは組織管理ブラウザかつポリシー設定で制限をしていましたが、remoteClientDataJSONの保護はどのように規定されるのでしょうか。 今後どのように仕様が定まっていくか注目していきたいです。 参考資料 ChromeポリシーWebAuthenticationRemoteDesktopAllowedOriginsについて chromeenterprise.google WebAuthn Level4 remoteClientDataJSON w3.org remoteClientDataJSONのExpaliner github.com/MicrosoftEdge/MSEdgeExplainers/blob/ma… remoteDesktopClientOverrideのExpaliner github.com/w3c/webauthn/wiki/Explainer:-Remote-De… WebAuthn.io duo-labs/webauthn.io
微软的东西做的不错(最起码比 Chrome 的好看点)……但是用 Edge 点进去就直接 Crash: 而且还是版本特定的问题? MSEdgeExplainers#1409 microsoftedge.github.io