From 128aafa5e1077949f237c19d059af3623b2c1d80 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 19 Nov 2025 15:56:08 -0500 Subject: [PATCH 1/5] =?UTF-8?q?Don=E2=80=99t=20unconditionally=20convert?= =?UTF-8?q?=20config=20keys=20to=20`kebab-case`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/compat/apply-config-to-theme.test.ts | 4 +- .../src/compat/apply-config-to-theme.ts | 90 +++++++++++++++++-- packages/tailwindcss/src/index.test.ts | 37 ++++++++ 3 files changed, 124 insertions(+), 7 deletions(-) diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts index 83fc2f2c94e9..f36e5ab15a7c 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts @@ -118,8 +118,8 @@ test('config values can be merged into the theme', () => { ]) expect(theme.resolve('2xl', ['--text'])).toEqual('2rem') expect(theme.resolveWith('2xl', ['--text'], ['--line-height'])).toEqual(['2rem', {}]) - expect(theme.resolve('super-wide', ['--tracking'])).toEqual('0.25em') - expect(theme.resolve('super-loose', ['--leading'])).toEqual('3') + expect(theme.resolve('superWide', ['--tracking'])).toEqual('0.25em') + expect(theme.resolve('superLoose', ['--leading'])).toEqual('3') expect(theme.resolve('1/2', ['--width'])).toEqual('60%') expect(theme.resolve('0.5', ['--width'])).toEqual('60%') expect(theme.resolve('100%', ['--width'])).toEqual('100%') diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts index a430f9faa332..067e66df1451 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.ts @@ -169,6 +169,81 @@ export function keyPathToCssProperty(path: string[]) { if (path[0] === 'screens') path[0] = 'breakpoint' if (path[0] === 'transitionTimingFunction') path[0] = 'ease' + if (path[0] == 'accentColor') path[0] = 'accent-color' + if (path[0] == 'backdropBlur') path[0] = 'backdrop-blur' + if (path[0] == 'backdropBrightness') path[0] = 'backdrop-brightness' + if (path[0] == 'backdropContrast') path[0] = 'backdrop-contrast' + if (path[0] == 'backdropGrayscale') path[0] = 'backdrop-grayscale' + if (path[0] == 'backdropHueRotate') path[0] = 'backdrop-hue-rotate' + if (path[0] == 'backdropInvert') path[0] = 'backdrop-invert' + if (path[0] == 'backdropOpacity') path[0] = 'backdrop-opacity' + if (path[0] == 'backdropSaturate') path[0] = 'backdrop-saturate' + if (path[0] == 'backdropSepia') path[0] = 'backdrop-sepia' + if (path[0] == 'backgroundColor') path[0] = 'background-color' + if (path[0] == 'backgroundOpacity') path[0] = 'background-opacity' + if (path[0] == 'borderColor') path[0] = 'border-color' + if (path[0] == 'borderOpacity') path[0] = 'border-opacity' + if (path[0] == 'borderSpacing') path[0] = 'border-spacing' + if (path[0] == 'boxShadowColor') path[0] = 'box-shadow-color' + if (path[0] == 'caretColor') path[0] = 'caret-color' + if (path[0] == 'divideColor') path[0] = 'divide-color' + if (path[0] == 'divideOpacity') path[0] = 'divide-opacity' + if (path[0] == 'divideWidth') path[0] = 'divide-width' + if (path[0] == 'flexBasis') path[0] = 'flex-basis' + if (path[0] == 'gradientColorStops') path[0] = 'gradient-color-stops' + if (path[0] == 'maxHeight') path[0] = 'max-height' + if (path[0] == 'minHeight') path[0] = 'min-height' + if (path[0] == 'minWidth') path[0] = 'min-width' + if (path[0] == 'outlineColor') path[0] = 'outline-color' + if (path[0] == 'placeholderColor') path[0] = 'placeholder-color' + if (path[0] == 'placeholderOpacity') path[0] = 'placeholder-opacity' + if (path[0] == 'ringColor') path[0] = 'ring-color' + if (path[0] == 'ringOffsetColor') path[0] = 'ring-offset-color' + if (path[0] == 'ringOpacity') path[0] = 'ring-opacity' + if (path[0] == 'scrollMargin') path[0] = 'scroll-margin' + if (path[0] == 'scrollPadding') path[0] = 'scroll-padding' + if (path[0] == 'textColor') path[0] = 'text-color' + if (path[0] == 'textDecorationColor') path[0] = 'text-decoration-color' + if (path[0] == 'textIndent') path[0] = 'text-indent' + if (path[0] == 'textOpacity') path[0] = 'text-opacity' + if (path[0] == 'backgroundImage') path[0] = 'background-image' + if (path[0] == 'backgroundPosition') path[0] = 'background-position' + if (path[0] == 'backgroundSize') path[0] = 'background-size' + if (path[0] == 'borderWidth') path[0] = 'border-width' + if (path[0] == 'dropShadow') path[0] = 'drop-shadow' + if (path[0] == 'flexGrow') path[0] = 'flex-grow' + if (path[0] == 'flexShrink') path[0] = 'flex-shrink' + if (path[0] == 'fontWeight') path[0] = 'font-weight' + if (path[0] == 'gradientColorStopPositions') path[0] = 'gradient-color-stop-positions' + if (path[0] == 'gridAutoColumns') path[0] = 'grid-auto-columns' + if (path[0] == 'gridAutoRows') path[0] = 'grid-auto-rows' + if (path[0] == 'gridColumn') path[0] = 'grid-column' + if (path[0] == 'gridColumnEnd') path[0] = 'grid-column-end' + if (path[0] == 'gridColumnStart') path[0] = 'grid-column-start' + if (path[0] == 'gridRow') path[0] = 'grid-row' + if (path[0] == 'gridRowEnd') path[0] = 'grid-row-end' + if (path[0] == 'gridRowStart') path[0] = 'grid-row-start' + if (path[0] == 'gridTemplateColumns') path[0] = 'grid-template-columns' + if (path[0] == 'gridTemplateRows') path[0] = 'grid-template-rows' + if (path[0] == 'hueRotate') path[0] = 'hue-rotate' + if (path[0] == 'listStyleType') path[0] = 'list-style-type' + if (path[0] == 'listStyleImage') path[0] = 'list-style-image' + if (path[0] == 'lineClamp') path[0] = 'line-clamp' + if (path[0] == 'objectPosition') path[0] = 'object-position' + if (path[0] == 'outlineOffset') path[0] = 'outline-offset' + if (path[0] == 'outlineWidth') path[0] = 'outline-width' + if (path[0] == 'ringOffsetWidth') path[0] = 'ring-offset-width' + if (path[0] == 'ringWidth') path[0] = 'ring-width' + if (path[0] == 'strokeWidth') path[0] = 'stroke-width' + if (path[0] == 'textDecorationThickness') path[0] = 'text-decoration-thickness' + if (path[0] == 'textUnderlineOffset') path[0] = 'text-underline-offset' + if (path[0] == 'transformOrigin') path[0] = 'transform-origin' + if (path[0] == 'transitionDelay') path[0] = 'transition-delay' + if (path[0] == 'transitionDuration') path[0] = 'transition-duration' + if (path[0] == 'transitionProperty') path[0] = 'transition-property' + if (path[0] == 'willChange') path[0] = 'will-change' + if (path[0] == 'zIndex') path[0] = 'z-index' + for (let part of path) { if (!IS_VALID_KEY.test(part)) return null } @@ -185,11 +260,16 @@ export function keyPathToCssProperty(path: string[]) { .map((path, idx, all) => (path === '1' && idx !== all.length - 1 ? '' : path)) // Resolve the key path to a CSS variable segment - .map((part) => - part - .replaceAll('.', '_') - .replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`), - ) + .map((part) => { + part = part.replaceAll('.', '_') + + if (part === 'lineHeight') return 'line-height' + if (part.startsWith('-')) { + part = part.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`) + } + + return part + }) // Remove the `DEFAULT` key at the end of a path // We're reading from CSS anyway so it'll be a string diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 5cd5282b00f7..99567b0c8d91 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -5935,6 +5935,43 @@ describe('`@property` polyfill', async () => { }" `) }) + + // TODO: Move test into compat folder + it('camel case keys are preserved', async () => { + await expect( + compileCss( + css` + @tailwind utilities; + @theme { + --color-blue-green: slate; + } + @config "./plugin.js"; + `, + ['bg-lightGreen', 'bg-blue-green', 'bg-blueGreen'], + { + loadModule: async () => { + return { + base: '/', + path: '', + module: { + theme: { + extend: { + backgroundColor: { + lightGreen: '#c0ffee', + }, + }, + }, + }, + } + }, + }, + ), + ).resolves.toMatchInlineSnapshot(` + ".bg-lightGreen { + background-color: #c0ffee; + }" + `) + }) }) describe('feature detection', () => { From e8fe386e1c3bf9e857537bb7c633a6de4b025e61 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 19 Nov 2025 19:47:20 -0500 Subject: [PATCH 2/5] Move test --- .../tailwindcss/src/compat/config.test.ts | 52 +++++++++++++++++++ packages/tailwindcss/src/index.test.ts | 37 ------------- 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/packages/tailwindcss/src/compat/config.test.ts b/packages/tailwindcss/src/compat/config.test.ts index d86d5304b3e7..25bd893d18d4 100644 --- a/packages/tailwindcss/src/compat/config.test.ts +++ b/packages/tailwindcss/src/compat/config.test.ts @@ -1719,3 +1719,55 @@ test('The theme() function does not try indexing into strings', async () => { " `) }) + +test('camel case keys are preserved', async () => { + let compiler = await compile( + css` + @tailwind utilities; + @theme { + --color-blue-green: slate; + } + @config "./plugin.js"; + `, + { + loadModule: async () => { + return { + base: '/', + path: '', + module: { + theme: { + extend: { + backgroundColor: { + lightGreen: '#c0ffee', + }, + }, + }, + }, + } + }, + }, + ) + + expect( + compiler.build([ + // From CSS + 'bg-blue-green', // should be output + 'bg-blueGreen', // should not + + // From JS config + 'bg-light-green', // should not be output + 'bg-lightGreen', // should be + ]), + ).toMatchInlineSnapshot(` + ".bg-blue-green { + background-color: var(--color-blue-green); + } + .bg-lightGreen { + background-color: #c0ffee; + } + :root, :host { + --color-blue-green: slate; + } + " + `) +}) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 99567b0c8d91..5cd5282b00f7 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -5935,43 +5935,6 @@ describe('`@property` polyfill', async () => { }" `) }) - - // TODO: Move test into compat folder - it('camel case keys are preserved', async () => { - await expect( - compileCss( - css` - @tailwind utilities; - @theme { - --color-blue-green: slate; - } - @config "./plugin.js"; - `, - ['bg-lightGreen', 'bg-blue-green', 'bg-blueGreen'], - { - loadModule: async () => { - return { - base: '/', - path: '', - module: { - theme: { - extend: { - backgroundColor: { - lightGreen: '#c0ffee', - }, - }, - }, - }, - } - }, - }, - ), - ).resolves.toMatchInlineSnapshot(` - ".bg-lightGreen { - background-color: #c0ffee; - }" - `) - }) }) describe('feature detection', () => { From 45c6e04d01aeda8d642382babcad9a4c8e546b93 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 20 Nov 2025 03:38:18 -0500 Subject: [PATCH 3/5] Simplify --- .../src/compat/apply-config-to-theme.ts | 92 +++---------------- 1 file changed, 14 insertions(+), 78 deletions(-) diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts index 067e66df1451..ce3958eb3a24 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.ts @@ -169,81 +169,6 @@ export function keyPathToCssProperty(path: string[]) { if (path[0] === 'screens') path[0] = 'breakpoint' if (path[0] === 'transitionTimingFunction') path[0] = 'ease' - if (path[0] == 'accentColor') path[0] = 'accent-color' - if (path[0] == 'backdropBlur') path[0] = 'backdrop-blur' - if (path[0] == 'backdropBrightness') path[0] = 'backdrop-brightness' - if (path[0] == 'backdropContrast') path[0] = 'backdrop-contrast' - if (path[0] == 'backdropGrayscale') path[0] = 'backdrop-grayscale' - if (path[0] == 'backdropHueRotate') path[0] = 'backdrop-hue-rotate' - if (path[0] == 'backdropInvert') path[0] = 'backdrop-invert' - if (path[0] == 'backdropOpacity') path[0] = 'backdrop-opacity' - if (path[0] == 'backdropSaturate') path[0] = 'backdrop-saturate' - if (path[0] == 'backdropSepia') path[0] = 'backdrop-sepia' - if (path[0] == 'backgroundColor') path[0] = 'background-color' - if (path[0] == 'backgroundOpacity') path[0] = 'background-opacity' - if (path[0] == 'borderColor') path[0] = 'border-color' - if (path[0] == 'borderOpacity') path[0] = 'border-opacity' - if (path[0] == 'borderSpacing') path[0] = 'border-spacing' - if (path[0] == 'boxShadowColor') path[0] = 'box-shadow-color' - if (path[0] == 'caretColor') path[0] = 'caret-color' - if (path[0] == 'divideColor') path[0] = 'divide-color' - if (path[0] == 'divideOpacity') path[0] = 'divide-opacity' - if (path[0] == 'divideWidth') path[0] = 'divide-width' - if (path[0] == 'flexBasis') path[0] = 'flex-basis' - if (path[0] == 'gradientColorStops') path[0] = 'gradient-color-stops' - if (path[0] == 'maxHeight') path[0] = 'max-height' - if (path[0] == 'minHeight') path[0] = 'min-height' - if (path[0] == 'minWidth') path[0] = 'min-width' - if (path[0] == 'outlineColor') path[0] = 'outline-color' - if (path[0] == 'placeholderColor') path[0] = 'placeholder-color' - if (path[0] == 'placeholderOpacity') path[0] = 'placeholder-opacity' - if (path[0] == 'ringColor') path[0] = 'ring-color' - if (path[0] == 'ringOffsetColor') path[0] = 'ring-offset-color' - if (path[0] == 'ringOpacity') path[0] = 'ring-opacity' - if (path[0] == 'scrollMargin') path[0] = 'scroll-margin' - if (path[0] == 'scrollPadding') path[0] = 'scroll-padding' - if (path[0] == 'textColor') path[0] = 'text-color' - if (path[0] == 'textDecorationColor') path[0] = 'text-decoration-color' - if (path[0] == 'textIndent') path[0] = 'text-indent' - if (path[0] == 'textOpacity') path[0] = 'text-opacity' - if (path[0] == 'backgroundImage') path[0] = 'background-image' - if (path[0] == 'backgroundPosition') path[0] = 'background-position' - if (path[0] == 'backgroundSize') path[0] = 'background-size' - if (path[0] == 'borderWidth') path[0] = 'border-width' - if (path[0] == 'dropShadow') path[0] = 'drop-shadow' - if (path[0] == 'flexGrow') path[0] = 'flex-grow' - if (path[0] == 'flexShrink') path[0] = 'flex-shrink' - if (path[0] == 'fontWeight') path[0] = 'font-weight' - if (path[0] == 'gradientColorStopPositions') path[0] = 'gradient-color-stop-positions' - if (path[0] == 'gridAutoColumns') path[0] = 'grid-auto-columns' - if (path[0] == 'gridAutoRows') path[0] = 'grid-auto-rows' - if (path[0] == 'gridColumn') path[0] = 'grid-column' - if (path[0] == 'gridColumnEnd') path[0] = 'grid-column-end' - if (path[0] == 'gridColumnStart') path[0] = 'grid-column-start' - if (path[0] == 'gridRow') path[0] = 'grid-row' - if (path[0] == 'gridRowEnd') path[0] = 'grid-row-end' - if (path[0] == 'gridRowStart') path[0] = 'grid-row-start' - if (path[0] == 'gridTemplateColumns') path[0] = 'grid-template-columns' - if (path[0] == 'gridTemplateRows') path[0] = 'grid-template-rows' - if (path[0] == 'hueRotate') path[0] = 'hue-rotate' - if (path[0] == 'listStyleType') path[0] = 'list-style-type' - if (path[0] == 'listStyleImage') path[0] = 'list-style-image' - if (path[0] == 'lineClamp') path[0] = 'line-clamp' - if (path[0] == 'objectPosition') path[0] = 'object-position' - if (path[0] == 'outlineOffset') path[0] = 'outline-offset' - if (path[0] == 'outlineWidth') path[0] = 'outline-width' - if (path[0] == 'ringOffsetWidth') path[0] = 'ring-offset-width' - if (path[0] == 'ringWidth') path[0] = 'ring-width' - if (path[0] == 'strokeWidth') path[0] = 'stroke-width' - if (path[0] == 'textDecorationThickness') path[0] = 'text-decoration-thickness' - if (path[0] == 'textUnderlineOffset') path[0] = 'text-underline-offset' - if (path[0] == 'transformOrigin') path[0] = 'transform-origin' - if (path[0] == 'transitionDelay') path[0] = 'transition-delay' - if (path[0] == 'transitionDuration') path[0] = 'transition-duration' - if (path[0] == 'transitionProperty') path[0] = 'transition-property' - if (path[0] == 'willChange') path[0] = 'will-change' - if (path[0] == 'zIndex') path[0] = 'z-index' - for (let part of path) { if (!IS_VALID_KEY.test(part)) return null } @@ -260,11 +185,22 @@ export function keyPathToCssProperty(path: string[]) { .map((path, idx, all) => (path === '1' && idx !== all.length - 1 ? '' : path)) // Resolve the key path to a CSS variable segment - .map((part) => { + .map((part, idx) => { part = part.replaceAll('.', '_') - if (part === 'lineHeight') return 'line-height' - if (part.startsWith('-')) { + let shouldConvert = + // The first "namespace" part should be converted to kebab-case + // This converts things like backgroundColor to `background-color` + idx === 0 || + // Any tuple nested key should be converted to kebab-case + // These are identified with a leading `-` + // e.g. `fontSize.xs.1.lineHeight` -> `font-size-xs--line-height` + part.startsWith('-') || + // `lineHeight` is a bit of a special case in which it does not + // always begin with a leading `-` even when as a nested tuple key + part === 'lineHeight' + + if (shouldConvert) { part = part.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`) } From cbd0cc80502a8f7a4c7c8a802df5832a95555898 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 20 Nov 2025 03:48:05 -0500 Subject: [PATCH 4/5] Update test --- integrations/upgrade/js-config.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index 039f3dc8dd57..12dd9eee7085 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -167,9 +167,9 @@ test( " --- src/index.html ---
-
+
--- src/input.css --- @import 'tailwindcss'; @@ -187,12 +187,12 @@ test( --color-red-500: #ef4444; --color-red-600: #dc2626; - --color-super-red: #ff0000; + --color-superRed: #ff0000; --color-steel: rgb(70 130 180); --color-smoke: rgba(245, 245, 245, var(--smoke-alpha, 1)); --opacity-*: initial; - --opacity-super-opaque: 95%; + --opacity-superOpaque: 95%; --text-*: initial; --text-xs: 0.75rem; @@ -265,9 +265,9 @@ test( --animate-spin-clockwise: spin-clockwise 1s linear infinite; --animate-spin-counterclockwise: spin-counterclockwise 1s linear infinite; - --tracking-super-wide: 0.25em; + --tracking-superWide: 0.25em; - --leading-super-loose: 3; + --leading-superLoose: 3; @keyframes spin-clockwise { 0% { From cc3bc8523166ab3567fa52d64b96ec7048ad46be Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 20 Nov 2025 17:35:01 -0500 Subject: [PATCH 5/5] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dcb564a2163..1fba8eaf94a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Skip comments in Ruby files when checking for class names ([#19243](https://github.com/tailwindlabs/tailwindcss/pull/19243)) - Skip over arbitrary property utilities with a top-level `!` in the value ([#19243](https://github.com/tailwindlabs/tailwindcss/pull/19243)) - Support environment API in `@tailwindcss/vite` ([#18970](https://github.com/tailwindlabs/tailwindcss/pull/18970)) +- Preserve case of theme keys from JS configs and plugins ([#19337](https://github.com/tailwindlabs/tailwindcss/pull/19337)) - Upgrade: Handle `future` and `experimental` config keys ([#19344](https://github.com/tailwindlabs/tailwindcss/pull/19344)) ### Added