From b84901211bd222ce352f63d7e945484e206638f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 21:03:06 +0000 Subject: [PATCH 1/3] feat: support JSX expression values on className variant attributes Transform conditional modifiers like className:hover={isActive ? 'a' : 'b'} into prefixed className expressions, extract the resulting classes for the Tailwind manifest, and merge multiple JSX class values safely with nested braces. Co-authored-by: Jeremy Butler --- README.md | 21 ++ src/core.ts | 587 +++++++++++++++++++++++++++++++++++------ src/tests/core.test.ts | 225 +++++++++++++++- tasks/todo.md | 61 ++--- 4 files changed, 776 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 7f91281..a35eb22 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ UseClassy transforms Tailwind variant attributes (`class:hover="..."`) into stan - Transforms attributes like `class:hover="text-blue-500"` to standard `class="hover:text-blue-500"`. - Supports chaining modifiers like `class:dark:hover="text-blue-500"`. +- Supports React conditional variants: `className:hover={isActive ? 'bg-blue-500' : 'bg-gray-200'}`. - Works seamlessly with React (`className`) and Vue/HTML (`class`). - Integrates with Vite's build process and dev server. No runtime overhead. - Smart Caching: Avoids reprocessing unchanged files during development. @@ -92,6 +93,26 @@ export default { /> ``` +### Conditional / dynamic variants + +JSX expression values work too — string literals inside the expression are prefixed with the variant: + +```tsx +// Input +` + const classes = new Set() + + const result = transformClassModifiers( + code, + classes, + REACT_CLASS_MODIFIER_REGEX, + 'className', + ) + + expect(result).toBe( + ``, + ) + expect(classes.has('disabled:opacity-50')).toBeTruthy() + expect(classes.has('disabled:cursor-not-allowed')).toBeTruthy() + }) + + it('should transform nested modifiers inside JSX expressions', () => { + const code = `
X
` + const classes = new Set() + + const result = transformClassModifiers( + code, + classes, + REACT_CLASS_MODIFIER_REGEX, + 'className', + ) + + expect(result).toBe( + `
X
`, + ) + expect(classes.has('sm:hover:text-lg')).toBeTruthy() + expect(classes.has('hover:text-sm')).toBeTruthy() + }) + + it('should leave className:modifier variable expressions unchanged', () => { + const code = '
Content
' + const classes = new Set() + + const result = transformClassModifiers( + code, + classes, + REACT_CLASS_MODIFIER_REGEX, + 'className', + ) + + expect(result).toBe(code) + expect(classes.size).toBe(0) + }) + + it('should rewrite string literals inside nested JSX braces', () => { + const code + = `
X
` + const classes = new Set() + + const result = transformClassModifiers( + code, + classes, + REACT_CLASS_MODIFIER_REGEX, + 'className', + ) + + expect(result).toBe( + `
X
`, + ) + expect(classes.has('hover:bg-blue-500')).toBeTruthy() + expect(classes.has('hover:bg-gray-200')).toBeTruthy() + }) + it('should handle multiple class values per modifier', () => { const code = '
Content
' const classes = new Set() @@ -645,10 +792,9 @@ describe('core module', () => { const result = mergeClassAttributes(code, 'className') - expect(result).toContain('className=') - expect(result).toContain('flex') - // Should handle multiple expressions by prioritizing function calls - expect(result).toContain('active ?') + expect(result).toBe( + '
Content
', + ) }) it('should handle template literals with complex expressions', () => { @@ -738,4 +884,75 @@ describe('core module', () => { expect(result).not.toContain('class:hover') }) }) + + describe('React conditional className:modifier pipeline', () => { + it('should merge static className with transformed conditional modifiers', () => { + const code = `` + const classes = new Set() + + const afterModifiers = transformClassModifiers( + code, + classes, + REACT_CLASS_MODIFIER_REGEX, + 'className', + ) + const result = mergeClassAttributes(afterModifiers, 'className') + + expect(result).toBe( + ``, + ) + expect(classes.has('hover:bg-blue-500')).toBeTruthy() + expect(classes.has('hover:bg-gray-200')).toBeTruthy() + expect(result).not.toContain('className:hover') + }) + + it('should merge nested-brace conditional modifiers with static classes', () => { + const code + = `
X
` + const classes = new Set() + + const afterModifiers = transformClassModifiers( + code, + classes, + REACT_CLASS_MODIFIER_REGEX, + 'className', + ) + const result = mergeClassAttributes(afterModifiers, 'className') + + expect(result).toBe( + `
X
`, + ) + expect(classes.has('hover:bg-blue-500')).toBeTruthy() + }) + + it('should keep multiple conditional modifiers when merging', () => { + const code = `` + const classes = new Set() + + const afterModifiers = transformClassModifiers( + code, + classes, + REACT_CLASS_MODIFIER_REGEX, + 'className', + ) + const result = mergeClassAttributes(afterModifiers, 'className') + + expect(result).toBe( + ``, + ) + expect(classes.has('hover:bg-blue-500')).toBeTruthy() + expect(classes.has('disabled:opacity-50')).toBeTruthy() + }) + }) }) diff --git a/tasks/todo.md b/tasks/todo.md index f0b3e11..b1a6ea7 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,41 +1,42 @@ -# Issue #36: Manifest written too late for Tailwind +# Feature: Dynamic/conditional variant syntax (JSX expressions) -## Review summary +## Goal -**Valid bug** against published `vite-plugin-useclassy@3.1.0`. +Support React JSX expression values on variant attributes, e.g.: -Tailwind v4 reads `.classy/output.classy.html` via `@source` while CSS compiles. -Published 3.1.0 only wrote that file in `buildEnd`, so the first cold `vite build` -missed UseClassy variants (`md:h-40`). A second build worked because the first -left a manifest behind. +```tsx +className:hover={isActive ? 'bg-blue-500' : 'bg-gray-200'} +``` -## Fix (this PR) +Today only quoted strings (`className:hover="..."`) are matched; expression forms are ignored. -- [x] Run project/blade scan on `buildStart` in **dev and build** -- [x] Pass instance `writeDirect` into early scan (triggers `onWrote`) -- [x] Invalidate CSS modules after manifest writes in Dev (HMR) -- [x] Allowlist the manifest in `.classy/.gitignore` so Oxide can read `@source` -- [x] Use function-based Vite watch ignore for `.classy/` (avoid HTML full reload) -- [x] Tests for early scan + CSS invalidation + gitignore allowlist -- [x] Commit, push, open PR (#37) +## Plan -## Verification +- [x] Branch `cursor/jsx-conditional-variants-1ea7` +- [x] Add balanced-brace scanner + string-literal class prefixing in `src/core.ts` +- [x] Extract modified classes from JSX expression modifiers into the Tailwind manifest +- [x] Transform `className:mod={expr}` / `class:mod={expr}` → `className={prefixedExpr}` +- [x] Ensure `mergeClassAttributes` handles nested `{}` in JSX values (needed after transform) +- [x] Tests for extract + transform + merge + nested modifiers + edge cases +- [x] Document React conditional usage in README +- [x] Run unit tests; commit, push, open PR -- Fresh `vite build` with empty `.classy/` includes variant classes in CSS (reproduced on react demo: `md:text-7xl`) -- Published 3.1.0 still fails first build; local fix succeeds -- Unit tests: 179 passed -- CI checks green on PR head +## Design notes -## Review (post-implementation) +- Keep zero new dependencies; extend the existing regex/scanner approach +- Rewrite whitespace-separated tokens inside `'...'`, `"..."`, and static `` `...` `` literals +- Leave template interpolations (`${...}`) untouched +- Nested modifiers keep current behavior: full chain + partials (`sm:hover` → `sm:hover:X sm:X hover:X`) +- Vue quoted `class:hover="..."` path unchanged +- Expressions with no string literals are left unchanged -Verified against reproduction matching issue #36: +## Review -1. **Cold `vite build`** with empty `.classy/`: CSS includes `md:h-40` on the first run. -2. **Dev HMR**: adding `class:hover="text-pink-500"` updates the manifest and the - Tailwind CSS (`?direct`) without a full page reload. -3. Published 3.1.0 still writes only at `buildEnd` and fails the cold build. +Implemented in `src/core.ts`: -Root causes addressed: -- Manifest was written too late for Tailwind's CSS pass → early `buildStart` scan. -- Dev updates did not refresh Tailwind → `handleHotUpdate` + CSS invalidation. -- Nested `.classy/.gitignore` of `*` blocked Oxide → allowlist the manifest file. +1. **`readBalancedJsxExpression`** — brace scanner that respects strings/templates/comments +2. **`rewriteClassLiteralsInExpression`** — prefixes class tokens inside literals +3. **`transformJsxExpressionModifiers`** — `className:hover={...}` → `className={...}` +4. **`mergeClassAttributes`** — scanner-based merge; keeps multiple JSX exprs; nested `{}` safe + +Verification: **190** unit tests passed (11 new). README documents the syntax. From 2723bc2bc263a4ab4f8aeba85bab446caa0f433a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 21:25:27 +0000 Subject: [PATCH 2/3] feat(demo): showcase conditional className variant expressions Add an interactive React demo for className:hover/{disabled} JSX expressions, widen attribute typings for conditional values, and coerce falsy interpolations when merging so `cond && 'class'` stays safe. Co-authored-by: Jeremy Butler --- README.md | 2 +- demos/react/src/App.tsx | 33 +++++++++++++++++++++++++++++++-- demos/react/src/react.d.ts | 4 ++-- src/core.ts | 4 +++- src/tests/core.test.ts | 10 +++++----- 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a35eb22..98f95b7 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ JSX expression values work too — string literals inside the expression are pre // Output + + + ); } diff --git a/demos/react/src/react.d.ts b/demos/react/src/react.d.ts index 0ba17f0..5cd8c7d 100644 --- a/demos/react/src/react.d.ts +++ b/demos/react/src/react.d.ts @@ -2,7 +2,7 @@ import "react"; declare module "react" { interface HTMLAttributes { - [key: `class:${string}`]: string; - [key: `className:${string}`]: string; + [key: `class:${string}`]: string | undefined | null | false; + [key: `className:${string}`]: string | undefined | null | false; } } diff --git a/src/core.ts b/src/core.ts index 5275cda..8bcbe99 100644 --- a/src/core.ts +++ b/src/core.ts @@ -623,7 +623,9 @@ function mergeParsedClassAttrs( const dynamicParts = jsxExprs.map((expr) => { if (expr.startsWith('`') && expr.endsWith('`')) return expr.slice(1, -1) - return `\${${expr}}` + // Coerce falsy runtime values (e.g. `cond && 'class'`) so template + // interpolation does not stringify `false` into the class list. + return `\${(${expr}) || ''}` }).join(' ') if (combinedStatic) diff --git a/src/tests/core.test.ts b/src/tests/core.test.ts index 76602fa..d56e3e8 100644 --- a/src/tests/core.test.ts +++ b/src/tests/core.test.ts @@ -706,7 +706,7 @@ describe('core module', () => { const result = mergeClassAttributes(code, 'className') expect(result).toBe( - '
Content
', + '
Content
', ) }) @@ -793,7 +793,7 @@ describe('core module', () => { const result = mergeClassAttributes(code, 'className') expect(result).toBe( - '
Content
', + '
Content
', ) }) @@ -903,7 +903,7 @@ describe('core module', () => { expect(result).toBe( ``, ) expect(classes.has('hover:bg-blue-500')).toBeTruthy() @@ -925,7 +925,7 @@ describe('core module', () => { const result = mergeClassAttributes(afterModifiers, 'className') expect(result).toBe( - `
X
`, + `
X
`, ) expect(classes.has('hover:bg-blue-500')).toBeTruthy() }) @@ -948,7 +948,7 @@ describe('core module', () => { expect(result).toBe( ``, ) expect(classes.has('hover:bg-blue-500')).toBeTruthy() From 266c30ec3f9ff4ba8ad4389a5309c36a4b8813db Mon Sep 17 00:00:00 2001 From: Jeremy Butler Date: Tue, 21 Jul 2026 19:34:02 +1000 Subject: [PATCH 3/3] feat: enhance JSX class handling by preserving comparison operands and method receivers - Implemented logic to prevent rewriting of comparison string operands and string method receivers within JSX expressions, ensuring they remain untouched during class attribute processing. - Added regression tests to verify that expressions like `status === 'active'` and `'primary'.includes(kind)` are correctly handled without modification. - Updated documentation to reflect changes in JSX conditional class rewrites. --- demos/react/src/App.tsx | 268 +++++++++++++++++++++++++++++++----- demos/react/src/react.d.ts | 14 +- demos/svelte/src/App.svelte | 223 +++++++++++++++++++++++++++--- src/core.ts | 144 ++++++++++++++++--- src/tests/core.test.ts | 115 ++++++++++++++++ tasks/lessons.md | 7 + 6 files changed, 699 insertions(+), 72 deletions(-) create mode 100644 tasks/lessons.md diff --git a/demos/react/src/App.tsx b/demos/react/src/App.tsx index 317f88b..177cfdf 100644 --- a/demos/react/src/App.tsx +++ b/demos/react/src/App.tsx @@ -1,53 +1,257 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { twMerge } from "tailwind-merge"; +/** Tiny class-map helper to exercise nested-brace rewrites (`cn({ '…': cond })`). */ +function cn(map: Record): string { + return Object.entries(map) + .filter(([, on]) => Boolean(on)) + .map(([cls]) => cls) + .join(" "); +} + +type Status = "active" | "muted" | "danger"; + function App() { const [isActive, setIsActive] = useState(true); const [isDisabled, setIsDisabled] = useState(false); + const [status, setStatus] = useState("active"); + const [kind, setKind] = useState("primary"); return ( -
-

- React Demo -

- -
- Tailwind Merge -
- -
-

Conditional variants

- - + React Demo + +

+ Smoke coverage for quoted modifiers, conditionals, comparison + operands, nested braces, and multi-modifier merges. +

+ -
); } +function Case({ + title, + detail, + children, +}: { + title: string; + detail: string; + children: ReactNode; +}) { + return ( +
+
+

{title}

+

{detail}

+
+ {children} +
+ ); +} + export default App; diff --git a/demos/react/src/react.d.ts b/demos/react/src/react.d.ts index 5cd8c7d..e160568 100644 --- a/demos/react/src/react.d.ts +++ b/demos/react/src/react.d.ts @@ -2,7 +2,17 @@ import "react"; declare module "react" { interface HTMLAttributes { - [key: `class:${string}`]: string | undefined | null | false; - [key: `className:${string}`]: string | undefined | null | false; + [key: `class:${string}`]: + | string + | undefined + | null + | false + | number; + [key: `className:${string}`]: + | string + | undefined + | null + | false + | number; } } diff --git a/demos/svelte/src/App.svelte b/demos/svelte/src/App.svelte index 442fac4..bee9cd1 100644 --- a/demos/svelte/src/App.svelte +++ b/demos/svelte/src/App.svelte @@ -1,25 +1,210 @@ -
-

- Svelte Demo -

- - + + + + + + +
+ +
+
+

+ Quoted modifiers + nested +

+

+ class:hover / class:sm:hover / class:md — UseClassy quoted values only +

+
+
+ Hover · resize for sm/md +
+
+ + +
+
+

+ Native class directive + UseClassy +

+

+ class:scale-110={pressed} and class:disabled stay native; class:hover is rewritten +

+
+ +
+ + +
+
+

+ Shorthand native + multi modifiers +

+

+ class:active shorthand preserved next to class:hover / class:sm:focus +

+
+ +
+ + +
+
+

+ Template class + variants +

+

+ Static tokens in class={`…`} extract; modifiers still merge +

+
+
+ + Template base + hover border +
+
+ + +
+
+

+ Status via native directives +

+

+ Conditional visuals use native class:name={cond}; chrome uses UseClassy quotes +

+
+ +
+ + +
+
+

+ Mixed visibility + nested focus +

+

+ class:hidden={!showBadge} with class:md:hover / class:focus-within +

+
+
+ + badge + + +
+
+
diff --git a/src/core.ts b/src/core.ts index 479d4b9..cb82283 100644 --- a/src/core.ts +++ b/src/core.ts @@ -23,8 +23,8 @@ export const REACT_CLASS_REGEX = /(?= 0 && /\s/.test(expr.charAt(before))) + before-- + + if (before >= 0) { + if ( + (before >= 2 && expr.slice(before - 2, before + 1) === '===') + || (before >= 2 && expr.slice(before - 2, before + 1) === '!==') + || (before >= 1 && expr.slice(before - 1, before + 1) === '==') + || (before >= 1 && expr.slice(before - 1, before + 1) === '!=') + ) { + return true + } + } + + let after = literalEndExclusive + while (after < expr.length && /\s/.test(expr.charAt(after))) + after++ + + if (after < expr.length) { + if ( + expr.startsWith('===', after) + || expr.startsWith('!==', after) + || expr.startsWith('==', after) + || expr.startsWith('!=', after) + || expr.charAt(after) === '.' + ) { + return true + } + } + + return false +} + /** * Rewrites class string/template literals inside a JSX expression so each token * receives the variant prefix (e.g. `hover:`). Returns null when no literals * were rewritten — callers should leave the original attribute unchanged. + * + * Comparison operands and string method receivers are left untouched so + * `status === 'active' ? 'bg-a' : 'bg-b'` does not become `'hover:active'`. */ function rewriteClassLiteralsInExpression( expr: string, @@ -236,6 +282,31 @@ function rewriteClassLiteralsInExpression( while (i < expr.length) { const ch = expr[i] + const next = expr[i + 1] + + // Skip comments so quotes inside them are not treated as class literals. + if (ch === '/' && next === '/') { + const start = i + i += 2 + while (i < expr.length && expr[i] !== '\n') + i++ + result += expr.slice(start, i) + continue + } + + if (ch === '/' && next === '*') { + const start = i + i += 2 + while (i < expr.length) { + if (expr[i] === '*' && expr[i + 1] === '/') { + i += 2 + break + } + i++ + } + result += expr.slice(start, i) + continue + } if (ch === '"' || ch === '\'') { const quote = ch @@ -256,53 +327,88 @@ function rewriteClassLiteralsInExpression( result += expr.slice(i) break } - result += quote + emitModified(content) + quote - i = j + 1 + const endExclusive = j + 1 + const nextContent = isNonClassStringLiteral(expr, i, endExclusive) + ? content + : emitModified(content) + result += quote + nextContent + quote + i = endExclusive continue } if (ch === '`') { let j = i + 1 - let staticPart = '' - let rebuilt = '`' let templateClosed = false + // Locate the closing backtick first so comparison/method templates + // can be skipped without emitting prefixed classes. while (j < expr.length) { if (expr[j] === '\\' && j + 1 < expr.length) { - staticPart += expr[j] + expr[j + 1] j += 2 continue } if (expr[j] === '`') { - if (staticPart) - rebuilt += emitModified(staticPart) - rebuilt += '`' templateClosed = true j++ break } if (expr[j] === '$' && expr[j + 1] === '{') { + const nested = readBalancedJsxExpression(expr, j + 1) + if (!nested) { + j = expr.length + break + } + j = nested.endIndex + 1 + continue + } + j++ + } + + if (!templateClosed) { + result += expr.slice(i) + break + } + + if (isNonClassStringLiteral(expr, i, j)) { + result += expr.slice(i, j) + i = j + continue + } + + let k = i + 1 + let staticPart = '' + let rebuilt = '`' + + while (k < j - 1) { + if (expr[k] === '\\' && k + 1 < expr.length) { + staticPart += expr[k] + expr[k + 1] + k += 2 + continue + } + if (expr[k] === '$' && expr[k + 1] === '{') { if (staticPart) { rebuilt += emitModified(staticPart) staticPart = '' } - const nested = readBalancedJsxExpression(expr, j + 1) + const nested = readBalancedJsxExpression(expr, k + 1) if (!nested) { - rebuilt += expr.slice(j) - j = expr.length + rebuilt += expr.slice(k, j - 1) break } - // Interpolations are runtime values — leave them untouched. rebuilt += '${' + nested.content + '}' - j = nested.endIndex + 1 + k = nested.endIndex + 1 continue } - staticPart += expr[j] - j++ + staticPart += expr[k] + k++ } - result += templateClosed ? rebuilt : expr.slice(i) - i = templateClosed ? j : expr.length + if (staticPart) + rebuilt += emitModified(staticPart) + rebuilt += '`' + + result += rebuilt + i = j continue } diff --git a/src/tests/core.test.ts b/src/tests/core.test.ts index b8deb73..9ab5ac1 100644 --- a/src/tests/core.test.ts +++ b/src/tests/core.test.ts @@ -214,6 +214,26 @@ describe('core module', () => { expect(modifierClasses.size).toBe(0) }) + it('should not extract comparison string operands from JSX expressions', () => { + const code + = `
X
` + const allClasses = new Set() + const modifierClasses = new Set() + + extractClasses( + code, + allClasses, + modifierClasses, + REACT_CLASS_REGEX, + REACT_CLASS_MODIFIER_REGEX, + ) + + expect(allClasses.has('hover:bg-blue-500')).toBeTruthy() + expect(allClasses.has('hover:bg-gray-200')).toBeTruthy() + expect(allClasses.has('hover:active')).toBeFalsy() + expect(modifierClasses.has('hover:active')).toBeFalsy() + }) + it('should ignore empty classes', () => { const code = '
Content
' const allClasses = new Set() @@ -401,6 +421,101 @@ describe('core module', () => { expect(classes.has('hover:bg-gray-200')).toBeTruthy() }) + it('should not rewrite comparison string operands inside JSX expressions', () => { + const code + = `
X
` + const classes = new Set() + + const result = transformClassModifiers( + code, + classes, + REACT_CLASS_MODIFIER_REGEX, + 'className', + ) + + expect(result).toBe( + `
X
`, + ) + expect(classes.has('hover:bg-blue-500')).toBeTruthy() + expect(classes.has('hover:bg-gray-200')).toBeTruthy() + expect(classes.has('hover:active')).toBeFalsy() + expect(result).toContain(`=== 'active'`) + expect(result).not.toContain(`=== 'hover:active'`) + }) + + it('should not rewrite string method receivers inside JSX expressions', () => { + const code + = `
X
` + const classes = new Set() + + const result = transformClassModifiers( + code, + classes, + REACT_CLASS_MODIFIER_REGEX, + 'className', + ) + + expect(result).toBe( + `
X
`, + ) + expect(classes.has('hover:primary')).toBeFalsy() + expect(classes.has('hover:bg-blue-500')).toBeTruthy() + }) + + it('should allow whitespace around = and { on JSX modifiers', () => { + const code + = `
X
` + const classes = new Set() + + const result = transformClassModifiers( + code, + classes, + REACT_CLASS_MODIFIER_REGEX, + 'className', + ) + + expect(result).toBe( + `
X
`, + ) + expect(classes.has('hover:bg-blue-500')).toBeTruthy() + expect(classes.has('hover:bg-gray-200')).toBeTruthy() + }) + + it('should ignore string literals inside comments in JSX expressions', () => { + const withBlock + = `
X
` + const withLine = `
X
` + const classes = new Set() + + const blockResult = transformClassModifiers( + withBlock, + classes, + REACT_CLASS_MODIFIER_REGEX, + 'className', + ) + expect(blockResult).toBe( + `
X
`, + ) + expect(classes.has('hover:skip-me')).toBeFalsy() + expect(classes.has('hover:bg-blue-500')).toBeTruthy() + + classes.clear() + const lineResult = transformClassModifiers( + withLine, + classes, + REACT_CLASS_MODIFIER_REGEX, + 'className', + ) + expect(lineResult).toContain(`// 'skip-me'`) + expect(lineResult).toContain(`'hover:bg-blue-500'`) + expect(lineResult).not.toContain(`'hover:skip-me'`) + expect(classes.has('hover:skip-me')).toBeFalsy() + expect(classes.has('hover:bg-blue-500')).toBeTruthy() + }) + it('should transform logical && className:modifier expressions', () => { const code = `` const classes = new Set() diff --git a/tasks/lessons.md b/tasks/lessons.md new file mode 100644 index 0000000..cafa781 --- /dev/null +++ b/tasks/lessons.md @@ -0,0 +1,7 @@ +# Lessons + +## JSX conditional class rewrites (2026-07-21) + +- When rewriting string literals inside `className:modifier={…}`, never blindly prefix every quoted string. +- Comparison operands (`===` / `!==` / `==` / `!=`) and string method receivers (`'x'.includes`) must stay untouched. +- Always add a regression test for `status === 'active' ? 'bg-a' : 'bg-b'` when changing the JSX expression rewriter.