Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/tailwindcss/src/utils/replace-shadow-colors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,51 @@ describe('without replacer', () => {
`"var(--my-shadow), 1px 1px var(--tw-shadow-color, var(--my-color)), 0 0 1px var(--tw-shadow-color, var(--my-color))"`,
)
})

it('should preserve alpha from rgba color with modern syntax', () => {
let parsed = replaceShadowColors('rgba(0 0 0 / 0.12) 0px 1px 2px', replacer)
expect(parsed).toMatchInlineSnapshot(
`"color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0 0 0)) 12%) 0px 1px 2px"`,
)
})

it('should preserve alpha from rgba color with legacy syntax', () => {
let parsed = replaceShadowColors('rgba(0, 0, 0, 0.12) 0px 1px 2px', replacer)
expect(parsed).toMatchInlineSnapshot(
`"color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0, 0, 0)) 12%) 0px 1px 2px"`,
)
})

it('should preserve alpha from rgba color with percentage', () => {
let parsed = replaceShadowColors('rgba(0 0 0 / 50%) 0px 1px 2px', replacer)
expect(parsed).toMatchInlineSnapshot(
`"color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0 0 0)) 50%) 0px 1px 2px"`,
)
})

it('should preserve alpha from hsla color', () => {
let parsed = replaceShadowColors('hsla(0 0% 0% / 0.3) 0px 1px 2px', replacer)
expect(parsed).toMatchInlineSnapshot(
`"color-mix(in srgb, transparent, var(--tw-shadow-color, hsl(0 0% 0%)) 30%) 0px 1px 2px"`,
)
})

it('should preserve different alpha values for multiple shadows', () => {
let parsed = replaceShadowColors(
'rgba(0 0 0 / 0.12) 0px -1px 3px 0px, rgba(0 0 0 / 0.14) 0px 2px 5px -5px, rgba(0 0 0 / 0.17) 0px 12px 15px -5px',
replacer,
)
expect(parsed).toMatchInlineSnapshot(
`"color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0 0 0)) 12%) 0px -1px 3px 0px, color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0 0 0)) 14%) 0px 2px 5px -5px, color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0 0 0)) 17%) 0px 12px 15px -5px"`,
)
})

it('should handle mixed colors with and without alpha', () => {
let parsed = replaceShadowColors('#000 0px 1px 2px, rgba(0 0 0 / 0.5) 0px 2px 4px', replacer)
expect(parsed).toMatchInlineSnapshot(
`"var(--tw-shadow-color, #000) 0px 1px 2px, color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0 0 0)) 50%) 0px 2px 4px"`,
)
})
})

describe('with replacer', () => {
Expand Down Expand Up @@ -84,4 +129,11 @@ describe('with replacer', () => {
`"var(--my-shadow), 1px 1px var(--tw-shadow-color, oklab(from var(--my-color) l a b / 50%)), 0 0 1px var(--tw-shadow-color, oklab(from var(--my-color) l a b / 50%))"`,
)
})

it('should use modifier alpha instead of original alpha when both are present', () => {
let parsed = replaceShadowColors('rgba(0 0 0 / 0.12) 0px 1px 2px', replacer)
expect(parsed).toMatchInlineSnapshot(
`"var(--tw-shadow-color, oklab(from rgb(0 0 0) l a b / 50%)) 0px 1px 2px"`,
)
})
})
91 changes: 90 additions & 1 deletion packages/tailwindcss/src/utils/replace-shadow-colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,77 @@ import { segment } from './segment'
const KEYWORDS = new Set(['inset', 'inherit', 'initial', 'revert', 'unset'])
const LENGTH = /^-?(\d+|\.\d+)(.*?)$/g

/**
* Extract the alpha channel from a color value.
* Returns the alpha as a percentage string (e.g., "12%") or null if no alpha is found.
*/
function extractAlpha(color: string): string | null {
// Modern rgba/hsla syntax with slash: rgba(0 0 0 / 0.12) or rgba(0 0 0 / 12%)
const slashAlphaMatch = color.match(/\/\s*([\d.]+)%?\s*\)/)
if (slashAlphaMatch) {
let alpha = slashAlphaMatch[1]
// If it's already a percentage, return it
if (slashAlphaMatch[0].includes('%')) {
return `${alpha}%`
}
// Convert decimal to percentage
const alphaNum = parseFloat(alpha)
if (!isNaN(alphaNum)) {
// Round to avoid floating point precision issues
return `${Math.round(alphaNum * 100)}%`
Comment on lines +10 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "replace-shadow-colors.ts" | head -20

Repository: tailwindlabs/tailwindcss

Length of output: 125


🏁 Script executed:

cat -n ./packages/tailwindcss/src/utils/replace-shadow-colors.ts

Repository: tailwindlabs/tailwindcss

Length of output: 5588


🏁 Script executed:

# Test the actual regex patterns with concrete examples to fully verify
python3 << 'EOF'
import re

# Test case 1: hsl with deg units and slash alpha
test_color_1 = "hsl(180deg 50% 50% / 0.3)"

# Current extractAlpha slash pattern
extract_slash_current = r"/\s*([\d.]+)%?\s*\)"
match = re.search(extract_slash_current, test_color_1)
print(f"extractAlpha current pattern on '{test_color_1}': {bool(match)}")
if match:
    print(f"  Captured groups: {match.groups()}")

# Current stripAlpha slash pattern
strip_slash_current = r"^(rgba?|hsla?)\(([\d\s.%]+)\s*\/\s*[\d.]+%?\s*\)$"
match = re.search(strip_slash_current, test_color_1, re.IGNORECASE)
print(f"stripAlpha current pattern on '{test_color_1}': {bool(match)}")

# Test case 2: rgba with percentage alpha (comma form)
test_color_2 = "rgba(0, 0, 0, 12%)"

# Current extractAlpha comma pattern
extract_comma_current = r"^(?:rgba|hsla)\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\s*\)$"
match = re.search(extract_comma_current, test_color_2, re.IGNORECASE)
print(f"\nextractAlpha current pattern on '{test_color_2}': {bool(match)}")

# Test case 3: Standard rgba with decimal alpha (comma form) - should work
test_color_3 = "rgba(0, 0, 0, 0.12)"
match = re.search(extract_comma_current, test_color_3, re.IGNORECASE)
print(f"extractAlpha current pattern on '{test_color_3}': {bool(match)}")
if match:
    print(f"  Captured groups: {match.groups()}")

# Test case 4: stripAlpha with percentage comma alpha
test_color_2 = "rgba(0, 0, 0, 12%)"
strip_comma_current = r"^(rgba?|hsla?)\(([\d\s.,%]+),\s*[\d.]+\s*\)$"
match = re.search(strip_comma_current, test_color_2, re.IGNORECASE)
print(f"stripAlpha current pattern on '{test_color_2}': {bool(match)}")

# Test case 5: stripAlpha with decimal comma alpha - should work
test_color_3 = "rgba(0, 0, 0, 0.12)"
match = re.search(strip_comma_current, test_color_3, re.IGNORECASE)
print(f"stripAlpha current pattern on '{test_color_3}': {bool(match)}")
if match:
    print(f"  Captured groups: {match.groups()}")
EOF

Repository: tailwindlabs/tailwindcss

Length of output: 531


🏁 Script executed:

# Verify the proposed fix patterns work correctly
python3 << 'EOF'
import re

test_cases = [
    ("hsl(180deg 50% 50% / 0.3)", True, "modern hsl with deg and decimal alpha"),
    ("rgb(0 0 0 / 0.12)", True, "modern rgb with decimal alpha"),
    ("rgba(255 128 64 / 30%)", True, "modern rgba with percentage alpha"),
    ("hsla(180deg 50% 50% / 0.3)", True, "modern hsla with deg and decimal alpha"),
    ("rgba(0, 0, 0, 0.12)", True, "legacy rgba with decimal alpha"),
    ("rgba(0, 0, 0, 12%)", True, "legacy rgba with percentage alpha"),
    ("hsla(180, 50%, 50%, 0.3)", True, "legacy hsla with decimal alpha"),
    ("hsla(180, 50%, 50%, 30%)", True, "legacy hsla with percentage alpha"),
    ("rgb(0 0 0)", False, "no alpha"),
    ("currentcolor", False, "not a function"),
]

print("=" * 80)
print("PROPOSED extractAlpha PATTERNS")
print("=" * 80)

# Proposed slash pattern from diff
proposed_slash = r"^(rgba?|hsla?)\((.+)\s*\/\s*([\d.]+%?)\s*\)$"

print(f"\nSlash pattern: {proposed_slash}")
print("-" * 80)

for color, should_match, desc in test_cases:
    if '/' not in color:
        continue
    match = re.match(proposed_slash, color, re.IGNORECASE)
    status = "✓" if bool(match) == should_match else "✗"
    print(f"{status} '{color}'")
    print(f"  Expected: {should_match}, Got: {bool(match)} ({desc})")
    if match:
        print(f"  Groups: func={match.group(1)}, values={match.group(2)}, alpha={match.group(3)}")

# Proposed comma pattern from diff
proposed_comma = r"^(?:rgba|hsla)\(([^,]+,[^,]+,[^,]+),\s*([\d.]+%?)\s*\)$"

print(f"\n\nComma pattern: {proposed_comma}")
print("-" * 80)

for color, should_match, desc in test_cases:
    if ',' not in color:
        continue
    match = re.match(proposed_comma, color, re.IGNORECASE)
    status = "✓" if bool(match) == should_match else "✗"
    print(f"{status} '{color}'")
    print(f"  Expected: {should_match}, Got: {bool(match)} ({desc})")
    if match:
        print(f"  Groups: values={match.group(1)}, alpha={match.group(2)}")

print("\n" + "=" * 80)
print("PROPOSED stripAlpha PATTERNS")
print("=" * 80)

# Proposed stripAlpha slash pattern from diff
proposed_strip_slash = r"^(rgba?|hsla?)\((.+)\s*\/\s*[\d.]+%?\s*\)$"

print(f"\nSlash pattern: {proposed_strip_slash}")
print("-" * 80)

strip_test_cases = [
    ("hsl(180deg 50% 50% / 0.3)", "hsl(180deg 50% 50% )", "modern hsl with deg"),
    ("rgb(0 0 0 / 0.12)", "rgb(0 0 0)", "modern rgb"),
    ("rgba(0, 0, 0, 0.12)", None, "legacy - handled by comma pattern"),
]

for color, expected_result, desc in strip_test_cases:
    if '/' not in color:
        continue
    match = re.match(proposed_strip_slash, color, re.IGNORECASE)
    status = "✓" if bool(match) else "✗"
    print(f"{status} '{color}' ({desc})")
    if match:
        func_type = match.group(1).lower().replace('a', '')
        values = match.group(2).strip()
        result = f"{func_type}({values})"
        print(f"  Result: '{result}'")

# Proposed stripAlpha comma pattern from diff
proposed_strip_comma = r"^(rgba|hsla)\(([^,]+,[^,]+,[^,]+),\s*[\d.]+%?\s*\)$"

print(f"\nComma pattern: {proposed_strip_comma}")
print("-" * 80)

comma_strip_test = [
    ("rgba(0, 0, 0, 0.12)", "rgb(0, 0, 0)"),
    ("rgba(0, 0, 0, 12%)", "rgb(0, 0, 0)"),
    ("hsla(180, 50%, 50%, 0.3)", "hsl(180, 50%, 50%)"),
]

for color, expected_result in comma_strip_test:
    match = re.match(proposed_strip_comma, color, re.IGNORECASE)
    status = "✓" if bool(match) else "✗"
    print(f"{status} '{color}'")
    if match:
        func_type = match.group(1).lower().replace('a', '')
        values = match.group(2).strip()
        result = f"{func_type}({values})"
        print(f"  Result: '{result}'")

EOF

Repository: tailwindlabs/tailwindcss

Length of output: 2425


Fix regex mismatch between extractAlpha() and stripAlpha() for consistent color/alpha handling.

Current code has misaligned patterns that create color parsing failures:

  1. extractAlpha() matches hsl(180deg 50% 50% / 0.3) but stripAlpha() rejects it (character class [\d\s.%]+ excludes unit letters like deg), leaving / 0.3 in the base color passed to replacement().
  2. Neither function handles percentage alphas in legacy comma form: rgba(0, 0, 0, 12%) and hsla(..., 30%) are never extracted, so custom --tw-shadow-color overrides lose per-shadow opacity.

The proposed fix patterns are correct, but the diff contains a group index error: the slash pattern /^(rgba?|hsla?)\((.+)\s*\/\s*([\d.]+%?)\s*\)$/i captures alpha in group [3], not [2] as shown in the diff. Correct the assignment to slashAlphaMatch[3] and apply the corresponding fixes to stripAlpha() to handle both modern (slash) and legacy (comma) syntax consistently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/tailwindcss/src/utils/replace-shadow-colors.ts` around lines 10 -
23, extractAlpha() is using the wrong capture index for the "slash" regex and
stripAlpha() has a mismatched pattern so modern hsl/rgba with unit tokens (like
"deg") or legacy percentage alphas (e.g. "rgba(..., 12%)" / "hsla(..., 30%)")
are not handled consistently; update extractAlpha() to pull the alpha from
slashAlphaMatch[3] (the third capture in
/^(rgba?|hsla?)\((.+)\s*\/\s*([\d.]+%?)\s*\)$/i) and normalize it to a percent
as currently done, and modify stripAlpha() to accept unit-containing color
components (allow letters like "deg" and % in the middle capture) and also
detect legacy comma-separated alpha including percentage values so that both
extractAlpha() and stripAlpha() produce matching base-color and alpha values
used by replacement().

}
return null
}

// Legacy rgba/hsla syntax with comma: rgba(0, 0, 0, 0.12)
// Must be rgba/hsla (with 'a') to have 4 values where the last is alpha`n const commaAlphaMatch = color.match(/^(?:rgba|hsla)\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\s*\)$/i)
if (commaAlphaMatch) {
Comment on lines +28 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n packages/tailwindcss/src/utils/replace-shadow-colors.ts | sed -n '25,35p'

Repository: tailwindlabs/tailwindcss

Length of output: 627


🏁 Script executed:

rg "commaAlphaMatch" packages/tailwindcss/src/utils/replace-shadow-colors.ts

Repository: tailwindlabs/tailwindcss

Length of output: 299


🏁 Script executed:

cd packages/tailwindcss && [ -f tsconfig.json ] && cat tsconfig.json || echo "No tsconfig.json found"

Repository: tailwindlabs/tailwindcss

Length of output: 109


Restore the commaAlphaMatch declaration to code.

The const commaAlphaMatch = ... declaration on line 29 is inside the // comment, making it unreachable. Line 30 references an undefined identifier and the file fails type-checking.

🐛 Proposed fix
   // Legacy rgba/hsla syntax with comma: rgba(0, 0, 0, 0.12)
-  // Must be rgba/hsla (with 'a') to have 4 values where the last is alpha`n  const commaAlphaMatch = color.match(/^(?:rgba|hsla)\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\s*\)$/i)
+  // Must be rgba/hsla (with 'a') to have 4 values where the last is alpha.
+  const commaAlphaMatch = color.match(
+    /^(?:rgba|hsla)\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\s*\)$/i,
+  )
   if (commaAlphaMatch) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Legacy rgba/hsla syntax with comma: rgba(0, 0, 0, 0.12)
// Must be rgba/hsla (with 'a') to have 4 values where the last is alpha`n const commaAlphaMatch = color.match(/^(?:rgba|hsla)\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\s*\)$/i)
if (commaAlphaMatch) {
// Legacy rgba/hsla syntax with comma: rgba(0, 0, 0, 0.12)
// Must be rgba/hsla (with 'a') to have 4 values where the last is alpha.
const commaAlphaMatch = color.match(
/^(?:rgba|hsla)\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\s*\)$/i,
)
if (commaAlphaMatch) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/tailwindcss/src/utils/replace-shadow-colors.ts` around lines 28 -
30, Restore the missing declaration for the regex match by re-adding the const
commaAlphaMatch =
color.match(/^(?:rgba|hsla)\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\s*\)$/i) so the
subsequent if (commaAlphaMatch) branch in replace-shadow-colors.ts can access
the parsed alpha; locate where commaAlphaMatch is currently referenced and
either uncomment or reinsert that exact declaration (using the same regex)
immediately before the if (commaAlphaMatch) check to fix the undefined
identifier and restore type-checking.

const alpha = commaAlphaMatch[1]
const alphaNum = parseFloat(alpha)
if (!isNaN(alphaNum)) {
// Round to avoid floating point precision issues
return `${Math.round(alphaNum * 100)}%`
}
return null
}

// No alpha found
return null
}

/**
* Extract the base color without the alpha channel.
* For rgba/hsla, returns rgb/hsl with the same values but without alpha.
* For other colors, returns as-is.
*/
function stripAlpha(color: string): string {
// Modern rgba/hsla syntax with slash: rgba(0 0 0 / 0.12) or hsla(0 0% 0% / 0.3)
const slashMatch = color.match(/^(rgba?|hsla?)\(([\d\s.%]+)\s*\/\s*[\d.]+%?\s*\)$/i)
if (slashMatch) {
const type = slashMatch[1].toLowerCase().replace('a', '')
const values = slashMatch[2].trim()
return `${type}(${values})`
}

// Legacy rgba/hsla syntax with comma: rgba(0, 0, 0, 0.12) or hsla(0, 0%, 0%, 0.3)
const commaMatch = color.match(/^(rgba?|hsla?)\(([\d\s.,%]+),\s*[\d.]+\s*\)$/i)
if (commaMatch) {
const type = commaMatch[1].toLowerCase().replace('a', '')
const values = commaMatch[2].trim()
return `${type}(${values})`
}

// No alpha to strip
return color
}

/**
* Check if a string already contains alpha handling (oklab, color-mix, etc.)
*/
function hasAlphaHandling(value: string): boolean {
return value.includes('oklab(') || value.includes('color-mix(') || value.includes('oklch(')
}

export function replaceShadowColors(input: string, replacement: (color: string) => string) {
let shadows = segment(input, ',').map((shadow) => {
shadow = shadow.trim()
Expand Down Expand Up @@ -33,7 +104,25 @@ export function replaceShadowColors(input: string, replacement: (color: string)
// we can't know what to replace.
if (offsetX === null || offsetY === null) return shadow

let replacementColor = replacement(color ?? 'currentcolor')
// Extract alpha from the original color if present
let alpha: string | null = null
let baseColor: string = color ?? 'currentcolor'

if (color) {
alpha = extractAlpha(color)
if (alpha) {
baseColor = stripAlpha(color)
}
}

let replacementColor = replacement(baseColor)

// Only apply color-mix wrapping if:
// 1. The original color had an alpha channel
// 2. The replacement doesn't already have alpha handling (oklab, color-mix, etc.)
if (alpha && !hasAlphaHandling(replacementColor)) {
replacementColor = `color-mix(in srgb, transparent, ${replacementColor} ${alpha})`
}

if (color !== null) {
// If a color was found, replace the color.
Expand Down