Canonicalization: make hex colors case insensitive (#20298)

This PR fixes an issue where hex-based colors in arbitrary properties
and values were considered case-sensitive even though they are
case-insensitive in CSS.

If you look at the linked issue, there is this input CSS:
```css
@theme {
  --color-brand-purple: #3f3cbb;
}
```

We expect that both `bg-[#3f3cbb]` and `bg-[#3F3CBB]` get canonicalized
to `color-brand-purple` but before this pr, only the first one would get
canonicalized that way (since it's a perfect match).

Technically a bunch more values are case-insensitive but a lot of them
_are_ sensitive so to get this 100% correct, a lot more parsing needs to
happen. I think we can start with this and expand the logic when needed.

Fixes: #20295

## Test plan

1. Added a regression test based on the linked issue
2. Added tests for arbitrary properties (`[color:#fff]` vs
`[color:#FFF]`), and tests for arbitrary properties (`bg-[#fff]` vs
`bg-[#FFF]`)
This commit is contained in:
Robin Malfait 2026-07-02 00:04:44 +02:00 • committed by GitHub
parent 39656f7d9a
commit e46b3d74ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 57 additions and 0 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Allow `@tailwindcss/cli` in `--watch` mode to use polling with `--poll` when filesystem events are unreliable or unavailable ([#20297](https://github.com/tailwindlabs/tailwindcss/pull/20297))
- Canonicalization: match arbitrary hex colors against theme colors case-insensitively (e.g. `bg-[#fff]` and `bg-[#FFF]` → `bg-white`) ([#20298](https://github.com/tailwindlabs/tailwindcss/pull/20298))
## [4.3.2] - 2026-06-26

View file

@ -316,6 +316,10 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
['[color:var(--color-red-500)]', 'text-red-500'],
['[background-color:var(--color-red-500)]', 'bg-red-500'],
// Arbitrary property to named utility (case insensitive)
['[color:#fff]', 'text-white'],
['[color:#FFF]', 'text-white'],
// Arbitrary property with modifier to named functional utility with modifier
['[color:var(--color-red-500)]/25', 'text-red-500/25'],
@ -354,6 +358,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
--*: initial;
--spacing: 0.25rem;
--color-red-500: red;
--color-white: #fff;
/* Equivalent of blue-500/50 */
--color-primary: color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent);
@ -394,6 +399,10 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
['bg-[theme(colors.red.500/75%)]', 'bg-red-500/75'],
['bg-[theme(colors.red.500/12.34%)]', 'bg-red-500/[12.34%]'],
// Arbitrary value with different casing
['bg-[#fff]', 'bg-white'],
['bg-[#FFF]', 'bg-white'],
// Values that don't contain only `theme(…)` calls should not be converted to
// use a modifier since the color is not the whole value.
[
@ -478,6 +487,9 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
])(testName, { timeout }, async (candidate, expected) => {
let input = css`
@import 'tailwindcss';
@theme {
--color-white: #fff;
}
`
await expectCanonicalization(input, candidate, expected)
@ -1508,4 +1520,30 @@ describe('regressions', () => {
expect(designSystem.canonicalizeCandidates(['px-[calc(1rem+0px)]'], options)).toEqual(['px-4'])
})
// https://github.com/tailwindlabs/tailwindcss/issues/20295
test('canonicalizations work when casing of arbitrary values is different', async () => {
let designSystem = await designSystems.get(__dirname).get(css`
@import 'tailwindcss';
@theme {
--color-brand-purple: #3f3cbb;
}
`)
let options: CanonicalizeOptions = {
collapse: true,
logicalToPhysical: true,
rem: 16,
}
expect(designSystem.canonicalizeCandidates(['bg-[#3F3cbb]'], options)).toEqual([
'bg-brand-purple',
])
expect(designSystem.canonicalizeCandidates(['bg-[#3F3Cbb]'], options)).toEqual([
'bg-brand-purple',
])
expect(designSystem.canonicalizeCandidates(['bg-[#3F3CBB]'], options)).toEqual([
'bg-brand-purple',
])
})
})

View file

@ -2425,6 +2425,12 @@ function createUtilitySignatureCache(
})
}
// #RGB
// #RGBA
// #RRGGBB
// #RRGGBBAA
const HEX_REGEX = /#(?:[a-f0-9]{8}|[a-f0-9]{6}|[a-f0-9]{4}|[a-f0-9]{3})/gi
// Optimize the CSS AST to make it suitable for signature comparison. We want to
// expand declarations, ignore comments, sort declarations etc...
function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: SignatureOptions) {
@ -2536,6 +2542,18 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
return a.property.localeCompare(b.property)
})
}
//
else if (node.kind === 'declaration' && node.value) {
// Leave CSS variables alone
if (node.property[0] === '-' && node.property[1] === '-') return
// Ensure hex colors are always lowercased
{
HEX_REGEX.lastIndex = 0
node.value = node.value.replace(HEX_REGEX, (color) => color.toLowerCase())
}
}
},
})