1+ import AxeBuilder from "@axe-core/playwright" ;
12import { expect , test } from "@playwright/test" ;
23
34/**
45 * Theme toggle (#107): Settings → Display → Theme flips the `.dark` class and
56 * `color-scheme` on <html> and persists across a reload. This exercises the
6- * MECHANISM ; the pixel-level light-mode contrast pass is a human visual review .
7+ * switching mechanism ; the rendered contrast contract is covered below .
78 */
89test ( "theme toggle flips the dark class and persists" , async ( { page } ) => {
910 await page . goto ( "/settings?tab=planning" ) ;
@@ -28,3 +29,67 @@ test("theme toggle flips the dark class and persists", async ({ page }) => {
2829 await page . getByRole ( "option" , { name : "Dark" } ) . click ( ) ;
2930 await expect ( html ) . toHaveClass ( / d a r k / ) ;
3031} ) ;
32+
33+ const CONTRAST_ROUTES = [ "/" , "/block" , "/factory" , "/assistant" , "/settings" ] as const ;
34+
35+ for ( const theme of [ "light" , "dark" ] as const ) {
36+ test ( `${ theme } theme keeps representative routes contrast-safe` , async ( { page } ) => {
37+ await page . addInitScript ( ( preference ) => {
38+ localStorage . setItem ( "pyops.theme" , preference ) ;
39+ } , theme ) ;
40+
41+ const failures : string [ ] = [ ] ;
42+ for ( const route of CONTRAST_ROUTES ) {
43+ await page . goto ( route , { waitUntil : "domcontentloaded" } ) ;
44+ await expect ( page . locator ( "nav" ) . getByRole ( "link" , { name : "PyOps" } ) ) . toBeVisible ( ) ;
45+ await page . waitForTimeout ( 250 ) ;
46+
47+ await page . evaluate ( ( ) => {
48+ const fixture = document . createElement ( "div" ) ;
49+ fixture . setAttribute ( "data-theme-contrast-fixture" , "" ) ;
50+ Object . assign ( fixture . style , {
51+ position : "fixed" ,
52+ top : "40px" ,
53+ left : "0" ,
54+ zIndex : "99999" ,
55+ fontSize : "14px" ,
56+ fontWeight : "400" ,
57+ } ) ;
58+
59+ const pairs = [
60+ [ "foreground" , "var(--foreground)" , "var(--background)" ] ,
61+ [ "muted" , "var(--muted-foreground)" , "var(--background)" ] ,
62+ [ "primary" , "var(--primary)" , "var(--background)" ] ,
63+ [ "primary action" , "var(--primary-foreground)" , "var(--primary-solid)" ] ,
64+ ...[ "success" , "warning" , "info" , "surplus" ] . flatMap ( ( token ) => [
65+ [ token , `var(--${ token } )` , "var(--background)" ] ,
66+ [
67+ `${ token } tint` ,
68+ `var(--${ token } )` ,
69+ `color-mix(in oklab, var(--${ token } ) 10%, var(--background))` ,
70+ ] ,
71+ ] ) ,
72+ ] ;
73+
74+ for ( const [ label , color , backgroundColor ] of pairs ) {
75+ const sample = document . createElement ( "span" ) ;
76+ sample . textContent = label ;
77+ Object . assign ( sample . style , { display : "block" , color, backgroundColor } ) ;
78+ fixture . append ( sample ) ;
79+ }
80+ document . body . append ( fixture ) ;
81+ } ) ;
82+
83+ const result = await new AxeBuilder ( { page } ) . withRules ( [ "color-contrast" ] ) . analyze ( ) ;
84+ for ( const violation of result . violations ) {
85+ for ( const node of violation . nodes ) {
86+ failures . push (
87+ `${ route } ${ node . target . join ( " " ) } : ${ node . failureSummary ?? violation . help } ` ,
88+ ) ;
89+ }
90+ }
91+ }
92+
93+ expect ( failures , failures . join ( "\n\n" ) ) . toEqual ( [ ] ) ;
94+ } ) ;
95+ }
0 commit comments