@@ -2,6 +2,8 @@ import fs from 'fs';
22import path from 'path' ;
33
44const SRC_DIR = path . resolve ( __dirname , '../../' ) ;
5+ const A11Y_CSS = path . resolve ( SRC_DIR , 'styles/accessibility.css' ) ;
6+ const TOKENS_CSS = path . resolve ( SRC_DIR , 'styles/tokens.css' ) ;
57
68function findCssFiles ( dir : string ) : string [ ] {
79 return fs . readdirSync ( dir , { withFileTypes : true } ) . flatMap ( ( entry ) => {
@@ -13,6 +15,157 @@ function findCssFiles(dir: string): string[] {
1315 } ) ;
1416}
1517
18+ /* ============================================================
19+ CSS token / block extraction helpers
20+ ============================================================ */
21+
22+ /** Extract the body of the first top-level block whose selector line matches. */
23+ function readBlock ( css : string , selector : RegExp ) : string {
24+ const lines = css . split ( '\n' ) ;
25+ const body : string [ ] = [ ] ;
26+ let inBlock = false ;
27+ let started = false ;
28+ let depth = 0 ;
29+
30+ for ( const line of lines ) {
31+ if ( ! inBlock ) {
32+ if ( selector . test ( line ) ) {
33+ inBlock = true ;
34+ const brace = ( line . match ( / \{ / g) || [ ] ) . length ;
35+ if ( brace > 0 ) {
36+ started = true ;
37+ depth = brace ;
38+ }
39+ }
40+ continue ;
41+ }
42+ if ( ! started ) {
43+ const brace = ( line . match ( / \{ / g) || [ ] ) . length ;
44+ if ( brace > 0 ) {
45+ started = true ;
46+ depth = brace ;
47+ }
48+ } else {
49+ depth += ( line . match ( / \{ / g) || [ ] ) . length - ( line . match ( / \} / g) || [ ] ) . length ;
50+ }
51+ body . push ( line . replace ( / ^ [ \t ] + / , '' ) ) ;
52+ if ( started && depth === 0 ) break ;
53+ }
54+ return body . join ( '\n' ) ;
55+ }
56+
57+ /** Extract a CSS custom property value from a block body. */
58+ function varValue ( block : string , name : string ) : string | null {
59+ // `name` already includes the leading `--` (e.g. "--ring-strong").
60+ const escaped = name . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
61+ const m = block . match ( new RegExp ( `${ escaped } \\s*:\\s*([^;]+);` ) ) ;
62+ return m ? m [ 1 ] . trim ( ) : null ;
63+ }
64+
65+ /* ============================================================
66+ Color / contrast helpers (WCAG 2.x APCA-free ratio math)
67+ ============================================================ */
68+
69+ type RGB = [ number , number , number ] ; // 0..1 floats
70+
71+ function channelToLinear ( c : number ) : number {
72+ return c <= 0.03928 ? c / 12.92 : Math . pow ( ( c + 0.055 ) / 1.055 , 2.4 ) ;
73+ }
74+
75+ function hexToRgb ( hex : string ) : RGB | null {
76+ const m = hex . trim ( ) . replace ( '#' , '' ) . match ( / ^ ( [ 0 - 9 a - f ] { 6 } ) $ / i) ;
77+ if ( ! m ) return null ;
78+ return [
79+ parseInt ( m [ 1 ] . slice ( 0 , 2 ) , 16 ) / 255 ,
80+ parseInt ( m [ 1 ] . slice ( 2 , 4 ) , 16 ) / 255 ,
81+ parseInt ( m [ 1 ] . slice ( 4 , 6 ) , 16 ) / 255 ,
82+ ] ;
83+ }
84+
85+ /** Parse `rgb(r,g,b)` / `rgba(r,g,b,a)` into floats. */
86+ function rgbaToRgb ( value : string ) : RGB | null {
87+ const m = value . match ( / r g b a ? \( \s * ( [ \d . ] + ) \s * , \s * ( [ \d . ] + ) \s * , \s * ( [ \d . ] + ) (?: \s * , \s * ( [ \d . ] + ) ) ? \s * \) / i) ;
88+ if ( ! m ) return null ;
89+ return [ Number ( m [ 1 ] ) / 255 , Number ( m [ 2 ] ) / 255 , Number ( m [ 3 ] ) / 255 ] ;
90+ }
91+
92+ function luminance ( rgb : RGB ) : number {
93+ return (
94+ 0.2126 * channelToLinear ( rgb [ 0 ] ) +
95+ 0.7152 * channelToLinear ( rgb [ 1 ] ) +
96+ 0.0722 * channelToLinear ( rgb [ 2 ] )
97+ ) ;
98+ }
99+
100+ function contrastRatio ( a : RGB , b : RGB ) : number {
101+ const l1 = luminance ( a ) ;
102+ const l2 = luminance ( b ) ;
103+ const [ hi , lo ] = [ Math . max ( l1 , l2 ) , Math . min ( l1 , l2 ) ] ;
104+ return ( hi + 0.05 ) / ( lo + 0.05 ) ;
105+ }
106+
107+ /** Resolve a token value (hex or rgba) to RGB. r,g,b are 0..255 ints. */
108+ function parseColor ( value : string ) : RGB | null {
109+ const trimmed = value . trim ( ) ;
110+ if ( / ^ # / . test ( trimmed ) ) return hexToRgb ( trimmed ) ;
111+ if ( / ^ r g b a ? \( / . test ( trimmed ) ) return rgbaToRgb ( trimmed ) ;
112+ return null ;
113+ }
114+
115+ /**
116+ * Composite a translucent foreground over an opaque background, returning the
117+ * resulting opaque RGB. Used to resolve `--surface-glass` tokens.
118+ */
119+ function composite ( fg : RGB , base : RGB , alpha : number ) : RGB {
120+ return [
121+ fg [ 0 ] * alpha + base [ 0 ] * ( 1 - alpha ) ,
122+ fg [ 1 ] * alpha + base [ 1 ] * ( 1 - alpha ) ,
123+ fg [ 2 ] * alpha + base [ 2 ] * ( 1 - alpha ) ,
124+ ] ;
125+ }
126+
127+ function alphaOf ( value : string ) : number | null {
128+ const m = value . match ( / , ? \s * ( [ \d . ] + ) \) \s * $ / ) ;
129+ return m ? Number ( m [ 1 ] ) : null ;
130+ }
131+
132+ /* ============================================================
133+ Shared fixtures extracted from tokens.css
134+ ============================================================ */
135+
136+ const tokensCss = fs . readFileSync ( TOKENS_CSS , 'utf-8' ) ;
137+ const a11yCss = fs . readFileSync ( A11Y_CSS , 'utf-8' ) ;
138+
139+ // Scopes define where each color scheme's tokens live in tokens.css.
140+ const COLOR_SCOPES : Record < 'dark' | 'light' , RegExp > = {
141+ dark : / ^ : r o o t \s * \{ / ,
142+ light : / h t m l : n o t \( \. d a r k - m o d e \) \s * \{ / ,
143+ } ;
144+
145+ /** The opaque background tokens by name — shared across schemes. */
146+ const BG_TOKEN_NAMES = [ '--bg' , '--bg-deep' , '--surface' , '--surface-2' ] ;
147+
148+ /** For each scheme, the RGB background tokens the focus ring must contrast with. */
149+ function backgroundTokens ( scheme : 'dark' | 'light' , baseForGlass : RGB ) : { name : string ; rgb : RGB } [ ] {
150+ const block = readBlock ( tokensCss , COLOR_SCOPES [ scheme ] ) ;
151+ const tokens : { name : string ; rgb : RGB } [ ] = [ ] ;
152+
153+ for ( const name of BG_TOKEN_NAMES ) {
154+ const raw = varValue ( block , name ) ;
155+ const rgb = raw ? parseColor ( raw ) : null ;
156+ if ( rgb ) tokens . push ( { name, rgb } ) ;
157+ }
158+
159+ const glassRaw = varValue ( block , '--surface-glass' ) ;
160+ const alpha = glassRaw ? alphaOf ( glassRaw ) : null ;
161+ const glassRgb = glassRaw ? rgbaToRgb ( glassRaw ) : null ;
162+ if ( glassRaw && alpha !== null && glassRgb ) {
163+ tokens . push ( { name : '--surface-glass' , rgb : composite ( glassRgb , baseForGlass , alpha ) } ) ;
164+ }
165+
166+ return tokens ;
167+ }
168+
16169describe ( '.visually-hidden utility class' , ( ) => {
17170 it ( 'is defined exactly once across the stylesheet tree' , ( ) => {
18171 const cssFiles = findCssFiles ( SRC_DIR ) ;
@@ -23,3 +176,86 @@ describe('.visually-hidden utility class', () => {
23176 expect ( definitions ) . toEqual ( [ path . resolve ( SRC_DIR , 'styles/accessibility.css' ) ] ) ;
24177 } ) ;
25178} ) ;
179+
180+ /* ============================================================
181+ Requirement 1 — :focus-visible contrast vs every bg token
182+ ============================================================ */
183+
184+ describe ( ':focus-visible outlines' , ( ) => {
185+ const fork = readBlock ( a11yCss , / \* : f o c u s - v i s i b l e / ) ;
186+
187+ it ( 'uses the mode-aware --ring-strong token (with gold fallback)' , ( ) => {
188+ expect ( fork ) . toContain ( 'var(--ring-strong, var(--ring, #f59e0b))' ) ;
189+ } ) ;
190+
191+ test . each ( [ 'dark' , 'light' ] as const ) (
192+ '%s scheme: --ring-strong holds >= 3:1 against every background token' ,
193+ ( scheme ) => {
194+ const block = readBlock ( tokensCss , COLOR_SCOPES [ scheme ] ) ;
195+ const ring = varValue ( block , '--ring-strong' ) ;
196+ expect ( ring ) . toBeTruthy ( ) ;
197+
198+ const ringRgb = parseColor ( ring as string ) ;
199+ expect ( ringRgb ) . not . toBeNull ( ) ;
200+
201+ // --surface-glass renders over the deepest page background.
202+ const baseForGlass = scheme === 'dark' ? ( hexToRgb ( '#070b16' ) as RGB ) : ( hexToRgb ( '#f7f9fc' ) as RGB ) ;
203+ const bgs = backgroundTokens ( scheme , baseForGlass ) ;
204+ expect ( bgs . length ) . toBeGreaterThan ( 0 ) ;
205+
206+ for ( const bg of bgs ) {
207+ const ratio = contrastRatio ( ringRgb as RGB , bg . rgb ) ;
208+ if ( ratio < 3 ) {
209+ throw new Error (
210+ `${ scheme } ring ${ ring } vs ${ bg . name } = ${ ratio . toFixed ( 2 ) } :1 (< 3:1 minimum)`
211+ ) ;
212+ }
213+ }
214+ }
215+ ) ;
216+ } ) ;
217+
218+ /* ============================================================
219+ Requirement 2 — minimum interactive target size (44 x 44)
220+ ============================================================ */
221+
222+ describe ( 'interactive target size (WCAG 2.5.5)' , ( ) => {
223+ const block = readBlock ( a11yCss , / I N T E R A C T I V E T A R G E T S I Z E / ) ;
224+
225+ test ( 'targets buttons, links, and form controls' , ( ) => {
226+ expect ( block ) . toContain ( 'button' ) ;
227+ expect ( block ) . toContain ( "a[href]" ) ;
228+ expect ( block ) . toContain ( 'input' ) ;
229+ expect ( block ) . toContain ( 'select' ) ;
230+ expect ( block ) . toContain ( 'textarea' ) ;
231+ } ) ;
232+
233+ it ( 'enforces at least 44 x 44 CSS px' , ( ) => {
234+ expect ( block ) . toMatch ( / m i n - h e i g h t : \s * 4 4 p x / ) ;
235+ expect ( block ) . toMatch ( / m i n - w i d t h : \s * 4 4 p x / ) ;
236+ } ) ;
237+ } ) ;
238+
239+ /* ============================================================
240+ Requirement 3 — prefers-reduced-motion disables animation
241+ ============================================================ */
242+
243+ describe ( 'prefers-reduced-motion' , ( ) => {
244+ const block = readBlock ( a11yCss , / p r e f e r s - r e d u c e d - m o t i o n : r e d u c e / ) ;
245+
246+ it ( 'declares the @media (prefers-reduced-motion: reduce) query' , ( ) => {
247+ expect ( a11yCss ) . toContain ( '@media (prefers-reduced-motion: reduce) {' ) ;
248+ } ) ;
249+
250+ it ( 'disables non-essential animation globally' , ( ) => {
251+ expect ( block ) . toMatch ( / a n i m a t i o n - \s * d u r a t i o n / ) ;
252+ } ) ;
253+
254+ it ( 'explicitly disables spinners, skeleton shimmer, and toast transitions' , ( ) => {
255+ expect ( block ) . toContain ( '.spinner' ) ;
256+ expect ( block ) . toContain ( '.loading-spinner .spinner' ) ;
257+ expect ( block ) . toContain ( '.skeleton' ) ;
258+ // Toast / transition-driven UI is covered by the universal transition kill.
259+ expect ( block ) . toMatch ( / t r a n s i t i o n - d u r a t i o n / ) ;
260+ } ) ;
261+ } ) ;
0 commit comments