1- import { beforeEach , describe , expect , it , vi } from "vitest" ;
1+ import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
22
33const cliHighlightMocks = vi . hoisted ( ( ) => ( {
44 highlight : vi . fn ( ( code : string ) => code ) ,
@@ -13,6 +13,25 @@ const { markdownTheme, searchableSelectListTheme, selectListTheme, theme } =
1313const stripAnsi = ( str : string ) =>
1414 str . replace ( new RegExp ( `${ String . fromCharCode ( 27 ) } \\[[0-9;]*m` , "g" ) , "" ) ;
1515
16+ function relativeLuminance ( hex : string ) : number {
17+ const channels = hex
18+ . replace ( "#" , "" )
19+ . match ( / .{ 2 } / g)
20+ ?. map ( ( part ) => Number . parseInt ( part , 16 ) / 255 )
21+ . map ( ( channel ) => ( channel <= 0.03928 ? channel / 12.92 : ( ( channel + 0.055 ) / 1.055 ) ** 2.4 ) ) ;
22+ if ( ! channels || channels . length !== 3 ) {
23+ throw new Error ( `invalid color: ${ hex } ` ) ;
24+ }
25+ return 0.2126 * channels [ 0 ] + 0.7152 * channels [ 1 ] + 0.0722 * channels [ 2 ] ;
26+ }
27+
28+ function contrastRatio ( foreground : string , background : string ) : number {
29+ const [ lighter , darker ] = [ relativeLuminance ( foreground ) , relativeLuminance ( background ) ] . toSorted (
30+ ( a , b ) => b - a ,
31+ ) ;
32+ return ( lighter + 0.05 ) / ( darker + 0.05 ) ;
33+ }
34+
1635describe ( "markdownTheme" , ( ) => {
1736 describe ( "highlightCode" , ( ) => {
1837 beforeEach ( ( ) => {
@@ -61,6 +80,207 @@ describe("theme", () => {
6180 } ) ;
6281} ) ;
6382
83+ describe ( "light background detection" , ( ) => {
84+ const originalEnv = { ...process . env } ;
85+
86+ afterEach ( ( ) => {
87+ process . env = { ...originalEnv } ;
88+ vi . resetModules ( ) ;
89+ } ) ;
90+
91+ async function importThemeWithEnv ( env : Record < string , string | undefined > ) {
92+ vi . resetModules ( ) ;
93+ for ( const [ key , value ] of Object . entries ( env ) ) {
94+ if ( value === undefined ) {
95+ delete process . env [ key ] ;
96+ } else {
97+ process . env [ key ] = value ;
98+ }
99+ }
100+ return import ( "./theme.js" ) ;
101+ }
102+
103+ it ( "uses dark palette by default" , async ( ) => {
104+ const mod = await importThemeWithEnv ( {
105+ OPENCLAW_THEME : undefined ,
106+ COLORFGBG : undefined ,
107+ } ) ;
108+ expect ( mod . lightMode ) . toBe ( false ) ;
109+ } ) ;
110+
111+ it ( "selects light palette when OPENCLAW_THEME=light" , async ( ) => {
112+ const mod = await importThemeWithEnv ( { OPENCLAW_THEME : "light" } ) ;
113+ expect ( mod . lightMode ) . toBe ( true ) ;
114+ } ) ;
115+
116+ it ( "selects dark palette when OPENCLAW_THEME=dark" , async ( ) => {
117+ const mod = await importThemeWithEnv ( { OPENCLAW_THEME : "dark" } ) ;
118+ expect ( mod . lightMode ) . toBe ( false ) ;
119+ } ) ;
120+
121+ it ( "treats OPENCLAW_THEME case-insensitively" , async ( ) => {
122+ const mod = await importThemeWithEnv ( { OPENCLAW_THEME : "LiGhT" } ) ;
123+ expect ( mod . lightMode ) . toBe ( true ) ;
124+ } ) ;
125+
126+ it ( "detects light background from COLORFGBG" , async ( ) => {
127+ const mod = await importThemeWithEnv ( {
128+ OPENCLAW_THEME : undefined ,
129+ COLORFGBG : "0;15" ,
130+ } ) ;
131+ expect ( mod . lightMode ) . toBe ( true ) ;
132+ } ) ;
133+
134+ it ( "treats COLORFGBG bg=7 (silver) as light" , async ( ) => {
135+ const mod = await importThemeWithEnv ( {
136+ OPENCLAW_THEME : undefined ,
137+ COLORFGBG : "0;7" ,
138+ } ) ;
139+ expect ( mod . lightMode ) . toBe ( true ) ;
140+ } ) ;
141+
142+ it ( "treats COLORFGBG bg=8 (bright black / dark gray) as dark" , async ( ) => {
143+ const mod = await importThemeWithEnv ( {
144+ OPENCLAW_THEME : undefined ,
145+ COLORFGBG : "15;8" ,
146+ } ) ;
147+ expect ( mod . lightMode ) . toBe ( false ) ;
148+ } ) ;
149+
150+ it ( "treats COLORFGBG bg < 7 as dark" , async ( ) => {
151+ const mod = await importThemeWithEnv ( {
152+ OPENCLAW_THEME : undefined ,
153+ COLORFGBG : "15;0" ,
154+ } ) ;
155+ expect ( mod . lightMode ) . toBe ( false ) ;
156+ } ) ;
157+
158+ it ( "treats 256-color COLORFGBG bg=232 (near-black greyscale) as dark" , async ( ) => {
159+ const mod = await importThemeWithEnv ( {
160+ OPENCLAW_THEME : undefined ,
161+ COLORFGBG : "15;232" ,
162+ } ) ;
163+ expect ( mod . lightMode ) . toBe ( false ) ;
164+ } ) ;
165+
166+ it ( "treats 256-color COLORFGBG bg=255 (near-white greyscale) as light" , async ( ) => {
167+ const mod = await importThemeWithEnv ( {
168+ OPENCLAW_THEME : undefined ,
169+ COLORFGBG : "0;255" ,
170+ } ) ;
171+ expect ( mod . lightMode ) . toBe ( true ) ;
172+ } ) ;
173+
174+ it ( "treats 256-color COLORFGBG bg=231 (white cube entry) as light" , async ( ) => {
175+ const mod = await importThemeWithEnv ( {
176+ OPENCLAW_THEME : undefined ,
177+ COLORFGBG : "0;231" ,
178+ } ) ;
179+ expect ( mod . lightMode ) . toBe ( true ) ;
180+ } ) ;
181+
182+ it ( "treats 256-color COLORFGBG bg=16 (black cube entry) as dark" , async ( ) => {
183+ const mod = await importThemeWithEnv ( {
184+ OPENCLAW_THEME : undefined ,
185+ COLORFGBG : "15;16" ,
186+ } ) ;
187+ expect ( mod . lightMode ) . toBe ( false ) ;
188+ } ) ;
189+
190+ it ( "treats bright 256-color green backgrounds as light when dark text contrasts better" , async ( ) => {
191+ const mod = await importThemeWithEnv ( {
192+ OPENCLAW_THEME : undefined ,
193+ COLORFGBG : "15;34" ,
194+ } ) ;
195+ expect ( mod . lightMode ) . toBe ( true ) ;
196+ } ) ;
197+
198+ it ( "treats bright 256-color cyan backgrounds as light when dark text contrasts better" , async ( ) => {
199+ const mod = await importThemeWithEnv ( {
200+ OPENCLAW_THEME : undefined ,
201+ COLORFGBG : "15;39" ,
202+ } ) ;
203+ expect ( mod . lightMode ) . toBe ( true ) ;
204+ } ) ;
205+
206+ it ( "falls back to dark mode for invalid COLORFGBG values" , async ( ) => {
207+ const mod = await importThemeWithEnv ( {
208+ OPENCLAW_THEME : undefined ,
209+ COLORFGBG : "garbage" ,
210+ } ) ;
211+ expect ( mod . lightMode ) . toBe ( false ) ;
212+ } ) ;
213+
214+ it ( "ignores pathological COLORFGBG values" , async ( ) => {
215+ const mod = await importThemeWithEnv ( {
216+ OPENCLAW_THEME : undefined ,
217+ COLORFGBG : "0;" . repeat ( 40 ) ,
218+ } ) ;
219+ expect ( mod . lightMode ) . toBe ( false ) ;
220+ } ) ;
221+
222+ it ( "OPENCLAW_THEME overrides COLORFGBG" , async ( ) => {
223+ const mod = await importThemeWithEnv ( {
224+ OPENCLAW_THEME : "dark" ,
225+ COLORFGBG : "0;15" ,
226+ } ) ;
227+ expect ( mod . lightMode ) . toBe ( false ) ;
228+ } ) ;
229+
230+ it ( "keeps assistantText as identity in both modes" , async ( ) => {
231+ const lightMod = await importThemeWithEnv ( { OPENCLAW_THEME : "light" } ) ;
232+ const darkMod = await importThemeWithEnv ( { OPENCLAW_THEME : "dark" } ) ;
233+ expect ( lightMod . theme . assistantText ( "hello" ) ) . toBe ( "hello" ) ;
234+ expect ( darkMod . theme . assistantText ( "hello" ) ) . toBe ( "hello" ) ;
235+ } ) ;
236+ } ) ;
237+
238+ describe ( "light palette accessibility" , ( ) => {
239+ it ( "keeps light theme text colors at WCAG AA contrast or better" , async ( ) => {
240+ vi . resetModules ( ) ;
241+ process . env . OPENCLAW_THEME = "light" ;
242+ const mod = await import ( "./theme.js" ) ;
243+ const backgrounds = {
244+ page : "#FFFFFF" ,
245+ user : mod . lightPalette . userBg ,
246+ pending : mod . lightPalette . toolPendingBg ,
247+ success : mod . lightPalette . toolSuccessBg ,
248+ error : mod . lightPalette . toolErrorBg ,
249+ code : mod . lightPalette . codeBlock ,
250+ } ;
251+
252+ const textPairs = [
253+ [ mod . lightPalette . text , backgrounds . page ] ,
254+ [ mod . lightPalette . dim , backgrounds . page ] ,
255+ [ mod . lightPalette . accent , backgrounds . page ] ,
256+ [ mod . lightPalette . accentSoft , backgrounds . page ] ,
257+ [ mod . lightPalette . systemText , backgrounds . page ] ,
258+ [ mod . lightPalette . link , backgrounds . page ] ,
259+ [ mod . lightPalette . quote , backgrounds . page ] ,
260+ [ mod . lightPalette . error , backgrounds . page ] ,
261+ [ mod . lightPalette . success , backgrounds . page ] ,
262+ [ mod . lightPalette . userText , backgrounds . user ] ,
263+ [ mod . lightPalette . dim , backgrounds . pending ] ,
264+ [ mod . lightPalette . dim , backgrounds . success ] ,
265+ [ mod . lightPalette . dim , backgrounds . error ] ,
266+ [ mod . lightPalette . toolTitle , backgrounds . pending ] ,
267+ [ mod . lightPalette . toolTitle , backgrounds . success ] ,
268+ [ mod . lightPalette . toolTitle , backgrounds . error ] ,
269+ [ mod . lightPalette . toolOutput , backgrounds . pending ] ,
270+ [ mod . lightPalette . toolOutput , backgrounds . success ] ,
271+ [ mod . lightPalette . toolOutput , backgrounds . error ] ,
272+ [ mod . lightPalette . code , backgrounds . code ] ,
273+ [ mod . lightPalette . border , backgrounds . page ] ,
274+ [ mod . lightPalette . quoteBorder , backgrounds . page ] ,
275+ [ mod . lightPalette . codeBorder , backgrounds . page ] ,
276+ ] as const ;
277+
278+ for ( const [ foreground , background ] of textPairs ) {
279+ expect ( contrastRatio ( foreground , background ) ) . toBeGreaterThanOrEqual ( 4.5 ) ;
280+ }
281+ } ) ;
282+ } ) ;
283+
64284describe ( "list themes" , ( ) => {
65285 it ( "reuses shared select-list styles in searchable list theme" , ( ) => {
66286 expect ( searchableSelectListTheme . selectedPrefix ( ">" ) ) . toBe ( selectListTheme . selectedPrefix ( ">" ) ) ;
0 commit comments