From 9d9946a5f782214dea0d2271037457c3243fac90 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 18:21:31 +0000 Subject: [PATCH 1/5] feat(react): add mods() for @ and named-group variants JSX cannot parse className:@md or className:group-hover/item. Keep the real Tailwind names via mods() / useMods() / classy.mods and scan those maps into the class manifest instead of inventing substitute characters. Co-authored-by: Jeremy Butler --- README.md | 36 ++- demos/react/src/App.tsx | 25 +- demos/react/vite.config.ts | 1 + src/core.ts | 271 +++++++++++++++++- src/init/agents.ts | 2 +- src/react.ts | 76 ++++- src/tests/core.test.ts | 36 +++ src/tests/react.test.ts | 63 +++- tasks/lessons.md | 6 +- templates/useclassy-authoring.cursor-rule.mdc | 2 +- templates/useclassy-skill/SKILL.md | 27 +- 11 files changed, 495 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index e493c63..8750eed 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,14 @@ npx vite-plugin-useclassy init `init` patches Vite and your CSS engine, plus VS Code IntelliSense for Tailwind. Run it from the app root (the folder with `package.json` and `vite.config.*`). -| Option | Default | Notes | -| ---------------- | ------------ | ---------------------------------------------------------------------------------- | -| `--language` | `'vue'` | `'vue'` \| `'react'` \| `'blade'` \| `'svelte'` | -| `--engine` | auto-detect | `'tailwind'` \| `'unocss'`; Tailwind wins if both are installed | -| `--with-skills` | `false` | Agent skill, Cursor rules, and `AGENTS.md` | -| `--with-claude` | `false` | Also copy to `.claude/skills/` (requires `--with-skills`) | -| `--force` | `false` | Overwrite locally edited skill files | -| `--dry-run` | `false` | Print planned edits | +| Option | Default | Notes | +| --------------- | ----------- | --------------------------------------------------------------- | +| `--language` | `'vue'` | `'vue'` \| `'react'` \| `'blade'` \| `'svelte'` | +| `--engine` | auto-detect | `'tailwind'` \| `'unocss'`; Tailwind wins if both are installed | +| `--with-skills` | `false` | Agent skill, Cursor rules, and `AGENTS.md` | +| `--with-claude` | `false` | Also copy to `.claude/skills/` (requires `--with-skills`) | +| `--force` | `false` | Overwrite locally edited skill files | +| `--dry-run` | `false` | Print planned edits | If detection fails, follow the [manual setup](#vite) below. @@ -58,6 +58,24 @@ If detection fails, follow the [manual setup](#vite) below. Expressions with no string literals (`className:hover={hoverClasses}`) are left alone. Import types with `import 'vite-plugin-useclassy/react'` (or `ClassyProps`). React 18/19 is an optional peer, only needed for those helpers. +JSX cannot parse `@` or `/` in attribute names, so container queries and named groups use `mods()` with the **same** Tailwind names: + +```tsx +import { classy, mods } from 'vite-plugin-useclassy/react' + +;
+``` + +`mods` is also available as `classy.mods`. The plugin scans these maps into the class manifest so Tailwind/UnoCSS still see `@md:p-6` and `group-hover/item:bg-red-500`. **Svelte.** Quoted modifiers transform; native directives do not. Put UseClassy before `@sveltejs/vite-plugin-svelte`. ```svelte @@ -70,7 +88,7 @@ Expressions with no string literals (`className:hover={hoverClasses}`) are left `class:sm:hover="underline"` emits `sm:hover:underline` only, the same composition as Tailwind / UnoCSS, not the individual `sm:` and `hover:` pieces. -Modifier names may include letters, digits, `_`, `-`, `:`, `/` (`group-hover/item`), and `@` (`@md`). Arbitrary variants (`[&>*]`, `data-[state=open]`) cannot be attribute names, so leave those on the base class. In React JSX, `/` is invalid in an attribute name, so named groups stay on `className`. +Modifier names may include letters, digits, `_`, `-`, `:`, `/` (`group-hover/item`), and `@` (`@md`). Arbitrary variants (`[&>*]`, `data-[state=open]`) cannot be attribute names, so leave those on the base class (Vue/HTML/Svelte) or pass them through React `mods({ '[&>*]': 'mt-2' })`. In React JSX, `/` and `@` are invalid in attribute names — use `mods({ '@md': '…', 'group-hover/item': '…' })` instead of inventing substitute characters. ## Vite diff --git a/demos/react/src/App.tsx b/demos/react/src/App.tsx index d05085b..d9dd401 100644 --- a/demos/react/src/App.tsx +++ b/demos/react/src/App.tsx @@ -1,5 +1,6 @@ import { useState, type ReactNode } from "react"; import { twMerge } from "tailwind-merge"; +import { classy, mods } from "vite-plugin-useclassy/react"; /** Tiny class-map helper to exercise nested-brace rewrites (`cn({ '…': cond })`). */ function cn(map: Record): string { @@ -32,8 +33,9 @@ function App() {

Smoke coverage for quoted modifiers, conditionals, comparison - operands, nested braces, and chained modifiers. Chained attributes - match Tailwind variant composition (`sm:hover:underline` only). + operands, nested braces, chained modifiers, and{" "} + mods() for{" "} + @md / named groups.

@@ -184,6 +186,25 @@ function App() {

+ +
+
+ Hover the card · resize the container for @md +
+
+
+ {/* Nested modifiers */} *]`, `data-[open]`) still cannot * be attribute names — `[` / `]` / `&` are not allowed, and JSX cannot parse - * `/` in `className:group-hover/item` (keep those tokens on the base class). + * `/` or `@` in `className:…` attributes. In React, use `mods({ '@md': '…', + * 'group-hover/item': '…' })` so the real Tailwind names stay unchanged. */ export const CLASS_MODIFIER_NAME_PATTERN = String.raw`[\w/:@-]+` @@ -464,6 +465,271 @@ function forEachJsxModifier( } } +/** + * Start of a React `mods({…})` / `useMods({…})` / `classy.mods({…})` call. + * Used to pull `@md` / `group-hover/item` (and other non-JSX attribute names) + * into the Tailwind/Uno class manifest. + */ +const MODS_CALL_START_REGEX = /\b(?:classy\s*\.\s*)?(?:useMods|mods)\s*\(\s*\{/g + +/** + * Reads one object-literal entry value starting at `start` (first non-space of + * the value). Stops at a top-level `,` or end of the object body. + */ +function readObjectEntryValue( + code: string, + start: number, +): { value: string, endIndex: number } | null { + let i = start + while (i < code.length && /\s/.test(code[i]!)) + i++ + if (i >= code.length) + return null + + const valueStart = i + let depthBrace = 0 + let depthParen = 0 + let depthBracket = 0 + let inQuote: '"' | '\'' | null = null + let inTemplate = false + let inLineComment = false + let inBlockComment = false + + for (; i < code.length; i++) { + const ch = code[i]! + const next = code[i + 1] + + if (inLineComment) { + if (ch === '\n') + inLineComment = false + continue + } + if (inBlockComment) { + if (ch === '*' && next === '/') { + inBlockComment = false + i++ + } + continue + } + if (inQuote) { + if (ch === '\\') { + i++ + continue + } + if (ch === inQuote) + inQuote = null + continue + } + if (inTemplate) { + if (ch === '\\') { + i++ + continue + } + if (ch === '`') { + inTemplate = false + continue + } + if (ch === '$' && next === '{') { + const nested = readBalancedJsxExpression(code, i + 1) + if (!nested) + return null + i = nested.endIndex + } + continue + } + + if (ch === '"' || ch === '\'') { + inQuote = ch + continue + } + if (ch === '`') { + inTemplate = true + continue + } + if (ch === '/' && next === '/') { + inLineComment = true + i++ + continue + } + if (ch === '/' && next === '*') { + inBlockComment = true + i++ + continue + } + + if (ch === '{') { + depthBrace++ + continue + } + if (ch === '}') { + if (depthBrace === 0 && depthParen === 0 && depthBracket === 0) { + return { value: code.slice(valueStart, i).trim(), endIndex: i } + } + depthBrace-- + continue + } + if (ch === '(') { + depthParen++ + continue + } + if (ch === ')') { + depthParen-- + continue + } + if (ch === '[') { + depthBracket++ + continue + } + if (ch === ']') { + depthBracket-- + continue + } + if (ch === ',' && depthBrace === 0 && depthParen === 0 && depthBracket === 0) { + return { value: code.slice(valueStart, i).trim(), endIndex: i } + } + } + + // Last entry in a mods({…}) body has no trailing comma or `}` (braces are stripped). + return { value: code.slice(valueStart).trim(), endIndex: code.length } +} + +/** + * Walks a `mods({…})` object literal and records prefixed utilities for the + * class manifest. Handles quoted keys (`'@md'`, `"group-hover/item"`), bare + * identifier keys (`hover`), and expression values with string literals. + */ +export function extractModsObjectClasses( + objectBody: string, + allFileClasses: Set, + modifierDerivedClasses: Set, +): void { + let i = 0 + while (i < objectBody.length) { + while (i < objectBody.length && /[\s,]/.test(objectBody[i]!)) + i++ + if (i >= objectBody.length) + break + + // Skip comments between entries. + if (objectBody[i] === '/' && objectBody[i + 1] === '/') { + i += 2 + while (i < objectBody.length && objectBody[i] !== '\n') + i++ + continue + } + if (objectBody[i] === '/' && objectBody[i + 1] === '*') { + i += 2 + while (i < objectBody.length && !(objectBody[i] === '*' && objectBody[i + 1] === '/')) + i++ + i += 2 + continue + } + + let modifier: string | null = null + + if (objectBody[i] === '"' || objectBody[i] === '\'') { + const quote = objectBody[i]! + let j = i + 1 + let key = '' + while (j < objectBody.length) { + if (objectBody[j] === '\\' && j + 1 < objectBody.length) { + key += objectBody[j + 1] + j += 2 + continue + } + if (objectBody[j] === quote) + break + key += objectBody[j] + j++ + } + if (j >= objectBody.length) + break + modifier = key + i = j + 1 + } + else if (/[A-Za-z_$]/.test(objectBody[i]!)) { + let j = i + 1 + while (j < objectBody.length && /[\w$]/.test(objectBody[j]!)) + j++ + modifier = objectBody.slice(i, j) + i = j + } + else { + // Unknown token — advance one char to avoid a tight loop. + i++ + continue + } + + while (i < objectBody.length && /\s/.test(objectBody[i]!)) + i++ + if (objectBody[i] !== ':') + continue + i++ + + const entry = readObjectEntryValue(objectBody, i) + if (!entry || !modifier?.trim()) + break + + const { value, endIndex } = entry + i = endIndex + + if (!value || value === 'false' || value === 'null' || value === 'undefined') + continue + + // Static string value: 'p-4' / "p-4" + if ( + (value.startsWith('\'') && value.endsWith('\'')) + || (value.startsWith('"') && value.endsWith('"')) + ) { + const classes = value.slice(1, -1) + for (const modifiedClass of buildModifiedClasses(classes, modifier)) { + if (!isTrackedGeneratedClass(modifiedClass)) + continue + allFileClasses.add(modifiedClass) + modifierDerivedClasses.add(modifiedClass) + } + continue + } + + // Expression value: reuse the JSX literal rewriter for string tokens only. + rewriteClassLiteralsInExpression(value, modifier, (cls) => { + allFileClasses.add(cls) + modifierDerivedClasses.add(cls) + }) + } +} + +/** + * Finds `mods({…})` / `useMods({…})` / `classy.mods({…})` calls and extracts + * prefixed utilities into the manifest sets. + */ +export function extractModsCallClasses( + code: string, + allFileClasses: Set, + modifierDerivedClasses: Set, +): void { + if (!code.includes('mods')) + return + + MODS_CALL_START_REGEX.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = MODS_CALL_START_REGEX.exec(code)) !== null) { + const openBraceIndex = match.index + match[0].length - 1 + const balanced = readBalancedJsxExpression(code, openBraceIndex) + if (!balanced) { + MODS_CALL_START_REGEX.lastIndex = openBraceIndex + 1 + continue + } + + extractModsObjectClasses( + balanced.content, + allFileClasses, + modifierDerivedClasses, + ) + MODS_CALL_START_REGEX.lastIndex = balanced.endIndex + 1 + } +} + /** * Extracts classes from the code, separating base classes and modifier-derived classes. */ @@ -525,6 +791,9 @@ export function extractClasses( modifierDerivedClasses.add(cls) }) }) + + // React mods({ '@md': '…', 'group-hover/item': '…' }) — names JSX cannot attribute + extractModsCallClasses(code, allFileClasses, modifierDerivedClasses) } function isTrackedGeneratedClass(cls: string): boolean { diff --git a/src/init/agents.ts b/src/init/agents.ts index fc48f9e..9d3692d 100644 --- a/src/init/agents.ts +++ b/src/init/agents.ts @@ -66,7 +66,7 @@ This project uses \`vite-plugin-useclassy\`. Write Tailwind variants as modifier attributes instead of inline variant prefixes: - Vue, Svelte, Blade, HTML: \`class="rounded px-4" class:hover="bg-blue-500"\` -- React: \`className="rounded px-4" className:hover="bg-blue-500"\` (JSX expressions with string literals are also supported, e.g. \`className:hover={on ? 'a' : 'b'}\`) +- React: \`className="rounded px-4" className:hover="bg-blue-500"\` (JSX expressions with string literals are also supported, e.g. \`className:hover={on ? 'a' : 'b'}\`). For \`@md\` / \`group-hover/item\` (invalid in JSX attribute names), use \`mods({ '@md': 'p-6', 'group-hover/item': 'bg-red-500' })\` — keep the real Tailwind names. Leave Vue \`:class\`, native Svelte \`class:name={cond}\` directives, and unrelated dynamic base expressions unchanged. diff --git a/src/react.ts b/src/react.ts index e50453d..e180026 100644 --- a/src/react.ts +++ b/src/react.ts @@ -16,10 +16,14 @@ declare module 'react' { } } -type ClassyArg = - | string - | Record - | (string | Record)[] +type ClassyArg = string | Record | (string | Record)[] + +/** + * Modifier map for React when the variant name is not a valid JSX attribute + * (`@md`, `group-hover/item`, arbitrary variants, etc.). Keys are real Tailwind + * / UnoCSS variant names — the same spelling as in CSS utilities. + */ +export type ModsMap = Record /** * Props helper for components that accept UseClassy modifier attributes. @@ -32,9 +36,20 @@ export type ClassyProps = TProps & { className?: string } +function tokenizeClasses(str: string, callback: (token: string) => void): void { + let start = 0 + const len = str.length + for (let i = 0; i <= len; i++) { + const ch = str[i] + if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || i === len) { + if (i > start) callback(str.substring(start, i)) + start = i + 1 + } + } +} + function classStringFromArg(arg: ClassyArg): string { - if (typeof arg === 'string') - return arg + if (typeof arg === 'string') return arg if (arg && typeof arg === 'object' && !Array.isArray(arg)) { return Object.entries(arg) @@ -46,7 +61,10 @@ function classStringFromArg(arg: ClassyArg): string { if (Array.isArray(arg)) { // `ClassyArg` arrays only contain string | Record items (no nested arrays), // so this recursion is bounded to one level deep. - return arg.map(item => classStringFromArg(item)).filter(Boolean).join(' ') + return arg + .map((item) => classStringFromArg(item)) + .filter(Boolean) + .join(' ') } return '' @@ -56,6 +74,39 @@ function joinClassyArgs(parts: ClassyArg[]): string { return parts.map(classStringFromArg).filter(Boolean).join(' ') } +/** + * Prefix class tokens with real Tailwind/UnoCSS variant names. + * Use this in React when JSX cannot spell the modifier as an attribute + * (`@md`, `group-hover/item`, `[&>*]`, …): + * + * ```tsx + *
+ * ``` + * + * Prefer `className:hover` (and other JSX-valid attributes) when the name is legal. + */ +export function mods(map: ModsMap): string { + const parts: string[] = [] + for (const [modifier, value] of Object.entries(map)) { + if (value === false || value === null || value === undefined || value === '') continue + if (!modifier.trim()) continue + tokenizeClasses(value, (token) => { + parts.push(`${modifier}:${token}`) + }) + } + return parts.join(' ') +} + +/** + * Memoized `mods` for React (same rules as `mods`). + */ +export function useMods(map: ModsMap): string { + // JSON.stringify provides value-based memoization; map is intentionally omitted + // from the dependency array because it changes reference on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => mods(map), [JSON.stringify(map)]) +} + /** * Memoized class string builder for React (same rules as `classy`). */ @@ -66,7 +117,12 @@ export function useClassy(...args: ClassyArg[]): string { return useMemo(() => joinClassyArgs(args), [JSON.stringify(args)]) } -/** Combine class names (strings, conditional maps, or nested arrays). */ -export function classy(...args: ClassyArg[]): string { - return joinClassyArgs(args) +export interface ClassyFn { + (...args: ClassyArg[]): string + mods: typeof mods } + +/** Combine class names (strings, conditional maps, or nested arrays). */ +export const classy: ClassyFn = Object.assign((...args: ClassyArg[]) => joinClassyArgs(args), { + mods, +}) diff --git a/src/tests/core.test.ts b/src/tests/core.test.ts index 9aad452..a034285 100644 --- a/src/tests/core.test.ts +++ b/src/tests/core.test.ts @@ -332,6 +332,42 @@ describe('core module', () => { expect(modifierClasses.has('dark:bg-gray-800')).toBeTruthy() expect(modifierClasses.has('dark:text-gray-500')).toBeFalsy() }) + + it('extracts React mods() maps with @ and / modifier names', () => { + const code = ` + import { classy, mods } from 'vite-plugin-useclassy/react' + const cn = classy( + 'rounded', + mods({ + '@md': 'p-4 text-base', + 'group-hover/item': 'bg-red-500', + hover: isOn && 'underline', + }), + ) + const again = classy.mods({ '@lg': 'gap-8' }) + const memo = useMods({ 'sm:hover': on ? 'scale-105' : 'scale-100' }) + ` + const allClasses = new Set() + const modifierClasses = new Set() + + extractClasses( + code, + allClasses, + modifierClasses, + REACT_CLASS_REGEX, + REACT_CLASS_MODIFIER_REGEX, + ) + + expect(allClasses.has('@md:p-4')).toBeTruthy() + expect(allClasses.has('@md:text-base')).toBeTruthy() + expect(allClasses.has('group-hover/item:bg-red-500')).toBeTruthy() + expect(allClasses.has('hover:underline')).toBeTruthy() + expect(allClasses.has('@lg:gap-8')).toBeTruthy() + expect(allClasses.has('sm:hover:scale-105')).toBeTruthy() + expect(allClasses.has('sm:hover:scale-100')).toBeTruthy() + expect(modifierClasses.has('@md:p-4')).toBeTruthy() + expect(modifierClasses.has('group-hover/item:bg-red-500')).toBeTruthy() + }) }) describe('transformClassModifiers', () => { diff --git a/src/tests/react.test.ts b/src/tests/react.test.ts index d6905f6..2bceae4 100644 --- a/src/tests/react.test.ts +++ b/src/tests/react.test.ts @@ -8,7 +8,7 @@ vi.mock('react', async () => { } }) -import { classy, useClassy } from '../react' +import { classy, useClassy, mods, useMods } from '../react' describe('classy', () => { describe('string arguments', () => { @@ -49,32 +49,75 @@ describe('classy', () => { }) it('should combine mixed string and object array items', () => { - expect(classy(['px-4', { 'text-red-500': true, 'text-blue-500': false }, 'py-2'])) - .toBe('px-4 text-red-500 py-2') + expect(classy(['px-4', { 'text-red-500': true, 'text-blue-500': false }, 'py-2'])).toBe( + 'px-4 text-red-500 py-2', + ) }) }) describe('mixed argument types', () => { it('should combine strings and objects at the top level', () => { - expect(classy('flex', { 'items-center': true, hidden: false }, 'gap-2')) - .toBe('flex items-center gap-2') + expect(classy('flex', { 'items-center': true, hidden: false }, 'gap-2')).toBe( + 'flex items-center gap-2', + ) }) it('should combine strings and arrays', () => { expect(classy('flex', ['items-center', 'gap-2'])).toBe('flex items-center gap-2') }) + + it('should combine mods() output with other classy args', () => { + expect(classy('rounded', mods({ '@md': 'p-4' }), { hidden: false })).toBe('rounded @md:p-4') + }) + }) +}) + +describe('mods', () => { + it('prefixes tokens with real Tailwind modifier names including @ and /', () => { + expect( + mods({ + '@md': 'p-4 text-base', + 'group-hover/item': 'bg-red-500', + }), + ).toBe('@md:p-4 @md:text-base group-hover/item:bg-red-500') + }) + + it('skips falsy values', () => { + expect( + mods({ + '@md': 'p-4', + 'group-hover/item': false, + hover: null, + focus: undefined, + dark: '', + }), + ).toBe('@md:p-4') + }) + + it('is available as classy.mods', () => { + expect(classy.mods({ '@md': 'p-6' })).toBe('@md:p-6') + }) + + it('supports chained modifier keys', () => { + expect(mods({ 'sm:hover': 'underline' })).toBe('sm:hover:underline') }) }) describe('useClassy', () => { it('should match classy for the same arguments', () => { - expect(useClassy('foo', { bar: true }, ['baz'])).toBe( - classy('foo', { bar: true }, ['baz']), - ) + expect(useClassy('foo', { bar: true }, ['baz'])).toBe(classy('foo', { bar: true }, ['baz'])) }) it('should join mixed class arguments', () => { - expect(useClassy('px-4', { 'text-red-500': true, hidden: false }, 'py-2')) - .toBe('px-4 text-red-500 py-2') + expect(useClassy('px-4', { 'text-red-500': true, hidden: false }, 'py-2')).toBe( + 'px-4 text-red-500 py-2', + ) + }) +}) + +describe('useMods', () => { + it('should match mods for the same map', () => { + const map = { '@md': 'p-4', 'group-hover/item': 'opacity-100' as string | false } + expect(useMods(map)).toBe(mods(map)) }) }) diff --git a/tasks/lessons.md b/tasks/lessons.md index 1f3afcb..420bf4a 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -75,7 +75,11 @@ - Do not rewrite UseClassy smoke demos into a polished fictional product UI (Harbor-style inbox, design-system cards, etc.) unless the user has approved a mock after seeing it. - Coverage pages can stay labeled and a bit clinical; that is easier to scan than a realistic layout that hides the cases. Prefer smaller visual cleanup (copy, titles, spacing) over a full scene rewrite. -## JSX conditional class rewrites (2026-07-21) +## React `@` / `/` modifiers (2026-08-25) + +- JSX attribute names cannot contain `@` or `/`, so `className:@md` and `className:group-hover/item` will never parse. +- Do not invent substitute characters (`$md`, `at-md`, `group-hover__item`). That fights Tailwind muscle memory. +- Use `mods({ '@md': '…', 'group-hover/item': '…' })` (also `classy.mods` / `useMods`) so keys keep the real variant names. Scan those maps into the class manifest. - When rewriting string literals inside `className:modifier={…}`, never blindly prefix every quoted string. - Comparison operands (`===` / `!==` / `==` / `!=`) and string method receivers (`'x'.includes`) must stay untouched. diff --git a/templates/useclassy-authoring.cursor-rule.mdc b/templates/useclassy-authoring.cursor-rule.mdc index a33d8c4..fb4331d 100644 --- a/templates/useclassy-authoring.cursor-rule.mdc +++ b/templates/useclassy-authoring.cursor-rule.mdc @@ -34,6 +34,6 @@ When this project uses `vite-plugin-useclassy`, write new static Tailwind varian - React: prefer double-quoted static strings; `className:mod={cond ? 'a' : 'b'}` is also valid when literals should be prefixed. - Leave Vue `:class`, Svelte native `class:name={cond}`, and unrelated dynamic base expressions unchanged. - **Svelte**: only transform quoted UseClassy modifiers (`class:hover="…"`). Do not rewrite native `class:name={cond}` or `class:name`. -- Keep arbitrary variants such as `[&>*]:mt-2` and `data-[state=open]:block` in the base string. Named groups (`group-hover/item`) and `@md` container queries work as Vue/HTML modifier names; in React JSX, keep named groups on `className` because `/` is not a valid attribute-name character. +- Keep arbitrary variants such as `[&>*]:mt-2` and `data-[state=open]:block` in the base string, or pass them through React `mods({ '[&>*]': 'mt-2' })`. Named groups (`group-hover/item`) and `@md` container queries work as Vue/HTML modifier names. In React JSX, `/` and `@` are invalid in attribute names — use `mods({ '@md': '…', 'group-hover/item': '…' })` with the real Tailwind names (also `classy.mods` / `useMods`). Never rename `@` or `/` to other characters. - Chained attributes match Tailwind/Uno composition: `class:sm:hover="underline"` generates `sm:hover:underline` only. Converting `sm:hover:underline` to `class:sm:hover="underline"` is behavior-preserving. - After refactoring, run formatting and relevant tests/build; verify dynamic classes and rendered states are unchanged. diff --git a/templates/useclassy-skill/SKILL.md b/templates/useclassy-skill/SKILL.md index 8cf7bf7..9e74409 100644 --- a/templates/useclassy-skill/SKILL.md +++ b/templates/useclassy-skill/SKILL.md @@ -14,24 +14,20 @@ Use UseClassy to separate Tailwind variants from base utilities: ```html + ``` ## Syntax -| Language | Base | Modifiers | -|----------|------|-----------| -| Vue / Blade | `class="…"` | `class:hover="…"`, `class:sm:hover="…"` | -| React | `className="…"` | `className:hover="…"` (also accepts `class:…`); JSX expressions allowed | -| Svelte | `class="…"` | Quoted only: `class:hover="…"` | +| Language | Base | Modifiers | +| ----------- | --------------- | ----------------------------------------------------------------------- | +| Vue / Blade | `class="…"` | `class:hover="…"`, `class:sm:hover="…"` | +| React | `className="…"` | `className:hover="…"` (also accepts `class:…`); JSX expressions allowed | +| Svelte | `class="…"` | Quoted only: `class:hover="…"` | -Modifier names may contain letters, numbers, `_`, `-`, `:`, `/` (named groups such as `group-hover/item`), and `@` (container queries such as `@md`). Arbitrary variants (`[&>*]`, `data-[state=open]`) cannot be attribute names — leave those tokens on the base class. In React JSX, `/` in an attribute name is invalid, so named groups must stay on `className`. +Modifier names may contain letters, numbers, `_`, `-`, `:`, `/` (named groups such as `group-hover/item`), and `@` (container queries such as `@md`). Arbitrary variants (`[&>*]`, `data-[state=open]`) cannot be attribute names — leave those tokens on the base class (or React `mods({…})`). In React JSX, `/` and `@` are invalid in attribute names; keep the real Tailwind names via `mods({ '@md': 'p-4', 'group-hover/item': 'bg-red-500' })` (also `classy.mods` / `useMods`). Do not invent substitute characters for `@` or `/`. - **Vue / Blade / Svelte / HTML:** modifier values must be double-quoted static class strings. - **React:** prefer double-quoted static strings. JSX expressions are also supported when string literals inside the expression should receive the variant prefix, e.g. `className:hover={on ? 'bg-blue-500' : 'bg-gray-200'}`. @@ -53,7 +49,8 @@ When asked to convert markup to UseClassy: Convert only static tokens that can be represented safely. Do not rewrite: - Dynamic expressions, template interpolations, conditional class helpers, Vue `:class`, or Svelte directives — unless you are intentionally using React's `className:mod={…}` expression form with string literals. -- Arbitrary variant prefixes such as `[&>*]:mt-2` or `data-[state=open]:block`; their characters are not valid in a UseClassy modifier name. +- Arbitrary variant prefixes such as `[&>*]:mt-2` or `data-[state=open]:block`; their characters are not valid in a UseClassy modifier attribute name. On React, prefer `mods({ '[&>*]': 'mt-2' })` instead. +- In React, do not invent substitute characters for `@` or `/`. Use `mods({ '@md': '…', 'group-hover/item': '…' })`. - Variant tokens embedded in variables or function calls (leave those variables unchanged, or store already-prefixed class names). ## Chained modifiers @@ -69,7 +66,7 @@ class:sm:hover="underline" - Put base utilities on `class` / `className`. - Vue / Blade / HTML: use `class:modifier="…"`. -- React: prefer `className:modifier="…"` for static variants. For runtime conditions that still use string literals, `className:modifier={cond ? 'a' : 'b'}` is valid and will prefix those literals. Leave `className={…}` base expressions unchanged when they are unrelated. +- React: prefer `className:modifier="…"` for static variants. For runtime conditions that still use string literals, `className:modifier={cond ? 'a' : 'b'}` is valid and will prefix those literals. Leave `className={…}` base expressions unchanged when they are unrelated. For modifiers JSX cannot parse (`@md`, `group-hover/item`, arbitrary variants), use `mods({ '@md': '…', 'group-hover/item': '…' })` — keep the real Tailwind names; do not invent substitute characters. - Vue: leave `:class` and other dynamic bindings unchanged. - **Svelte**: only transform quoted UseClassy modifiers. Native `class:active={cond}` and `class:active` stay untouched — do not rewrite those. - Do not move conditional base utilities into modifier attributes on Vue/Svelte/Blade; UseClassy modifiers represent Tailwind variants. React is the exception for `className:mod={…}` expression values. From d3330b2904481da573c2a5a26c04abaab2883504 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 18:52:35 +0000 Subject: [PATCH 2/5] =?UTF-8?q?revert(react):=20drop=20mods()=20=E2=80=94?= =?UTF-8?q?=20use=20className:@md=20attributes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the same attribute spelling as Vue. UseClassy already rewrites className:@md and className:group-hover/item before JSX parse, matching className:sm:hover. Character substitution and a separate mods() helper both felt unnatural. Co-authored-by: Jeremy Butler --- README.md | 22 +- demos/react/src/App.tsx | 20 +- demos/react/vite.config.ts | 1 - src/core.ts | 274 +----------------- src/init/agents.ts | 2 +- src/react.ts | 76 +---- src/tests/core.test.ts | 77 ++--- src/tests/index.test.ts | 31 ++ src/tests/react.test.ts | 63 +--- tasks/lessons.md | 6 +- templates/useclassy-authoring.cursor-rule.mdc | 2 +- templates/useclassy-skill/SKILL.md | 19 +- 12 files changed, 122 insertions(+), 471 deletions(-) diff --git a/README.md b/README.md index 8750eed..113a978 100644 --- a/README.md +++ b/README.md @@ -53,29 +53,15 @@ If detection fails, follow the [manual setup](#vite) below. - + + +