fix(compiler): vanilla-css correctness — markers, collisions, theme vars

Three correctness fixes for the `vanilla-css` Tailwind target, each
previously untested:

1. Preserve marker utilities. Utilities that produce no declarations
   (`group`, `peer`, `group/<name>`) were silently dropped when rewriting
   `className` to the semantic name, breaking every descendant
   `group-*` / `peer-*` variant. They're now kept on the element
   (`"play-button group"`), alongside any other class Tailwind doesn't
   recognize.

2. Flag class-name collisions. Two distinct elements that derive the same
   class name but resolve to different utilities would have their
   conflicting declarations silently merged into one rule (e.g. the
   forward/back `seek-icon`, buffering vs preview `spinner-icon`,
   current/duration `time-value`). The plugin now tracks a per-name
   utility signature and throws a fixable DiagnosticError on mismatch,
   while identical recurrences (repeated `Tooltip.Popup`) pass cleanly.

3. Emit referenced theme variables. The generated CSS referenced
   `var(--spacing)`, `var(--color-*)`, `var(--ease-*)` etc. with nothing
   defining them. `emitCss` now accepts a `resolveThemeVar` resolver
   (backed by `design.resolveThemeVar`) and emits a leading, transitively
   resolved theme block — scoped to the skin's hoist root when set — so
   the output resolves without a separate Tailwind theme on the page.

Adds 11 tests (markers, collisions, theme emission incl. transitive,
already-declared, scoped, and back-compat). Compiler suite: 169 passing.

Note: `@property`-registered `--tw-*` slots (e.g. `--tw-content`) resolve
to undefined and are left for a follow-up.
This commit is contained in:
Rahim
2026-06-17 17:13:40 -07:00
parent d562279df1
commit 578f0ca3c4
5 changed files with 315 additions and 13 deletions
@@ -146,6 +146,66 @@ describe('tailwindPlugin — target: vanilla-css', () => {
expect(code).not.toContain('"flex items-center"');
});
it('preserves marker utilities (group/peer) alongside the derived name', () => {
const source = `function App(){ return <PlayButton className="group"/>; }`;
const { code } = compile(source, {
target: 'react',
plugins: [tailwindPlugin({ design, target: 'vanilla-css' })],
});
// `group` produces no declarations but is required by descendant
// `group-*` variants, so it must survive on the element.
expect(code).toContain('"play-button group"');
});
it('keeps markers and still emits rules for declaration-producing utilities', () => {
const source = `function App(){ return <PlayButton className={cn('flex', 'group')}/>; }`;
let captured: readonly CompiledRule[] | undefined;
const { code } = compile(source, {
target: 'react',
plugins: [
tailwindPlugin({
design,
target: 'vanilla-css',
onRules: (rules) => {
captured = rules;
},
}),
],
});
expect(code).toContain('"play-button group"');
expect(captured).toBeDefined();
expect(captured!.map((r) => r.className)).toContain('play-button');
expect(captured!.flatMap((r) => r.utility.declarations)).toContainEqual({ property: 'display', value: 'flex' });
});
it('preserves a marker while wrapping pass-through expressions in cn()', () => {
const source = `function App(){ return <PlayButton className={cn('group', extra)}/>; }`;
const { code } = compile(source, {
target: 'react',
plugins: [tailwindPlugin({ design, target: 'vanilla-css' })],
});
expect(code).toMatch(/cn\("play-button group",\s*extra\)/);
});
it('throws a diagnostic when two elements derive the same name with different styles', () => {
const source = `function App(){ return <div><SeekIcon className="flex"/><SeekIcon className="block"/></div>; }`;
expect(() =>
compile(source, {
target: 'react',
plugins: [tailwindPlugin({ design, target: 'vanilla-css' })],
})
).toThrow(/class name 'seek-icon' is derived from elements with different styles/);
});
it('does not flag identical recurrences of the same derived name', () => {
const source = `function App(){ return <div><PlayButton className="flex"/><PlayButton className="flex"/></div>; }`;
const { code } = compile(source, {
target: 'react',
plugins: [tailwindPlugin({ design, target: 'vanilla-css' })],
});
expect(code).toContain('"play-button"');
});
it('rewrites className to a token-path-derived name on a bare HTML element', () => {
const source = `function App(){ return <div className={styles.bufferingIndicator}/>; }`;
const { code } = compile(source, {
@@ -381,4 +441,27 @@ function App(){ return <PlayButton className={iconButton}/>; }`;
const css = await cssPromise;
expect(collapse(css)).toContain(collapse('.foo{display:flex;}'));
});
it('emits referenced theme variables in the onCss output', async () => {
// `p-4` lowers to `padding: calc(var(--spacing) * 4)` — the output must
// define `--spacing` so it resolves without a separate Tailwind theme.
const source = `function App(){ return <Foo className="p-4"/>; }`;
const cssPromise = new Promise<string>((resolve) => {
compile(source, {
target: 'react',
plugins: [
tailwindPlugin({
design,
target: 'vanilla-css',
hoistVars: { rootSelector: '[data-skin="x"]' },
onCss: (out) => {
if (out.kind === 'merged') resolve(out.css);
},
}),
],
});
});
const css = await cssPromise;
expect(css).toMatch(/\[data-skin="x"\]\s*{[^}]*--spacing:/);
});
});