Skip to content

Commit 56cddc2

Browse files
committed
feat: add colorSchemePreview setting for light-dark() values
Adds a new setting `tailwindCSS.colorSchemePreview` that controls whether the light or dark color is shown in previews for CSS `light-dark()` function values. - Accepts 'light' (default) or 'dark' - Useful for developers working primarily in one color scheme - Updated resolveLightDark() to handle nested parentheses correctly - Added comprehensive tests for the resolveLightDark function https://claude.ai/code/session_01Fi6Utr1yb5jk3Xo9yvY567
1 parent 4babab3 commit 56cddc2

5 files changed

Lines changed: 129 additions & 17 deletions

File tree

packages/tailwindcss-language-service/src/documentColorProvider.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ export async function getDocumentColors(
2121
let settings = await state.editor.getConfiguration(document.uri)
2222
if (settings.tailwindCSS.colorDecorators === false) return colors
2323

24+
let preferDark = settings.tailwindCSS.colorSchemePreview === 'dark'
25+
2426
let classLists = await findClassListsInDocument(state, document)
2527
classLists.forEach((classList) => {
2628
let classNames = getClassNamesInClassList(classList, state.blocklist)
2729
classNames.forEach((className) => {
28-
let color = getColor(state, className.className)
30+
let color = getColor(state, className.className, preferDark)
2931
if (color === null || typeof color === 'string' || (color.alpha ?? 1) === 0) {
3032
return
3133
}

packages/tailwindcss-language-service/src/util/color.test.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { test, expect } from 'vitest'
1+
import { test, expect, describe } from 'vitest'
22
import namedColors from 'color-name'
3-
import { findColors } from './color'
3+
import { findColors, resolveLightDark } from './color'
44

55
let table: string[] = []
66

@@ -80,3 +80,53 @@ test('invalid hex', () => {
8080
expect(findColors(`#7f7f7fz`)).toEqual([])
8181
expect(findColorsRegex(`#7f7f7fz`)).toEqual([])
8282
})
83+
84+
describe('resolveLightDark', () => {
85+
test('extracts light color by default', () => {
86+
const input = 'light-dark(oklch(0.5 0.1 50), oklch(0.8 0.2 60))'
87+
expect(resolveLightDark(input)).toBe('oklch(0.5 0.1 50)')
88+
})
89+
90+
test('extracts light color when preferDark is false', () => {
91+
const input = 'light-dark(oklch(0.5 0.1 50), oklch(0.8 0.2 60))'
92+
expect(resolveLightDark(input, false)).toBe('oklch(0.5 0.1 50)')
93+
})
94+
95+
test('extracts dark color when preferDark is true', () => {
96+
const input = 'light-dark(oklch(0.5 0.1 50), oklch(0.8 0.2 60))'
97+
expect(resolveLightDark(input, true)).toBe('oklch(0.8 0.2 60)')
98+
})
99+
100+
test('handles hex colors', () => {
101+
const input = 'light-dark(#ffffff, #000000)'
102+
expect(resolveLightDark(input, false)).toBe('#ffffff')
103+
expect(resolveLightDark(input, true)).toBe('#000000')
104+
})
105+
106+
test('handles rgb colors', () => {
107+
const input = 'light-dark(rgb(255, 255, 255), rgb(0, 0, 0))'
108+
expect(resolveLightDark(input, false)).toBe('rgb(255, 255, 255)')
109+
expect(resolveLightDark(input, true)).toBe('rgb(0, 0, 0)')
110+
})
111+
112+
test('handles multiple light-dark functions in string', () => {
113+
const input =
114+
'color: light-dark(#fff, #000); background: light-dark(#eee, #111);'
115+
expect(resolveLightDark(input, false)).toBe(
116+
'color: #fff; background: #eee;',
117+
)
118+
expect(resolveLightDark(input, true)).toBe('color: #000; background: #111;')
119+
})
120+
121+
test('trims whitespace from colors', () => {
122+
const input = 'light-dark( #ffffff , #000000 )'
123+
expect(resolveLightDark(input, false)).toBe('#ffffff')
124+
expect(resolveLightDark(input, true)).toBe('#000000')
125+
})
126+
127+
test('returns input unchanged when no light-dark function', () => {
128+
const input = '#ffffff'
129+
expect(resolveLightDark(input, false)).toBe('#ffffff')
130+
expect(resolveLightDark(input, true)).toBe('#ffffff')
131+
})
132+
})

packages/tailwindcss-language-service/src/util/color.ts

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,20 @@ function getKeywordColor(value: unknown): KeywordColor | null {
4949
return null
5050
}
5151

52-
function getColorsInString(state: State, str: string): ParsedColor[] {
52+
function getColorsInString(state: State, str: string, preferDark: boolean = false): ParsedColor[] {
5353
if (/(?:box|drop)-shadow/.test(str) && !/--tw-drop-shadow/.test(str)) return []
5454

5555
str = replaceCssVarsWithFallbacks(state, str)
5656
str = removeColorMixWherePossible(str)
57-
str = resolveLightDark(str)
57+
str = resolveLightDark(str, preferDark)
5858

5959
return parseColors(str)
6060
}
6161

6262
function getColorFromDecls(
6363
state: State,
6464
decls: Record<string, string | string[]>,
65+
preferDark: boolean = false,
6566
): ParsedColor | null {
6667
let props = Object.keys(decls).filter((prop) => {
6768
// ignore content: "";
@@ -104,7 +105,7 @@ function getColorFromDecls(
104105
const propsToCheck = areAllCustom ? props : nonCustomProps
105106

106107
const colors = propsToCheck.flatMap((prop) =>
107-
ensureArray(decls[prop]).flatMap((str) => getColorsInString(state, str)),
108+
ensureArray(decls[prop]).flatMap((str) => getColorsInString(state, str, preferDark)),
108109
)
109110

110111
// check that all of the values are the same color, ignoring alpha
@@ -137,7 +138,11 @@ function getColorFromDecls(
137138
return null
138139
}
139140

140-
function getColorFromRoot(state: State, css: AstNode[]): ParsedColor | null {
141+
function getColorFromRoot(
142+
state: State,
143+
css: AstNode[],
144+
preferDark: boolean = false,
145+
): ParsedColor | null {
141146
let decls: Record<string, string[]> = {}
142147

143148
walk(css, (node) => {
@@ -171,7 +176,7 @@ function getColorFromRoot(state: State, css: AstNode[]): ParsedColor | null {
171176
return WalkAction.Continue
172177
})
173178

174-
return getColorFromDecls(state, decls)
179+
return getColorFromDecls(state, decls, preferDark)
175180
}
176181

177182
let isNegative = /^-/
@@ -190,13 +195,17 @@ function isLikelyColorless(className: string) {
190195
return false
191196
}
192197

193-
export function getColor(state: State, className: string): ParsedColor | null {
198+
export function getColor(
199+
state: State,
200+
className: string,
201+
preferDark: boolean = false,
202+
): ParsedColor | null {
194203
if (state.v4) {
195204
// FIXME: This is a performance optimization and not strictly correct
196205
if (isLikelyColorless(className)) return null
197206

198207
let css = state.designSystem.compile([className])[0]
199-
let color = getColorFromRoot(state, css)
208+
let color = getColorFromRoot(state, css, preferDark)
200209

201210
let prefix = state.designSystem.theme.prefix ?? ''
202211

@@ -205,7 +214,7 @@ export function getColor(state: State, className: string): ParsedColor | null {
205214
if (prefix && !color && !className.startsWith(prefix + ':')) {
206215
className = `${prefix}:${className}`
207216
css = state.designSystem.compile([className])[0]
208-
color = getColorFromRoot(state, css)
217+
color = getColorFromRoot(state, css, preferDark)
209218
}
210219

211220
return color
@@ -215,7 +224,7 @@ export function getColor(state: State, className: string): ParsedColor | null {
215224
if (state.classNames) {
216225
const item = dlv(state.classNames.classNames, [className, '__info'])
217226
if (item && item.__rule) {
218-
return getColorFromDecls(state, removeMeta(item))
227+
return getColorFromDecls(state, removeMeta(item), preferDark)
219228
}
220229
}
221230

@@ -244,7 +253,7 @@ export function getColor(state: State, className: string): ParsedColor | null {
244253
decls[decl.prop] = decl.value
245254
}
246255
})
247-
return getColorFromDecls(state, decls)
256+
return getColorFromDecls(state, decls, preferDark)
248257
}
249258

250259
let parts = getClassNameParts(state, className)
@@ -253,7 +262,7 @@ export function getColor(state: State, className: string): ParsedColor | null {
253262
const item = dlv(state.classNames.classNames, [...parts, '__info'])
254263
if (!item.__rule) return null
255264

256-
return getColorFromDecls(state, removeMeta(item))
265+
return getColorFromDecls(state, removeMeta(item), preferDark)
257266
}
258267

259268
export function getColorFromValue(value: unknown): ParsedColor | null {
@@ -315,10 +324,53 @@ function removeColorMixWherePossible(str: string) {
315324
})
316325
}
317326

318-
const LIGHT_DARK_REGEX = /light-dark\(\s*(.*?)\s*,\s*.*?\s*\)/g
327+
const LIGHT_DARK_START = /light-dark\(\s*/g
328+
329+
export function resolveLightDark(str: string, preferDark: boolean = false): string {
330+
let result = ''
331+
let lastIndex = 0
332+
let match: RegExpExecArray | null
333+
334+
LIGHT_DARK_START.lastIndex = 0
335+
336+
while ((match = LIGHT_DARK_START.exec(str)) !== null) {
337+
result += str.slice(lastIndex, match.index)
338+
339+
let start = match.index + match[0].length
340+
let depth = 1
341+
let commaIndex = -1
342+
let i = start
343+
344+
// Find the comma separating light and dark values, handling nested parentheses
345+
while (i < str.length && depth > 0) {
346+
let char = str[i]
347+
if (char === '(') {
348+
depth++
349+
} else if (char === ')') {
350+
depth--
351+
} else if (char === ',' && depth === 1 && commaIndex === -1) {
352+
commaIndex = i
353+
}
354+
i++
355+
}
356+
357+
if (commaIndex === -1 || depth !== 0) {
358+
// Invalid light-dark() syntax, keep original
359+
result += match[0]
360+
lastIndex = start
361+
continue
362+
}
363+
364+
let lightColor = str.slice(start, commaIndex).trim()
365+
let darkColor = str.slice(commaIndex + 1, i - 1).trim()
366+
367+
result += preferDark ? darkColor : lightColor
368+
lastIndex = i
369+
LIGHT_DARK_START.lastIndex = i
370+
}
319371

320-
function resolveLightDark(str: string) {
321-
return str.replace(LIGHT_DARK_REGEX, (_, lightColor) => lightColor)
372+
result += str.slice(lastIndex)
373+
return result
322374
}
323375

324376
const COLOR_FNS = new Set([

packages/tailwindcss-language-service/src/util/state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type TailwindCssSettings = {
5656
showPixelEquivalents: boolean
5757
rootFontSize: number
5858
colorDecorators: boolean
59+
colorSchemePreview: 'light' | 'dark'
5960
lint: {
6061
cssConflict: DiagnosticSeveritySetting
6162
invalidApply: DiagnosticSeveritySetting
@@ -195,6 +196,7 @@ export function getDefaultTailwindSettings(): Settings {
195196
suggestions: true,
196197
validate: true,
197198
colorDecorators: true,
199+
colorSchemePreview: 'light',
198200
rootFontSize: 16,
199201
lint: {
200202
cssConflict: 'warning',

packages/vscode-tailwindcss/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,12 @@
222222
"markdownDescription": "Controls whether the editor should render inline color decorators for Tailwind CSS classes and helper functions.",
223223
"scope": "language-overridable"
224224
},
225+
"tailwindCSS.colorSchemePreview": {
226+
"type": "string",
227+
"enum": ["light", "dark"],
228+
"default": "light",
229+
"markdownDescription": "Which color value to preview for `light-dark()` CSS functions."
230+
},
225231
"tailwindCSS.validate": {
226232
"type": "boolean",
227233
"default": true,

0 commit comments

Comments
 (0)