From ddccbd89d4853fc7f87fbc421a6e072d63cf87b2 Mon Sep 17 00:00:00 2001 From: Rahim Date: Wed, 24 Jun 2026 13:04:37 -0700 Subject: [PATCH] fix(compiler): preserve selector-scoped tailwind vars --- packages/compiler/src/tailwind/emit.ts | 7 +- .../compiler/src/tailwind/tests/emit.test.ts | 11 + .../src/tailwind/tests/output-modes.test.ts | 285 ++++++++++++++++++ 3 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/src/tailwind/tests/output-modes.test.ts diff --git a/packages/compiler/src/tailwind/emit.ts b/packages/compiler/src/tailwind/emit.ts index 3df66d7a..d207163b 100644 --- a/packages/compiler/src/tailwind/emit.ts +++ b/packages/compiler/src/tailwind/emit.ts @@ -411,6 +411,8 @@ interface EmitUnit { atRulePath: readonly string[]; /** Selector text for the rule (e.g. `.play-button:hover` or `.play-button`). */ selector: string; + /** Whether this unit targets the base class selector with no selector variants. */ + isBaseSelector: boolean; /** Declarations to emit inside the rule. */ declarations: readonly Declaration[]; } @@ -438,6 +440,7 @@ function composeRules( entry = { atRulePath: u.atRulePath, selector: u.selector, + isBaseSelector: u.isBaseSelector, declarations: [], declSet: new Set(), }; @@ -536,7 +539,7 @@ function applyHoist( const rootValues = new Map>(); const allValues = new Map>(); for (const entry of merged.values()) { - const isRoot = entry.atRulePath.length === 0; + const isRoot = entry.atRulePath.length === 0 && entry.isBaseSelector; for (const d of entry.declarations) { if (!d.property.startsWith('--')) continue; const all = allValues.get(d.property) ?? new Set(); @@ -584,6 +587,7 @@ function applyHoist( rootEntry = { atRulePath: [], selector: rootSelector, + isBaseSelector: true, declarations: [], declSet: new Set(), }; @@ -834,6 +838,7 @@ function buildEmitUnit(className: string, branch: UtilityCssBranch): EmitUnit { return { atRulePath, selector: `.${className}${selectorTail}`, + isBaseSelector: selectorTail.length === 0, declarations: branch.declarations, }; } diff --git a/packages/compiler/src/tailwind/tests/emit.test.ts b/packages/compiler/src/tailwind/tests/emit.test.ts index e10f4e97..4b1650f6 100644 --- a/packages/compiler/src/tailwind/tests/emit.test.ts +++ b/packages/compiler/src/tailwind/tests/emit.test.ts @@ -358,6 +358,17 @@ describe('emitCss — hoist', () => { expect(/\.skin\s*{[^}]*--media-error-dialog-transition-duration/.test(out.css)).toBe(false); }); + it('does not hoist a property that only appears on a selector variant', async () => { + const fullscreenVariant = { kind: 'pseudo' as const, selector: ':fullscreen', raw: ':fullscreen' }; + const out = await emitCss({ + rules: [rule('a', [{ property: '--media-border-radius', value: '0' }], [fullscreenVariant])], + hoist: { rootSelector: '.skin' }, + }); + if (out.kind !== 'merged') throw new Error('expected merged'); + expect(out.css).toMatch(/\.a:fullscreen\s*{[^}]*--media-border-radius:\s*0/); + expect(/\.skin\s*{[^}]*--media-border-radius/.test(out.css)).toBe(false); + }); + it('only hoists CSS custom properties (no plain props)', async () => { const out = await emitCss({ rules: [rule('a', [{ property: 'color', value: 'red' }]), rule('b', [{ property: 'color', value: 'red' }])], diff --git a/packages/compiler/src/tailwind/tests/output-modes.test.ts b/packages/compiler/src/tailwind/tests/output-modes.test.ts new file mode 100644 index 00000000..0d664ca0 --- /dev/null +++ b/packages/compiler/src/tailwind/tests/output-modes.test.ts @@ -0,0 +1,285 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { compile as compileSource } from '../../compile'; +import { clearTokenModuleCache } from '../evaluator'; +import { tailwind } from '../plugin'; + +const MINIMAL_CSS = ` +@import "tailwindcss"; + +@theme { + --color-brand: oklch(0.7 0.2 250); +} +`; + +const TOKENS = ` +import { cn } from '@videojs/utils/style'; + +export const styles = { + controls: cn( + 'flex flex-wrap', + 'pointer-fine:transition-[scale,filter,opacity]', + 'motion-reduce:[--media-controls-transition-duration:50ms]', + 'contrast-more:[--media-surface-background-color:oklch(0_0_0)]', + '[@media(prefers-reduced-transparency:reduce)]:[--media-surface-background-color:oklch(0_0_0)]', + '@2xl/media-root:flex-nowrap' + ), + button: cn( + 'group', + 'flex items-center', + 'disabled:opacity-50', + 'data-[availability=unsupported]:hidden', + 'aria-expanded:bg-current/10', + 'focus-visible:outline-current' + ), + icon: cn( + 'hidden opacity-0', + 'group-data-paused:block', + 'group-data-paused:opacity-100', + 'group-not-data-paused:opacity-0' + ), + thumbnail: cn( + 'absolute', + '[left:var(--media-slider-pointer)]', + 'has-[[role=img]:not([data-hidden])]:opacity-100' + ), +}; +`; + +const SOURCE = ` +import { styles } from './tokens'; + +export function Fixture() { + return ( + + + +
+ + + ); +} +`; + +let workDir: string; +let sourcePath: string; +let cssPath: string; + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'compiler-output-modes-')); + sourcePath = writeFixture('skin.tsx', SOURCE); + cssPath = writeFixture('tailwind.css', MINIMAL_CSS); + writeFixture('tokens.ts', TOKENS); +}); + +afterEach(() => { + clearTokenModuleCache(); +}); + +describe('tailwind output modes', () => { + it('inlines static token-backed utilities without emitting CSS assets', async () => { + const result = await compileSource(SOURCE, { + filename: sourcePath, + configDir: workDir, + config: { plugins: [tailwind({ mode: 'inline' })] }, + }); + + expect(result.assets).toEqual([]); + expect(result.code).not.toContain('styles.'); + expect(result.code).toMatchInlineSnapshot(` + "export function Fixture() { + return ( + + +
+ + ); + } + " + `); + }); + + it('extracts static utilities to scoped vanilla CSS assets', async () => { + const result = await compileSource(SOURCE, { + filename: sourcePath, + configDir: workDir, + config: { + plugins: [ + tailwind({ + mode: 'extract', + input: cssPath, + vars: { hoist: { rootSelector: '.media-test-skin' } }, + }), + ], + }, + }); + + expect(result.assets).toHaveLength(1); + expect(result.assets[0]).toMatchObject({ type: 'css', fileName: 'skin.css' }); + expect(result.code).toMatchInlineSnapshot(` + "export function Fixture() { + return ( + + +
+ + ); + } + " + `); + + const css = result.assets[0]!.source; + expect(css).not.toMatch(/\.media-test-skin\s*{[^}]*--media-border-radius/); + expect(css).toMatchInlineSnapshot(` + ".media-test-skin { + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --spacing: 0.25rem; + } + + .media-test-skin { + --media-popover-side-offset: 0.5rem; + } + + .button { + align-items: center; + display: flex; + } + + .button:disabled { + opacity: 50%; + } + + .button:focus-visible { + outline-color: currentcolor; + } + + .button[aria-expanded="true"] { + background-color: color-mix(in oklab, currentcolor 10%, transparent); + } + + .button[data-availability="unsupported"] { + display: none; + } + + .controls { + display: flex; + flex-wrap: wrap; + } + + .fixture-root { + --media-controls-transition-duration: 100ms; + color: var(--media-color-primary,oklch(1 0 0)); + padding: calc(var(--spacing) * 4); + width: 100%; + } + + .fixture-root video { + display: block; + } + + .fixture-root:fullscreen { + --media-border-radius: 0; + } + + .icon { + display: none; + opacity: 0%; + } + + .icon:is(:where(.group):not(*[data-paused]) *) { + opacity: 0%; + } + + .icon:is(:where(.group)[data-paused] *) { + display: block; + opacity: 100%; + } + + .thumbnail { + left: var(--media-slider-pointer); + position: absolute; + } + + .thumbnail:has(*:is([role=img]:not([data-hidden]))) { + opacity: 100%; + } + + @container media-root (width >= 42rem) { + .controls { + flex-wrap: nowrap; + } + } + + @media (pointer: fine) { + .controls { + transition-duration: var(--tw-duration, var(--default-transition-duration)); + transition-property: scale,filter,opacity; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + } + } + + @media (prefers-contrast: more) { + .controls { + --media-surface-background-color: oklch(0 0 0); + } + } + + @media (prefers-reduced-motion: reduce) { + .controls { + --media-controls-transition-duration: 50ms; + } + } + + @media (prefers-reduced-transparency:reduce) { + .controls { + --media-surface-background-color: oklch(0 0 0); + } + } + + @media (width >= 40rem) { + .fixture-root { + max-width: 40rem; + } + } + + @media (width >= 48rem) { + .fixture-root { + max-width: 48rem; + } + } + + @media (width >= 64rem) { + .fixture-root { + max-width: 64rem; + } + } + + @media (width >= 80rem) { + .fixture-root { + max-width: 80rem; + } + } + + @media (width >= 96rem) { + .fixture-root { + max-width: 96rem; + } + }" + `); + }); +}); + +function writeFixture(relative: string, content: string): string { + const absolute = join(workDir, relative); + writeFileSync(absolute, content, 'utf8'); + return absolute; +}