From 3edc58653948bd13237809e247c0dfe68edb9a6c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:39:11 +0900 Subject: [PATCH 01/12] fix(wasm): enable Boa's js feature so vanilla-extract files stop panicking Boa's Context::default() reads std::time::Instant, which panics on wasm32-unknown-unknown without the js feature. Every .css.ts extraction crashed in the full WASM; the lite build is unaffected. Add a wasm-bindgen test and a bun regression that runs in CI. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- Cargo.lock | 14 ++++++++ bindings/devup-ui-wasm/Cargo.toml | 10 +++++- bindings/devup-ui-wasm/tests/wasm.rs | 13 +++++++ .../bun-plugin/__regression__/css-emit.bun.ts | 36 +++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 59af8ade9..bfd4eb3fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,6 +160,7 @@ dependencies = [ "float16", "futures-concurrency", "futures-lite", + "getrandom", "hashbrown 0.17.1", "icu_calendar", "icu_normalizer", @@ -188,6 +189,7 @@ dependencies = [ "thiserror", "time", "timezone_provider", + "web-time", "xsum", ] @@ -582,6 +584,7 @@ name = "devup-ui-wasm" version = "0.1.0" dependencies = [ "bimap", + "boa_engine", "console_error_panic_hook", "css", "extractor", @@ -2380,6 +2383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "js-sys", "libc", "num-conv", "num_threads", @@ -2624,6 +2628,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml index 9b61daaf9..aecbd6c33 100644 --- a/bindings/devup-ui-wasm/Cargo.toml +++ b/bindings/devup-ui-wasm/Cargo.toml @@ -15,7 +15,11 @@ crate-type = ["cdylib"] [features] default = ["vanilla-extract"] -vanilla-extract = ["extractor/vanilla-extract", "sheet/vanilla-extract"] +vanilla-extract = [ + "extractor/vanilla-extract", + "sheet/vanilla-extract", + "dep:boa_engine", +] [dependencies] wasm-bindgen = "0.2.128" @@ -39,6 +43,10 @@ serde-wasm-bindgen = "0.6.5" # `getrandom_backend="wasm_js"` cfg requires on wasm32 targets. # Keep this major version in sync with boa_engine's transitive getrandom. getrandom = { version = "0.4", features = ["wasm_js"] } +# Not used directly. The extractor evaluates `.css.ts` modules with Boa, whose +# `Context::default()` reads the clock. Without the `js` feature that clock is +# `std::time::Instant`, which panics on wasm32-unknown-unknown. +boa_engine = { version = "0.22", optional = true, features = ["js"] } [dev-dependencies] wasm-bindgen-test = "0.3.78" diff --git a/bindings/devup-ui-wasm/tests/wasm.rs b/bindings/devup-ui-wasm/tests/wasm.rs index 677639f4f..51176bdda 100644 --- a/bindings/devup-ui-wasm/tests/wasm.rs +++ b/bindings/devup-ui-wasm/tests/wasm.rs @@ -46,3 +46,16 @@ fn test_object_to_typography() { assert_eq!(typography.line_height.unwrap(), "1.5"); assert_eq!(typography.letter_spacing.unwrap(), "1px"); } + +#[allow(dead_code)] +#[wasm_bindgen_test] +fn test_extract_vanilla_extract_file() { + let output = extractor::extract( + "styles.css.ts", + "import { style } from '@devup-ui/react'\nexport const box = style({ color: 'red' })\n", + extractor::ExtractOption::default(), + ) + .unwrap(); + assert_eq!(output.styles.len(), 1); + assert!(!output.code.contains("style(")); +} diff --git a/packages/bun-plugin/__regression__/css-emit.bun.ts b/packages/bun-plugin/__regression__/css-emit.bun.ts index 836570bde..dc0e1fa8f 100644 --- a/packages/bun-plugin/__regression__/css-emit.bun.ts +++ b/packages/bun-plugin/__regression__/css-emit.bun.ts @@ -68,3 +68,39 @@ await import(cssPath) } }, ) + +it('emits vanilla-extract styles through the full WASM engine', () => { + const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) + try { + writeFileSync(join(cwd, 'bunfig.toml'), '') + writeFileSync( + join(cwd, 'styles.css.ts'), + `import { style } from '@devup-ui/react' +export const box = style({ width: '109px' }) +`, + ) + writeFileSync( + join(cwd, 'check.ts'), + `import { readFileSync } from 'node:fs' +import { expect } from 'bun:test' + +await import(${JSON.stringify(pluginEntry.replaceAll('\\', '/'))}) +const { box } = await import('./styles.css.ts') +expect(box).toBeTruthy() +expect(readFileSync('./df/devup-ui/devup-ui.css', 'utf-8')).toContain('width:109px') +`, + ) + const result = Bun.spawnSync([process.execPath, 'run', 'check.ts'], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, + }) + expect( + result.exitCode, + result.stdout.toString() + result.stderr.toString(), + ).toBe(0) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) From 519f1b7e2844879db2dc06acbc52925dc781b538 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:43:46 +0900 Subject: [PATCH 02/12] feat(eslint-plugin): add prefer-media-shorthand and no-typography-token-prefix rules prefer-media-shorthand suggests _motionReduce, _print and the other media shorthands for _media entries and @media keys that spell out the same query. no-typography-token-prefix reports typography=$heading, which names no preset. Both are auto-fixable and part of the recommended config. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../__snapshots__/recommended.test.ts.snap | 36 +++++ .../eslint-plugin/src/configs/recommended.ts | 6 + .../src/rules/__tests__/index.test.ts | 2 + packages/eslint-plugin/src/rules/index.ts | 2 + .../no-typography-token-prefix/README.md | 39 +++++ .../__tests__/index.test.ts | 56 +++++++ .../rules/no-typography-token-prefix/index.ts | 97 ++++++++++++ .../rules/prefer-media-shorthand/README.md | 48 ++++++ .../__tests__/index.test.ts | 76 ++++++++++ .../src/rules/prefer-media-shorthand/index.ts | 141 ++++++++++++++++++ .../src/utils/property-key-name.ts | 11 ++ 11 files changed, 514 insertions(+) create mode 100644 packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md create mode 100644 packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts create mode 100644 packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts create mode 100644 packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md create mode 100644 packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts create mode 100644 packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts create mode 100644 packages/eslint-plugin/src/utils/property-key-name.ts diff --git a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap index 98996cba1..2771fb317 100644 --- a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap +++ b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap @@ -39,6 +39,23 @@ exports[`recommended export recommended config 1`] = ` }, "name": "no-duplicate-value", }, + "no-typography-token-prefix": { + "create": [Function: create], + "defaultOptions": [], + "meta": { + "docs": { + "description": "Disallow the \`$\` token prefix on typography keys.", + "url": "https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/no-typography-token-prefix", + }, + "fixable": "code", + "messages": { + "noTypographyTokenPrefix": "Typography keys take no \`$\` prefix. Use "{{name}}".", + }, + "schema": [], + "type": "problem", + }, + "name": "no-typography-token-prefix", + }, "no-useless-responsive": { "create": [Function: create], "defaultOptions": [], @@ -73,6 +90,23 @@ exports[`recommended export recommended config 1`] = ` }, "name": "no-useless-tailing-nulls", }, + "prefer-media-shorthand": { + "create": [Function: create], + "defaultOptions": [], + "meta": { + "docs": { + "description": "Prefer media shorthand props over their media queries.", + "url": "https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/prefer-media-shorthand", + }, + "fixable": "code", + "messages": { + "preferMediaShorthand": "Use the \`{{shorthand}}\` shorthand for this query.", + }, + "schema": [], + "type": "suggestion", + }, + "name": "prefer-media-shorthand", + }, "style-order-range": { "create": [Function: create], "defaultOptions": [], @@ -96,8 +130,10 @@ exports[`recommended export recommended config 1`] = ` "rules": { "@devup-ui/css-utils-literal-only": "error", "@devup-ui/no-duplicate-value": "error", + "@devup-ui/no-typography-token-prefix": "error", "@devup-ui/no-useless-responsive": "error", "@devup-ui/no-useless-tailing-nulls": "error", + "@devup-ui/prefer-media-shorthand": "warn", "@devup-ui/style-order-range": "error", }, }, diff --git a/packages/eslint-plugin/src/configs/recommended.ts b/packages/eslint-plugin/src/configs/recommended.ts index 3827b73a7..b36e20df4 100644 --- a/packages/eslint-plugin/src/configs/recommended.ts +++ b/packages/eslint-plugin/src/configs/recommended.ts @@ -1,8 +1,10 @@ import { cssUtilsLiteralOnly, noDuplicateValue, + noTypographyTokenPrefix, noUselessResponsive, noUselessTailingNulls, + preferMediaShorthand, styleOrderRange, } from '../rules' @@ -16,6 +18,8 @@ export default [ 'no-duplicate-value': noDuplicateValue, 'no-useless-responsive': noUselessResponsive, 'style-order-range': styleOrderRange, + 'no-typography-token-prefix': noTypographyTokenPrefix, + 'prefer-media-shorthand': preferMediaShorthand, }, }, }, @@ -25,6 +29,8 @@ export default [ '@devup-ui/no-duplicate-value': 'error', '@devup-ui/no-useless-responsive': 'error', '@devup-ui/style-order-range': 'error', + '@devup-ui/no-typography-token-prefix': 'error', + '@devup-ui/prefer-media-shorthand': 'warn', }, }, ] diff --git a/packages/eslint-plugin/src/rules/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/__tests__/index.test.ts index 16aa96261..624966813 100644 --- a/packages/eslint-plugin/src/rules/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/__tests__/index.test.ts @@ -10,6 +10,8 @@ describe('export index', () => { noDuplicateValue: expect.any(Object), noUselessResponsive: expect.any(Object), styleOrderRange: expect.any(Object), + noTypographyTokenPrefix: expect.any(Object), + preferMediaShorthand: expect.any(Object), }) }) }) diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 671847783..4f12cc9f4 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -1,5 +1,7 @@ export * from './css-utils-literal-only' export * from './no-duplicate-value' +export * from './no-typography-token-prefix' export * from './no-useless-responsive' export * from './no-useless-tailing-nulls' +export * from './prefer-media-shorthand' export * from './style-order-range' diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md b/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md new file mode 100644 index 000000000..a8d91d3a9 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md @@ -0,0 +1,39 @@ +# no-typography-token-prefix + +Disallow the `$` token prefix on `typography` values. + +## Rule Details + +`typography` takes the bare key of a `theme.typography` entry. Unlike color, +length, and shadow tokens it has no `$` prefix; `typography="$heading"` names a +preset that does not exist, so no style is applied. + +The rule checks string values of `typography` on Devup UI components and +utilities, including values inside responsive arrays, conditionals, and +selector objects. + +### Examples + +#### ❌ Incorrect + +```tsx +import { Box, css } from '@devup-ui/react' + +; +; +css({ _hover: { typography: '$title' } }) +``` + +#### ✅ Correct + +```tsx +import { Box, css } from '@devup-ui/react' + +; +; +css({ _hover: { typography: 'title' } }) +``` + +## Auto-fixable + +The `$` prefix is removed. diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts new file mode 100644 index 000000000..9c0395101 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts @@ -0,0 +1,56 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { noTypographyTokenPrefix } from '../index' + +describe('no-typography-token-prefix rule', () => { + const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + const imports = 'import { Box, css } from "@devup-ui/react";\n' + ruleTester.run('no-typography-token-prefix rule', noTypographyTokenPrefix, { + valid: [ + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}css({ color: '$primary', ['typography']: 1 })` }, + { code: `${imports}css({ '$heading': 1 })` }, + { code: `${imports}css({ typography: 1 })` }, + { code: `import { Box } from "other";\n` }, + { code: `const a = { typography: '$heading' }` }, + ], + invalid: [ + { + code: `${imports}`, + output: `${imports}`, + errors: [ + { messageId: 'noTypographyTokenPrefix', data: { name: 'heading' } }, + ], + }, + { + code: `${imports}`, + output: `${imports}`, + errors: [ + { messageId: 'noTypographyTokenPrefix' }, + { messageId: 'noTypographyTokenPrefix' }, + ], + }, + { + code: `${imports}css({ _hover: { typography: '$title' } })`, + output: `${imports}css({ _hover: { typography: 'title' } })`, + errors: [{ messageId: 'noTypographyTokenPrefix' }], + }, + { + code: `${imports}`, + output: `${imports}`, + errors: [{ messageId: 'noTypographyTokenPrefix' }], + }, + ], + }) +}) diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts b/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts new file mode 100644 index 000000000..31ae5ec2a --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts @@ -0,0 +1,97 @@ +import { + AST_NODE_TYPES, + ESLintUtils, + type TSESTree, +} from '@typescript-eslint/utils' + +import { ImportStorage } from '../../utils/import-storage' +import { propertyKeyName } from '../../utils/property-key-name' + +const createRule = ESLintUtils.RuleCreator( + (name) => + `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, +) + +/** Whether `literal` is a value of a `typography` prop rather than a condition. */ +function isTypographyValue( + child: TSESTree.Node, + parent: TSESTree.Node | undefined = child.parent, +): boolean { + switch (parent?.type) { + case AST_NODE_TYPES.JSXAttribute: + return ( + parent.name.type === AST_NODE_TYPES.JSXIdentifier && + parent.name.name === 'typography' + ) + case AST_NODE_TYPES.Property: + return parent.value === child && propertyKeyName(parent) === 'typography' + case AST_NODE_TYPES.ConditionalExpression: + return parent.test !== child && isTypographyValue(parent) + case AST_NODE_TYPES.JSXExpressionContainer: + case AST_NODE_TYPES.ArrayExpression: + case AST_NODE_TYPES.LogicalExpression: + return isTypographyValue(parent) + default: + return false + } +} + +export const noTypographyTokenPrefix = createRule({ + name: 'no-typography-token-prefix', + defaultOptions: [], + meta: { + schema: [], + messages: { + noTypographyTokenPrefix: + 'Typography keys take no `$` prefix. Use "{{name}}".', + }, + type: 'problem', + fixable: 'code', + docs: { + description: 'Disallow the `$` token prefix on typography keys.', + }, + }, + create(context) { + const importStorage = new ImportStorage() + let devupContext: + TSESTree.CallExpression | TSESTree.JSXOpeningElement | null = null + return { + ImportDeclaration(node) { + importStorage.addImportByDeclaration(node) + }, + CallExpression(node) { + if (!devupContext && importStorage.checkContextType(node) === 'UTIL') { + devupContext = node + } + }, + 'CallExpression:exit'(node) { + if (devupContext === node) devupContext = null + }, + JSXOpeningElement(node) { + if (importStorage.checkContextType(node) === 'COMPONENT') { + devupContext = node + } + }, + 'JSXOpeningElement:exit'(node) { + if (devupContext === node) devupContext = null + }, + Literal(node) { + if ( + !devupContext || + typeof node.value !== 'string' || + !node.value.startsWith('$') || + !isTypographyValue(node) + ) + return + const name = node.value.slice(1) + const quote = node.raw[0] + context.report({ + node, + messageId: 'noTypographyTokenPrefix', + data: { name }, + fix: (fixer) => fixer.replaceText(node, `${quote}${name}${quote}`), + }) + }, + } + }, +}) diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md b/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md new file mode 100644 index 000000000..93128c120 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md @@ -0,0 +1,48 @@ +# prefer-media-shorthand + +Prefer the media shorthand props over spelling out the same media query. + +## Rule Details + +`_media` entries and `'@media …'` keys whose query is exactly one of the +shorthands are reported. Whitespace and letter case in the query are ignored. + +| Query | Shorthand | +| ---------------------------------------- | ---------------- | +| `all` | `_all` | +| `print` | `_print` | +| `screen` | `_screen` | +| `(prefers-reduced-motion: reduce)` | `_motionReduce` | +| `(prefers-reduced-motion: no-preference)` | `_motionSafe` | +| `(orientation: portrait)` | `_portrait` | +| `(orientation: landscape)` | `_landscape` | +| `(prefers-contrast: more)` | `_contrastMore` | +| `(prefers-contrast: less)` | `_contrastLess` | +| `(forced-colors: active)` | `_forcedColors` | + +### Examples + +#### ❌ Incorrect + +```tsx +import { Box, css } from '@devup-ui/react' + +; +css({ '@media print': { color: 'black' } }) +``` + +#### ✅ Correct + +```tsx +import { Box, css } from '@devup-ui/react' + +; +css({ _print: { color: 'black' } }) +css({ _media: { '(min-width: 500px)': { p: 1 } } }) +``` + +## Auto-fixable + +A `_media` object holding only the matched query, and a static `'@media …'` +key, are rewritten to the shorthand. A `_media` object with other queries is +reported without a fix. diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts new file mode 100644 index 000000000..df0232e17 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts @@ -0,0 +1,76 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { preferMediaShorthand } from '../index' + +describe('prefer-media-shorthand rule', () => { + const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + const imports = 'import { Box, css } from "@devup-ui/react";\n' + ruleTester.run('prefer-media-shorthand rule', preferMediaShorthand, { + valid: [ + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}` }, + { code: `${imports}css({ '@media (hover: none)': { color: 'red' } })` }, + { code: `${imports}css({ [key]: { color: 'red' }, 1: 2, ...rest })` }, + { + code: `${imports}css({ _media: { [key]: { color: 'red' }, ...rest } })`, + }, + { code: `${imports}css({ '@mediaprint': { color: 'red' } })` }, + { code: `${imports}css(styles)` }, + { + code: `import { Box } from "other";\n`, + }, + { code: `css({ '@media print': { color: 'red' } })` }, + ], + invalid: [ + { + code: `${imports}`, + output: `${imports}`, + errors: [ + { + messageId: 'preferMediaShorthand', + data: { shorthand: '_motionReduce' }, + }, + ], + }, + { + code: `${imports}css({ _media: { print: { color: 'red' } } })`, + output: `${imports}css({ _print: { color: 'red' } })`, + errors: [{ messageId: 'preferMediaShorthand' }], + }, + { + code: `${imports}css({ _media: { '(orientation:portrait)': { p: 1 }, '(min-width: 1px)': { p: 2 } } })`, + output: null, + errors: [ + { + messageId: 'preferMediaShorthand', + data: { shorthand: '_portrait' }, + }, + ], + }, + { + code: `${imports}css({ '@media (forced-colors: active)': { color: 'red' } })`, + output: `${imports}css({ _forcedColors: { color: 'red' } })`, + errors: [{ messageId: 'preferMediaShorthand' }], + }, + { + code: `${imports}css({ ['@media screen']: { color: 'red' } })`, + output: null, + errors: [{ messageId: 'preferMediaShorthand' }], + }, + { + code: `${imports}`, + output: `${imports}`, + errors: [{ messageId: 'preferMediaShorthand' }], + }, + ], + }) +}) diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts b/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts new file mode 100644 index 000000000..c82d25d4a --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts @@ -0,0 +1,141 @@ +import { + AST_NODE_TYPES, + ESLintUtils, + type TSESTree, +} from '@typescript-eslint/utils' + +import { ImportStorage } from '../../utils/import-storage' +import { propertyKeyName } from '../../utils/property-key-name' + +const createRule = ESLintUtils.RuleCreator( + (name) => + `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, +) + +const MEDIA_SHORTHANDS = new Map([ + ['all', '_all'], + ['print', '_print'], + ['screen', '_screen'], + ['(prefers-reduced-motion:reduce)', '_motionReduce'], + ['(prefers-reduced-motion:no-preference)', '_motionSafe'], + ['(orientation:portrait)', '_portrait'], + ['(orientation:landscape)', '_landscape'], + ['(prefers-contrast:more)', '_contrastMore'], + ['(prefers-contrast:less)', '_contrastLess'], + ['(forced-colors:active)', '_forcedColors'], +]) + +const AT_MEDIA = /^@media(?=[\s(])/ + +function shorthandFor(query: string): string | undefined { + return MEDIA_SHORTHANDS.get(query.replace(/\s+/g, '').toLowerCase()) +} + +export const preferMediaShorthand = createRule({ + name: 'prefer-media-shorthand', + defaultOptions: [], + meta: { + schema: [], + messages: { + preferMediaShorthand: 'Use the `{{shorthand}}` shorthand for this query.', + }, + type: 'suggestion', + fixable: 'code', + docs: { + description: 'Prefer media shorthand props over their media queries.', + }, + }, + create(context) { + const importStorage = new ImportStorage() + let devupContext: + TSESTree.CallExpression | TSESTree.JSXOpeningElement | null = null + + function checkMediaRecord( + owner: TSESTree.Property | TSESTree.JSXAttribute, + record: TSESTree.Node, + ) { + if (record.type !== AST_NODE_TYPES.ObjectExpression) return + for (const prop of record.properties) { + if (prop.type !== AST_NODE_TYPES.Property) continue + const query = propertyKeyName(prop) + const shorthand = query === undefined ? undefined : shorthandFor(query) + if (!shorthand) continue + const value = context.sourceCode.getText(prop.value) + context.report({ + node: prop.key, + messageId: 'preferMediaShorthand', + data: { shorthand }, + fix: + record.properties.length === 1 + ? (fixer) => + fixer.replaceText( + owner, + owner.type === AST_NODE_TYPES.JSXAttribute + ? `${shorthand}={${value}}` + : `${shorthand}: ${value}`, + ) + : null, + }) + } + } + + return { + ImportDeclaration(node) { + importStorage.addImportByDeclaration(node) + }, + CallExpression(node) { + if ( + !devupContext && + importStorage.checkContextType(node) === 'UTIL' && + node.arguments.length === 1 && + node.arguments[0].type === AST_NODE_TYPES.ObjectExpression + ) { + devupContext = node + } + }, + 'CallExpression:exit'(node) { + if (devupContext === node) devupContext = null + }, + JSXOpeningElement(node) { + if (importStorage.checkContextType(node) === 'COMPONENT') { + devupContext = node + } + }, + 'JSXOpeningElement:exit'(node) { + if (devupContext === node) devupContext = null + }, + JSXAttribute(node) { + if ( + devupContext && + node.name.type === AST_NODE_TYPES.JSXIdentifier && + node.name.name === '_media' && + node.value?.type === AST_NODE_TYPES.JSXExpressionContainer + ) { + checkMediaRecord(node, node.value.expression) + } + }, + Property(node) { + if (!devupContext) return + const name = propertyKeyName(node) + if (name === undefined) return + if (name === '_media') { + checkMediaRecord(node, node.value) + return + } + const shorthand = AT_MEDIA.test(name) + ? shorthandFor(name.slice('@media'.length)) + : undefined + if (shorthand) { + context.report({ + node: node.key, + messageId: 'preferMediaShorthand', + data: { shorthand }, + fix: node.computed + ? null + : (fixer) => fixer.replaceText(node.key, shorthand), + }) + } + }, + } + }, +}) diff --git a/packages/eslint-plugin/src/utils/property-key-name.ts b/packages/eslint-plugin/src/utils/property-key-name.ts new file mode 100644 index 000000000..41f20250c --- /dev/null +++ b/packages/eslint-plugin/src/utils/property-key-name.ts @@ -0,0 +1,11 @@ +import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils' + +export function propertyKeyName(node: TSESTree.Property): string | undefined { + if (node.key.type === AST_NODE_TYPES.Identifier && !node.computed) + return node.key.name + if ( + node.key.type === AST_NODE_TYPES.Literal && + typeof node.key.value === 'string' + ) + return node.key.value +} From 99ac1bef04bb1689cb156206b7d73e8a13d93f6e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:45:48 +0900 Subject: [PATCH 03/12] feat(css): add at-rule composition primitives Media shorthand queries, at-rule key parsing, query normalization, media query combination (merge, never-match, nest) and the deterministic order at-rule blocks are emitted in. StyleSelector::At now carries its enclosing at-rules and owning file. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/css/src/at_rule.rs | 570 +++++++++++++++++++++++++++++++++ libs/css/src/lib.rs | 26 +- libs/css/src/style_selector.rs | 356 +++++++++++++++++--- libs/css/src/theme_tokens.rs | 24 ++ 4 files changed, 932 insertions(+), 44 deletions(-) create mode 100644 libs/css/src/at_rule.rs diff --git a/libs/css/src/at_rule.rs b/libs/css/src/at_rule.rs new file mode 100644 index 000000000..55b0c8e41 --- /dev/null +++ b/libs/css/src/at_rule.rs @@ -0,0 +1,570 @@ +//! At-rule preludes: media shorthands, query normalization, media query +//! combination, and the deterministic order at-rule blocks are emitted in. + +use crate::style_selector::AtRuleKind; +use crate::utils::to_kebab_case; + +/// Media shorthand props and the media query each one stands for, keyed by the +/// kebab-cased name shared with the matching Tailwind variant. +const MEDIA_SHORTHANDS: [(&str, &str); 10] = [ + ("all", "all"), + ("contrast-less", "(prefers-contrast:less)"), + ("contrast-more", "(prefers-contrast:more)"), + ("forced-colors", "(forced-colors:active)"), + ("landscape", "(orientation:landscape)"), + ("motion-reduce", "(prefers-reduced-motion:reduce)"), + ("motion-safe", "(prefers-reduced-motion:no-preference)"), + ("portrait", "(orientation:portrait)"), + ("print", "print"), + ("screen", "screen"), +]; + +/// The media query a shorthand prop (`print`, `motionReduce`, `motion-reduce`, …) +/// stands for. +#[must_use] +pub fn media_shorthand_query(name: &str) -> Option<&'static str> { + let name = to_kebab_case(name); + MEDIA_SHORTHANDS + .iter() + .find(|(key, _)| *key == name) + .map(|(_, query)| *query) +} + +/// Split an at-rule key such as `@media print` into its kind and prelude. +#[must_use] +pub fn split_at_rule_key(key: &str) -> Option<(AtRuleKind, &str)> { + let rest = key.trim().strip_prefix('@')?; + [ + ("media", AtRuleKind::Media), + ("supports", AtRuleKind::Supports), + ("container", AtRuleKind::Container), + ] + .into_iter() + .find_map(|(name, kind)| { + let query = rest.strip_prefix(name)?; + (query.starts_with([' ', '(']) && !query.trim().is_empty()).then_some((kind, query.trim())) + }) +} + +const fn is_word_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '%') || !c.is_ascii() +} + +fn leading_word(text: &str) -> &str { + let end = text + .char_indices() + .find(|(_, c)| !is_word_char(*c)) + .map_or(text.len(), |(index, _)| index); + &text[..end] +} + +fn trailing_word(text: &str) -> &str { + let start = text + .char_indices() + .rev() + .take_while(|(_, c)| is_word_char(*c)) + .last() + .map_or(text.len(), |(index, _)| index); + &text[start..] +} + +fn is_keyword(word: &str) -> bool { + ["and", "or", "not", "only"] + .iter() + .any(|keyword| word.eq_ignore_ascii_case(keyword)) +} + +/// Whether `text` ends with a standalone `and`/`or`/`not`/`only` keyword, which +/// needs a space before a following `(` so it is not read as a function. +fn ends_with_keyword(text: &str) -> bool { + let word = trailing_word(text); + is_keyword(word) + && text[..text.len() - word.len()] + .chars() + .next_back() + .is_none_or(|c| matches!(c, ' ' | '(' | ')' | ',')) +} + +/// Whether the whitespace between `prev` and `next` must survive as one space. +const fn keeps_space(prev: char, next: char, in_function: bool) -> bool { + if matches!(prev, '(' | ',') || matches!(next, ')' | ',') { + return false; + } + in_function + || !(matches!(prev, ':' | '<' | '>' | '=' | ')') || matches!(next, ':' | '<' | '>' | '=')) +} + +/// Rewrite an at-rule prelude into its shortest equivalent spelling. +/// +/// Whitespace is dropped wherever the tokens stay unambiguous (`(min-width: 1px)` +/// becomes `(min-width:1px)`, `) and (` becomes `)and (`) and kept where CSS +/// needs it: between words and before a `(` that follows a word, since `and(` +/// or `sidebar(` would tokenize as a function. A missing space in `and(` is +/// added back. Function arguments (`calc(1px + 2px)`, `selector(a :hover)`) +/// and quoted strings keep their significant whitespace. +#[must_use] +pub fn normalize_query(query: &str) -> String { + let mut out = String::with_capacity(query.len()); + // One entry per open parenthesis: `true` when it opened a function, whose + // arguments can contain significant whitespace. + let mut parens: Vec = Vec::new(); + let mut pending_space = false; + let mut chars = query.chars(); + while let Some(c) = chars.next() { + if c.is_whitespace() { + pending_space = true; + continue; + } + let in_function = parens.last().copied().unwrap_or(false); + let keyword_paren = c == '(' && !in_function && ends_with_keyword(&out); + let function_paren = c == '(' + && !pending_space + && !keyword_paren + && out.chars().next_back().is_some_and(is_word_char); + if let Some(prev) = out.chars().next_back() + && (keyword_paren || (pending_space && keeps_space(prev, c, in_function))) + { + out.push(' '); + } + pending_space = false; + out.push(c); + match c { + '(' => parens.push(function_paren), + ')' => { + parens.pop(); + } + '"' | '\'' => { + let mut escaped = false; + for inner in chars.by_ref() { + out.push(inner); + if escaped { + escaped = false; + } else if inner == '\\' { + escaped = true; + } else if inner == c { + break; + } + } + } + _ => {} + } + } + out +} + +/// Result of folding two media queries into one. +#[derive(Debug, PartialEq, Eq)] +pub enum MediaCombination { + /// Both queries hold exactly when this single query list holds. + Merged(String), + /// The queries can never hold together (e.g. `print` inside `screen`). + Never, + /// No single query expresses both; emit nested `@media` blocks instead. + Nest, +} + +struct MediaQuery<'a> { + negated: bool, + only: bool, + media_type: Option<&'a str>, + condition: Option<&'a str>, +} + +fn split_list(query: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0; + for (index, c) in query.char_indices() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(query[start..index].trim()); + start = index + 1; + } + _ => {} + } + } + parts.push(query[start..].trim()); + parts +} + +fn parse_media_query(query: &str) -> Option> { + let condition_only = MediaQuery { + negated: false, + only: false, + media_type: None, + condition: Some(query), + }; + if query.starts_with('(') { + return Some(condition_only); + } + let first = leading_word(query); + let rest = &query[first.len()..]; + let (negated, only, typed) = if first.eq_ignore_ascii_case("not") { + if rest.starts_with(" (") { + return Some(condition_only); + } + (true, false, rest.strip_prefix(' ')?) + } else if first.eq_ignore_ascii_case("only") { + (false, true, rest.strip_prefix(' ')?) + } else { + (false, false, query) + }; + let media_type = leading_word(typed); + if media_type.is_empty() || is_keyword(media_type) { + return None; + } + let after = &typed[media_type.len()..]; + let condition = if after.is_empty() { + None + } else { + Some( + after + .strip_prefix(" and ") + .filter(|condition| condition.starts_with('(') || condition.starts_with("not "))?, + ) + }; + Some(MediaQuery { + negated, + only, + media_type: Some(media_type), + condition, + }) +} + +/// Turn `not print` into `screen` (and back), the only negations with a +/// positive spelling; anything else cannot be folded into another query. +const fn resolve_negation(query: MediaQuery<'_>) -> Result, MediaCombination> { + if !query.negated { + return Ok(query); + } + if query.condition.is_some() { + return Err(MediaCombination::Nest); + } + let complement = match query.media_type { + Some(media_type) if media_type.eq_ignore_ascii_case("print") => "screen", + Some(media_type) if media_type.eq_ignore_ascii_case("screen") => "print", + Some(media_type) if media_type.eq_ignore_ascii_case("all") => { + return Err(MediaCombination::Never); + } + _ => return Err(MediaCombination::Nest), + }; + Ok(MediaQuery { + negated: false, + only: false, + media_type: Some(complement), + condition: None, + }) +} + +fn has_top_level_or(condition: &str) -> bool { + let mut depth = 0usize; + let mut previous = ' '; + for (index, c) in condition.char_indices() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + _ if depth == 0 + && !is_word_char(previous) + && leading_word(&condition[index..]).eq_ignore_ascii_case("or") => + { + return true; + } + _ => {} + } + previous = c; + } + false +} + +fn specific_type(media_type: Option<&str>) -> Option<&str> { + media_type.filter(|media_type| !media_type.eq_ignore_ascii_case("all")) +} + +fn combine_single(outer: MediaQuery<'_>, inner: MediaQuery<'_>) -> MediaCombination { + let (outer, inner) = match (resolve_negation(outer), resolve_negation(inner)) { + (Ok(outer), Ok(inner)) => (outer, inner), + (Err(MediaCombination::Never), _) | (_, Err(MediaCombination::Never)) => { + return MediaCombination::Never; + } + _ => return MediaCombination::Nest, + }; + let media_type = match ( + specific_type(outer.media_type), + specific_type(inner.media_type), + ) { + (None, media_type) | (media_type, None) => media_type, + (Some(a), Some(b)) if a.eq_ignore_ascii_case(b) => Some(a), + _ => return MediaCombination::Never, + }; + let conditions: Vec<&str> = [outer.condition, inner.condition] + .into_iter() + .flatten() + .collect(); + let joined = conditions.len() > 1; + + let mut result = String::new(); + if let Some(media_type) = media_type { + if outer.only || inner.only { + result.push_str("only "); + } + result.push_str(media_type); + if !conditions.is_empty() { + result.push_str(" and "); + } + } + for (index, condition) in conditions.iter().enumerate() { + if index > 0 { + result.push_str("and "); + } + let wrap = (has_top_level_or(condition) && (joined || media_type.is_some())) + || (joined && leading_word(condition).eq_ignore_ascii_case("not")); + if wrap { + result.push('('); + result.push_str(condition); + result.push(')'); + } else { + result.push_str(condition); + } + } + if result.is_empty() { + result.push_str("all"); + } + MediaCombination::Merged(result) +} + +/// Fold `inner` into `outer` so one `@media` holds exactly when both would. +/// +/// Both preludes must already be normalized. Query lists distribute over each +/// other, `not print`/`not screen` become `screen`/`print`, conflicting media +/// types collapse to [`MediaCombination::Never`], and anything that has no +/// single-query spelling (such as `not print and (color)`) asks for nesting. +#[must_use] +pub fn combine_media_queries(outer: &str, inner: &str) -> MediaCombination { + let mut merged: Vec = Vec::new(); + for outer_query in split_list(outer) { + for inner_query in split_list(inner) { + let (Some(outer_query), Some(inner_query)) = ( + parse_media_query(outer_query), + parse_media_query(inner_query), + ) else { + return MediaCombination::Nest; + }; + match combine_single(outer_query, inner_query) { + MediaCombination::Merged(query) => { + if !merged.contains(&query) { + merged.push(query); + } + } + MediaCombination::Never => {} + MediaCombination::Nest => return MediaCombination::Nest, + } + } + } + if merged.is_empty() { + MediaCombination::Never + } else { + MediaCombination::Merged(merged.join(",")) + } +} + +fn length_px(value: &str) -> Option { + let number_end = value + .char_indices() + .find(|(_, c)| !(c.is_ascii_digit() || *c == '.')) + .map_or(value.len(), |(index, _)| index); + let number: f64 = value[..number_end].parse().ok()?; + let unit = &value[number_end..]; + let scale = if unit.eq_ignore_ascii_case("em") || unit.eq_ignore_ascii_case("rem") { + 16.0 + } else { + 1.0 + }; + Some((number * scale * 100.0).round() as i64) +} + +/// The first width bound of a normalized query: `(true, px)` for a lower bound +/// (`min-width`, `width>=`), `(false, px)` for an upper one (`max-width`, `width<=`). +fn width_bound(query: &str) -> Option<(bool, i64)> { + query.split(['(', ')']).find_map(|term| { + if let Some(value) = term.strip_prefix("min-width:") { + return Some((true, length_px(value)?)); + } + if let Some(value) = term.strip_prefix("max-width:") { + return Some((false, length_px(value)?)); + } + let split = term.find(['<', '>'])?; + let (left, right) = term.split_at(split); + let lower = right.starts_with('>'); + let right = right.trim_start_matches(['<', '>', '=']); + if left == "width" { + Some((lower, length_px(right)?)) + } else if right == "width" || right.starts_with("width<") || right.starts_with("width>") { + Some((!lower, length_px(left)?)) + } else { + None + } + }) +} + +/// Sort key giving at-rule blocks a deterministic cascade. +/// +/// Width queries come first, mobile-first (`min-width` ascending, then +/// `max-width` descending), then bare media types, then other media features, +/// then user preferences (`prefers-*`, `forced-colors`) so those win last. +#[must_use] +pub fn query_order(kind: AtRuleKind, query: &str) -> (u8, i64) { + if matches!(kind, AtRuleKind::Media | AtRuleKind::Container) + && let Some((lower, px)) = width_bound(query) + { + return if lower { (0, px) } else { (1, -px) }; + } + if kind != AtRuleKind::Media { + (3, 0) + } else if query.contains("prefers-") || query.contains("forced-colors") { + (4, 0) + } else if query.contains('(') { + (3, 0) + } else { + (2, 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case("print", Some("print"))] + #[case("screen", Some("screen"))] + #[case("all", Some("all"))] + #[case("motionReduce", Some("(prefers-reduced-motion:reduce)"))] + #[case("motion-reduce", Some("(prefers-reduced-motion:reduce)"))] + #[case("motionSafe", Some("(prefers-reduced-motion:no-preference)"))] + #[case("portrait", Some("(orientation:portrait)"))] + #[case("landscape", Some("(orientation:landscape)"))] + #[case("contrastMore", Some("(prefers-contrast:more)"))] + #[case("contrastLess", Some("(prefers-contrast:less)"))] + #[case("forcedColors", Some("(forced-colors:active)"))] + #[case("speech", None)] + #[case("hover", None)] + fn test_media_shorthand_query(#[case] name: &str, #[case] expected: Option<&str>) { + assert_eq!(media_shorthand_query(name), expected); + } + + #[rstest] + #[case("@media print", Some((AtRuleKind::Media, "print")))] + #[case("@media(min-width: 1px)", Some((AtRuleKind::Media, "(min-width: 1px)")))] + #[case(" @supports (display: grid) ", Some((AtRuleKind::Supports, "(display: grid)")))] + #[case("@container sidebar (min-width: 1px)", Some((AtRuleKind::Container, "sidebar (min-width: 1px)")))] + #[case("@media", None)] + #[case("@media ", None)] + #[case("@mediaprint", None)] + #[case("@layer reset", None)] + #[case("media print", None)] + fn test_split_at_rule_key(#[case] key: &str, #[case] expected: Option<(AtRuleKind, &str)>) { + assert_eq!(split_at_rule_key(key), expected); + } + + #[rstest] + #[case("print", "print")] + #[case(" print ", "print")] + #[case("(prefers-reduced-motion: reduce)", "(prefers-reduced-motion:reduce)")] + #[case("( min-width : 768px )", "(min-width:768px)")] + #[case( + "(min-width: 768px) and (max-width: 1024px)", + "(min-width:768px)and (max-width:1024px)" + )] + #[case("screen and (min-width: 500px)", "screen and (min-width:500px)")] + #[case( + "only screen and(min-width:768px)", + "only screen and (min-width:768px)" + )] + #[case("not (display: grid)", "not (display:grid)")] + #[case("not(display:grid)", "not (display:grid)")] + #[case("(a) or(b)", "(a)or (b)")] + #[case("sidebar (min-width: 400px)", "sidebar (min-width:400px)")] + #[case( + "(orientation: portrait), (hover: none)", + "(orientation:portrait),(hover:none)" + )] + #[case("(width >= 600px)", "(width>=600px)")] + #[case("(400px <= width <= 700px)", "(400px<=width<=700px)")] + #[case("(min-width: calc(100px + 2em))", "(min-width:calc(100px + 2em))")] + #[case("selector(a :hover)", "selector(a :hover)")] + #[case("selector(:not(a))", "selector(:not(a))")] + #[case("style(--label: \"a b\")", "style(--label: \"a b\")")] + #[case("style(--label: 'it\\'s x')", "style(--label: 'it\\'s x')")] + #[case("(not (color)) or (hover)", "(not (color))or (hover)")] + #[case("", "")] + fn test_normalize_query(#[case] query: &str, #[case] expected: &str) { + assert_eq!(normalize_query(query), expected); + } + + #[rstest] + #[case("(min-width:768px)", "(prefers-reduced-motion:reduce)", MediaCombination::Merged("(min-width:768px)and (prefers-reduced-motion:reduce)".to_string()))] + #[case("(min-width:768px)", "print", MediaCombination::Merged("print and (min-width:768px)".to_string()))] + #[case("print", "(prefers-reduced-motion:reduce)", MediaCombination::Merged("print and (prefers-reduced-motion:reduce)".to_string()))] + #[case("(min-width:768px)", "screen and (min-width:100px)", MediaCombination::Merged("screen and (min-width:768px)and (min-width:100px)".to_string()))] + #[case("(min-width:768px)", "only screen", MediaCombination::Merged("only screen and (min-width:768px)".to_string()))] + #[case("(min-width:768px)", "not print", MediaCombination::Merged("screen and (min-width:768px)".to_string()))] + #[case("(min-width:768px)", "not screen", MediaCombination::Merged("print and (min-width:768px)".to_string()))] + #[case("(min-width:768px)", "not all", MediaCombination::Never)] + #[case("(min-width:768px)", "not tv", MediaCombination::Nest)] + #[case("(min-width:768px)", "not print and (color)", MediaCombination::Nest)] + #[case("print", "screen", MediaCombination::Never)] + #[case("print", "PRINT", MediaCombination::Merged("print".to_string()))] + #[case("all", "print", MediaCombination::Merged("print".to_string()))] + #[case("all", "all", MediaCombination::Merged("all".to_string()))] + #[case("(min-width:768px)", "(orientation:portrait),(hover:none)", MediaCombination::Merged("(min-width:768px)and (orientation:portrait),(min-width:768px)and (hover:none)".to_string()))] + #[case("(min-width:768px)", "print,screen", MediaCombination::Merged("print and (min-width:768px),screen and (min-width:768px)".to_string()))] + #[case("print", "print,screen", MediaCombination::Merged("print".to_string()))] + #[case("(min-width:768px)", "(a)or (b)", MediaCombination::Merged("(min-width:768px)and ((a)or (b))".to_string()))] + #[case("print", "(a)or (b)", MediaCombination::Merged("print and ((a)or (b))".to_string()))] + #[case("(min-width:768px)", "not (color)", MediaCombination::Merged("(min-width:768px)and (not (color))".to_string()))] + #[case("print", "not (color)", MediaCombination::Merged("print and not (color)".to_string()))] + #[case("(a)", "(b)", MediaCombination::Merged("(a)and (b)".to_string()))] + #[case("(min-width:768px)", "print and speech", MediaCombination::Nest)] + #[case("(min-width:768px)", "only", MediaCombination::Nest)] + #[case("(min-width:768px)", "not", MediaCombination::Nest)] + #[case("(min-width:768px)", "and", MediaCombination::Nest)] + #[case("(min-width:768px)", "", MediaCombination::Nest)] + fn test_combine_media_queries( + #[case] outer: &str, + #[case] inner: &str, + #[case] expected: MediaCombination, + ) { + assert_eq!(combine_media_queries(outer, inner), expected); + } + + #[rstest] + #[case(AtRuleKind::Media, "(min-width:500px)", (0, 50_000))] + #[case(AtRuleKind::Media, "(min-width:30em)", (0, 48_000))] + #[case(AtRuleKind::Media, "(min-width:2rem)", (0, 3_200))] + #[case(AtRuleKind::Media, "(width>=600px)", (0, 60_000))] + #[case(AtRuleKind::Media, "(width>600px)", (0, 60_000))] + #[case(AtRuleKind::Media, "(600px<=width)", (0, 60_000))] + #[case(AtRuleKind::Media, "(400px<=width<=700px)", (0, 40_000))] + #[case(AtRuleKind::Media, "(max-width:1000px)", (1, -100_000))] + #[case(AtRuleKind::Media, "(width<=900px)", (1, -90_000))] + #[case(AtRuleKind::Media, "(900px>=width)", (1, -90_000))] + #[case(AtRuleKind::Media, "screen and (min-width:10px)", (0, 1_000))] + #[case(AtRuleKind::Media, "(min-width:abc)", (3, 0))] + #[case(AtRuleKind::Media, "(height>=10px)", (3, 0))] + #[case(AtRuleKind::Media, "print", (2, 0))] + #[case(AtRuleKind::Media, "(orientation:portrait)", (3, 0))] + #[case(AtRuleKind::Media, "(prefers-reduced-motion:reduce)", (4, 0))] + #[case(AtRuleKind::Media, "(forced-colors:active)", (4, 0))] + #[case(AtRuleKind::Container, "(min-width:400px)", (0, 40_000))] + #[case(AtRuleKind::Container, "sidebar (orientation:portrait)", (3, 0))] + #[case(AtRuleKind::Supports, "(display:grid)", (3, 0))] + fn test_query_order( + #[case] kind: AtRuleKind, + #[case] query: &str, + #[case] expected: (u8, i64), + ) { + assert_eq!(query_order(kind, query), expected); + } +} diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index f9479f02e..bdd33131e 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -1,3 +1,4 @@ +pub mod at_rule; pub mod atom_hoist; pub mod class_map; mod constant; @@ -186,6 +187,11 @@ impl ExactSizeIterator for DisassembleProperty {} #[must_use] pub fn disassemble_property(property: &str) -> DisassembleProperty { + // Nested selector keys (`&:hover`, `:focus`, `.parent &`) are not properties; + // keep them verbatim so class names and case survive. + if property.starts_with(':') || property.contains('&') { + return DisassembleProperty::Fallback(Some(property.to_string())); + } if let Some(properties) = HAS_CUSTOM_SHORTHANDS .load(Ordering::Relaxed) .then(|| { @@ -292,10 +298,14 @@ pub fn add_selector_params(selector: StyleSelector, params: &str) -> StyleSelect kind, query, selector, + outer, + file, } => StyleSelector::At { kind, query, selector: selector.map(|s| format!("{s}({params})")), + outer, + file, }, } } @@ -1120,7 +1130,9 @@ mod tests { Some(&StyleSelector::At { kind: AtRuleKind::Media, query: "print".to_string(), - selector: None + selector: None, + outer: vec![], + file: None, }) ), ".cls" @@ -1132,7 +1144,9 @@ mod tests { Some(&StyleSelector::At { kind: AtRuleKind::Media, query: "print".to_string(), - selector: Some("&:hover".to_string()) + selector: Some("&:hover".to_string()), + outer: vec![], + file: None, }) ), ".cls:hover" @@ -1197,14 +1211,18 @@ mod tests { StyleSelector::At { kind: AtRuleKind::Media, query: "print".to_string(), - selector: Some("&:is".to_string()) + selector: Some("&:is".to_string()), + outer: vec![], + file: None, }, "test" ), StyleSelector::At { kind: AtRuleKind::Media, query: "print".to_string(), - selector: Some("&:is(test)".to_string()) + selector: Some("&:is(test)".to_string()), + outer: vec![], + file: None, } ); } diff --git a/libs/css/src/style_selector.rs b/libs/css/src/style_selector.rs index ff839bb3c..a36bdc0f4 100644 --- a/libs/css/src/style_selector.rs +++ b/libs/css/src/style_selector.rs @@ -7,6 +7,7 @@ use std::{ use serde::{Deserialize, Serialize}; use crate::{ + at_rule::{MediaCombination, combine_media_queries, media_shorthand_query, normalize_query}, constant::SELECTOR_ORDER, selector_separator::SelectorSeparator, to_kebab_case, @@ -47,12 +48,25 @@ impl From<&str> for AtRuleKind { } } +#[derive(Debug, PartialEq, PartialOrd, Ord, Clone, Hash, Eq, Serialize, Deserialize)] +pub struct AtRule { + pub kind: AtRuleKind, + pub query: String, +} + #[derive(Debug, PartialEq, Clone, Hash, Eq, Serialize, Deserialize)] pub enum StyleSelector { At { kind: AtRuleKind, query: String, selector: Option, + /// At-rules enclosing this one, outermost first. Only set when the + /// rules cannot be folded into a single `@media` query. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + outer: Vec, + /// Source file of a `globalCss` rule, so re-extracting that file drops it. + #[serde(default, skip_serializing_if = "Option::is_none")] + file: Option, }, Selector(String), // selector, file @@ -84,10 +98,14 @@ pub fn optimize_selector(selector: StyleSelector) -> StyleSelector { kind, query, selector, + outer, + file, } => StyleSelector::At { kind, query, selector: selector.map(collapse_owned_selector), + outer, + file, }, StyleSelector::Selector(selector) => { StyleSelector::Selector(collapse_owned_selector(selector)) @@ -111,20 +129,22 @@ impl Ord for StyleSelector { kind: ka, query: a, selector: aa, + outer: oa, + file: fa, }, StyleSelector::At { kind: kb, query: b, selector: bb, + outer: ob, + file: fb, }, - ) => { - let k = (*ka as u8).cmp(&(*kb as u8)); - if k != Ordering::Equal { - return k; - } - let c = a.cmp(b); - if c == Ordering::Equal { aa.cmp(bb) } else { c } - } + ) => (*ka as u8) + .cmp(&(*kb as u8)) + .then_with(|| a.cmp(b)) + .then_with(|| aa.cmp(bb)) + .then_with(|| oa.cmp(ob)) + .then_with(|| fa.cmp(fb)), (StyleSelector::Selector(a), StyleSelector::Selector(b)) => { let order_cmp = get_selector_order(a).cmp(&get_selector_order(b)); if order_cmp == Ordering::Equal { @@ -181,11 +201,13 @@ impl From<&str> for StyleSelector { } else if let Some(s) = value.strip_prefix("theme-") { // first character should lower case StyleSelector::Selector(format!(":root[data-theme={}] &", to_camel_case(s))) - } else if matches!(value.as_ref(), "print" | "screen" | "speech" | "all") { + } else if let Some(query) = media_shorthand_query(&value) { StyleSelector::At { kind: AtRuleKind::Media, - query: value.into_owned(), + query: query.to_string(), selector: None, + outer: vec![], + file: None, } } else { let post = to_kebab_case(&value); @@ -214,23 +236,19 @@ impl From<[&str; 2]> for StyleSelector { )) } } -impl From<(&StyleSelector, &str)> for StyleSelector { - fn from(value: (&StyleSelector, &str)) -> Self { - if let StyleSelector::Global(_, file) = value.0 { - let post = to_kebab_case(value.1); - StyleSelector::Global( - format!( - "{}{}{}", - value.0, - SelectorSeparator::from(post.as_ref()), - post - ), - file.clone(), - ) - } else { - StyleSelector::from([&value.0.to_string(), value.1]) - } + +/// Write an at-rule prelude (`@media print`, `@media(min-width:1px)`), spacing +/// the query only when it does not open with a parenthesis. +pub fn write_at_rule( + out: &mut impl std::fmt::Write, + kind: AtRuleKind, + query: &str, +) -> std::fmt::Result { + write!(out, "@{kind}")?; + if !query.starts_with('(') { + out.write_char(' ')?; } + out.write_str(query) } impl Display for StyleSelector { @@ -241,12 +259,14 @@ impl Display for StyleSelector { kind, query, selector, + outer, + .. } => { - write!(f, "@{kind}")?; - if !query.starts_with('(') { + for rule in outer { + write_at_rule(f, rule.kind, &rule.query)?; f.write_str(" ")?; } - f.write_str(query)?; + write_at_rule(f, *kind, query)?; if let Some(selector) = selector { write!(f, " {selector}")?; } @@ -272,6 +292,87 @@ impl StyleSelector { StyleSelector::At { .. } => std::borrow::Cow::Owned(self.to_string()), } } + + /// Nest a selector `template` (e.g. `&:hover`, `:root[data-theme=dark] &`) + /// inside `parent`, substituting the parent's selector for `&`. + #[must_use] + pub fn nest_selector(parent: Option<&Self>, template: &str) -> Self { + match parent { + None => Self::Selector(template.to_string()), + Some(Self::Selector(selector)) => Self::Selector(template.replace('&', selector)), + Some(Self::Global(selector, file)) => { + Self::Global(template.replace('&', selector), file.clone()) + } + Some(Self::At { + kind, + query, + selector, + outer, + file, + }) => Self::At { + kind: *kind, + query: query.clone(), + selector: Some( + selector + .as_deref() + .map_or_else(|| template.to_string(), |s| template.replace('&', s)), + ), + outer: outer.clone(), + file: file.clone(), + }, + } + } + + /// Wrap `parent` (the bare class when `None`) in an at-rule. Nested + /// `@media` rules fold into one query where possible; `None` means the + /// combined condition can never match, so the styles must be dropped. + #[must_use] + pub fn nest_at_rule(parent: Option<&Self>, kind: AtRuleKind, query: &str) -> Option { + let query = normalize_query(query); + let (selector, outer, file) = match parent { + None => (None, vec![], None), + Some(Self::Selector(selector)) => (Some(selector.clone()), vec![], None), + Some(Self::Global(selector, file)) => { + (Some(selector.clone()), vec![], Some(file.clone())) + } + Some(Self::At { + kind: parent_kind, + query: parent_query, + selector, + outer, + file, + }) => { + if *parent_kind == AtRuleKind::Media && kind == AtRuleKind::Media { + match combine_media_queries(parent_query, &query) { + MediaCombination::Merged(query) => { + return Some(Self::At { + kind, + query, + selector: selector.clone(), + outer: outer.clone(), + file: file.clone(), + }); + } + MediaCombination::Never => return None, + MediaCombination::Nest => {} + } + } + let mut outer = outer.clone(); + outer.push(AtRule { + kind: *parent_kind, + query: parent_query.clone(), + }); + (selector.clone(), outer, file.clone()) + } + }; + Some(Self::At { + kind, + query, + selector, + outer, + file, + }) + } } /// Rank a `Global` selector's pseudo suffix by scanning `SELECTOR_ORDER` once. @@ -355,6 +456,7 @@ mod tests { #[case("group-focus-visible", StyleSelector::Selector(":is([role=group],[data-group]):focus-visible &".to_string()))] #[case("group-1", StyleSelector::Selector(":is([role=group],[data-group]):1 &".to_string()))] #[case(["theme-dark", "placeholder"], StyleSelector::Selector(":root[data-theme=dark] &::placeholder".to_string()))] + #[case(["theme-dark", "&:hover"], StyleSelector::Selector(":root[data-theme=dark] &:hover".to_string()))] #[case("theme-light", StyleSelector::Selector(":root[data-theme=light] &".to_string()))] #[case("*[aria=disabled='true'] &:hover", StyleSelector::Selector("*[aria=disabled='true'] &:hover".to_string()))] fn test_style_selector( @@ -369,28 +471,28 @@ mod tests { #[case(StyleSelector::At { kind: AtRuleKind::Media, query: "screen and (max-width: 600px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, "@media screen and (max-width: 600px)" )] #[case(StyleSelector::At { kind: AtRuleKind::Supports, query: "(display: grid)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, "@supports(display: grid)" )] #[case(StyleSelector::At { kind: AtRuleKind::Container, query: "(min-width: 768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, "@container(min-width: 768px)" )] #[case(StyleSelector::At { kind: AtRuleKind::Container, query: "sidebar (min-width: 400px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, "@container sidebar (min-width: 400px)" )] @@ -398,7 +500,7 @@ mod tests { #[case(StyleSelector::At { kind: AtRuleKind::Layer, query: "reset".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, "@layer reset" )] @@ -412,7 +514,7 @@ mod tests { StyleSelector::At { kind: AtRuleKind::Media, query: "screen".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, StyleSelector::Selector("&:hover".to_string()), std::cmp::Ordering::Greater @@ -426,12 +528,12 @@ mod tests { StyleSelector::At { kind: AtRuleKind::Media, query: "a".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, StyleSelector::At { kind: AtRuleKind::Media, query: "b".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, std::cmp::Ordering::Less )] @@ -439,12 +541,12 @@ mod tests { StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width: 768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, StyleSelector::At { kind: AtRuleKind::Supports, query: "(display: grid)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, std::cmp::Ordering::Less )] @@ -463,7 +565,7 @@ mod tests { StyleSelector::At { kind: AtRuleKind::Media, query: "screen".to_string(), - selector: None, + selector: None, outer: vec![], file: None, }, std::cmp::Ordering::Less )] @@ -631,6 +733,180 @@ mod tests { ); } + fn at(kind: AtRuleKind, query: &str, selector: Option<&str>) -> StyleSelector { + StyleSelector::At { + kind, + query: query.to_string(), + selector: selector.map(str::to_string), + outer: vec![], + file: None, + } + } + + #[rstest] + #[case(None, "&:hover", StyleSelector::Selector("&:hover".to_string()))] + #[case( + Some(StyleSelector::Selector("&:hover".to_string())), + ":root[data-theme=dark] &", + StyleSelector::Selector(":root[data-theme=dark] &:hover".to_string()) + )] + #[case( + Some(StyleSelector::Global("body".to_string(), "a.tsx".to_string())), + "&:hover", + StyleSelector::Global("body:hover".to_string(), "a.tsx".to_string()) + )] + #[case( + Some(at(AtRuleKind::Media, "print", None)), + "&:hover", + at(AtRuleKind::Media, "print", Some("&:hover")) + )] + #[case( + Some(at(AtRuleKind::Media, "print", Some("&:focus"))), + "&:hover", + at(AtRuleKind::Media, "print", Some("&:focus:hover")) + )] + fn test_nest_selector( + #[case] parent: Option, + #[case] template: &str, + #[case] expected: StyleSelector, + ) { + assert_eq!( + StyleSelector::nest_selector(parent.as_ref(), template), + expected + ); + } + + #[rstest] + #[case( + None, + AtRuleKind::Media, + "(prefers-reduced-motion: reduce)", + Some(at(AtRuleKind::Media, "(prefers-reduced-motion:reduce)", None)) + )] + #[case( + Some(StyleSelector::Selector("&:hover".to_string())), + AtRuleKind::Media, + "print", + Some(at(AtRuleKind::Media, "print", Some("&:hover"))) + )] + #[case( + Some(StyleSelector::Global("body".to_string(), "a.tsx".to_string())), + AtRuleKind::Media, + "print", + Some(StyleSelector::At { + kind: AtRuleKind::Media, + query: "print".to_string(), + selector: Some("body".to_string()), + outer: vec![], + file: Some("a.tsx".to_string()), + }) + )] + #[case( + Some(at(AtRuleKind::Media, "print", Some("&:hover"))), + AtRuleKind::Media, + "(prefers-reduced-motion:reduce)", + Some(at( + AtRuleKind::Media, + "print and (prefers-reduced-motion:reduce)", + Some("&:hover") + )) + )] + #[case( + Some(at(AtRuleKind::Media, "print", None)), + AtRuleKind::Media, + "screen", + None + )] + #[case( + Some(at(AtRuleKind::Media, "print", None)), + AtRuleKind::Supports, + "(display: grid)", + Some(StyleSelector::At { + kind: AtRuleKind::Supports, + query: "(display:grid)".to_string(), + selector: None, + outer: vec![AtRule { kind: AtRuleKind::Media, query: "print".to_string() }], + file: None, + }) + )] + #[case( + Some(at(AtRuleKind::Media, "not print and (color)", None)), + AtRuleKind::Media, + "(hover:none)", + Some(StyleSelector::At { + kind: AtRuleKind::Media, + query: "(hover:none)".to_string(), + selector: None, + outer: vec![AtRule { kind: AtRuleKind::Media, query: "not print and (color)".to_string() }], + file: None, + }) + )] + fn test_nest_at_rule( + #[case] parent: Option, + #[case] kind: AtRuleKind, + #[case] query: &str, + #[case] expected: Option, + ) { + assert_eq!( + StyleSelector::nest_at_rule(parent.as_ref(), kind, query), + expected + ); + } + + #[test] + fn test_nested_at_rule_display_and_order() { + let nested = StyleSelector::At { + kind: AtRuleKind::Supports, + query: "(display:grid)".to_string(), + selector: Some("&:hover".to_string()), + outer: vec![AtRule { + kind: AtRuleKind::Media, + query: "print".to_string(), + }], + file: None, + }; + assert_eq!( + nested.to_string(), + "@media print @supports(display:grid) &:hover" + ); + assert_eq!( + nested.as_class_str(), + "@media print @supports(display:grid) &:hover" + ); + + let plain = at(AtRuleKind::Supports, "(display:grid)", Some("&:hover")); + assert_eq!(plain.cmp(&nested), std::cmp::Ordering::Less); + let global = StyleSelector::At { + kind: AtRuleKind::Supports, + query: "(display:grid)".to_string(), + selector: Some("&:hover".to_string()), + outer: vec![], + file: Some("a.tsx".to_string()), + }; + assert_eq!(plain.cmp(&global), std::cmp::Ordering::Less); + assert_eq!( + optimize_selector(global.clone()), + global, + "optimizing keeps the enclosing rules and file" + ); + } + + #[test] + fn test_media_shorthand_selector() { + assert_eq!( + StyleSelector::from("motion-reduce"), + at(AtRuleKind::Media, "(prefers-reduced-motion:reduce)", None) + ); + assert_eq!( + StyleSelector::from("print"), + at(AtRuleKind::Media, "print", None) + ); + assert_eq!( + StyleSelector::from("speech"), + StyleSelector::Selector("&:speech".to_string()) + ); + } + #[rstest] #[case(AtRuleKind::Media, "media")] #[case(AtRuleKind::Supports, "supports")] diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index 961e8c1f4..d98859d87 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -5,6 +5,7 @@ use std::sync::{LazyLock, RwLock}; struct ThemeTokenRegistry { length: BTreeMap>, shadow: BTreeMap>, + typography: Vec, } static TOKEN_REGISTRY: LazyLock> = @@ -20,6 +21,21 @@ pub fn set_theme_token_levels( } } +pub fn set_typography_keys(keys: Vec) { + if let Ok(mut registry) = TOKEN_REGISTRY.write() { + registry.typography = keys; + } +} + +/// Typography names defined by the registered theme, so a dynamic +/// `typography` value under a selector can resolve to one class per name. +pub fn get_typography_keys() -> Vec { + TOKEN_REGISTRY + .read() + .map(|registry| registry.typography.clone()) + .unwrap_or_default() +} + /// Look up a `$token` in the length and shadow registries. /// Returns the responsive breakpoint levels if the token is defined /// with more than one level, regardless of which CSS property it's used on. @@ -94,6 +110,14 @@ mod tests { assert!(!is_responsive_theme_token("noprefix")); } + #[test] + fn test_typography_keys() { + set_typography_keys(vec!["body".to_string(), "title".to_string()]); + assert_eq!(get_typography_keys(), vec!["body", "title"]); + set_typography_keys(vec![]); + assert!(get_typography_keys().is_empty()); + } + #[test] fn test_is_responsive_theme_token_without_prefix() { let value = std::hint::black_box("noprefix"); From 986173053ee112f682b1af29f4cacb63f7405058 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:48:00 +0900 Subject: [PATCH 04/12] fix(extractor): compose at-rules with selectors and resolve vanilla-extract references Nest _print, _media and @media keys with selectors in both directions (props, globalCss, template literals, Tailwind variants, StyleX conditions), apply typography conditionally, and keep responsive globalCss values in the base layer. vanilla-extract selectors, values and globalStyle now resolve references to styles, keyframes, fonts and themes, and createVar/fontFace/layer exports no longer shift onto each other. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/css_utils.rs | 434 ++++------- .../extract_global_style_from_expression.rs | 114 ++- .../extract_style_from_expression.rs | 307 +++++--- libs/extractor/src/lib.rs | 53 +- libs/extractor/src/prop_modify_utils.rs | 10 +- ...xtractor__tests__at_rules_at_prefix-2.snap | 4 +- ...xtractor__tests__at_rules_at_prefix-3.snap | 4 +- .../extractor__tests__at_rules_at_prefix.snap | 4 +- ...__tests__at_rules_underscore_prefix-2.snap | 4 +- ...__tests__at_rules_underscore_prefix-3.snap | 4 +- ...__tests__at_rules_underscore_prefix-4.snap | 4 +- ...or__tests__at_rules_underscore_prefix.snap | 4 +- ...or__tests__container_at_rule_in_css-2.snap | 4 +- ...ctor__tests__container_at_rule_in_css.snap | 4 +- ...__css_container_at_rule_with_selector.snap | 4 +- ...extract_static_css_with_media_query-2.snap | 4 + ...extract_static_css_with_media_query-3.snap | 2 + ...__extract_static_css_with_media_query.snap | 2 + ...tractor__tests__global_css_at_rules-2.snap | 6 +- ...tractor__tests__global_css_at_rules-3.snap | 6 +- ...tractor__tests__global_css_at_rules-4.snap | 18 +- ...extractor__tests__global_css_at_rules.snap | 6 +- ...actor__tests__media_query_selectors-2.snap | 2 + ...actor__tests__media_query_selectors-3.snap | 4 +- ...actor__tests__media_query_selectors-4.snap | 2 + ...actor__tests__media_query_selectors-5.snap | 4 + ...tractor__tests__media_query_selectors.snap | 4 + ...ests__selector_refs_with_global_theme.snap | 4 +- .../extractor__tests__stylex_media_query.snap | 4 +- ...__tests__stylex_nested_hover_in_media.snap | 4 +- ...tests__stylex_nested_media_then_hover.snap | 8 +- ...tractor__tests__stylex_supports_query.snap | 4 +- ...tor__tests__stylex_types_in_condition.snap | 4 +- ..._vanilla_extract_all_exports_combined.snap | 2 +- ...tor__tests__vanilla_extract_container.snap | 4 +- ...__vanilla_extract_imports_combination.snap | 6 +- ...sts__vanilla_extract_keyframes_export.snap | 4 +- ..._tests__vanilla_extract_media_queries.snap | 16 +- ...la_extract_selector_class_replacement.snap | 4 +- ...__vanilla_extract_selector_references.snap | 6 +- ...t_selector_refs_triggers_with_classes.snap | 4 +- ...r__tests__vanilla_extract_selectors-2.snap | 4 +- ...ctor__tests__vanilla_extract_supports.snap | 4 +- ...la_extract_with_at_container_selector.snap | 8 +- libs/extractor/src/stylex.rs | 107 +-- libs/extractor/src/tailwind.rs | 159 ++-- libs/extractor/src/vanilla_extract.rs | 689 ++++++++++-------- libs/extractor/src/visit.rs | 48 +- 48 files changed, 1162 insertions(+), 949 deletions(-) diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index be4b5e863..b594560af 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -4,9 +4,10 @@ use std::fmt::Write as _; use crate::utils::{get_str_by_property_key, get_string_by_literal_expression, wrap_direct_call}; use css::{ + at_rule::split_at_rule_key, optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value}, rm_css_comment::rm_css_comment, - style_selector::{AtRuleKind, StyleSelector}, + style_selector::StyleSelector, }; use oxc_allocator::Allocator; use oxc_span::SPAN; @@ -421,238 +422,13 @@ pub fn css_to_style_literal( styles } -const AT_RULES: [(&str, AtRuleKind); 3] = [ - ("@media", AtRuleKind::Media), - ("@supports", AtRuleKind::Supports), - ("@container", AtRuleKind::Container), -]; - pub fn css_to_style( css: &str, level: u8, selector: &Option, ) -> Vec { let mut styles = vec![]; - let mut input = css; - - // Split by at-rules (@media, @supports, @container) to handle multiple at-rules in a single input. - // Every at-rule prefix begins with `@`, so a single `@` byte scan is a sound necessary-condition - // guard: if the input has no `@` at all (the overwhelmingly common declaration block), none of the - // three prefixes can match, so skip all three `input.contains(at_rule)` substring scans entirely. - if input.contains('@') { - // Classify which of the three at-rule prefixes are present in ONE pass over the - // `@`-anchored positions, instead of re-scanning the whole input with a separate - // `input.contains(at_rule)` per prefix inside the loop. For an `@`-bearing block - // carrying just one kind (the common single-`@media`/`@supports`/`@container` case), - // the two absent kinds previously each paid a full substring scan; now every - // `@`-run is examined once here and the loop reads a precomputed `bool`. The - // presence check keys off the byte right after `@` (`m`/`s`/`c`), matching the - // prefixes' first distinguishing letter, so it is byte-identical to `contains`. - let mut present = [false; AT_RULES.len()]; - for (pos, _) in input.match_indices('@') { - let rest = &input[pos..]; - for (i, (at_rule, _)) in AT_RULES.iter().enumerate() { - if !present[i] && rest.starts_with(at_rule) { - present[i] = true; - } - } - } - for (idx, (at_rule, _)) in AT_RULES.iter().enumerate() { - // Only the multi-segment case recurses. Walk the non-empty trimmed `@rule` segments with - // a single `split` pass (dropping the earlier separate `count` scan + identical `collect` - // scan) via a peekable iterator: confirm a *second* non-empty segment before allocating, - // so the common single-`@media`/`@supports`/`@container` block (already dispatched by an - // outer recursion level) still skips materializing a throwaway `Vec`. - // Absent prefixes are skipped via the precomputed `present` flags (no re-scan). - if !present[idx] { - continue; - } - let mut segments = input.split(at_rule).filter_map(|s| { - let s = s.trim(); - (!s.is_empty()).then_some(s) - }); - if let Some(first) = segments.next() - && let Some(second) = segments.next() - { - // Re-attach the known `at_rule` prefix to each segment with a presized - // `String` + two `push_str` instead of `format!`, which pulls in the - // `Arguments` formatting machinery and its grow path. Both lengths are - // known up front, so a single exact allocation suffices. Byte-identical - // to `format!("{at_rule}{seg}")`. - let join_at = |seg: &str| { - let mut s = String::with_capacity(at_rule.len() + seg.len()); - s.push_str(at_rule); - s.push_str(seg); - s - }; - styles.extend(css_to_style(&join_at(first), level, selector)); - styles.extend(css_to_style(&join_at(second), level, selector)); - for rest in segments { - styles.extend(css_to_style(&join_at(rest), level, selector)); - } - return styles; - } - } - } - - if input.contains('{') { - while let Some(start) = input.find('{') { - // Check if there are properties before the selector - let before_brace = &input[..start].trim(); - - // The overwhelmingly common case has no `;`-separated plain props before the - // selector (e.g. `&:hover { ... }`), which maps to the single-part `else` - // branch below. Only build the `Vec<&str>` split when a `;` is actually present. - let (plain_props, selector_part): (&str, &str) = if before_brace.contains(';') { - // Split by semicolon to find the last part which should be the selector - let parts: Vec<&str> = before_brace.split(';').map(str::trim).collect(); - - // Find the selector part (the last part that doesn't contain ':') - // or if all parts contain ':', then the last part is the selector - // `before_brace` contains `;`, so `split` always yields at least two parts. - // Check if any part doesn't contain ':' (which would be a selector). - let mut selector_idx = parts.len(); - for (i, part) in parts.iter().enumerate().rev() { - if !part.contains(':') || part.starts_with('&') || part.starts_with('@') { - selector_idx = i; - break; - } - } - - // Borrow the props/selector partition directly from `before_brace` - // instead of allocating two `join(";")` Strings. The split boundary is the - // byte offset of the `selector_idx`-th `;` (parts are `;`-separated), so - // `[..boundary]` is the props run and `[boundary + 1..]` the selector run. - // `css_to_style_block` re-splits/re-trims each `;` part on the props side and - // the selector side is `.trim()`ed downstream, so the parsed output stays - // byte-identical to the previous `join(";")` form. - // `selector_idx` is 0 when the very first `;`-part is the selector - // (e.g. `foo;color:red { .. }`), leaving no props run before it. - // `checked_sub` yields `None` there, which the match below already - // treats as "the whole run is the selector" - the same result release - // builds reached by wrapping, without the debug-build overflow panic. - let boundary = selector_idx - .checked_sub(1) - .and_then(|nth| before_brace.match_indices(';').nth(nth).map(|(idx, _)| idx)); - match boundary { - Some(b) => (before_brace[..b].trim(), before_brace[b + 1..].trim()), - // No selector boundary: whole `before_brace` is the selector. - None => ("", before_brace), - } - } else { - ("", before_brace) - }; - - // Process plain properties if any - if !plain_props.is_empty() { - styles.extend(css_to_style_block(plain_props, level, selector)); - } - - let rest = &input[start + 1..]; - - // Find the matching closing brace by counting braces - let mut brace_count = 1; - let mut end = 0; - for (i, byte) in rest.bytes().enumerate() { - match byte { - b'{' => brace_count += 1, - b'}' => { - brace_count -= 1; - if brace_count == 0 { - end = i; - break; - } - } - _ => {} - } - } - - // If we didn't find a matching brace, use the first '}' as fallback - if brace_count > 0 { - end = rest.find('}').unwrap_or(rest.len()); - } - let block = &rest[..end]; - let sel = &if let Some(StyleSelector::At { kind, query, .. }) = selector { - let local_sel = selector_part.trim().to_string(); - Some(StyleSelector::At { - kind: *kind, - query: query.clone(), - selector: if local_sel == "&" { - None - } else { - Some(local_sel) - }, - }) - } else { - let sel = selector_part.trim().to_string(); - if let Some((prefix, kind)) = - AT_RULES.iter().find(|(prefix, _)| sel.starts_with(prefix)) - { - // The prefix contains neither spaces nor "and(", so slicing it off - // first is equivalent to slicing after the replaces. Strip spaces into - // one pre-sized buffer, then a single "and(" -> "and (" normalization. - let rest = &sel[prefix.len()..]; - let mut query = String::with_capacity(rest.len()); - for ch in rest.chars() { - if ch != ' ' { - query.push(ch); - } - } - // `and(` only appears in multi-condition media queries. Skip the - // second-buffer allocation of `replace` on the common - // single-condition path where the normalization is a no-op. - let query = if query.contains("and(") { - query.replace("and(", "and (") - } else { - query - }; - Some(StyleSelector::At { - kind: *kind, - query, - selector: None, - }) - } else if sel.is_empty() { - selector.clone() - } else { - Some(StyleSelector::Selector(sel)) - } - }; - let block = if block.contains('{') { - css_to_style(block, level, sel) - } else { - css_to_style_block(block, level, sel) - }; - - // Find the matching closing brace - let closing_brace_pos = start + 1 + end; - - // Process the block - styles.extend(block); - - // Update input to continue processing after the closing brace - // Check if there's more content after the closing brace - if closing_brace_pos + 1 < input.len() { - let remaining = &input[closing_brace_pos + 1..].trim(); - if remaining.is_empty() { - break; - } - // If there's remaining text after the closing brace, process it - // This handles cases like "} color: blue;" - if remaining.contains('{') { - // If it contains '{', continue the loop - input = remaining; - } else { - // If it doesn't contain '{', process it as a block and break - styles.extend(css_to_style_block(remaining, level, selector)); - break; - } - } else { - break; - } - } - } else { - styles.extend(css_to_style_block(input, level, selector)); - } + collect_css_block(&rm_css_comment(css), level, selector, &mut styles); // A single declaration (or none) is trivially ordered, so skip the comparison // sort's setup entirely for the very common single-property case. The multi-source @@ -664,6 +440,70 @@ pub fn css_to_style( styles } +/// Walk one level of CSS text: declarations apply to `selector`, and every +/// nested `prelude { … }` block recurses with its prelude composed onto it, so +/// `&:hover { @media print { … } }` keeps both the hover and the media query. +fn collect_css_block( + css: &str, + level: u8, + selector: &Option, + styles: &mut Vec, +) { + let mut rest = css; + while let Some(open) = rest.find('{') { + let head = &rest[..open]; + let (declarations, prelude) = head.rsplit_once(';').unwrap_or(("", head)); + styles.extend(css_to_style_block(declarations, level, selector)); + + let body_start = open + 1; + let mut depth = 1usize; + let body_end = rest[body_start..] + .char_indices() + .find_map(|(index, c)| { + match c { + '{' => depth += 1, + '}' => depth -= 1, + _ => {} + } + (depth == 0).then_some(body_start + index) + }) + .unwrap_or(rest.len()); + let body = &rest[body_start..body_end]; + let prelude = prelude.trim(); + if prelude.is_empty() || prelude == "&" { + collect_css_block(body, level, selector, styles); + } else if let Some(nested) = nest_prelude(selector.as_ref(), prelude) { + collect_css_block(body, level, &Some(nested), styles); + } + rest = rest.get(body_end + 1..).unwrap_or_default(); + } + styles.extend(css_to_style_block(rest, level, selector)); +} + +/// The selector a nested block applies to: at-rule preludes wrap `parent`, +/// `&`-selectors substitute it, and a bare selector under a selector nests as +/// a descendant. `None` drops the block (an unknown at-rule, or conditions +/// that can never match together). +fn nest_prelude(parent: Option<&StyleSelector>, prelude: &str) -> Option { + if prelude.starts_with('@') { + let (kind, query) = split_at_rule_key(prelude)?; + return StyleSelector::nest_at_rule(parent, kind, query); + } + let parent_selector = match parent { + Some(StyleSelector::Selector(selector) | StyleSelector::Global(selector, _)) => { + Some(selector) + } + Some(StyleSelector::At { selector, .. }) => selector.as_ref(), + None => None, + }; + let template = if prelude.contains('&') || parent_selector.is_none() { + Cow::Borrowed(prelude) + } else { + Cow::Owned(format!("& {prelude}")) + }; + Some(StyleSelector::nest_selector(parent, &template)) +} + /// Optimize a declaration's value only when its property warrants multi-value /// optimization, borrowing otherwise. Shared by `css_to_style_block` and /// `optimize_css_block`, which both made this identical decision inline. @@ -868,6 +708,7 @@ fn remove_semicolon_before_closing_brace(value: &mut String) { mod tests { use super::*; + use css::style_selector::AtRuleKind; use oxc_allocator::Allocator; use oxc_ast::ast::{Expression, Statement}; use oxc_parser::Parser; @@ -942,12 +783,12 @@ mod tests { ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -965,22 +806,22 @@ mod tests { ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)and (max-width:1024px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)and (max-width:1024px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -999,22 +840,22 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), + selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), + selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None, })), ] )] @@ -1033,22 +874,22 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ] )] @@ -1077,42 +918,42 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ] )] @@ -1131,22 +972,22 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -1169,12 +1010,12 @@ mod tests { ("display", "grid", Some(StyleSelector::At { kind: AtRuleKind::Supports, query: "(display:grid)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("grid-template-columns", "1fr 1fr", Some(StyleSelector::At { kind: AtRuleKind::Supports, query: "(display:grid)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -1188,7 +1029,7 @@ mod tests { ("display", "flex", Some(StyleSelector::At { kind: AtRuleKind::Supports, query: "(display:flex)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ] )] @@ -1199,8 +1040,8 @@ mod tests { vec![ ("display", "block", Some(StyleSelector::At { kind: AtRuleKind::Supports, - query: "not(display:grid)".to_string(), - selector: None, + query: "not (display:grid)".to_string(), + selector: None, outer: vec![], file: None, })), ] )] @@ -1213,7 +1054,7 @@ mod tests { ("padding", "10px", Some(StyleSelector::At { kind: AtRuleKind::Container, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -1224,8 +1065,8 @@ mod tests { vec![ ("display", "flex", Some(StyleSelector::At { kind: AtRuleKind::Container, - query: "sidebar(min-width:400px)".to_string(), - selector: None, + query: "sidebar (min-width:400px)".to_string(), + selector: None, outer: vec![], file: None, })), ] )] @@ -1451,12 +1292,12 @@ mod tests { ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -1474,22 +1315,22 @@ mod tests { ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)and (max-width:1024px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)and (max-width:1024px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -1508,22 +1349,22 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), + selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), + selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None, })), ] )] @@ -1542,22 +1383,22 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ] )] @@ -1586,42 +1427,42 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: Some("&:hover".to_string()), + selector: Some("&:hover".to_string()), outer: vec![], file: None, })), ] )] @@ -1640,22 +1481,22 @@ mod tests { ("border", "1px solid #FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#FFF", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(min-width:768px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("border", "1px solid #000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ("color", "#000", Some(StyleSelector::At { kind: AtRuleKind::Media, query: "(max-width:768px)and (min-width:480px)".to_string(), - selector: None, + selector: None, outer: vec![], file: None, })), ] )] @@ -1681,29 +1522,30 @@ mod tests { ("background", "blue", Some(StyleSelector::Selector("div".to_string()))), ] )] + // As in CSS nesting, only the text after the last `;` is the nested rule's + // selector; everything before it is declarations. #[case( "color:red;background:blue { width: 1px; }", - vec![( - "width", - "1px", - Some(StyleSelector::Selector( - "color:red;background:blue".to_string() - )) - )] + vec![ + ("color", "red", None), + ("width", "1px", Some(StyleSelector::Selector("background:blue".to_string()))), + ] )] - // The selector is the very first `;`-part, so there is no props run before it. #[case( "foo;color:red { width: 1px; }", vec![( "width", "1px", - Some(StyleSelector::Selector("foo;color:red".to_string())) + Some(StyleSelector::Selector("color:red".to_string())) )] )] #[case( "color: red;;invalid;display: block;", vec![("color", "red", None), ("display", "block", None)] )] + // Blocks that can never apply produce nothing: unknown at-rules and + // mutually exclusive media types. + #[case("@layer base { color: red; } @media print { @media screen { color: red; } }", vec![])] fn test_css_to_style( #[case] input: &str, #[case] expected: Vec<(&str, &str, Option)>, diff --git a/libs/extractor/src/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs index 15ba1c04f..fd8a70208 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -9,7 +9,9 @@ use crate::{ }, extractor::{ GlobalExtractResult, - extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, + extract_style_from_expression::{ + LiteralHandling, at_rule_record_kind, extract_style_from_expression, + }, }, utils::{ get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, @@ -17,9 +19,10 @@ use crate::{ }, }; use css::{ + at_rule::{media_shorthand_query, split_at_rule_key}, disassemble_property, optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value, wrap_url}, - style_selector::StyleSelector, + style_selector::{AtRule, AtRuleKind, StyleSelector}, }; use oxc_ast::{ ast::{ArrayExpressionElement, Expression, ObjectPropertyKind}, @@ -32,6 +35,35 @@ pub fn extract_global_style_from_expression<'a>( file: &str, ) -> GlobalExtractResult<'a> { let mut styles = vec![]; + collect_global_styles(ast_builder, expression, file, &[], &mut styles); + GlobalExtractResult { + styles, + style_order: None, + } +} + +/// A top-level at-rule key wrapping a selector map: `'@media print'` or a +/// media shorthand such as `_print` / `_motionReduce`. +fn global_at_rule_key(name: &str) -> Option { + let (kind, query) = split_at_rule_key(name).or_else(|| { + let query = media_shorthand_query(name.strip_prefix('_')?)?; + Some((AtRuleKind::Media, query)) + })?; + Some(AtRule { + kind, + query: query.to_string(), + }) +} + +/// Collect a `globalCss` selector map whose rules sit inside `at_rules` +/// (outermost first). +fn collect_global_styles<'a>( + ast_builder: &AstBuilder<'a>, + expression: &mut Expression<'a>, + file: &str, + at_rules: &[AtRule], + styles: &mut Vec>, +) { let expression = unwrap_syntax_only_mut(expression); if let Expression::ObjectExpression(obj) = expression { @@ -39,7 +71,29 @@ pub fn extract_global_style_from_expression<'a>( match p { ObjectPropertyKind::ObjectProperty(o) => { if let Some(name) = get_string_by_property_key(&o.key) { - if name == "imports" { + if let Some(kind) = at_rule_record_kind(&name) + && let Expression::ObjectExpression(record) = &mut o.value + { + for entry in &mut record.properties { + if let ObjectPropertyKind::ObjectProperty(entry) = entry + && let Some(query) = get_string_by_property_key(&entry.key) + { + let mut nested = at_rules.to_vec(); + nested.push(AtRule { kind, query }); + collect_global_styles( + ast_builder, + &mut entry.value, + file, + &nested, + styles, + ); + } + } + } else if let Some(at_rule) = global_at_rule_key(&name) { + let mut nested = at_rules.to_vec(); + nested.push(at_rule); + collect_global_styles(ast_builder, &mut o.value, file, &nested, styles); + } else if name == "imports" { if let Expression::ArrayExpression(arr) = &o.value { for p in &arr.elements { // `...spread` elements carry no statically readable url. @@ -169,21 +223,30 @@ pub fn extract_global_style_from_expression<'a>( None }; - let extracted = extract_style_from_expression( - ast_builder, - None, - &mut o.value, - 0, - &Some(StyleSelector::Global( - if let Some(name) = name.strip_prefix("_") { - StyleSelector::from(name).to_string().replace('&', "*") - } else { - name - }, - file.to_string(), - )), - LiteralHandling::ExpandResponsiveThemeToken, + let global = StyleSelector::Global( + if let Some(name) = name.strip_prefix("_") { + StyleSelector::from(name).to_string().replace('&', "*") + } else { + name + }, + file.to_string(), ); + // `None` when the enclosing at-rules can never match together. + let selector = at_rules.iter().try_fold(global, |selector, rule| { + StyleSelector::nest_at_rule(Some(&selector), rule.kind, &rule.query) + }); + let extracted = selector + .map(|selector| { + extract_style_from_expression( + ast_builder, + None, + &mut o.value, + 0, + &Some(selector), + LiteralHandling::ExpandResponsiveThemeToken, + ) + }) + .unwrap_or_default(); // Filter out @layer property from styles and set layer on remaining styles for mut style in extracted.styles { @@ -206,20 +269,15 @@ pub fn extract_global_style_from_expression<'a>( } } ObjectPropertyKind::SpreadProperty(o) => { - styles.extend( - extract_global_style_from_expression( - ast_builder, - o.argument.get_inner_expression_mut(), - file, - ) - .styles, + collect_global_styles( + ast_builder, + o.argument.get_inner_expression_mut(), + file, + at_rules, + styles, ); } } } } - GlobalExtractResult { - styles, - style_order: None, - } } diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index cc4a9311b..2d98df84d 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -16,11 +16,12 @@ use crate::{ }, }; use css::{ - add_selector_params, disassemble_property, get_enum_property_map, get_enum_property_value, - is_enum_property, + add_selector_params, + at_rule::{media_shorthand_query, split_at_rule_key}, + disassemble_property, get_enum_property_map, get_enum_property_value, is_enum_property, is_special_property::is_special_property, - style_selector::StyleSelector, - theme_tokens::{get_responsive_theme_token, is_responsive_theme_token}, + style_selector::{AtRuleKind, StyleSelector, optimize_selector}, + theme_tokens::{get_responsive_theme_token, get_typography_keys, is_responsive_theme_token}, utils::to_kebab_case, }; use oxc_allocator::{CloneIn, GetAllocator}; @@ -220,6 +221,31 @@ pub fn extract_style_from_expression<'a>( }; } + if let Some(name) = name + && is_nested_selector_key(name) + { + let mut styles = vec![]; + for part in split_selector_list(name) { + if let Some(nested) = nest_selectors_key(selector.as_ref(), part) { + styles.extend( + extract_style_from_expression( + ast_builder, + None, + expression, + level, + &Some(nested), + literal_handling, + ) + .styles, + ); + } + } + return ExtractResult { + styles, + ..ExtractResult::default() + }; + } + if let Some(name) = name // First-byte dispatch: only names beginning with `a` (`as`), `s` // (`selectors`), `@`/`_` (at-rule + `_`-selector prefixes) or `t` @@ -242,76 +268,23 @@ pub fn extract_style_from_expression<'a>( { let mut props = vec![]; for p in &mut obj.properties { - if let ObjectPropertyKind::ObjectProperty(o) = p { - let Some(key_name) = o.key.name() else { - continue; - }; - let name = key_name.trim(); - let mut part_of_selector = vec![]; - - let mut level = 0; - let mut last_idx = 0; - for (idx, c) in name.char_indices() { - if c == '(' { - level += 1; - } - if c == ')' { - level -= 1; - } - if c == ',' && level == 0 { - part_of_selector.push(name[last_idx..idx].trim()); - last_idx = idx + 1; - } - } - if !name.is_empty() { - part_of_selector.push(name[last_idx..].trim()); - } - - // The parent selector's owned string is invariant across every - // comma-part, so materialize it ONCE here instead of calling - // `selector.to_string()` up to 3× per part inside the closure. - let parent_sel = selector.as_ref().map(ToString::to_string); - for sel in part_of_selector.iter().map(|name| { - if let Some(parent_sel) = parent_sel.as_deref() { - if name.starts_with('_') { - if name.starts_with("_theme") { - StyleSelector::from([ - to_kebab_case(name.strip_prefix("_").unwrap_or(name)) - .as_ref(), - parent_sel, - ]) - .to_string() - } else { - StyleSelector::from([ - parent_sel, - to_kebab_case(name.strip_prefix("_").unwrap_or(name)) - .as_ref(), - ]) - .to_string() - } - } else { - name.replace('&', parent_sel) - } - } else if name.starts_with('_') { - StyleSelector::from( - to_kebab_case(name.strip_prefix("_").unwrap_or(name)).as_ref(), - ) - .to_string() - } else { - StyleSelector::from(name.strip_prefix("_").unwrap_or(name)).to_string() + if let ObjectPropertyKind::ObjectProperty(o) = p + && let Some(key_name) = o.key.name() + { + for part in split_selector_list(key_name.trim()) { + if let Some(child) = nest_selectors_key(selector.as_ref(), part) { + props.extend( + extract_style_from_expression( + ast_builder, + None, + &mut o.value, + level, + &Some(child), + literal_handling, + ) + .styles, + ); } - }) { - props.extend( - extract_style_from_expression( - ast_builder, - None, - &mut o.value, - level, - &Some(StyleSelector::Selector(sel)), - literal_handling, - ) - .styles, - ); } } } @@ -321,31 +294,16 @@ pub fn extract_style_from_expression<'a>( }; } - // Handle at-rules: @media, @supports, @container (or _media, _supports, _container) - let at_rule_name = name - .strip_prefix("@") - .or_else(|| name.strip_prefix("_")) - .filter(|n| matches!(*n, "media" | "supports" | "container")); - - if let Some(at_rule) = at_rule_name + if let Some(kind) = at_rule_record_kind(name) && let Expression::ObjectExpression(obj) = expression { let mut props = vec![]; - // The parent selector renders identically for every query in this - // at-rule object: format it once and clone the resulting `String` - // per iteration instead of re-running `Display` formatting. `query` - // is moved into the selector (it was previously cloned even though - // the original was dropped immediately after). - let parent = selector.as_ref().map(ToString::to_string); for p in &mut obj.properties { if let ObjectPropertyKind::ObjectProperty(o) = p && let Some(query) = get_string_by_property_key(&o.key) + && let Some(at_selector) = + StyleSelector::nest_at_rule(selector.as_ref(), kind, &query) { - let at_selector = StyleSelector::At { - kind: at_rule.into(), - query, - selector: parent.clone(), - }; props.extend( extract_style_from_expression( ast_builder, @@ -365,19 +323,23 @@ pub fn extract_style_from_expression<'a>( }; } - if let Some(new_selector) = name.strip_prefix("_") { - return extract_style_from_expression( - ast_builder, - None, - expression, - level, - &Some(if let Some(selector) = selector { - (selector, new_selector).into() - } else { - new_selector.into() - }), - literal_handling, - ); + let nested = if let Some((kind, query)) = split_at_rule_key(name) { + Some(StyleSelector::nest_at_rule(selector.as_ref(), kind, query)) + } else { + name.strip_prefix('_') + .map(|child| nest_underscore_name(selector.as_ref(), child)) + }; + if let Some(nested) = nested { + return nested.map_or_else(ExtractResult::default, |nested| { + extract_style_from_expression( + ast_builder, + None, + expression, + level, + &Some(nested), + literal_handling, + ) + }); } typo = name == "typography"; } @@ -385,9 +347,7 @@ pub fn extract_style_from_expression<'a>( if let Some(name) = name { ExtractResult { styles: if typo { - vec![ExtractStyleProp::Static(ExtractStyleValue::Typography( - value.into_owned(), - ))] + vec![typography_style(value.into_owned(), level, selector)] } else if matches!( literal_handling, LiteralHandling::ExpandResponsiveThemeToken @@ -508,7 +468,12 @@ pub fn extract_style_from_expression<'a>( // typo branch is safe. The non-typo branch must handle the // `name.is_none()` case (pseudo-selector recursion) by // returning empty styles. - styles: if typo { + styles: if typo + && let Some(style) = + conditional_typography(ast_builder, expression, level, selector) + { + vec![style] + } else if typo { vec![ExtractStyleProp::Expression { expression: Expression::new_template_literal( SPAN, @@ -566,6 +531,18 @@ pub fn extract_style_from_expression<'a>( // like any other non-extracted style prop. if IGNORED_IDENTIFIERS.contains(&identifier.name.as_str()) { ExtractResult::default() + } else if typo + && let Some(style) = conditional_typography( + ast_builder, + &Expression::new_identifier(SPAN, identifier.name.as_str(), ast_builder), + level, + selector, + ) + { + ExtractResult { + styles: vec![style], + ..ExtractResult::default() + } } else if let Some(name) = name { if typo { ExtractResult { @@ -841,6 +818,120 @@ pub fn extract_style_from_expression<'a>( } } +pub(crate) fn at_rule_record_kind(name: &str) -> Option { + match name.strip_prefix('@').or_else(|| name.strip_prefix('_'))? { + "media" => Some(AtRuleKind::Media), + "supports" => Some(AtRuleKind::Supports), + "container" => Some(AtRuleKind::Container), + _ => None, + } +} + +fn split_selector_list(key: &str) -> Vec<&str> { + let mut parts = vec![]; + let mut depth = 0usize; + let mut start = 0; + for (index, c) in key.char_indices() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(key[start..index].trim()); + start = index + 1; + } + _ => {} + } + } + if !key.is_empty() { + parts.push(key[start..].trim()); + } + parts +} + +/// Resolve a `_name` style key (without the `_`) under `parent`: media +/// shorthands such as `print` or `motionReduce` wrap it in `@media`, anything +/// else nests a selector. `None` means the styles can never apply. +fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Option { + let name = to_kebab_case(name); + if let Some(query) = media_shorthand_query(&name) { + return StyleSelector::nest_at_rule(parent, AtRuleKind::Media, query); + } + Some(StyleSelector::nest_selector( + parent, + &StyleSelector::from(name.as_ref()).to_string(), + )) +} + +/// Emotion/styled-components style object keys that are nested selectors +/// (`'&:hover'`, `':hover'`, `'.parent &'`) rather than CSS properties. +fn is_nested_selector_key(key: &str) -> bool { + key.starts_with(':') || key.contains('&') +} + +fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Option { + if let Some((kind, query)) = split_at_rule_key(key) { + StyleSelector::nest_at_rule(parent, kind, query) + } else if let Some(name) = key.strip_prefix('_') { + nest_underscore_name(parent, name) + } else if key.starts_with(':') { + Some(StyleSelector::nest_selector(parent, &format!("&{key}"))) + } else if parent.is_some() { + Some(StyleSelector::nest_selector(parent, key)) + } else { + Some(StyleSelector::from(key)) + } +} + +fn typography_atom(name: &str, level: u8, selector: &Option) -> ExtractStaticStyle { + ExtractStaticStyle { + property: "typography".to_string(), + value: name.to_string(), + level, + selector: selector.clone().map(optimize_selector), + style_order: None, + layer: None, + theme_token_resolution: ThemeTokenResolution::default(), + } +} + +/// A `typography` preset outside any selector or breakpoint uses the theme's +/// shared `typo-*` class. Under a condition the class would apply +/// unconditionally, so it becomes a `typography` atom the sheet expands into +/// the preset's declarations under that condition. +fn typography_style<'a>( + name: String, + level: u8, + selector: &Option, +) -> ExtractStyleProp<'a> { + ExtractStyleProp::Static(if selector.is_none() && level == 0 { + ExtractStyleValue::Typography(name) + } else { + ExtractStyleValue::Static(typography_atom(&name, level, selector)) + }) +} + +/// A dynamic `typography` value under a condition picks, at runtime, one +/// conditional atom per typography preset registered in the theme. +fn conditional_typography<'a>( + ast_builder: &AstBuilder<'a>, + condition: &Expression<'a>, + level: u8, + selector: &Option, +) -> Option> { + if selector.is_none() && level == 0 { + return None; + } + let condition = condition.clone_in(ast_builder.allocator()); + let map = get_typography_keys() + .into_iter() + .map(|key| { + let atom = ExtractStyleValue::Static(typography_atom(&key, level, selector)); + (key, vec![ExtractStyleProp::Static(atom)]) + }) + .collect(); + Some(ExtractStyleProp::Enum { condition, map }) +} + pub fn dynamic_style<'a>( ast_builder: &AstBuilder<'a>, name: &str, diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 62018cfa3..9b3759ae4 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -269,37 +269,28 @@ fn extract_with_source_map( match vanilla_extract::execute_vanilla_extract(&transformed_code, &option.package, filename) { Ok(collected) => { - // Check if any styles are referenced in selectors - let referenced = vanilla_extract::find_selector_references(&collected); - - if referenced.is_empty() { - // No selector references, use simple code generation - Some(vanilla_extract::collected_styles_to_code( - &collected, - &option.package, - )) + // Keyframes names are generated, so extract the referenced ones + // first and substitute their names into the styles using them. + let referenced = vanilla_extract::referenced_keyframes(&collected); + let keyframes_names = if referenced.is_empty() { + FxHashMap::default() } else { - // Two-pass extraction: first extract referenced styles to get their class names - let partial_code = vanilla_extract::collected_styles_to_code_partial( - &collected, - &option.package, + extract_class_map_from_code( + filename, + &vanilla_extract::keyframes_to_code( + &collected, + &option.package, + &referenced, + ), + &option, &referenced, - ); - - // Build class map by extracting the partial code - let class_map = if partial_code.is_empty() { - FxHashMap::default() - } else { - extract_class_map_from_code(filename, &partial_code, &option, &referenced)? - }; - - // Generate full code with class names substituted into selectors - Some(vanilla_extract::collected_styles_to_code_with_classes( - &collected, - &option.package, - &class_map, - )) - } + )? + }; + Some(vanilla_extract::collected_styles_to_code_with_keyframes( + &collected, + &option.package, + &keyframes_names, + )) } // Fall back to treating as regular file if execution fails Err(_) => None, @@ -11077,14 +11068,14 @@ const color = "red"; .unwrap() )); - // Test _speech media query selector + // Test _motionReduce media query shorthand reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", r#"import {Box} from '@devup-ui/core' - + "#, ExtractOption { package: "@devup-ui/core".to_string(), diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index bf26a8bb6..07cd67a71 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -2,7 +2,9 @@ use crate::extract_style::ExtractStyleProperty; use crate::extract_style::style_property::StyleProperty; use crate::gen_class_name::gen_class_names; use crate::gen_style::gen_styles; -use crate::tailwind::{has_tailwind_classes, parse_single_class, parse_tailwind_to_styles}; +use crate::tailwind::{ + TailwindClass, has_tailwind_classes, parse_single_class, parse_tailwind_to_styles, +}; use crate::utils::{get_str_by_property_key, merge_object_expressions}; use crate::{ExtractStyleProp, ExtractStyleValue}; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -432,8 +434,10 @@ fn extract_tailwind_from_class_name<'a>( let mut tailwind_styles: Vec = Vec::with_capacity(all_classes.bytes().filter(u8::is_ascii_whitespace).count() + 1); for class in all_classes.split_whitespace() { - if let Some(parsed) = parse_single_class(class) { - let mut static_style = parsed.to_static_style(); + if let Some(mut static_style) = parse_single_class(class) + .as_ref() + .and_then(TailwindClass::to_static_style) + { if let Some(order) = style_order { static_style.style_order = Some(order); } diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap index 64b06f5e4..d997635d3 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap index 76e04e7ef..3c0446efc 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Supports, - query: "(display: flex)", + query: "(display:flex)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap index 4955f592a..50e7177f0 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 400px)", + query: "(min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap index 6838840f4..0519ae097 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap index c8acba680..831bd6971 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Supports, - query: "(display: grid)", + query: "(display:grid)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap index 46797c5fa..5784017e3 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "sidebar (min-width: 400px)", + query: "sidebar (min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap index 24ed3c839..ebb729213 100644 --- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap +++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 400px)", + query: "(min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap index 83b4b00f5..b22b41278 100644 --- a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 500px)", + query: "(min-width:500px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap index 039f02dde..d5654738f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap +++ b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 400px)", + query: "(min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap b/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap index aaee60699..6d92afc50 100644 --- a/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap +++ b/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap @@ -22,8 +22,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 300px)", + query: "(min-width:300px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap index fbecc3169..98721d76f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap @@ -16,6 +16,8 @@ ToBTreeSet { selector: Some( "&:active", ), + outer: [], + file: None, }, ), style_order: None, @@ -34,6 +36,8 @@ ToBTreeSet { selector: Some( "&:hover", ), + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap index 36e107415..fe6259b5e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap @@ -14,6 +14,8 @@ ToBTreeSet { kind: Media, query: "(min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap index 2077d8c2d..ec26c961a 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap @@ -14,6 +14,8 @@ ToBTreeSet { kind: Media, query: "(min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap index 5d0a57e40..d0c56ad6b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap @@ -12,10 +12,14 @@ ToBTreeSet { selector: Some( At { kind: Supports, - query: "(display: grid)", + query: "(display:grid)", selector: Some( ".grid-container", ), + outer: [], + file: Some( + "test.tsx", + ), }, ), style_order: Some( diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap index b8b66f73a..87e38938b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap @@ -12,10 +12,14 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 400px)", + query: "(min-width:400px)", selector: Some( ".card", ), + outer: [], + file: Some( + "test.tsx", + ), }, ), style_order: Some( diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap index 111d8b3ac..603a5090c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap @@ -12,10 +12,14 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(prefers-color-scheme: dark)", + query: "(prefers-color-scheme:dark)", selector: Some( "body", ), + outer: [], + file: Some( + "test.tsx", + ), }, ), style_order: Some( @@ -49,10 +53,14 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: Some( "body", ), + outer: [], + file: Some( + "test.tsx", + ), }, ), style_order: Some( @@ -69,10 +77,14 @@ ToBTreeSet { selector: Some( At { kind: Supports, - query: "(display: grid)", + query: "(display:grid)", selector: Some( ".container", ), + outer: [], + file: Some( + "test.tsx", + ), }, ), style_order: Some( diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap index 6344e3cac..22912fd62 100644 --- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap +++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap @@ -12,10 +12,14 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: Some( "body", ), + outer: [], + file: Some( + "test.tsx", + ), }, ), style_order: Some( diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap index 55c45fd83..35ea4ba4c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap @@ -14,6 +14,8 @@ ToBTreeSet { kind: Media, query: "screen", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap index 65ff6056f..6eed386ef 100644 --- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "speech", + query: "(prefers-reduced-motion:reduce)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap index 53fca335f..98fafeea5 100644 --- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap @@ -14,6 +14,8 @@ ToBTreeSet { kind: Media, query: "all", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap index d0dbe75ae..7f9d008aa 100644 --- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap +++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap @@ -14,6 +14,8 @@ ToBTreeSet { kind: Media, query: "screen", selector: None, + outer: [], + file: None, }, ), style_order: None, @@ -40,6 +42,8 @@ ToBTreeSet { kind: Media, query: "print", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap index b344f636e..748b4a0d2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap +++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap @@ -14,6 +14,8 @@ ToBTreeSet { kind: Media, query: "print", selector: None, + outer: [], + file: None, }, ), style_order: None, @@ -30,6 +32,8 @@ ToBTreeSet { kind: Media, query: "print", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap b/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap index 3fb1e1961..bb890d2f8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap +++ b/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap @@ -55,7 +55,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "a:hover &", + ".f0_parent:hover &", ), ), style_order: None, @@ -63,5 +63,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\n;\nexport const vars = { \"colors\": {\n\t\"primary\": \"var(--colors-primary-global_theme_0-0)\",\n\t\"secondary\": \"var(--colors-secondary-global_theme_0-1)\"\n} };\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\nexport const vars = { \"colors\": {\n\t\"primary\": \"var(--colors-primary-global_theme_0-0)\",\n\t\"secondary\": \"var(--colors-secondary-global_theme_0-1)\"\n} };\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap index 28cdad898..f5a9bc039 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(max-width: 600px)", + query: "(max-width:600px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap index 61381013d..fd53e2ba7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap @@ -12,10 +12,12 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(hover: hover)", + query: "(hover:hover)", selector: Some( "&:hover", ), + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap index f03c0266e..02e2c49cd 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap @@ -12,10 +12,12 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: Some( "&:hover", ), + outer: [], + file: None, }, ), style_order: None, @@ -30,8 +32,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap index fcfcdda02..bd72492ed 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap @@ -22,8 +22,10 @@ ToBTreeSet { selector: Some( At { kind: Supports, - query: "(display: grid)", + query: "(display:grid)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap index 2488f50ad..3dc45fd4e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap @@ -22,8 +22,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(min-width: 768px)", + query: "(min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap index 6c1e22b62..9f4b29a89 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap @@ -114,5 +114,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const box = \"b\";\n;\n;\n;\nexport const fade = \"c\";\nexport const buttons = {\n\tprimary: \"a d\",\n\tsecondary: \"e\"\n};\nexport const myLayer = \"--var-0\";\nexport const myVar = \"__devup_font_0_0\";\nexport const myFont = \"__container_0__\";\nexport const vars = \"components\";\nexport const myContainer = { \"color\": { \"primary\": \"var(--color-primary-global_theme_0-0)\" } };\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const box = \"b\";\n;\n;\n;\nexport const fade = \"c\";\nexport const buttons = {\n\tprimary: \"a d\",\n\tsecondary: \"e\"\n};\nexport const myVar = \"--var-0\";\nexport const myFont = \"__devup_font_0_0\";\nexport const myContainer = \"__container_0__\";\nexport const myLayer = \"components\";\nexport const vars = { \"color\": { \"primary\": \"var(--color-primary-global_theme_0-0)\" } };\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap index 070fd6a42..563c444df 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap @@ -32,8 +32,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "__container_0__ (min-width: 400px)", + query: "__container_0__ (min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap index 7c1f58e30..695a64586 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "animation", - value: "myFont", + value: "a", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__font_0__", + value: "__devup_font_0_0", level: 0, selector: Some( Global( @@ -84,5 +84,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const animated = \"a\";\n;\n;\nexport const myFont = \"b\";\nexport const fadeIn = \"__devup_font_0_0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const animated = \"b\";\n;\n;\nexport const fadeIn = \"a\";\nexport const myFont = \"__devup_font_0_0\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap index 4f9210114..2cf86d4c6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "animation", - value: "spin", + value: "a", level: 0, selector: None, style_order: None, @@ -67,5 +67,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const spinner = \"a\";\nconst internal = \"b\";\nexport const spin = \"c\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const spinner = \"b\";\nconst internal = \"c\";\nexport const spin = \"a\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap index b90173c4b..b693d81a3 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap @@ -12,8 +12,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(prefers-color-scheme: dark)", + query: "(prefers-color-scheme:dark)", selector: None, + outer: [], + file: None, }, ), style_order: None, @@ -28,8 +30,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "(prefers-color-scheme: dark)", + query: "(prefers-color-scheme:dark)", selector: None, + outer: [], + file: None, }, ), style_order: None, @@ -54,8 +58,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "screen and (min-width: 768px)", + query: "screen and (min-width:768px)", selector: None, + outer: [], + file: None, }, ), style_order: None, @@ -70,8 +76,10 @@ ToBTreeSet { selector: Some( At { kind: Media, - query: "screen and (min-width: 1024px)", + query: "screen and (min-width:1024px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap index 86bf61d85..8ef25f573 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap @@ -11,7 +11,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "a:hover &", + ".f0_parent:hover &", ), ), style_order: None, @@ -29,5 +29,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap index c591ffd98..e7f52c8e8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap @@ -21,7 +21,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "a:hover &", + ".f0_parent:hover &", ), ), style_order: None, @@ -35,7 +35,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "a + &", + ".f0_parent + &", ), ), style_order: None, @@ -43,5 +43,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\nexport const sibling = \"c\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\nexport const sibling = \"c\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap index 833027ad5..66d773c71 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap @@ -11,7 +11,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "a:hover &", + ".f0_parent:hover &", ), ), style_order: None, @@ -81,5 +81,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\n;\n;\nexport const fade = \"c\";\nexport const myContainer = \"--var-0\";\nexport const colorVar = \"__devup_font_0_0\";\nexport const myLayer = \"__container_0__\";\nexport const myFont = \"ui\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\n;\nexport const fade = \"c\";\nexport const colorVar = \"--var-0\";\nexport const myFont = \"__devup_font_0_0\";\nexport const myContainer = \"__container_0__\";\nexport const myLayer = \"ui\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap index 97d273b2f..c6d642b08 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap @@ -21,7 +21,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "a:hover &", + ".f0_parent:hover &", ), ), style_order: None, @@ -43,5 +43,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b c\";\nexport const parent = \"a\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a b\";\nexport const parent = \"c\" + \" f0_parent\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap index d2aa8abd1..d68c6b0da 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap @@ -22,8 +22,10 @@ ToBTreeSet { selector: Some( At { kind: Supports, - query: "(display: grid)", + query: "(display:grid)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap index 20ef3082f..096ea417c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap @@ -22,8 +22,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 400px)", + query: "(min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, @@ -38,8 +40,10 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "(min-width: 400px)", + query: "(min-width:400px)", selector: None, + outer: [], + file: None, }, ), style_order: None, diff --git a/libs/extractor/src/stylex.rs b/libs/extractor/src/stylex.rs index 9f3519ab0..10cddf7c3 100644 --- a/libs/extractor/src/stylex.rs +++ b/libs/extractor/src/stylex.rs @@ -165,20 +165,16 @@ pub fn decompose_value_conditions( ) -> Vec { // String literal → leaf if let Some(s) = get_string_by_literal_expression(value) { - return vec![DecomposedStyle { - property: css_property.to_string(), - value: Some(s.into_owned()), - selector: compose_selectors(parent_selectors), - }]; + return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors) + .into_iter() + .collect(); } // NullLiteral → tracked but no CSS if matches!(value, Expression::NullLiteral(_)) { - return vec![DecomposedStyle { - property: css_property.to_string(), - value: None, - selector: compose_selectors(parent_selectors), - }]; + return decomposed_leaf(css_property, None, parent_selectors) + .into_iter() + .collect(); } // CallExpression: firstThatWorks() → multiple fallback values with current selectors @@ -190,11 +186,11 @@ pub fn decompose_value_conditions( if let Some(arg_expr) = arg.as_expression() && let Some(s) = get_string_by_literal_expression(arg_expr) { - results.push(DecomposedStyle { - property: css_property.to_string(), - value: Some(s.into_owned()), - selector: compose_selectors(parent_selectors), - }); + results.extend(decomposed_leaf( + css_property, + Some(s.into_owned()), + parent_selectors, + )); } } return results; @@ -206,11 +202,9 @@ pub fn decompose_value_conditions( && let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression()) { if let Some(s) = get_string_by_literal_expression(inner) { - return vec![DecomposedStyle { - property: css_property.to_string(), - value: Some(s.into_owned()), - selector: compose_selectors(parent_selectors), - }]; + return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors) + .into_iter() + .collect(); } return vec![]; } @@ -258,14 +252,15 @@ pub fn decompose_value_conditions( results } -/// Compose a list of selector parts into a single `StyleSelector`. -fn compose_selectors(parts: &[SelectorPart]) -> Option { - if parts.is_empty() { - return None; - } - +/// A leaf style under `parent_selectors`, or `None` when those conditions can +/// never match together (e.g. `@media print` inside `@media screen`). +fn decomposed_leaf( + css_property: &str, + value: Option, + parent_selectors: &[SelectorPart], +) -> Option { let mut pseudo_str: Option = None; - for p in parts { + for p in parent_selectors { if let SelectorPart::Pseudo(s) = p { pseudo_str .get_or_insert_with(|| String::from("&")) @@ -273,21 +268,23 @@ fn compose_selectors(parts: &[SelectorPart]) -> Option { } } - // Last at-rule wins; read it with a reverse scan (no intermediate Vec). - let last_at_rule = parts.iter().rev().find_map(|p| match p { - SelectorPart::AtRule { kind, query } => Some((*kind, query.as_str())), - SelectorPart::Pseudo(_) => None, - }); - - if let Some((kind, query)) = last_at_rule { - Some(StyleSelector::At { - kind, - query: query.to_string(), - selector: pseudo_str, - }) - } else { - pseudo_str.map(StyleSelector::Selector) + // Every enclosing at-rule applies, outermost first. + let mut selector = pseudo_str.map(StyleSelector::Selector); + for p in parent_selectors { + if let SelectorPart::AtRule { kind, query } = p { + selector = Some(StyleSelector::nest_at_rule( + selector.as_ref(), + *kind, + query, + )?); + } } + + Some(DecomposedStyle { + property: css_property.to_string(), + value, + selector, + }) } /// Parse an at-rule key like `"@media (max-width: 600px)"` into kind + query. @@ -319,4 +316,32 @@ mod tests { assert_eq!(normalize_stylex_property("color"), "color"); assert_eq!(normalize_stylex_property("zIndex"), "z-index"); } + + #[test] + fn test_decompose_folds_every_at_rule() { + let allocator = oxc_allocator::Allocator::default(); + let source = "({ default: 'red', '@media print': { ':hover': { '@media (prefers-reduced-motion: reduce)': 'blue', '@media screen': 'green' } } })"; + let program = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::ts()) + .parse() + .program; + let oxc_ast::ast::Statement::ExpressionStatement(statement) = &program.body[0] else { + panic!("expected expression statement"); + }; + + let value = statement.expression.without_parentheses(); + let styles: Vec<_> = decompose_value_conditions("color", value, &[]) + .into_iter() + .map(|style| (style.value, style.selector.map(|s| s.to_string()))) + .collect(); + assert_eq!( + styles, + vec![ + (Some("red".to_string()), None), + ( + Some("blue".to_string()), + Some("@media print and (prefers-reduced-motion:reduce) &:hover".to_string()) + ), + ] + ); + } } diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 26ecb8357..87e9f5178 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -179,51 +179,16 @@ impl TailwindVariant { TailwindVariant::PeerInvalid => { StyleSelector::Selector(".peer:invalid ~ &".to_string()) } - TailwindVariant::Print => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "print".to_string(), - selector: None, - }, - TailwindVariant::Screen => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "screen".to_string(), - selector: None, - }, - TailwindVariant::Portrait => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(orientation: portrait)".to_string(), - selector: None, - }, - TailwindVariant::Landscape => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(orientation: landscape)".to_string(), - selector: None, - }, - TailwindVariant::MotionReduce => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(prefers-reduced-motion: reduce)".to_string(), - selector: None, - }, - TailwindVariant::MotionSafe => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(prefers-reduced-motion: no-preference)".to_string(), - selector: None, - }, - TailwindVariant::ContrastMore => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(prefers-contrast: more)".to_string(), - selector: None, - }, - TailwindVariant::ContrastLess => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(prefers-contrast: less)".to_string(), - selector: None, - }, - TailwindVariant::ForcedColors => StyleSelector::At { - kind: css::style_selector::AtRuleKind::Media, - query: "(forced-colors: active)".to_string(), - selector: None, - }, + // Media variants share the `_print`/`_motionReduce`/… prop table. + TailwindVariant::Print => StyleSelector::from("print"), + TailwindVariant::Screen => StyleSelector::from("screen"), + TailwindVariant::Portrait => StyleSelector::from("portrait"), + TailwindVariant::Landscape => StyleSelector::from("landscape"), + TailwindVariant::MotionReduce => StyleSelector::from("motion-reduce"), + TailwindVariant::MotionSafe => StyleSelector::from("motion-safe"), + TailwindVariant::ContrastMore => StyleSelector::from("contrast-more"), + TailwindVariant::ContrastLess => StyleSelector::from("contrast-less"), + TailwindVariant::ForcedColors => StyleSelector::from("forced-colors"), TailwindVariant::Rtl => StyleSelector::Selector("[dir=rtl] &".to_string()), TailwindVariant::Ltr => StyleSelector::Selector("[dir=ltr] &".to_string()), } @@ -331,8 +296,9 @@ fn remove_all_substr(haystack: &mut String, needle: &str) { } impl TailwindClass { - /// Convert to `ExtractStaticStyle` - pub fn to_static_style(&self) -> ExtractStaticStyle { + /// Convert to `ExtractStaticStyle`; `None` when the variants' media + /// conditions exclude each other (e.g. `print:screen:`). + pub fn to_static_style(&self) -> Option { // For transform property, negative is already incorporated into the value // (e.g., translateX(-1rem)), so don't add prefix again. Only the negative // branch needs an owned String for the `-` prefix; the common non-negative @@ -350,22 +316,27 @@ impl TailwindClass { None } else { // Combine multiple variants into a single selector - Some(self.combine_selectors()) + Some(self.combine_selectors()?) }; - ExtractStaticStyle::new(self.property, value.as_ref(), self.responsive, selector) + Some(ExtractStaticStyle::new( + self.property, + value.as_ref(), + self.responsive, + selector, + )) } - /// Combine multiple variant selectors - fn combine_selectors(&self) -> StyleSelector { + /// Combine multiple variant selectors; `None` when they can never match together. + fn combine_selectors(&self) -> Option { if self.variants.len() == 1 { - return self.variants[0].to_selector(); + return Some(self.variants[0].to_selector()); } // For multiple variants, combine them // e.g., dark:hover: becomes :root[data-theme=dark] &:hover let mut selector_str = String::new(); - let mut has_at_rule = None; + let mut at_rules = vec![]; for variant in &self.variants { let sel = variant.to_selector(); @@ -394,9 +365,7 @@ impl TailwindClass { } } } - StyleSelector::At { kind, query, .. } => { - has_at_rule = Some((kind, query)); - } + StyleSelector::At { kind, query, .. } => at_rules.push((kind, query)), // SAFETY: TailwindVariant::to_selector() never produces Global. // This arm exists only for exhaustive matching. If reached, it indicates // a bug where a new TailwindVariant was added that produces Global. @@ -406,19 +375,18 @@ impl TailwindClass { } } - if let Some((kind, query)) = has_at_rule { - StyleSelector::At { + // Every media variant applies, so fold them all in order (`print:motion-reduce:` + // becomes `@media print and (prefers-reduced-motion:reduce)`). + let mut selector = + (!selector_str.is_empty()).then_some(StyleSelector::Selector(selector_str)); + for (kind, query) in at_rules { + selector = Some(StyleSelector::nest_at_rule( + selector.as_ref(), kind, - query, - selector: if selector_str.is_empty() { - None - } else { - Some(selector_str) - }, - } - } else { - StyleSelector::Selector(selector_str) + &query, + )?); } + selector } } @@ -1292,8 +1260,10 @@ pub fn parse_tailwind_to_styles(class_str: &str) -> Vec { Vec::with_capacity(class_str.bytes().filter(u8::is_ascii_whitespace).count() + 1); for class in class_str.split_whitespace() { - if let Some(parsed) = parse_single_class(class) { - let static_style = parsed.to_static_style(); + if let Some(static_style) = parse_single_class(class) + .as_ref() + .and_then(TailwindClass::to_static_style) + { styles.push(ExtractStyleValue::Static(static_style)); } } @@ -3715,7 +3685,7 @@ mod tests { assert_eq!(parsed.value, "1rem"); assert!(parsed.negative); - let static_style = parsed.to_static_style(); + let static_style = parsed.to_static_style().expect("style"); assert_eq!(static_style.value(), "-1rem"); } @@ -3749,7 +3719,7 @@ mod tests { #[test] fn test_to_static_style() { let parsed = parse_single_class("bg-red-500").expect("Should parse"); - let static_style = parsed.to_static_style(); + let static_style = parsed.to_static_style().expect("style"); assert_eq!(static_style.property(), "background-color"); // ExtractStaticStyle::new() uses optimize_value() which uppercases hex colors @@ -3761,7 +3731,7 @@ mod tests { #[test] fn test_to_static_style_with_responsive() { let parsed = parse_single_class("md:p-4").expect("Should parse"); - let static_style = parsed.to_static_style(); + let static_style = parsed.to_static_style().expect("style"); assert_eq!(static_style.property(), "padding"); assert_eq!(static_style.value(), "1rem"); @@ -3771,7 +3741,7 @@ mod tests { #[test] fn test_to_static_style_with_variant() { let parsed = parse_single_class("hover:bg-blue-500").expect("Should parse"); - let static_style = parsed.to_static_style(); + let static_style = parsed.to_static_style().expect("style"); assert_eq!(static_style.property(), "background-color"); assert!(static_style.selector().is_some()); @@ -4072,14 +4042,15 @@ mod tests { // Wave 1.4: Media variant selectors (lines 153-162) #[rstest] + #[case(TailwindVariant::Print, "print")] #[case(TailwindVariant::Screen, "screen")] - #[case(TailwindVariant::Portrait, "(orientation: portrait)")] - #[case(TailwindVariant::Landscape, "(orientation: landscape)")] - #[case(TailwindVariant::MotionReduce, "(prefers-reduced-motion: reduce)")] - #[case(TailwindVariant::MotionSafe, "(prefers-reduced-motion: no-preference)")] - #[case(TailwindVariant::ContrastMore, "(prefers-contrast: more)")] - #[case(TailwindVariant::ContrastLess, "(prefers-contrast: less)")] - #[case(TailwindVariant::ForcedColors, "(forced-colors: active)")] + #[case(TailwindVariant::Portrait, "(orientation:portrait)")] + #[case(TailwindVariant::Landscape, "(orientation:landscape)")] + #[case(TailwindVariant::MotionReduce, "(prefers-reduced-motion:reduce)")] + #[case(TailwindVariant::MotionSafe, "(prefers-reduced-motion:no-preference)")] + #[case(TailwindVariant::ContrastMore, "(prefers-contrast:more)")] + #[case(TailwindVariant::ContrastLess, "(prefers-contrast:less)")] + #[case(TailwindVariant::ForcedColors, "(forced-colors:active)")] fn test_variant_to_selector_media_queries( #[case] variant: TailwindVariant, #[case] expected_query: &str, @@ -4132,13 +4103,14 @@ mod tests { assert_eq!(parsed.variants[0], TailwindVariant::Print); assert_eq!(parsed.variants[1], TailwindVariant::Hover); - let static_style = parsed.to_static_style(); + let static_style = parsed.to_static_style().expect("style"); let selector = static_style.selector().expect("Should have selector"); // Should combine into At rule with nested hover selector if let StyleSelector::At { kind, query, selector: nested, + .. } = selector { assert_eq!(*kind, css::style_selector::AtRuleKind::Media); @@ -4149,6 +4121,29 @@ mod tests { } } + #[rstest] + #[case( + "print:motion-reduce:hidden", + Some("@media print and (prefers-reduced-motion:reduce)") + )] + #[case( + "motion-reduce:landscape:hover:hidden", + Some("@media(prefers-reduced-motion:reduce)and (orientation:landscape) &:hover") + )] + #[case("print:screen:hidden", None)] + fn test_combine_selectors_folds_every_media_variant( + #[case] class: &str, + #[case] expected: Option<&str>, + ) { + let parsed = parse_single_class(class).expect("Should parse"); + assert_eq!( + parsed + .to_static_style() + .and_then(|style| style.selector().map(ToString::to_string)), + expected.map(str::to_string) + ); + } + #[test] fn test_remove_all_substr_matches_str_replace() { for (haystack, needle) in [ @@ -4221,7 +4216,7 @@ mod tests { negative: false, }; let expected = old_combine(variants); - match cls.combine_selectors() { + match cls.combine_selectors().expect("selector") { StyleSelector::Selector(actual) => { assert_eq!(actual, expected, "variants={variants:?}"); } diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 7c0bd3cef..276369a98 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -94,6 +94,22 @@ pub struct CollectedStyles { pub themes: FxHashMap, /// Non-style constant exports: `variable_name` -> value (as code string) pub constant_exports: FxHashMap, + /// `__style_N__` placeholder -> what it refers to, for placeholders left in + /// selectors and values + pub references: FxHashMap, +} + +/// Target of a placeholder that a selector or value interpolated +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Reference { + /// `style()`: `variable_name` and the unique class it gains when referenced + Style { name: String, class_name: String }, + /// `keyframes()`: `variable_name`, resolved to the generated keyframes name + Keyframes(String), + /// `createTheme()`: its generated class name + Class(String), + /// `fontFace()`: its generated font-family name + Font(String), } /// Internal state for collecting styles during JS execution @@ -387,7 +403,7 @@ pub fn execute_vanilla_extract( #[derive(Debug, Clone)] enum VarInfo { /// A style API call (style, keyframes, createContainer, etc.) - StyleApi { exported: bool }, + StyleApi { exported: bool, api: StyleApi }, /// A regular constant export with its original code Constant(String), /// The vars object from createTheme array destructuring [themeClass, vars] @@ -409,12 +425,12 @@ fn extract_var_names(code: &str, _package: &str) -> Vec<(String, VarInfo)> { // Check for array destructuring: const [themeClass, vars] = createTheme(...) if let oxc_ast::ast::BindingPattern::ArrayPattern(array_pat) = &decl.id { - if is_style_api_call(init) { + if let Some(api) = style_api_call(init) { // First element is the theme class if let Some(Some(first)) = array_pat.elements.first() && let oxc_ast::ast::BindingPattern::BindingIdentifier(id) = first { - vars.push((id.name.to_string(), VarInfo::StyleApi { exported })); + vars.push((id.name.to_string(), VarInfo::StyleApi { exported, api })); } // Second element is the vars object - mark as ThemeVars if let Some(Some(second)) = array_pat.elements.get(1) @@ -424,8 +440,8 @@ fn extract_var_names(code: &str, _package: &str) -> Vec<(String, VarInfo)> { } } } else if let Some(name) = decl.id.get_identifier_name() { - if is_style_api_call(init) { - vars.push((name.to_string(), VarInfo::StyleApi { exported })); + if let Some(api) = style_api_call(init) { + vars.push((name.to_string(), VarInfo::StyleApi { exported, api })); } else if exported { // Extract the original init expression using span let span = init.span(); @@ -460,26 +476,28 @@ fn extract_var_names(code: &str, _package: &str) -> Vec<(String, VarInfo)> { vars } -/// Check if an expression is a call to a style API (style, keyframes, styleVariants, etc.) -fn is_style_api_call(expr: &oxc_ast::ast::Expression) -> bool { +/// Which placeholder counter a style API call draws from +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StyleApi { + FontFace, + GlobalTheme, + Other, +} + +/// The style API an expression calls (style, keyframes, styleVariants, etc.) +fn style_api_call(expr: &oxc_ast::ast::Expression) -> Option { if let oxc_ast::ast::Expression::CallExpression(call) = expr && let oxc_ast::ast::Expression::Identifier(id) = &call.callee { - let name = id.name.as_str(); - return matches!( - name, - "style" - | "keyframes" - | "styleVariants" - | "fontFace" - | "createVar" - | "createContainer" - | "layer" - | "createGlobalTheme" - | "createTheme" - ); - } - false + return match id.name.as_str() { + "fontFace" => Some(StyleApi::FontFace), + "createGlobalTheme" => Some(StyleApi::GlobalTheme), + "style" | "keyframes" | "styleVariants" | "createVar" | "createContainer" | "layer" + | "createTheme" => Some(StyleApi::Other), + _ => None, + }; + } + None } /// Remap style placeholder IDs to original variable names @@ -495,7 +513,6 @@ fn remap_style_names( // Build mapping from placeholder ID to original name // The order of style() calls matches the order of variable declarations let mut placeholder_to_name: FxHashMap = FxHashMap::default(); - let mut font_placeholder_to_name: FxHashMap = FxHashMap::default(); let mut new_styles = FxHashMap::default(); let mut new_keyframes = FxHashMap::default(); let mut new_style_variants = FxHashMap::default(); @@ -524,35 +541,54 @@ fn remap_style_names( for (name, info) in vars { match info { - VarInfo::StyleApi { exported } => { - // First check if this is a fontFace (uses __font_N__ placeholder) + VarInfo::StyleApi { + exported, + api: StyleApi::FontFace, + } => { let font_placeholder = format!("__font_{font_idx}__"); if let Some((json, font_family, _)) = old_font_faces.remove(&font_placeholder) { - font_placeholder_to_name.insert(font_placeholder, name.clone()); + collected + .references + .insert(font_placeholder, Reference::Font(font_family.clone())); new_font_faces.insert(name.clone(), (json, font_family, *exported)); font_idx += 1; - continue; } - - // Check if this is a createGlobalTheme (uses __global_theme_N__ placeholder) + } + VarInfo::StyleApi { + exported, + api: StyleApi::GlobalTheme, + } => { let global_theme_placeholder = format!("__global_theme_{global_theme_idx}__"); if let Some(mut entry) = old_global_themes.remove(&global_theme_placeholder) { entry.exported = *exported; new_global_themes.insert(name.clone(), entry); global_theme_idx += 1; - continue; } - + } + VarInfo::StyleApi { + exported, + api: StyleApi::Other, + } => { let placeholder = format!("__style_{style_idx}__"); placeholder_to_name.insert(placeholder.clone(), name.clone()); if let Some(mut entry) = old_styles.remove(&placeholder) { entry.exported = *exported; new_styles.insert(name.clone(), entry); + collected.references.insert( + placeholder, + Reference::Style { + name: name.clone(), + class_name: format!("{file_prefix}_{name}"), + }, + ); style_idx += 1; } else if let Some(mut entry) = old_keyframes.remove(&placeholder) { entry.exported = *exported; new_keyframes.insert(name.clone(), entry); + collected + .references + .insert(placeholder, Reference::Keyframes(name.clone())); style_idx += 1; } else if let Some((variants, _)) = old_style_variants.remove(&placeholder) { new_style_variants.insert(name.clone(), (variants, *exported)); @@ -591,6 +627,9 @@ fn remap_style_names( .push((format!(".{class_name}"), vars_json)); } + collected + .references + .insert(placeholder, Reference::Class(class_name.clone())); entry.exported = *exported; entry.vars_name = None; entry.class_name = class_name; @@ -641,23 +680,6 @@ fn remap_style_names( .collect(); } - // Replace font placeholders in style JSONs with actual font-family names - // Build a mapping from placeholder to font-family name - let font_family_map: FxHashMap<&str, &str> = font_placeholder_to_name - .iter() - .filter_map(|(placeholder, name)| { - new_font_faces - .get(name) - .map(|(_, font_family, _)| (placeholder.as_str(), font_family.as_str())) - }) - .collect(); - - // Replace placeholders in a single pass per style JSON. - // This is needed for font-family references and selectors like `${parent}:hover &`. - for entry in new_styles.values_mut() { - replace_placeholders_in_json(&mut entry.json, &font_family_map, &placeholder_to_name); - } - collected.styles = new_styles; collected.keyframes = new_keyframes; collected.style_variants = new_style_variants; @@ -669,12 +691,12 @@ fn remap_style_names( collected.themes = new_themes; } -fn replace_placeholders_in_json( - json: &mut String, - font_family_map: &FxHashMap<&str, &str>, - placeholder_to_name: &FxHashMap, -) { - let source = json.as_str(); +/// Replace every `__name__` token that `lookup` resolves; `None` when nothing +/// was replaced. +fn replace_placeholders( + source: &str, + mut lookup: impl FnMut(&str, &str) -> Option, +) -> Option { let mut search_start = 0; let mut last_copied = 0; let mut output = None::; @@ -685,16 +707,10 @@ fn replace_placeholders_in_json( break; }; let end = start + 2 + relative_end + 2; - let placeholder = &source[start..end]; - let replacement = font_family_map - .get(placeholder) - .copied() - .or_else(|| placeholder_to_name.get(placeholder).map(String::as_str)); - - if let Some(replacement) = replacement { + if let Some(replacement) = lookup(&source[start..end], &source[end..]) { let output = output.get_or_insert_with(|| String::with_capacity(source.len())); output.push_str(&source[last_copied..start]); - output.push_str(replacement); + output.push_str(&replacement); last_copied = end; search_start = end; } else { @@ -702,12 +718,11 @@ fn replace_placeholders_in_json( } } - if let Some(mut output) = output { + output.map(|mut output| { output.push_str(&source[last_copied..]); - *json = output; - } + output + }) } - /// Convert TypeScript to JavaScript using Oxc Transformer and replace imports fn preprocess_typescript(code: &str, package: &str) -> String { let allocator = Allocator::default(); @@ -1165,83 +1180,178 @@ fn register_vanilla_extract_apis( Ok(()) } -/// Find all style names that are referenced in selectors of other styles -/// Returns a set of style names that need to be extracted first -pub fn find_selector_references(collected: &CollectedStyles) -> FxHashSet { - let mut referenced = rustc_hash::FxHashSet::default(); - // Build the probe strings once per style name instead of per (entry, name) pair - let probes: Vec<(String, String, &str)> = collected - .styles - .keys() - .map(|name| (format!("\"{name}:"), format!("\"{name} "), name.as_str())) - .collect(); +/// Placeholders interpolated into a selector end inside a JSON key. +fn in_json_key(rest: &str) -> bool { + rest.find('"') + .is_some_and(|i| rest[i + 1..].starts_with(':')) +} - for entry in collected.styles.values() { - // Cheap necessary-condition gate: a `":"` / `" "` selector reference can - // only appear inside a `"selectors":{...}` block, so a JSON with no `selectors` object - // cannot match ANY probe. Skip the whole K-probe inner loop for those entries (the - // common case — most style JSONs carry no cross-references). Byte-identical to probing - // every entry: the skipped entries provably contribute nothing to `referenced`. - if !entry.json.contains("selectors") { - continue; - } - // Check if this style's JSON contains references to other style names - for (probe_colon, probe_space, style_name) in &probes { - // Look for patterns like "stylename:" or "stylename " in selectors - // The JSON has selectors like {"selectors":{"parent:hover &":{...}}} - if entry.json.contains(probe_colon.as_str()) - || entry.json.contains(probe_space.as_str()) - { - referenced.insert((*style_name).to_string()); - } +impl CollectedStyles { + fn resolve( + &self, + source: &str, + keyframes_names: &FxHashMap, + selector: impl Fn(&str) -> bool, + ) -> String { + replace_placeholders(source, |token, rest| { + Some(match self.references.get(token)? { + Reference::Style { class_name, .. } | Reference::Class(class_name) => { + if selector(rest) { + format!(".{class_name}") + } else { + class_name.clone() + } + } + Reference::Keyframes(name) => keyframes_names.get(name)?.clone(), + Reference::Font(family) => family.clone(), + }) + }) + .unwrap_or_else(|| source.to_string()) + } + + fn resolve_json(&self, json: &str, keyframes_names: &FxHashMap) -> String { + self.resolve(json, keyframes_names, in_json_key) + } + + /// Every text a placeholder can be interpolated into. + fn texts(&self) -> impl Iterator { + self.styles + .values() + .map(|entry| entry.json.as_str()) + .chain(self.keyframes.values().map(|entry| entry.json.as_str())) + .chain( + self.style_variants + .values() + .flat_map(|(variants, _)| variants.values()) + .map(|variant| variant.styles_json.as_str()), + ) + .chain( + self.global_styles + .iter() + .flat_map(|(selector, json)| [selector.as_str(), json.as_str()]), + ) + } + + fn referenced(&self) -> FxHashSet<&Reference> { + let mut referenced = FxHashSet::default(); + for text in self.texts() { + replace_placeholders(text, |token, _| { + if let Some(reference) = self.references.get(token) { + referenced.insert(reference); + } + None + }); } + referenced } - referenced + /// `variable_name` -> unique class of every style some selector or value refers to + fn referenced_classes(&self) -> FxHashMap<&str, &str> { + self.referenced() + .into_iter() + .filter_map(|reference| match reference { + Reference::Style { name, class_name } => Some((name.as_str(), class_name.as_str())), + _ => None, + }) + .collect() + } } -/// Generate code only for specific styles (used for first-pass extraction) -pub fn collected_styles_to_code_partial( +/// Names of the keyframes some style refers to; their generated names have to be +/// known before the styles referring to them are generated. +pub fn referenced_keyframes(collected: &CollectedStyles) -> FxHashSet { + collected + .referenced() + .into_iter() + .filter_map(|reference| match reference { + Reference::Keyframes(name) => Some(name.clone()), + _ => None, + }) + .collect() +} + +/// Code declaring only the keyframes in `names`, extracted first to learn their +/// generated names +pub fn keyframes_to_code( collected: &CollectedStyles, package: &str, - style_names: &FxHashSet, + names: &FxHashSet, ) -> String { - let mut output = String::new(); - - if !style_names.is_empty() { - output.push_str("import { css } from '"); - output.push_str(package); - output.push('\''); - } - - // Generate only the specified styles - let mut styles: Vec<_> = collected - .styles + let mut keyframes: Vec<_> = collected + .keyframes .iter() - .filter(|(name, _)| style_names.contains(*name)) + .filter(|(name, _)| names.contains(*name)) .collect(); - styles.sort_by_key(|(name, _)| *name); - - for (name, entry) in styles { - // Generate as non-exported for first pass - if !output.is_empty() { - output.push('\n'); - } - output.push_str("const "); + keyframes.sort_by_key(|(name, _)| *name); + let mut output = format!("import {{ keyframes }} from '{package}'"); + for (name, entry) in keyframes { + output.push_str("\nconst "); output.push_str(name); - output.push_str(" = css("); - output.push_str(&entry.json); + output.push_str(" = keyframes("); + output.push_str(&collected.resolve_json(&entry.json, &FxHashMap::default())); output.push(')'); } - output } -/// Convert collected styles to code with selector references replaced by class names -pub fn collected_styles_to_code_with_classes( +fn inner_json(json: &str) -> &str { + json.trim() + .trim_start_matches('{') + .trim_end_matches('}') + .trim() +} + +/// `css(...)` of `own` composed onto `bases`, plus the unique classes that let +/// selectors target them +fn composed_css( + collected: &CollectedStyles, + keyframes_names: &FxHashMap, + referenced_classes: &FxHashMap<&str, &str>, + bases: &[&str], + own_name: Option<&str>, + own_json: &str, +) -> String { + let mut parts = Vec::with_capacity(bases.len() + 1); + let mut classes = Vec::new(); + for base in bases { + if let Some(entry) = collected.styles.get(*base) { + let json = collected.resolve_json(&entry.json, keyframes_names); + let inner = inner_json(&json); + if !inner.is_empty() { + parts.push(inner.to_string()); + } + } + classes.extend(referenced_classes.get(base)); + } + let own = collected.resolve_json(own_json, keyframes_names); + let css = if bases.is_empty() { + format!("css({own})") + } else { + let inner = inner_json(&own); + if !inner.is_empty() { + parts.push(inner.to_string()); + } + format!("css({{{}}})", parts.join(",")) + }; + classes.extend(own_name.and_then(|name| referenced_classes.get(name))); + if classes.is_empty() { + css + } else { + let mut unique = String::new(); + for class in classes { + unique.push(' '); + unique.push_str(class); + } + format!("{css} + \"{unique}\"") + } +} + +/// Convert collected styles to code, with keyframes references replaced by +/// `keyframes_names` (`variable_name` -> generated name) +pub fn collected_styles_to_code_with_keyframes( collected: &CollectedStyles, package: &str, - class_map: &FxHashMap, + keyframes_names: &FxHashMap, ) -> String { let mut code_parts = Vec::with_capacity(collected.styles.len() + 4); @@ -1271,73 +1381,23 @@ pub fn collected_styles_to_code_with_classes( )); } - // Generate style declarations with selector references replaced - let style_json_map: FxHashMap<&str, &str> = collected - .styles - .iter() - .map(|(name, entry)| (name.as_str(), entry.json.as_str())) - .collect(); - - // Pre-build search/replace pairs to avoid format!() per iteration - let replacements: Vec<_> = class_map - .iter() - .map(|(style_name, class_name)| { - ( - format!("\"{style_name}:"), - format!("\"{class_name}:"), - format!("\"{style_name} "), - format!("\"{class_name} "), - ) - }) - .collect(); - + let referenced_classes = collected.referenced_classes(); let mut styles: Vec<_> = collected.styles.iter().collect(); styles.sort_by_key(|(name, _)| *name); for (name, entry) in styles { let prefix = if entry.exported { "export " } else { "" }; - - // Replace style name references with class names in JSON - let mut json = entry.json.clone(); - for (search_colon, replace_colon, search_space, replace_space) in &replacements { - if json.contains(search_colon.as_str()) { - json = json.replace(search_colon, replace_colon); - } - if json.contains(search_space.as_str()) { - json = json.replace(search_space, replace_space); - } - } - - if entry.bases.is_empty() { - code_parts.push(format!("{prefix}const {name} = css({json})")); - } else { - // Composition: merge all base styles - let mut merged_parts = Vec::new(); - for base_name in &entry.bases { - if let Some(base_json) = style_json_map.get(base_name.as_str()) { - let inner = base_json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !inner.is_empty() { - merged_parts.push(inner.to_string()); - } - } - } - let own_inner = json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !own_inner.is_empty() { - merged_parts.push(own_inner.to_string()); - } - let merged_json = format!("{{{}}}", merged_parts.join(",")); - code_parts.push(format!("{prefix}const {name} = css({merged_json})")); - } + let bases: Vec<&str> = entry.bases.iter().map(String::as_str).collect(); + let css = composed_css( + collected, + keyframes_names, + &referenced_classes, + &bases, + Some(name), + &entry.json, + ); + code_parts.push(format!("{prefix}const {name} = {css}")); } - // Generate createTheme exports (class name and optionally vars object) // Note: CSS variables are added to global_styles during remapping let mut themes: Vec<_> = collected.themes.iter().collect(); @@ -1364,8 +1424,12 @@ pub fn collected_styles_to_code_with_classes( } } - // Add remaining code generation (globalCss, keyframes, etc.) - call original function's logic - append_non_style_code(collected, package, &mut code_parts); + append_non_style_code( + collected, + keyframes_names, + &referenced_classes, + &mut code_parts, + ); code_parts.join("\n") } @@ -1373,12 +1437,17 @@ pub fn collected_styles_to_code_with_classes( /// Append non-style code parts (globalCss, keyframes, fontFaces, etc.) fn append_non_style_code( collected: &CollectedStyles, - _package: &str, + keyframes_names: &FxHashMap, + referenced_classes: &FxHashMap<&str, &str>, code_parts: &mut Vec, ) { - // Generate globalCss calls for (selector, json) in &collected.global_styles { - code_parts.push(format!("globalCss({{ \"{selector}\": {json} }})")); + let selector = collected.resolve(selector, keyframes_names, |_| true); + let json = collected.resolve_json(json, keyframes_names); + code_parts.push(format!( + "globalCss({{ {}: {json} }})", + serde_json::Value::String(selector) + )); } // Generate @font-face rules @@ -1426,61 +1495,38 @@ fn append_non_style_code( let prefix = if entry.exported { "export " } else { "" }; code_parts.push(format!( "{}const {} = keyframes({})", - prefix, name, entry.json + prefix, + name, + collected.resolve_json(&entry.json, keyframes_names) )); } - // Generate styleVariants let mut variants: Vec<_> = collected.style_variants.iter().collect(); - if !variants.is_empty() { - let style_json_map: FxHashMap<&str, &str> = collected - .styles - .iter() - .map(|(name, entry)| (name.as_str(), entry.json.as_str())) - .collect(); - variants.sort_by_key(|(name, _)| *name); - for (name, (variant_map, exported)) in variants { - let mut variant_entries: Vec<_> = variant_map.iter().collect(); - variant_entries.sort_by_key(|(k, _)| *k); - let mut object_parts = Vec::new(); - for (variant_key, variant) in variant_entries { - let value = if let Some(base_name) = &variant.base { - let mut merged_parts = Vec::new(); - if let Some(base_json) = style_json_map.get(base_name.as_str()) { - let inner = base_json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !inner.is_empty() { - merged_parts.push(inner.to_string()); - } - } - let own_inner = variant - .styles_json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !own_inner.is_empty() { - merged_parts.push(own_inner.to_string()); - } - format!("css({{{}}})", merged_parts.join(",")) - } else { - format!("css({})", variant.styles_json) - }; - object_parts.push(format!(" {variant_key}: {value}")); - } - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!( - "{}const {} = {{\n{}\n}}", - prefix, - name, - object_parts.join(",\n") - )); + variants.sort_by_key(|(name, _)| *name); + for (name, (variant_map, exported)) in variants { + let mut variant_entries: Vec<_> = variant_map.iter().collect(); + variant_entries.sort_by_key(|(k, _)| *k); + let mut object_parts = Vec::new(); + for (variant_key, variant) in variant_entries { + let bases: Vec<&str> = variant.base.iter().map(String::as_str).collect(); + let value = composed_css( + collected, + keyframes_names, + referenced_classes, + &bases, + None, + &variant.styles_json, + ); + object_parts.push(format!(" {variant_key}: {value}")); } + let prefix = if *exported { "export " } else { "" }; + code_parts.push(format!( + "{}const {} = {{\n{}\n}}", + prefix, + name, + object_parts.join(",\n") + )); } - // Generate createVar declarations let mut vars: Vec<_> = collected.vars.iter().collect(); vars.sort_by_key(|(name, _)| *name); @@ -1529,8 +1575,9 @@ fn append_non_style_code( } /// Convert collected styles to code that can be processed by existing extract logic +#[cfg(test)] pub fn collected_styles_to_code(collected: &CollectedStyles, package: &str) -> String { - collected_styles_to_code_with_classes(collected, package, &FxHashMap::default()) + collected_styles_to_code_with_keyframes(collected, package, &FxHashMap::default()) } /// Parse a styleVariants object and extract variant info @@ -1935,8 +1982,11 @@ export const lightTheme = createTheme(vars, { .into_iter() .collect(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("import { css } from '@devup-ui/react'")); // The composed style should have both base and own styles merged assert!(code.contains("padding")); @@ -2638,66 +2688,99 @@ export const box = style({ padding: 8 })"; // handle the target package imports #[test] - fn test_find_selector_references_no_refs() { - // Test find_selector_references with no selector references + fn test_referenced_keyframes_without_references() { let mut collected = CollectedStyles::default(); collected.styles.insert( "box".to_string(), StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.styles.insert( - "text".to_string(), - StyleEntry { - json: r#"{"color":"blue"}"#.to_string(), + json: r#"{"padding":"8px","animation":"__style_9__ 1s"}"#.to_string(), exported: true, bases: SmallVec::new(), }, ); - - let refs = super::find_selector_references(&collected); - assert!(refs.is_empty()); + assert!(super::referenced_keyframes(&collected).is_empty()); } #[test] - fn test_collected_styles_to_code_partial_empty() { - // Test collected_styles_to_code_partial with empty style_names - let collected = CollectedStyles::default(); - let empty_set = rustc_hash::FxHashSet::default(); - let code = - super::collected_styles_to_code_partial(&collected, "@devup-ui/react", &empty_set); - assert!(code.is_empty()); + fn test_keyframes_to_code_only_referenced() { + let mut collected = CollectedStyles::default(); + for name in ["fade", "spin"] { + collected.keyframes.insert( + name.to_string(), + StyleEntry { + json: r#"{"from":{"opacity":0}}"#.to_string(), + exported: false, + bases: SmallVec::new(), + }, + ); + } + let names = std::iter::once("fade".to_string()).collect(); + assert_eq!( + super::keyframes_to_code(&collected, "@devup-ui/react", &names), + "import { keyframes } from '@devup-ui/react'\nconst fade = keyframes({\"from\":{\"opacity\":0}})" + ); } #[test] - fn test_collected_styles_to_code_with_classes_selector_replacement() { - // Test selector class name replacement in collected_styles_to_code_with_classes + fn test_collected_styles_to_code_resolves_references() { let mut collected = CollectedStyles::default(); - collected.styles.insert( - "parent".to_string(), - StyleEntry { - json: r#"{"background":"white"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); + let entry = |json: &str| StyleEntry { + json: json.to_string(), + exported: true, + bases: SmallVec::new(), + }; + collected + .styles + .insert("parent".to_string(), entry(r#"{"background":"white"}"#)); collected.styles.insert( "child".to_string(), - StyleEntry { - json: r#"{"selectors":{"parent:hover &":{"color":"blue"}}}"#.to_string(), - exported: true, - bases: SmallVec::new(), + entry(r#"{"animation":"__style_2__ 1s","selectors":{"__style_0__:hover &":{"color":"blue"}}}"#), + ); + collected + .keyframes + .insert("fade".to_string(), entry(r#"{"from":{"opacity":0}}"#)); + collected.global_styles.push(( + "__style_0__ > a".to_string(), + r#"{"color":"__style_1__"}"#.to_string(), + )); + collected.references.insert( + "__style_0__".to_string(), + Reference::Style { + name: "parent".to_string(), + class_name: "f0_parent".to_string(), }, ); + collected.references.insert( + "__style_1__".to_string(), + Reference::Class("f0_theme".to_string()), + ); + collected.references.insert( + "__style_2__".to_string(), + Reference::Keyframes("fade".to_string()), + ); + collected.references.insert( + "__font_0__".to_string(), + Reference::Font("__devup_font_0_0".to_string()), + ); - let class_map: rustc_hash::FxHashMap = - std::iter::once(("parent".to_string(), "a".to_string())).collect(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); - assert!(code.contains("a:hover")); + let keyframes_names = std::iter::once(("fade".to_string(), "k".to_string())).collect(); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &keyframes_names, + ); + assert!(code.contains(r#"const child = css({"animation":"k 1s","selectors":{".f0_parent:hover &":{"color":"blue"}}})"#), "{code}"); + assert!( + code.contains(r#"const parent = css({"background":"white"}) + " f0_parent""#), + "{code}" + ); + assert!( + code.contains(r#"globalCss({ ".f0_parent > a": {"color":"f0_theme"} })"#), + "{code}" + ); + // An unresolved keyframes reference is left as is. + let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); + assert!(code.contains("__style_2__ 1s"), "{code}"); } #[test] @@ -2885,8 +2968,11 @@ export const box = style({ padding: 8 })"; ); let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("[theme, vars]")); } @@ -2914,8 +3000,11 @@ export const box = style({ padding: 8 })"; ); let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("const simpleTheme = \"f0_simple\"")); } @@ -3047,8 +3136,11 @@ export const box = style({ padding: 8 })"; .insert("sizes".to_string(), (variants, true)); let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("sizes")); } @@ -3069,8 +3161,11 @@ export const box = style({ padding: 8 })"; .insert("CONFIG".to_string(), "{ debug: true }".to_string()); let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("export const CONFIG")); } @@ -3126,8 +3221,11 @@ export const box = style({ padding: 8 })"; let class_map: rustc_hash::FxHashMap = std::iter::once(("box".to_string(), "a".to_string())).collect(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("css")); assert!(code.contains("globalCss")); @@ -3158,8 +3256,11 @@ export const box = style({ padding: 8 })"; ); let class_map = rustc_hash::FxHashMap::default(); - let code = - super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map); + let code = super::collected_styles_to_code_with_keyframes( + &collected, + "@devup-ui/react", + &class_map, + ); assert!(code.contains("const simpleTheme = \"f0_simple\"")); } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 6da7cceed..8850391ec 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1092,12 +1092,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &self.filename, ); // already set style order - self.styles.extend(styles.into_iter().flat_map(|mut ex| { - if let ExtractStyleProp::Static(css) = &mut ex { - css.set_style_order(style_order.unwrap_or(0)); - } - ex.into_extract() - })); + let style_order = style_order.unwrap_or(0); + self.styles.extend( + styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract) + .map(|mut style| { + style.set_style_order(style_order); + style + }), + ); self.global_css_result(r.is_component()) } } else if call.arguments.len() == 2 @@ -1139,12 +1143,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &mut folded, &self.filename, ); - self.styles.extend(styles.into_iter().flat_map(|mut ex| { - if let ExtractStyleProp::Static(css) = &mut ex { - css.set_style_order(style_order.unwrap_or(0)); - } - ex.into_extract() - })); + let style_order = style_order.unwrap_or(0); + self.styles.extend( + styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract) + .map(|mut style| { + style.set_style_order(style_order); + style + }), + ); *it = self.global_css_result(util_type.is_component()); } else { *it = match util_type.as_ref() { @@ -1633,12 +1641,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { styles, style_order, } = extract_global_style_from_expression(&self.ast, expression, &self.filename); - self.styles.extend(styles.into_iter().flat_map(|mut ex| { - if let ExtractStyleProp::Static(css) = &mut ex { - css.set_style_order(style_order.unwrap_or(0)); - } - ex.into_extract() - })); + let style_order = style_order.unwrap_or(0); + self.styles.extend( + styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract) + .map(|mut style| { + style.set_style_order(style_order); + style + }), + ); } elem.opening_element.attributes.remove(i); } From 7986d4b9845eca719c99489c0080c293c87089f8 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:50:08 +0900 Subject: [PATCH 05/12] fix(sheet): order at-rules after breakpoints and merge adjacent rules Rules order by selector group before breakpoint level and at-rules follow every plain rule; globalCss and custom layers keep SELECTOR_ORDER instead of sorting selectors alphabetically. Adjacent rules with the same selector share one block, and a declaration written next to a conditional typography preset wins over it. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/sheet/src/lib.rs | 953 ++++++++++++------ .../snapshots/sheet__tests__create_css-5.snap | 2 +- .../sheet__tests__create_css_sort_test-2.snap | 2 +- .../sheet__tests__create_css_sort_test.snap | 2 +- ..._create_css_with_selector_sort_test-3.snap | 2 +- .../snapshots/sheet__tests__deserialize.snap | 1 + ...sheet__tests__motion_reduce_selector.snap} | 2 +- .../sheet__tests__print_selector-2.snap | 2 +- .../sheet__tests__print_selector.snap | 2 +- .../sheet__tests__selector_with_prefix.snap | 2 +- .../sheet__tests__selector_with_query.snap | 2 +- .../sheet__tests__style_order_create_css.snap | 2 +- .../sheet__tests__theme_selector-2.snap | 2 +- .../sheet__tests__theme_selector-3.snap | 2 +- .../sheet__tests__theme_selector.snap | 2 +- .../sheet__tests__wrong_breakpoint.snap | 2 +- libs/sheet/src/theme.rs | 45 + 17 files changed, 702 insertions(+), 325 deletions(-) rename libs/sheet/src/snapshots/{sheet__tests__speech_selector.snap => sheet__tests__motion_reduce_selector.snap} (64%) diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 695a0c2e6..a2b824991 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -2,12 +2,15 @@ pub mod theme; use crate::theme::Theme; use css::{ + at_rule::{MediaCombination, combine_media_queries, query_order}, atom_hoist::{atom_hoist_threshold, is_atom_hoist}, file_map::canonical, file_routes::route_count_for_files, get_custom_shorthand_names, sheet_to_classname, - style_selector::{AtRuleKind, StyleSelector, get_selector_order, global_selector_order}, - theme_tokens::set_theme_token_levels, + style_selector::{ + AtRule, AtRuleKind, StyleSelector, get_selector_order, global_selector_order, write_at_rule, + }, + theme_tokens::{set_theme_token_levels, set_typography_keys}, utils::compile_regex, write_merge_selector, }; @@ -31,43 +34,50 @@ macro_rules! push_fmt { }}; } -/// Shared 5-field comparator for the decorate-sort-undecorate keyed tuples in -/// `create_style_with_layers`. Both the `global_keyed` (`bool` first field) and -/// `keyed` (`u8` first field) buckets share the shape -/// `(K, order: u8, selector_str: &str, prop: &StyleSheetProperty)` and sort by the -/// exact same chain: precomputed order key first (`bool`/`u8` both compare via -/// `Ord`), then selector string, then property, then value. Extracted so the two -/// call sites cannot drift; produces byte-identical ordering to the former inline -/// closures. -fn keyed_prop_cmp( - a: &(K, u8, &str, &StyleSheetProperty), - b: &(K, u8, &str, &StyleSheetProperty), -) -> std::cmp::Ordering { - a.0.cmp(&b.0) - .then_with(|| a.1.cmp(&b.1)) - .then_with(|| a.2.cmp(b.2)) - .then_with(|| a.3.property.cmp(&b.3.property)) - .then_with(|| a.3.value.cmp(&b.3.value)) +/// Plain rules first, then pseudo selectors in `SELECTOR_ORDER`. +fn selector_group(selector: Option<&str>) -> (u8, u8) { + selector.map_or((0, 0), |selector| (1, get_selector_order(selector))) } -fn global_prop_key(prop: &StyleSheetProperty) -> (bool, u8, &str, &StyleSheetProperty) { - match &prop.selector { - Some(StyleSelector::Global(selector, _)) => { - if let Some(i) = selector.find(':') { - ( - true, - global_selector_order(&selector[i..]), - selector.as_str(), - prop, - ) - } else { - (false, 0u8, selector.as_str(), prop) - } +/// Typography preset declarations come first so a declaration written directly +/// on the same selector wins, and each preset stays contiguous so its rules merge. +fn prop_cmp(a: &StyleSheetProperty, b: &StyleSheetProperty) -> std::cmp::Ordering { + b.typography.cmp(&a.typography).then_with(|| { + if a.typography { + a.class_name + .cmp(&b.class_name) + .then_with(|| a.property.cmp(&b.property)) + .then_with(|| a.value.cmp(&b.value)) + } else { + a.property + .cmp(&b.property) + .then_with(|| a.value.cmp(&b.value)) + .then_with(|| a.class_name.cmp(&b.class_name)) } - _ => (false, 0u8, "", prop), - } + }) } +/// (selector group, level, selector) +type RuleOrder<'a> = ((u8, u8), u8, &'a str); +/// (enclosing at-rules outermost first, selector group, level, selector) +type AtRuleOrder<'a> = (Vec<(u8, (u8, i64), &'a str)>, (u8, u8), u8, &'a str); + +/// The blocks a rule sits in: its breakpoint level and at-rule chain. +#[derive(Clone, Copy, PartialEq, Eq)] +struct Wrapper<'a> { + level: u8, + at_rule: Option<(&'a [AtRule], AtRuleKind, &'a str)>, +} + +/// Global selectors without a pseudo part first, then by `SELECTOR_ORDER`. +fn global_selector_group(selector: &str) -> (bool, u8) { + selector.find(':').map_or((false, 0), |i| { + (true, global_selector_order(&selector[i..])) + }) +} + +type GlobalProp<'a> = (u8, &'a str, &'a StyleSheetProperty); + #[derive(Debug, Hash, Eq, PartialEq, Deserialize, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct StyleSheetProperty { @@ -82,6 +92,9 @@ pub struct StyleSheetProperty { /// CSS layer name (from vanilla-extract `layer()`) #[serde(rename = "l", skip_serializing_if = "Option::is_none")] pub layer: Option, + /// Declaration expanded from a conditional `typography` preset + #[serde(rename = "t", default, skip_serializing_if = "std::ops::Not::not")] + pub typography: bool, } #[derive(Debug, Hash, Eq, PartialEq, Deserialize, Serialize)] @@ -117,13 +130,14 @@ impl Ord for StyleSheetProperty { } impl StyleSheetProperty { - fn write_extract(&self, css: &mut String) { - write_merge_selector(css, &self.class_name, self.selector.as_ref()); - css.push('{'); + fn same_rule(&self, other: &Self) -> bool { + self.class_name == other.class_name && self.selector == other.selector + } + + fn write_declaration(&self, css: &mut String) { css.push_str(&self.property); css.push(':'); css.push_str(&convert_theme_variable_value(&self.value)); - css.push('}'); } } @@ -291,9 +305,37 @@ impl StyleSheet { style_order: Option, filename: Option<&str>, layer: Option<&str>, + ) -> bool { + self.insert_property( + level, + style_order, + filename, + StyleSheetProperty { + class_name: class_name.to_string(), + property: property.to_string(), + value: value.to_string(), + selector: selector.cloned(), + layer: layer.map(ToString::to_string), + typography: false, + }, + ) + } + + fn insert_property( + &mut self, + level: u8, + style_order: Option, + filename: Option<&str>, + prop: StyleSheetProperty, ) -> bool { // register global css file for cache - if let Some(StyleSelector::Global(_, file)) = selector { + if let Some( + StyleSelector::Global(_, file) + | StyleSelector::At { + file: Some(file), .. + }, + ) = &prop.selector + { // Probe with the borrowed `&str` first so the owned `String` is only // allocated on first registration, not on repeat (HMR/multi-property) calls. // Matches the borrow-probe-first pattern in add_import/add_font_face/add_css. @@ -314,13 +356,7 @@ impl StyleSheet { .or_default() .entry(level) .or_default() - .insert(StyleSheetProperty { - class_name: class_name.to_string(), - property: property.to_string(), - value: value.to_string(), - selector: selector.cloned(), - layer: layer.map(ToString::to_string), - }) + .insert(prop) } pub fn add_import(&mut self, file: &str, import: &str) { @@ -414,12 +450,11 @@ impl StyleSheet { let bucket_empty = if let Some(prop_map) = self.properties.get_mut(&property_key) { for map in prop_map.values_mut() { for props in map.values_mut() { - props.retain(|prop| { - if let Some(StyleSelector::Global(_, f)) = prop.selector.as_ref() { - f != file - } else { - true - } + props.retain(|prop| match prop.selector.as_ref() { + Some( + StyleSelector::Global(_, f) | StyleSelector::At { file: Some(f), .. }, + ) => f != file, + _ => true, }); } // remove empty map @@ -443,6 +478,7 @@ impl StyleSheet { theme.get_length_token_levels(), theme.get_shadow_token_levels(), ); + set_typography_keys(theme.typography.keys().cloned().collect()); self.theme = theme; } @@ -466,6 +502,34 @@ impl StyleSheet { let bucket_scope = if single_css { None } else { Some(filename) }; for style in styles { match style { + // A conditional `typography` preset: its class is the atom, and the + // preset's declarations are emitted under the atom's selector. + ExtractStyleValue::Static(st) if st.property() == "typography" => { + let (StyleProperty::ClassName(class_name) + | StyleProperty::Variable { class_name, .. }) = st.extract(name_scope); + for (level, property, value) in + self.theme.typography_declarations(st.value(), st.level()) + { + if self.insert_property( + level, + st.style_order(), + bucket_scope, + StyleSheetProperty { + class_name: class_name.clone(), + property: property.to_string(), + value, + selector: st.selector().cloned(), + layer: st.layer().map(ToString::to_string), + typography: true, + }, + ) { + collected = true; + if st.style_order() == Some(0) { + updated_base_style = true; + } + } + } + } ExtractStyleValue::Static(st) => { let is_first_value = st.theme_token_resolution() == ThemeTokenResolution::FirstValue; @@ -744,245 +808,232 @@ impl StyleSheet { fn create_style_with_layers>( &self, map: &BTreeMap>, - mut layered_styles: Option<&mut LayeredStyles>, + layered_styles: Option<&mut LayeredStyles>, ) -> String { // Estimate ~64 bytes per property for pre-allocation let prop_count: usize = map.values().map(FxHashSet::len).sum(); let mut current_css = String::with_capacity(prop_count * 64); + let mut class_rules: Vec<(RuleOrder<'_>, &StyleSheetProperty)> = + Vec::with_capacity(prop_count); + let mut at_rules: Vec<(AtRuleOrder<'_>, Wrapper<'_>, &StyleSheetProperty)> = Vec::new(); + let mut global_props: Vec> = Vec::new(); for (level, props) in map { - // Single pass bucketing: the two prior `partition` calls always - // heap-allocated all four output `Vec`s even when the Global/At - // buckets stay empty (the common case). One scan pushing each - // `&prop` into one of three pre-declared `Vec`s leaves empty buckets - // as non-allocating `Vec::new()` and avoids the second full scan. - let mut global_props: Vec<_> = Vec::new(); - let mut at_rules: Vec<_> = Vec::new(); - // Plain (non-Global, non-At) selectors dominate: presize this bucket - // to `props.len()` to avoid its 1→4→8→… grow-reallocs. `global_props` - // / `at_rules` stay `Vec::new()` so empty buckets never allocate. - let mut sorted_props: Vec<&StyleSheetProperty> = Vec::with_capacity(props.len()); for prop in props { let prop: &StyleSheetProperty = prop.borrow(); - match prop.selector { - Some(StyleSelector::Global(_, _)) => global_props.push(prop), - Some(StyleSelector::At { .. }) => at_rules.push(prop), - _ => sorted_props.push(prop), - } - } - // Decorate-sort-undecorate for the Global bucket, mirroring the - // plain-selector `keyed` sort below. `global_props.sort()` compares via - // `StyleSheetProperty::cmp` → `StyleSelector::cmp`'s Global arm, which for - // EACH comparison re-runs `global_selector_order` (a `SELECTOR_ORDER` table - // walk) on BOTH operands — O(n log n) redundant re-scans of the same - // selector strings. Compute each prop's Global order key ONCE. The key - // `(has_colon, order, selector_str, property, value)` reproduces the Global - // arm byte-for-byte: equal selector strings ⇒ `Ordering::Equal` there, so - // `StyleSheetProperty::cmp` falls through to (property, value); no-colon - // props (`has_colon=false`) sort before colon props and tie on the selector - // string; colon props sort by (order, selector string). - // The common level has no Global selectors, so `global_props` is empty. - // Skip the three vector materializations (keyed `with_capacity`, `extend`, - // sorted `collect`) entirely in that case, mirroring the `at_rules.is_empty()` - // guard below; only decorate-sort-undecorate when there is actually work. - // An empty sort/collect yields an empty vec anyway, so output is byte-identical. - let global_props: Vec<&StyleSheetProperty> = if global_props.is_empty() { - Vec::new() - } else { - let mut global_keyed: Vec<(bool, u8, &str, &StyleSheetProperty)> = - Vec::with_capacity(global_props.len()); - global_keyed.extend(global_props.into_iter().map(global_prop_key)); - global_keyed.sort_by(keyed_prop_cmp); - global_keyed - .into_iter() - .map(|(_, _, _, prop)| prop) - .collect() - }; - // Decorate-sort-undecorate for the plain-selector bucket: sorting via - // `StyleSheetProperty::cmp` re-runs `get_selector_order` (a byte scan + - // linear `SELECTOR_ORDER` probe) on BOTH operands of every comparison, - // i.e. O(n log n) redundant scans of the same `Selector` strings. Here - // `sorted_props` holds only `None`/`Selector(_)` variants (Global/At were - // filtered out above), so compute each prop's order key ONCE into a keyed - // vec and sort that. The key `(is_some, order, selector_str, property, - // value)` reproduces `StyleSheetProperty::cmp` byte-for-byte: `None` props - // (is_some=0, order=0, selector_str="") sort by (property, value); `Selector` - // props (is_some=1) sort by (order, selector_str, property, value) — matching - // `StyleSelector::cmp`'s `get_selector_order` then string tie-break. - // The common level often has ONLY global/at props, leaving `sorted_props` - // empty. Skip the `with_capacity`/`extend`/`sort_by` triple entirely in that - // case, mirroring the `global_props`/`at_rules` guards above and below; an - // empty build+sort yields an empty vec anyway, so output is byte-identical. - let keyed: Vec<(u8, u8, &str, &StyleSheetProperty)> = if sorted_props.is_empty() { - Vec::new() - } else { - let mut keyed: Vec<(u8, u8, &str, &StyleSheetProperty)> = - Vec::with_capacity(sorted_props.len()); - keyed.extend(sorted_props.into_iter().map(|prop| match &prop.selector { - Some(StyleSelector::Selector(s)) => { - (1u8, get_selector_order(s), s.as_str(), prop) + match &prop.selector { + Some(StyleSelector::Global(selector, _)) => { + global_props.push((*level, selector.as_str(), prop)); } - _ => (0u8, 0u8, "", prop), - })); - keyed.sort_by(keyed_prop_cmp); - keyed - }; - // The common level has no `@`-rule atoms, so `at_rules` is empty. - // Skip the `sort()` no-op and the per-level `BTreeMap` construction - // entirely in that case; only regroup when there is actually work. - let at_rules: BTreeMap<(AtRuleKind, &String), Vec<_>> = if at_rules.is_empty() { - BTreeMap::new() - } else { - at_rules.sort(); - let mut map: BTreeMap<(AtRuleKind, &String), Vec<_>> = BTreeMap::new(); - for prop in at_rules { - if let Some(StyleSelector::At { kind, query, .. }) = &prop.selector { - map.entry((*kind, query)).or_default().push(prop); + Some(StyleSelector::At { + kind, + query, + selector, + outer, + .. + }) => { + let chain = outer + .iter() + .map(|rule| (rule.kind, rule.query.as_str())) + .chain(std::iter::once((*kind, query.as_str()))) + .map(|(kind, query)| (kind as u8, query_order(kind, query), query)) + .collect(); + let selector = selector.as_deref(); + at_rules.push(( + ( + chain, + selector_group(selector), + *level, + selector.unwrap_or(""), + ), + Wrapper { + level: *level, + at_rule: Some((outer.as_slice(), *kind, query.as_str())), + }, + prop, + )); } + Some(StyleSelector::Selector(selector)) => class_rules.push(( + (selector_group(Some(selector)), *level, selector.as_str()), + prop, + )), + None => class_rules.push(((selector_group(None), *level, ""), prop)), } - map - }; + } + } + if !global_props.is_empty() { + self.write_global_props(&mut current_css, global_props, layered_styles); + } - let break_point = if *level == 0 { - None - } else { - Some( - self.theme - .breakpoints - .get(*level as usize) - .copied() - .unwrap_or_else(|| self.theme.breakpoints.last().copied().unwrap_or(0)), + // Selector group (plain, then `SELECTOR_ORDER`) sorts before the breakpoint + // level, so `:active` still follows a `:hover` set at a wider breakpoint while + // one selector's responsive values keep ascending. At-rules follow every plain + // rule so a condition beats the breakpoint values it overrides; among + // themselves they order by condition, then selector group, then level. + class_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| prop_cmp(a.1, b.1))); + at_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| prop_cmp(a.2, b.2))); + + let mut open: Option<(Wrapper<'_>, Option)> = None; + let mut open_rule: Option<&StyleSheetProperty> = None; + for (wrapper, prop) in class_rules + .iter() + .map(|((_, level, _), prop)| { + ( + Wrapper { + level: *level, + at_rule: None, + }, + *prop, ) - }; - - if !global_props.is_empty() { - // Separate layered and non-layered global props. Only pay the - // partition + clone-into-map cost when the caller actually - // consumes the layered output (base/global path); callers that - // discard layers pass `None` and keep every global prop inline. - let non_layered_props = if let Some(layered_styles) = layered_styles.as_deref_mut() - { - let (layered_props, non_layered_props): (Vec<_>, Vec<_>) = global_props - .into_iter() - .partition(|prop| prop.layer.is_some()); - - // Collect layered props for later processing - for prop in layered_props { - if let Some(layer) = &prop.layer - && let Some(StyleSelector::Global(selector, _)) = &prop.selector - { - // Borrow-probe existing entry by layer.as_str() before cloning - let bucket = match layered_styles.get_mut(layer.as_str()) { - Some(bucket) => bucket, - None => layered_styles.entry(layer.clone()).or_default(), - }; - bucket.push(( - selector.clone(), - prop.property.clone(), - prop.value.clone(), - )); - } - } - non_layered_props + }) + .chain(at_rules.iter().map(|(_, wrapper, prop)| (*wrapper, *prop))) + { + if open.as_ref().is_none_or(|(current, _)| *current != wrapper) { + if open_rule.take().is_some() { + current_css.push('}'); + } + if let Some((_, Some(depth))) = open { + current_css.extend(std::iter::repeat_n('}', depth)); + } + let depth = self.open_wrapper(&mut current_css, wrapper); + open = Some((wrapper, depth)); + } + if let Some((_, Some(_))) = open { + if open_rule.is_some_and(|rule| rule.same_rule(prop)) { + current_css.push(';'); } else { - global_props - }; - - // Process non-layered global props as before - if !non_layered_props.is_empty() { - let mut selector_map: BTreeMap<_, Vec<_>> = BTreeMap::new(); - for prop in non_layered_props { - if let Some(StyleSelector::Global(selector, _)) = &prop.selector { - selector_map - .entry(selector) - .or_insert_with(|| Vec::with_capacity(1)) - .push(prop); - } - } - if let Some(break_point) = break_point { - push_fmt!(&mut current_css, "@media(min-width:{break_point}px){{"); - } - for (selector, props) in selector_map { - current_css.push_str(selector); - current_css.push('{'); - let mut first = true; - for prop in props { - if !first { - current_css.push(';'); - } - first = false; - current_css.push_str(&prop.property); - current_css.push(':'); - current_css.push_str(&prop.value); - } - current_css.push('}'); - } - if break_point.is_some() { + if open_rule.is_some() { current_css.push('}'); } + write_merge_selector( + &mut current_css, + &prop.class_name, + prop.selector.as_ref(), + ); + current_css.push('{'); + open_rule = Some(prop); + } + prop.write_declaration(&mut current_css); + } + } + if open_rule.is_some() { + current_css.push('}'); + } + if let Some((_, Some(depth))) = open { + current_css.extend(std::iter::repeat_n('}', depth)); + } + current_css + } + + fn break_point(&self, level: u8) -> u16 { + self.theme + .breakpoints + .get(level as usize) + .copied() + .unwrap_or_else(|| self.theme.breakpoints.last().copied().unwrap_or(0)) + } + + /// Open the blocks `wrapper` needs and return how many to close, or `None` + /// when the breakpoint and the media query can never hold together. + fn open_wrapper(&self, css: &mut String, wrapper: Wrapper<'_>) -> Option { + let mut rules: Vec<(AtRuleKind, Cow<'_, str>)> = + wrapper + .at_rule + .map_or_else(Vec::new, |(outer, kind, query)| { + outer + .iter() + .map(|rule| (rule.kind, Cow::Borrowed(rule.query.as_str()))) + .chain(std::iter::once((kind, Cow::Borrowed(query)))) + .collect() + }); + if wrapper.level > 0 { + let bp_query = format!("(min-width:{}px)", self.break_point(wrapper.level)); + // The breakpoint folds into the outermost `@media` when one query can + // express both; otherwise it wraps the whole chain. + let combined = match rules.first() { + Some((AtRuleKind::Media, query)) => combine_media_queries(&bp_query, query), + _ => MediaCombination::Nest, + }; + match combined { + MediaCombination::Merged(merged) => rules[0].1 = Cow::Owned(merged), + MediaCombination::Never => return None, + MediaCombination::Nest => { + rules.insert(0, (AtRuleKind::Media, Cow::Owned(bp_query))); } } + } + for (kind, query) in &rules { + let _ = write_at_rule(css, *kind, query); + css.push('{'); + } + Some(rules.len()) + } - if !keyed.is_empty() { - if let Some(break_point) = break_point { - push_fmt!(&mut current_css, "@media(min-width:{break_point}px){{"); + fn write_global_props( + &self, + css: &mut String, + mut global_props: Vec>, + mut layered_styles: Option<&mut LayeredStyles>, + ) { + // Same order as class rules: selector group before breakpoint level, so a + // `:hover` set at a wider breakpoint still precedes `:active`. + global_props.sort_by(|a, b| { + global_selector_group(a.1) + .cmp(&global_selector_group(b.1)) + .then_with(|| a.0.cmp(&b.0)) + .then_with(|| a.1.cmp(b.1)) + .then_with(|| a.2.property.cmp(&b.2.property)) + .then_with(|| a.2.value.cmp(&b.2.value)) + }); + + let mut open_level: Option = None; + let mut open_selector: Option<&str> = None; + for (level, selector, prop) in global_props { + if let (Some(layered_styles), Some(layer)) = + (layered_styles.as_deref_mut(), prop.layer.as_ref()) + { + let bucket = match layered_styles.get_mut(layer.as_str()) { + Some(bucket) => bucket, + None => layered_styles.entry(layer.clone()).or_default(), + }; + bucket.push(( + selector.to_string(), + prop.property.clone(), + prop.value.clone(), + )); + continue; + } + if open_level != Some(level) { + if open_selector.take().is_some() { + css.push('}'); } - for (_, _, _, prop) in &keyed { - prop.write_extract(&mut current_css); + if open_level.is_some_and(|open| open > 0) { + css.push('}'); } - if break_point.is_some() { - current_css.push('}'); + if level > 0 { + push_fmt!(css, "@media(min-width:{}px){{", self.break_point(level)); } + open_level = Some(level); } - for ((kind, query), props) in at_rules { - if let Some(break_point) = break_point { - match kind { - AtRuleKind::Media => { - push_fmt!( - &mut current_css, - "@media(min-width:{break_point}px)and {query}{{" - ); - } - AtRuleKind::Supports => { - push_fmt!( - &mut current_css, - "@media(min-width:{break_point}px){{@supports{query}{{" - ); - } - AtRuleKind::Container => { - push_fmt!( - &mut current_css, - "@media(min-width:{break_point}px){{@container{query}{{" - ); - } - AtRuleKind::Layer => { - push_fmt!( - &mut current_css, - "@media(min-width:{break_point}px){{@layer {query}{{" - ); - } - } - for prop in props { - prop.write_extract(&mut current_css); - } - match kind { - AtRuleKind::Media => current_css.push('}'), - _ => current_css.push_str("}}"), - } - } else { - push_fmt!(&mut current_css, "@{kind}"); - if query.starts_with('(') { - push_fmt!(&mut current_css, "{query}{{"); - } else { - push_fmt!(&mut current_css, " {query}{{"); - } - for prop in props { - prop.write_extract(&mut current_css); - } - current_css.push('}'); + if open_selector == Some(selector) { + css.push(';'); + } else { + if open_selector.is_some() { + css.push('}'); } + css.push_str(selector); + css.push('{'); + open_selector = Some(selector); } + css.push_str(&prop.property); + css.push(':'); + css.push_str(&prop.value); + } + if open_selector.is_some() { + css.push('}'); + } + if open_level.is_some_and(|open| open > 0) { + css.push('}'); } - current_css } #[inline] @@ -1155,30 +1206,22 @@ impl StyleSheet { // Generate styles wrapped in @layer blocks for (layer_name, styles) in layered_styles { - // Group by selector - let mut selector_map: BTreeMap> = BTreeMap::new(); - for (selector, property, value) in styles { - selector_map - .entry(selector) - .or_default() - .push((property, value)); - } - push_fmt!(&mut css, "@layer {layer_name}{{"); - for (selector, props) in selector_map { - css.push_str(&selector); - css.push('{'); - let mut first = true; - for (p, v) in props { - if !first { - css.push(';'); + let mut open_selector: Option<&str> = None; + for (selector, p, v) in &styles { + if open_selector == Some(selector.as_str()) { + css.push(';'); + } else { + if open_selector.is_some() { + css.push('}'); } - first = false; - push_fmt!(&mut css, "{p}:{v}"); + css.push_str(selector); + css.push('{'); + open_selector = Some(selector); } - css.push('}'); + push_fmt!(&mut css, "{p}:{v}"); } - css.push('}'); + css.push_str("}}"); } } // Atom hoisting: emit shared (hoisted) order!=0 atoms into the global @@ -1412,6 +1455,8 @@ mod tests { kind: AtRuleKind::Media, query: "(hover:hover)".to_string(), selector: None, + outer: vec![], + file: None, }; sheet.add_property( "atr", @@ -2198,14 +2243,14 @@ mod tests { } #[test] - fn test_speech_selector() { + fn test_motion_reduce_selector() { let mut sheet = StyleSheet::default(); sheet.add_property( "test", "display", 0, "none", - Some(&"speech".into()), + Some(&"motion-reduce".into()), None, None, ); @@ -2241,6 +2286,8 @@ mod tests { kind: AtRuleKind::Media, query: "(min-width: 1024px)".to_string(), selector: Some("&:hover".to_string()), + outer: vec![], + file: None, }), None, None, @@ -2254,6 +2301,8 @@ mod tests { kind: AtRuleKind::Media, query: "(min-width: 1024px)".to_string(), selector: Some("&:hover".to_string()), + outer: vec![], + file: None, }), None, None, @@ -2274,6 +2323,8 @@ mod tests { kind: AtRuleKind::Supports, query: "(display: grid)".to_string(), selector: None, + outer: vec![], + file: None, }), None, None, @@ -2294,6 +2345,8 @@ mod tests { kind: AtRuleKind::Container, query: "(min-width: 768px)".to_string(), selector: None, + outer: vec![], + file: None, }), None, None, @@ -2868,6 +2921,31 @@ mod tests { assert_debug_snapshot!(css.split("*/").nth(1).unwrap()); } + #[test] + fn test_custom_layer_keeps_selector_order() { + let mut sheet = StyleSheet::default(); + for (selector, value) in [("a:active", "blue"), ("a:hover", "red"), ("a", "black")] { + sheet.add_property_with_layer( + "a", + "color", + 0, + value, + Some(&StyleSelector::Global( + selector.to_string(), + "links.css.ts".to_string(), + )), + Some(0), + None, + Some("ui"), + ); + } + let css = sheet.create_css(None, false); + assert!( + css.contains("@layer ui{a{color:black}a:hover{color:red}a:active{color:blue}}"), + "{css}" + ); + } + #[test] fn test_at_rules_with_breakpoints() { let mut sheet = StyleSheet::default(); @@ -2881,6 +2959,8 @@ mod tests { kind: AtRuleKind::Supports, query: "(display: grid)".to_string(), selector: None, + outer: vec![], + file: None, }), Some(0), None, @@ -2904,6 +2984,8 @@ mod tests { kind: AtRuleKind::Container, query: "(min-width: 400px)".to_string(), selector: None, + outer: vec![], + file: None, }), Some(0), None, @@ -2945,6 +3027,8 @@ mod tests { kind: AtRuleKind::Layer, query: "components".to_string(), selector: None, + outer: vec![], + file: None, }), Some(0), None, @@ -2975,6 +3059,7 @@ mod tests { value: value.to_string(), selector: None, layer: None, + typography: false, }; assert_eq!(make("color", "red").cmp(&make("color", "red")), Equal); assert!(make("color", "red") < make("color", "white")); @@ -2991,6 +3076,7 @@ mod tests { value: value.to_string(), selector, layer: None, + typography: false, }; let hover = || Some(StyleSelector::Selector("&:hover".to_string())); @@ -3008,29 +3094,11 @@ mod tests { } #[test] - fn test_global_prop_key_variants() { - let make = |selector: Option| StyleSheetProperty { - class_name: "a".to_string(), - property: "color".to_string(), - value: "red".to_string(), - selector, - layer: None, - }; - let pseudo = make(Some(StyleSelector::Global( - "a:hover".to_string(), - "test.tsx".to_string(), - ))); - let plain = make(Some(StyleSelector::Global( - "body".to_string(), - "test.tsx".to_string(), - ))); - let local = make(Some(StyleSelector::Selector("&:hover".to_string()))); - - assert!(global_prop_key(&pseudo).0); - assert_eq!(global_prop_key(&pseudo).2, "a:hover"); - assert!(!global_prop_key(&plain).0); - assert_eq!(global_prop_key(&plain).2, "body"); - assert_eq!(global_prop_key(&local).2, ""); + fn test_global_selector_group() { + assert_eq!(global_selector_group("body"), (false, 0)); + assert_eq!(global_selector_group("a:hover"), (true, 0)); + assert_eq!(global_selector_group("a:active"), (true, 3)); + assert_eq!(global_selector_group(":root"), (true, 0)); } #[test] @@ -3196,6 +3264,269 @@ mod tests { ); } + fn pipeline_css(theme: Theme, source: &str) -> String { + css::debug::set_debug(false); + css::set_prefix(None); + css::atom_hoist::set_atom_hoist(None); + reset_class_map(); + reset_file_map(); + let mut sheet = StyleSheet::default(); + sheet.set_theme(theme); + let output = extract( + "test.tsx", + &format!("import {{Box,css,styled,globalCss}} from '@devup-ui/core';\n{source}"), + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: std::collections::HashMap::new(), + }, + ) + .unwrap(); + sheet.update_styles(&output.styles, "test.tsx", true); + // Class names come from a process-wide counter; number them by first + // appearance so the expected CSS only pins down structure and order. + let mut names: Vec = vec![]; + compile_regex(r"\.([A-Za-z_][\w-]*)") + .replace_all( + sheet.create_css(None, false).split("*/").nth(1).unwrap(), + |caps: ®ex_lite::Captures| { + let index = names.iter().position(|n| *n == caps[1]).unwrap_or_else(|| { + names.push(caps[1].to_string()); + names.len() - 1 + }); + format!(".c{index}") + }, + ) + .into_owned() + } + + #[test] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_at_rule_pipeline() { + for (source, expected) in [ + // Conditions (at-rules) win over breakpoint values; pseudo states keep + // `SELECTOR_ORDER` across breakpoints. + ( + "", + ".c0{background:red}@media(min-width:768px){.c1{background:blue}}.c2:hover{background:green}", + ), + ( + "", + ".c0:hover{background:red}@media(min-width:768px){.c1:hover{background:blue}}.c2:active{background:green}", + ), + ( + "", + ".c0{transition:opacity .2s}@media(min-width:768px){.c1{transition:all .3s}}@media(prefers-reduced-motion:reduce){.c2{transition:none}}", + ), + ( + "", + ".c0{background:red}@media(min-width:768px){.c1{background:blue}}@supports(display:grid){.c2{background:green}}", + ), + ( + "", + "@media(min-width:500px){.c0{padding:4px}}@media(min-width:1000px){.c1{padding:8px}}@media(max-width:900px){.c2{padding:16px}}@media(max-width:300px){.c3{padding:12px}}", + ), + ( + "", + "@media(prefers-reduced-motion:reduce){.c0:hover{background:b}.c1:active{background:a}}", + ), + // Nesting in either direction keeps both the condition and the selector. + ( + "", + "@media print{.c0:hover{background:red}.c1:focus{color:red}}", + ), + ( + "", + "@media(prefers-reduced-motion:no-preference){.c0:hover{transform:scale(1.05)}:is([role=group],[data-group]):hover .c1{color:blue}:root[data-theme=dark] .c2{color:red}}", + ), + ( + "", + "@media(prefers-reduced-motion:reduce){.c0:hover{background:red}.c1::before{content:\"\"}}", + ), + ( + "", + ":root[data-theme=dark] .c0:hover{background:red}", + ), + // Nested at-rules fold into one query or nest; exclusive ones are dropped. + ( + "", + "@media print and (prefers-reduced-motion:reduce){.c0{background:red}}", + ), + ( + "", + "@media(prefers-reduced-motion:reduce){@supports(display:grid){.c0{background:red}}}", + ), + // Breakpoints combine with the query: media type first, lists distribute, + // `not print` becomes `screen`, anything else nests. + ( + "", + "@media print{.c0{background:red}}@media print and (min-width:768px){.c1{background:blue}}", + ), + ( + "", + "@media screen and (min-width:768px){.c0{color:red}}@media(min-width:768px)and (orientation:portrait),(min-width:768px)and (hover:none){.c1{background:blue}}", + ), + ( + "", + "@media(min-width:768px){@media not print and (color){.c0{background:blue}}}", + ), + ( + "", + "@media(min-width:768px){@supports not (display:grid){.c0{background:blue}}}", + ), + // Emotion-style object keys and template literals. + ( + "
", + ".c0:focus{color:red}@media print{.c1{color:blue}.c2:hover{color:green}}", + ), + ( + "
", + ".c0{transition:all .3s}.c1:hover span{color:blue}@media print{.c2:hover{color:red}}@media(prefers-reduced-motion:reduce){.c3{transition:none}}", + ), + ( + "", + "@media print and (prefers-reduced-motion:reduce){.c0{display:none}}", + ), + ( + "globalCss({ '@media (prefers-reduced-motion: reduce)': { '*': { transition: 'none' } }, _print: { body: { bg: 'white', _hover: { color: 'red' } } }, _media: { '(min-width: 768px)': { body: { m: 2 } } }, _screen: { _print: { body: { color: 'red' } } } })", + "@layer b;@layer b{@media(min-width:768px){body{margin:8px}}@media print{body{background:white}body:hover{color:red}}@media(prefers-reduced-motion:reduce){*{transition:none}}}", + ), + ( + "", + ".c0:hover{margin:4px}.c1:focus{margin:4px}@media print{.c2{background:red}}@media(hover:none){.c3{color:red}}", + ), + // Global pseudo rules keep `SELECTOR_ORDER` across breakpoints. + ( + "globalCss({ a: { _hover: { color: 'red' }, _active: { color: 'blue' }, _focus: { color: 'green' } } })", + "@layer b;@layer b{a:hover{color:red}a:focus{color:green}a:active{color:blue}}", + ), + ( + "globalCss({ a: { color: 'black', _hover: { color: [null, null, 'red'] }, _active: { color: 'blue' } } })", + "@layer b;@layer b{a{color:black}@media(min-width:768px){a:hover{color:red}}a:active{color:blue}}", + ), + ( + "globalCss({ a: { color: ['red', null, 'blue'] } })", + "@layer b;@layer b{a{color:red}@media(min-width:768px){a{color:blue}}}", + ), + ] { + assert_eq!(pipeline_css(Theme::default(), source), expected, "{source}"); + } + } + + #[test] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_conditional_typography_pipeline() { + for (source, expected) in [ + ( + "", + "@layer t;.c2:hover{font-size:12px;line-height:1.2}", + ), + ( + "", + "@layer t;@media(prefers-reduced-motion:reduce){.c2{font-family:var(--heading);font-size:20px;font-weight:700}}@media(min-width:768px)and (prefers-reduced-motion:reduce){.c2{font-size:32px}}", + ), + ( + "", + "@layer t;@media(min-width:768px){.c2:hover{font-family:var(--heading);font-size:32px;font-weight:700}}", + ), + ( + "", + "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c3:hover{font-size:32px}}", + ), + ( + "", + "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c3:hover{font-size:32px}}", + ), + ("", "@layer t;"), + // A declaration written next to the preset wins over the preset's. + ( + "", + "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-size:11px}", + ), + ( + "globalCss({ body: { typography: 'small' } })", + "@layer b,t;@layer b{body{font-size:12px;line-height:1.2}}", + ), + ] { + let mut theme = Theme::default(); + theme.add_typography( + "small", + vec![Some(Typography::new( + None, + Some("12px".to_string()), + None, + Some("1.2".to_string()), + None, + ))], + ); + theme.add_typography( + "title", + vec![ + Some(Typography::new( + Some("$heading".to_string()), + Some("20px".to_string()), + Some("700".to_string()), + None, + Some(" ".to_string()), + )), + None, + Some(Typography::new( + None, + Some("32px".to_string()), + None, + None, + None, + )), + ], + ); + let css = pipeline_css(theme, &format!("const size = 'small';\n{source}")); + // Drop the theme's own `.typo-*` layer; only the conditional atoms matter here. + let start = css.find("@layer t{").unwrap(); + let mut depth = 0; + let end = css[start..] + .char_indices() + .find_map(|(index, c)| { + match c { + '{' => depth += 1, + '}' => depth -= 1, + _ => {} + } + (c == '}' && depth == 0).then_some(start + index + 1) + }) + .unwrap(); + assert_eq!(format!("{}{}", &css[..start], &css[end..]), expected); + } + } + + #[test] + #[serial] + fn test_rm_global_css_drops_global_at_rules() { + let mut sheet = StyleSheet::default(); + let output = extract( + "global.tsx", + "import {globalCss} from '@devup-ui/core';globalCss({ body: { color: 'red', _motionReduce: { transition: 'none' } } })", + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: std::collections::HashMap::new(), + }, + ) + .unwrap(); + sheet.update_styles(&output.styles, "global.tsx", true); + assert!(sheet.create_css(None, false).contains("transition:none")); + + assert!(sheet.rm_global_css("global.tsx", true)); + let css = sheet.create_css(None, false); + assert!(!css.contains("transition:none"), "{css}"); + assert!(!css.contains("color:red"), "{css}"); + } + #[test] #[serial] fn test_dynamic_style_important_full_pipeline() { diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap b/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap index c57eb2315..851fa55b1 100644 --- a/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap +++ b/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -":is([role=group],[data-group]):focus-visible .test{background:blue}:is([role=group],[data-group]):focus-visible .test{background:red}" +":is([role=group],[data-group]):focus-visible .test{background:blue;background:red}" diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap index 3193a504f..3f9785c49 100644 --- a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap +++ b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -".test{border:1px solid}.test{border-color:red}" +".test{border:1px solid;border-color:red}" diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap index d9ef15491..6062adb5f 100644 --- a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap +++ b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width:480px){.test{background:some}.test{background-color:red}}" +"@media(min-width:480px){.test{background:some;background-color:red}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap b/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap index d9ef15491..6062adb5f 100644 --- a/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap +++ b/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width:480px){.test{background:some}.test{background-color:red}}" +"@media(min-width:480px){.test{background:some;background-color:red}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap index 77513dadb..252f09dc8 100644 --- a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap +++ b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap @@ -13,6 +13,7 @@ StyleSheet { value: "40px", selector: None, layer: None, + typography: false, }, }, }, diff --git a/libs/sheet/src/snapshots/sheet__tests__speech_selector.snap b/libs/sheet/src/snapshots/sheet__tests__motion_reduce_selector.snap similarity index 64% rename from libs/sheet/src/snapshots/sheet__tests__speech_selector.snap rename to libs/sheet/src/snapshots/sheet__tests__motion_reduce_selector.snap index a180ad3b7..4ac3f1f88 100644 --- a/libs/sheet/src/snapshots/sheet__tests__speech_selector.snap +++ b/libs/sheet/src/snapshots/sheet__tests__motion_reduce_selector.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media speech{.test{display:none}}" +"@media(prefers-reduced-motion:reduce){.test{display:none}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap b/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap index ca6c61257..88e3e2b12 100644 --- a/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap +++ b/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -".test{margin-bottom:40px}.test{margin-top:40px}@media print{.test{margin-left:40px}.test{margin-right:40px}}" +".test{margin-bottom:40px;margin-top:40px}@media print{.test{margin-left:40px;margin-right:40px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__print_selector.snap b/libs/sheet/src/snapshots/sheet__tests__print_selector.snap index 053d618c4..0cce6708d 100644 --- a/libs/sheet/src/snapshots/sheet__tests__print_selector.snap +++ b/libs/sheet/src/snapshots/sheet__tests__print_selector.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media print{.test{margin-bottom:40px}.test{margin-left:40px}.test{margin-right:40px}.test{margin-top:40px}}@media(min-width:480px)and print{.test{margin-bottom:40px}.test{margin-left:40px}.test{margin-right:40px}.test{margin-top:40px}}" +"@media print{.test{margin-bottom:40px;margin-left:40px;margin-right:40px;margin-top:40px}}@media print and (min-width:480px){.test{margin-bottom:40px;margin-left:40px;margin-right:40px;margin-top:40px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap b/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap index 57a93b52f..b19be1fae 100644 --- a/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap +++ b/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width:480px){:is([role=group],[data-group]):hover .test{margin-left:40px}:is([role=group],[data-group]):hover .test{margin-right:40px}}@media(min-width:768px){:is([role=group],[data-group]):hover .test{margin-left:50px}:is([role=group],[data-group]):hover .test{margin-right:50px}}" +"@media(min-width:480px){:is([role=group],[data-group]):hover .test{margin-left:40px;margin-right:40px}}@media(min-width:768px){:is([role=group],[data-group]):hover .test{margin-left:50px;margin-right:50px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap b/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap index b693d37d7..824a6032e 100644 --- a/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap +++ b/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width: 1024px){.test:hover{margin-bottom:40px}.test:hover{margin-top:40px}}" +"@media(min-width: 1024px){.test:hover{margin-bottom:40px;margin-top:40px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap b/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap index 2c1121290..c59953840 100644 --- a/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap +++ b/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@layer o1,o2;@layer o1{.test{margin-left:40px}.test{margin-right:40px}@media(min-width:480px){.test{margin-left:40px}.test{margin-left:44px}.test{margin-right:40px}.test{margin-right:44px}}}@layer o2{@media(min-width:480px){.test{margin-left:50px}.test{margin-right:50px}}}.test{margin-left:70px}.test{margin-right:70px}@media(min-width:480px){.test{margin-left:60px}.test{margin-right:60px}}" +"@layer o1,o2;@layer o1{.test{margin-left:40px;margin-right:40px}@media(min-width:480px){.test{margin-left:40px;margin-left:44px;margin-right:40px;margin-right:44px}}}@layer o2{@media(min-width:480px){.test{margin-left:50px;margin-right:50px}}}.test{margin-left:70px;margin-right:70px}@media(min-width:480px){.test{margin-left:60px;margin-right:60px}}" diff --git a/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap b/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap index 1946f076e..f2d00072b 100644 --- a/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap +++ b/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -".test{margin-left:41px}.test{margin-left:42px}.test{margin-right:41px}.test{margin-right:42px}:root[data-theme=light] .test{margin-left:50px}:root[data-theme=light] .test{margin-left:51px}:root[data-theme=light] .test{margin-right:50px}:root[data-theme=light] .test{margin-right:51px}" +".test{margin-left:41px;margin-left:42px;margin-right:41px;margin-right:42px}:root[data-theme=light] .test{margin-left:50px;margin-left:51px;margin-right:50px;margin-right:51px}" diff --git a/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap b/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap index 771bbc05b..71c4134fb 100644 --- a/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap +++ b/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -":root[data-theme=light] .test:hover{margin-left:50px}:root[data-theme=light] .test:hover{margin-right:50px}:root[data-theme=light] .test:active{margin-left:50px}:root[data-theme=light] .test:active{margin-right:50px}" +":root[data-theme=light] .test:hover{margin-left:50px;margin-right:50px}:root[data-theme=light] .test:active{margin-left:50px;margin-right:50px}" diff --git a/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap b/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap index db1c61873..b576c552b 100644 --- a/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap +++ b/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -":root[data-theme=dark] .test{margin-bottom:40px}:root[data-theme=dark] .test{margin-left:40px}:root[data-theme=dark] .test{margin-right:40px}:root[data-theme=dark] .test{margin-top:40px}:root[data-theme=light] .test{margin-left:50px}:root[data-theme=light] .test{margin-right:50px}" +":root[data-theme=dark] .test{margin-bottom:40px;margin-left:40px;margin-right:40px;margin-top:40px}:root[data-theme=light] .test{margin-left:50px;margin-right:50px}" diff --git a/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap b/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap index a227ce36d..c051ab081 100644 --- a/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap +++ b/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap @@ -2,4 +2,4 @@ source: libs/sheet/src/lib.rs expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()" --- -"@media(min-width:1600px){.test{margin-left:40px}.test{margin-right:40px}}" +"@media(min-width:1600px){.test{margin-left:40px;margin-right:40px}}" diff --git a/libs/sheet/src/theme.rs b/libs/sheet/src/theme.rs index 9d7b11c62..29ff73962 100644 --- a/libs/sheet/src/theme.rs +++ b/libs/sheet/src/theme.rs @@ -606,6 +606,51 @@ impl Theme { default_variant_key(&self.colors).map(str::to_string) } + /// Declarations of typography preset `name` applied from breakpoint `level` + /// up: preset entries at or below `level` merge into `level`, wider ones + /// keep their own breakpoint. Values resolve like the `.typo-*` classes. + #[must_use] + pub fn typography_declarations( + &self, + name: &str, + level: u8, + ) -> Vec<(u8, &'static str, String)> { + let mut declarations: Vec<(u8, &'static str, String)> = vec![]; + let Some(typography) = self.typography.get(name) else { + return declarations; + }; + for (index, entry) in typography.0.iter().enumerate() { + let Some(entry) = entry else { + continue; + }; + let target = level.max(index as u8); + for (property, value) in [ + ("font-family", &entry.font_family), + ("font-size", &entry.font_size), + ("font-weight", &entry.font_weight), + ("line-height", &entry.line_height), + ("letter-spacing", &entry.letter_spacing), + ] { + let Some(value) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) else { + continue; + }; + let resolved = value.strip_prefix('$').map_or_else( + || optimize_value(value).into_owned(), + |token| format!("var(--{token})"), + ); + if let Some(existing) = declarations + .iter_mut() + .find(|(l, p, _)| *l == target && *p == property) + { + existing.2 = resolved; + } else { + declarations.push((target, property, resolved)); + } + } + } + declarations + } + #[must_use] pub fn get_length_token_levels(&self) -> BTreeMap> { token_levels(&self.length) From 31a8aa5f9e3e9255caa054b542ddb869ae1f7366 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:52:13 +0900 Subject: [PATCH 06/12] feat(react): type media shorthands and at-rule keys Add _motionReduce, _motionSafe, _portrait, _landscape, _contrastMore, _contrastLess and _forcedColors, accept @media/@supports/@container keys, allow top-level at-rules in globalCss, and drop the never-matching _speech. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/react/AGENTS.md | 2 +- .../src/types/props/__tests__/index.test-d.ts | 24 +++++++++++++++ .../react/src/types/props/selector/index.ts | 29 +++++++++++++++---- .../src/utils/__tests__/global-css.test-d.ts | 13 +++++++++ packages/react/src/utils/global-css.ts | 15 ++++++++++ 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/packages/react/AGENTS.md b/packages/react/AGENTS.md index b9011b401..2807aa5f5 100644 --- a/packages/react/AGENTS.md +++ b/packages/react/AGENTS.md @@ -68,7 +68,7 @@ src/ ### Theme Tokens ```tsx // Color token - // Typography preset + // Typography preset ``` ## ANTI-PATTERNS diff --git a/packages/react/src/types/props/__tests__/index.test-d.ts b/packages/react/src/types/props/__tests__/index.test-d.ts index 309493e4b..32bc57f14 100644 --- a/packages/react/src/types/props/__tests__/index.test-d.ts +++ b/packages/react/src/types/props/__tests__/index.test-d.ts @@ -128,6 +128,30 @@ describe('index', () => { `, }) }) + it('media shorthands and at-rule keys', () => { + assertType>({ + _motionReduce: { transition: 'none' }, + _motionSafe: { _hover: { transform: 'scale(1.05)' } }, + _portrait: { flexDir: 'column' }, + _landscape: [{ flexDir: 'row' }, null, { flexDir: 'column' }], + _contrastMore: { borderColor: 'black' }, + _contrastLess: { borderColor: 'gray' }, + _forcedColors: { borderColor: 'CanvasText' }, + _print: { display: 'none' }, + _screen: { display: 'block' }, + _all: { color: 'black' }, + '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, + '@supports (display: grid)': { display: 'grid' }, + '@container sidebar (min-width: 400px)': { p: 2 }, + _hover: { + _motionReduce: { transform: 'none' }, + '@media print': { color: 'black' }, + }, + }) + + expectTypeOf>().not.toHaveProperty('_speech') + }) + it('DevupComponentAdditionalProps', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars function Foo({ children: _ }: { children?: string; c: string }) { diff --git a/packages/react/src/types/props/selector/index.ts b/packages/react/src/types/props/selector/index.ts index 891a13b1f..81d19ad0c 100644 --- a/packages/react/src/types/props/selector/index.ts +++ b/packages/react/src/types/props/selector/index.ts @@ -71,13 +71,30 @@ export type AtRuleRecord = Partial< Record> > +/** Props that wrap styles in a fixed `@media` query, e.g. `_motionReduce`. */ +export type MediaShorthand = + | '_print' + | '_screen' + | '_all' + | '_portrait' + | '_landscape' + | '_motionReduce' + | '_motionSafe' + | '_contrastMore' + | '_contrastLess' + | '_forcedColors' + +export type MediaShorthandProps = { + [K in MediaShorthand]?: SelectorProps +} + +/** At-rule keys carrying their condition, e.g. `'@media print'`. */ +export type AtRuleKey = + `@media ${string}` | `@supports ${string}` | `@container ${string}` + export interface DevupSelectorProps - extends SimpleSelectorProps, AdvancedSelectorProps { - // media query - _print?: SelectorProps - _screen?: SelectorProps - _speech?: SelectorProps - _all?: SelectorProps + extends SimpleSelectorProps, AdvancedSelectorProps, MediaShorthandProps { + [key: AtRuleKey]: SelectorProps | undefined // at-rules (underscore prefix) _container?: AtRuleRecord diff --git a/packages/react/src/utils/__tests__/global-css.test-d.ts b/packages/react/src/utils/__tests__/global-css.test-d.ts index 0be126b4e..5175a9987 100644 --- a/packages/react/src/utils/__tests__/global-css.test-d.ts +++ b/packages/react/src/utils/__tests__/global-css.test-d.ts @@ -12,4 +12,17 @@ describe('globalCss', () => { }, }) }) + + it('top-level at-rules', () => { + assertType({ + '@media (prefers-reduced-motion: reduce)': { + '*, *::before, *::after': { transition: 'none' }, + }, + _motionReduce: { html: { scrollBehavior: 'auto' } }, + _print: { body: { bg: 'white' } }, + _media: { '(min-width: 768px)': { body: { m: 2 } } }, + '@supports': { '(display: grid)': { main: { display: 'grid' } } }, + body: { _motionReduce: { transition: 'none' } }, + }) + }) }) diff --git a/packages/react/src/utils/global-css.ts b/packages/react/src/utils/global-css.ts index c35d0ad3e..dd86476c8 100644 --- a/packages/react/src/utils/global-css.ts +++ b/packages/react/src/utils/global-css.ts @@ -1,10 +1,12 @@ import type { DevupCommonProps } from '../types/props' import type { AdvancedSelector, + AtRuleKey, CamelCase, DevupSelectorProps, DevupThemeSelectorProps, ExtractSelector, + MediaShorthand, SimpleSelector, } from '../types/props/selector' @@ -36,6 +38,19 @@ export type GlobalCssProps = { [ K in `${keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap | '.' | '*' | '#' | ':' | '['}${string}` ]?: DevupCommonProps & DevupSelectorProps & DevupThemeSelectorProps +} & { + // Top-level at-rules wrap whole selector maps: `'@media print': { body: … }`. + [K in AtRuleKey | MediaShorthand]?: GlobalCssProps +} & { + [ + K in + | '_media' + | '_supports' + | '_container' + | '@media' + | '@supports' + | '@container' + ]?: Record } interface FontFaceProps { From b21b6185c85eb8335127ca0a9a32f65ee37af943 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:54:20 +0900 Subject: [PATCH 07/12] docs: document media shorthands and at-rules, fix typography examples typography takes the bare preset key, so the examples no longer write typography=$heading. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 2 +- README_ko.md | 2 +- SKILL.md | 17 ++- .../app/(detail)/docs/api/selector/page.mdx | 126 ++++++++++++++++++ .../(detail)/docs/devup/breakpoints/page.mdx | 2 +- .../(detail)/docs/devup/devup-json/page.mdx | 4 +- .../(detail)/docs/devup/typography/page.mdx | 24 ++-- .../src/app/(detail)/docs/features/page.mdx | 2 +- 8 files changed, 160 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d51c35d6b..14c0d1658 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ const example = ( ```tsx // Type-safe theme tokens const textExample = -const boxExample = +const boxExample = ``` **Custom shorthands:** diff --git a/README_ko.md b/README_ko.md index 095a0183c..1f97db94c 100644 --- a/README_ko.md +++ b/README_ko.md @@ -186,7 +186,7 @@ const generated = ( ```tsx // 타입 세이프 테마 토큰 const textExample = -const boxExample = +const boxExample = ``` **사용자 정의 shorthand:** diff --git a/SKILL.md b/SKILL.md index ade2c1c06..cdb07c166 100644 --- a/SKILL.md +++ b/SKILL.md @@ -215,8 +215,23 @@ Available: `_groupHover`, `_groupFocus`, `_groupActive`, `_groupDisabled`. // @ prefix syntax (equivalent) + +// Query in the key (Emotion style), also inside `selectors` and css()/styled() + +``` + +Media shorthands wrap styles in a fixed query and nest in either direction with selectors: +`_print`, `_screen`, `_all`, `_portrait`, `_landscape`, `_motionReduce` (`prefers-reduced-motion: reduce`), `_motionSafe` (`no-preference`), `_contrastMore`, `_contrastLess`, `_forcedColors`. + +```tsx + + +globalCss({ _motionReduce: { "*, *::before, *::after": { transition: "none" } } }) ``` +- Conditions beat breakpoint values: at-rule styles are emitted after every responsive rule. +- Nested `@media` rules merge into one query (`print and (prefers-reduced-motion:reduce)`); ones that can never match together (`_print` inside `_screen`) are dropped. + ### Custom Selectors ```tsx @@ -365,7 +380,7 @@ const spin = keyframes({ from: { transform: "rotate(0)" }, to: { transform: "rot ``` - **Colors**: Use with `$` prefix in JSX props: `` -- **Typography**: Use with `$` prefix: `` +- **Typography**: Use the preset name without `$`: ``. Under selectors or at-rules (`_hover={{ typography: "heading" }}`) the preset applies only under that condition. - **Length**: Responsive length tokens: ``, `` - **Shadow**: Responsive shadow tokens: `` - **extends**: Inherit from base config files (deep merge, last wins) diff --git a/apps/landing/src/app/(detail)/docs/api/selector/page.mdx b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx index f48115420..ce48b5440 100644 --- a/apps/landing/src/app/(detail)/docs/api/selector/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx @@ -182,3 +182,129 @@ The attributes defined in Pseudo-Class correspond to those defined in Selector, + +## Media Shorthands + +These props wrap their styles in a fixed media query. They accept responsive arrays and nest in either direction with selectors and other at-rules. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SelectorMedia Query
+ _print + + print +
+ _screen + + screen +
+ _all + + all +
+ _portrait + + (orientation:portrait) +
+ _landscape + + (orientation:landscape) +
+ _motionReduce + + (prefers-reduced-motion:reduce) +
+ _motionSafe + + (prefers-reduced-motion:no-preference) +
+ _contrastMore + + (prefers-contrast:more) +
+ _contrastLess + + (prefers-contrast:less) +
+ _forcedColors + + (forced-colors:active) +
+ +```tsx +const example = ( + +) +``` + +## At-Rules + +`_media`, `_supports` and `_container` take a record of queries to styles. A query can also sit in the key itself, as in Emotion, and `globalCss` accepts at-rules around whole selector maps. + +```tsx +const example = ( + +) + +globalCss({ + _motionReduce: { '*, *::before, *::after': { transition: 'none' } }, +}) +``` + +Conditions always win over breakpoint values: at-rule styles come after every responsive rule. Among themselves they are ordered by condition, with width queries mobile-first (`min-width` ascending, then `max-width` descending), then media types, other features, and user preferences such as `prefers-*` last. diff --git a/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx b/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx index 205036bb9..77195034b 100644 --- a/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx +++ b/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx @@ -172,7 +172,7 @@ Or use responsive typography from `devup.json`: ``` ```tsx -Responsive Heading +Responsive Heading ``` ## Responsive with Pseudo Selectors diff --git a/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx b/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx index 9344e90d1..462776cec 100644 --- a/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx +++ b/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx @@ -223,8 +223,8 @@ See [Shadow](/docs/devup/shadow) for more details. ### Typography ```tsx -const headingExample = Heading -const bodyExample = Body text +const headingExample = Heading +const bodyExample = Body text ``` ### Length diff --git a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx index a356fffd6..9df1f4f6f 100644 --- a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx +++ b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx @@ -60,10 +60,10 @@ Define typography styles in `devup.json`: Use typography with the `typography` prop: ```tsx -const h1Example = Heading 1 -const h2Example = Heading 2 -const bodyExample = Body text -const captionExample = Caption text +const h1Example = Heading 1 +const h2Example = Heading 2 +const bodyExample = Body text +const captionExample = Caption text ``` ## Typography Properties @@ -298,17 +298,17 @@ Here's a recommended typography scale: ```tsx // With Text component -const titleExample = Main Title -const bodyExample = Paragraph text goes here. +const titleExample = Main Title +const bodyExample = Paragraph text goes here. // With Box component -const boxExample = Caption in a box +const boxExample = Caption in a box ``` ```tsx // With styled components const Title = styled('h1', { - typography: '$h1', + typography: 'h1', color: '$text', }) ``` @@ -319,7 +319,7 @@ Typography can be combined with other style props: ```tsx const example = ( - + Centered Title ) @@ -330,9 +330,9 @@ const example = ( Typography tokens are fully type-safe: ```tsx -// TypeScript autocomplete shows: $h1, $h2, $body, etc. -const example = +// TypeScript autocomplete shows: h1, h2, body, etc. +const example = // Invalid tokens show TypeScript errors -const invalidExample = // Error! +const invalidExample = // Error! ``` diff --git a/apps/landing/src/app/(detail)/docs/features/page.mdx b/apps/landing/src/app/(detail)/docs/features/page.mdx index 26c9e0071..ebb85645c 100644 --- a/apps/landing/src/app/(detail)/docs/features/page.mdx +++ b/apps/landing/src/app/(detail)/docs/features/page.mdx @@ -162,7 +162,7 @@ Define themes in `devup.json` with full TypeScript support. ```tsx // Type-safe theme tokens with autocomplete const textExample = -const boxExample = +const boxExample = ``` Theme switching happens via CSS variables — zero JavaScript execution required. From ab73e17238251f004dd4bc0e020df65929938497 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 00:56:27 +0900 Subject: [PATCH 08/12] chore: add changepacks for at-rule composition and CSS output fixes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_at_rule_composition.json | 8 ++++++++ .changepacks/changepack_log_css_output_fixes.json | 8 ++++++++ 2 files changed, 16 insertions(+) create mode 100644 .changepacks/changepack_log_at_rule_composition.json create mode 100644 .changepacks/changepack_log_css_output_fixes.json diff --git a/.changepacks/changepack_log_at_rule_composition.json b/.changepacks/changepack_log_at_rule_composition.json new file mode 100644 index 000000000..9c7889f27 --- /dev/null +++ b/.changepacks/changepack_log_at_rule_composition.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch" + }, + "note": "Fix at-rule composition and cascade: `_print`/`_media`/`@media …` nest correctly with selectors in both directions and inside `globalCss`, nested media queries merge (`print and (…)`), breakpoints put media types first, distribute query lists, and nest `not …` queries, and at-rules are emitted after all breakpoint values while pseudo selectors keep `SELECTOR_ORDER` across breakpoints. Add `_motionReduce`, `_motionSafe`, `_portrait`, `_landscape`, `_contrastMore`, `_contrastLess` and `_forcedColors`, remove the never-matching `_speech`, normalize query whitespace, support Emotion-style `'@media …'`/`'&:hover'` object keys, fix template literals losing declarations before `@media` or selectors around nested at-rules, apply `typography` conditionally under selectors, and drop stale global at-rules on re-extraction", + "date": "2026-09-26T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_css_output_fixes.json b/.changepacks/changepack_log_css_output_fixes.json new file mode 100644 index 000000000..1cdced775 --- /dev/null +++ b/.changepacks/changepack_log_css_output_fixes.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Fix the full WASM build panicking on every vanilla-extract `.css.ts` file (Boa read the clock without its `js` feature), resolve vanilla-extract references to other styles, keyframes, fonts and themes in selectors, values and `globalStyle`, keep `globalCss` pseudo selectors in `SELECTOR_ORDER` across breakpoints, merge adjacent rules with the same selector, and let declarations written next to a conditional `typography` preset win over it. Add the `prefer-media-shorthand` and `no-typography-token-prefix` ESLint rules", + "date": "2026-09-27T00:00:00.000Z" +} From f6ea583e2487cf8a31c261fad936a5c214cecdbb Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 04:19:00 +0900 Subject: [PATCH 09/12] fix(extractor): give vanilla-extract and StyleX numbers their library units vanilla-extract styles add px to numbers (in .css.ts files and in style/globalStyle/keyframes calls of ordinary modules) and StyleX adds px/ms to static and dynamic numbers, instead of Devup UI's spacing scale. The unitless property list now matches vanilla-extract's, so strokeWidth, columns, zoom, orphans and similar keep bare numbers everywhere. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/extract_style/constant.rs | 35 ++- .../extractor/extract_style_from_stylex.rs | 35 +-- libs/extractor/src/import_alias_visit.rs | 137 ++++++++- libs/extractor/src/lib.rs | 126 +++++++++ ...__tests__stylex_create_numeric_values.snap | 4 +- ...ests__stylex_create_numeric_with_unit.snap | 2 +- ...xtractor__tests__stylex_dynamic_basic.snap | 2 +- ...xtractor__tests__stylex_dynamic_mixed.snap | 2 +- ...or__tests__stylex_dynamic_multi_param.snap | 2 +- ...__tests__stylex_dynamic_numeric_value.snap | 2 +- ...xtractor__tests__stylex_types_numeric.snap | 2 +- ..._vanilla_extract_all_exports_combined.snap | 2 +- ...r__tests__vanilla_extract_composition.snap | 4 +- ...sts__vanilla_extract_constant_exports.snap | 2 +- ...ct_css_ts_with_vanilla_extract_import.snap | 2 +- ...nilla_extract_global_theme_empty_vars.snap | 2 +- ...illa_extract_global_theme_placeholder.snap | 2 +- ...anilla_extract_global_theme_with_vars.snap | 2 +- ...__vanilla_extract_non_exported_styles.snap | 4 +- ...__vanilla_extract_selector_references.snap | 2 +- ...r__tests__vanilla_extract_selectors-2.snap | 2 +- ...la_extract_style_composition_multiple.snap | 4 +- ...__tests__vanilla_extract_style_css_js.snap | 2 +- ...__tests__vanilla_extract_style_css_ts.snap | 2 +- ...sts__vanilla_extract_style_variants-2.snap | 4 +- ..._vanilla_extract_style_variants_mixed.snap | 4 +- ...illa_extract_style_variants_with_base.snap | 10 +- ...nilla_extract_theme_export_variations.snap | 2 +- ...nilla_extract_theme_without_vars_json.snap | 2 +- ..._tests__vanilla_extract_with_computed.snap | 4 +- ...r__tests__vanilla_extract_with_spread.snap | 4 +- ..._tests__vanilla_extract_with_variable.snap | 2 +- libs/extractor/src/stylex.rs | 260 +++++++++++++++++- libs/extractor/src/utils.rs | 51 ++++ libs/extractor/src/vanilla_extract.rs | 66 ++++- libs/extractor/src/visit.rs | 81 +++++- .../src/__tests__/static-vanilla.test.ts | 77 ------ packages/next-plugin/src/static-vanilla.ts | 194 ------------- 38 files changed, 787 insertions(+), 355 deletions(-) delete mode 100644 packages/next-plugin/src/__tests__/static-vanilla.test.ts delete mode 100644 packages/next-plugin/src/static-vanilla.ts diff --git a/libs/extractor/src/extract_style/constant.rs b/libs/extractor/src/extract_style/constant.rs index a1e1821c4..1bb014102 100644 --- a/libs/extractor/src/extract_style/constant.rs +++ b/libs/extractor/src/extract_style/constant.rs @@ -1,6 +1,9 @@ use phf::phf_set; -pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { +/// Properties whose numbers are unitless in CSS, so they are kept as written +/// instead of being scaled to `px`. Includes every property vanilla-extract +/// leaves unitless, so numbers from `.css.ts` files keep their meaning. +pub(crate) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "opacity", "flex", "z-index", @@ -12,6 +15,7 @@ pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "flex-grow", "flex-shrink", "order", + "grid-area", "grid-column", "grid-column-start", "grid-column-end", @@ -21,5 +25,32 @@ pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "animation-iteration-count", "tab-size", "moz-tab-size", - "-webkit-line-clamp" + "-webkit-line-clamp", + "webkit-line-clamp", + "border-image", + "border-image-outset", + "border-image-slice", + "border-image-width", + "box-flex", + "box-flex-group", + "box-ordinal-group", + "column-count", + "columns", + "initial-letter", + "max-lines", + "orphans", + "widows", + "zoom", + "fill-opacity", + "flood-opacity", + "mask-border", + "mask-border-outset", + "mask-border-slice", + "mask-border-width", + "shape-image-threshold", + "stop-opacity", + "stroke-dashoffset", + "stroke-miterlimit", + "stroke-opacity", + "stroke-width" }; diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index 568ec9b42..7b469760b 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -3,10 +3,10 @@ use crate::extract_style::extract_dynamic_style::ExtractDynamicStyle; use crate::extract_style::extract_static_style::ExtractStaticStyle; use crate::extract_style::extract_style_value::ExtractStyleValue; use crate::stylex::{ - SelectorPart, StylexIncludeRef, decompose_value_conditions, is_first_that_works_call, - is_include_call_static, is_types_call, normalize_stylex_property, + SelectorPart, StylexIncludeRef, decompose_value_conditions, dynamic_number_suffix, + is_first_that_works_call, is_include_call_static, is_types_call, normalize_stylex_property, + stylex_value, }; -use crate::utils::get_string_by_literal_expression; use css::optimize_value::optimize_value; use css::sheet_to_variable_name; use css::style_selector::StyleSelector; @@ -45,12 +45,9 @@ pub fn extract_stylex_declarations(value: &Expression<'_>) -> Vec<(String, Strin let ObjectPropertyKind::ObjectProperty(prop) = prop else { return None; }; - let name = get_str_by_property_key(&prop.key)?; - let value = get_string_by_literal_expression(&prop.value)?; - Some(( - normalize_stylex_property(name.as_ref()), - optimize_value(&value).into_owned(), - )) + let property = normalize_stylex_property(get_str_by_property_key(&prop.key)?.as_ref()); + let value = stylex_value(&property, &prop.value)?; + Some((property, optimize_value(&value).into_owned())) }) .collect() } @@ -110,7 +107,7 @@ pub fn extract_stylex_namespace_styles<'a>( ) -> Vec<( String, Vec>, - Option>, + Option>, Vec, )> { let Expression::ObjectExpression(obj) = expression else { @@ -250,7 +247,7 @@ pub fn extract_stylex_namespace_styles<'a>( } // Phase 1: static string/number values - let css_value = if let Some(s) = get_string_by_literal_expression(&style_prop.value) { + let css_value = if let Some(s) = stylex_value(&css_property, &style_prop.value) { s } else if matches!(&style_prop.value, Expression::ObjectExpression(_)) { // Phase 2: value-level conditions @@ -272,7 +269,7 @@ pub fn extract_stylex_namespace_styles<'a>( // CSS fallback: output in reverse order (least preferred first, most preferred last). for arg in call.arguments.iter().rev() { if let Some(arg_expr) = arg.as_expression() - && let Some(s) = get_string_by_literal_expression(arg_expr) + && let Some(s) = stylex_value(&css_property, arg_expr) { styles.push(raw_static_style(css_property.clone(), &s, None)); } @@ -283,7 +280,7 @@ pub fn extract_stylex_namespace_styles<'a>( // stylex.types.length('100px') → extract inner value '100px' && let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression()) { - let css_value = if let Some(s) = get_string_by_literal_expression(inner) { + let css_value = if let Some(s) = stylex_value(&css_property, inner) { s } else { continue; // Can't resolve inner value @@ -310,12 +307,16 @@ pub fn extract_stylex_namespace_styles<'a>( } /// Extract styles from a dynamic `StyleX` namespace (arrow function). -/// Returns (`styles_for_css`, `css_vars`) where `css_vars` maps `param_index` to CSS variable name. +/// Returns (`styles_for_css`, `css_vars`) where `css_vars` maps `param_index` to a CSS variable +/// name and the unit a number passed for it gets. #[allow(clippy::type_complexity)] fn extract_stylex_dynamic_namespace<'a>( arrow: &oxc_ast::ast::ArrowFunctionExpression<'a>, keyframe_names: &FxHashMap, -) -> Option<(Vec>, Vec<(usize, String)>)> { +) -> Option<( + Vec>, + Vec<(usize, String, &'static str)>, +)> { // 1. Extract parameter names let param_names: Vec = arrow .params @@ -373,7 +374,7 @@ fn extract_stylex_dynamic_namespace<'a>( if let Some(param_idx) = is_dynamic { // Dynamic property: generate CSS variable let var_name = sheet_to_variable_name(&css_property, 0, None); - css_vars.push((param_idx, var_name)); + css_vars.push((param_idx, var_name, dynamic_number_suffix(&css_property))); let param_name = ¶m_names[param_idx]; styles.push(ExtractStyleProp::Static(ExtractStyleValue::Dynamic( ExtractDynamicStyle::new(&css_property, 0, param_name, None), @@ -386,7 +387,7 @@ fn extract_stylex_dynamic_namespace<'a>( styles.push(raw_static_style(css_property, anim_name, None)); continue; } - let css_value = if let Some(s) = get_string_by_literal_expression(&prop.value) { + let css_value = if let Some(s) = stylex_value(&css_property, &prop.value) { s } else { continue; diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 259bf357f..c039151de 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -8,14 +8,83 @@ //! - `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` use crate::ImportAlias; -use crate::utils::is_vanilla_extract_file; +use crate::utils::{ + get_str_by_property_key, is_unitless_key, is_vanilla_extract_file, js_number_literal, +}; use oxc_allocator::Allocator; -use oxc_ast::ast::{ImportDeclarationSpecifier, ModuleExportName}; +use oxc_ast::ast::{ + Argument, CallExpression, Expression, ImportDeclarationSpecifier, ModuleExportName, + ObjectPropertyKind, Statement, +}; +use oxc_ast_visit::{Visit, walk::walk_call_expression}; use oxc_parser::Parser; -use oxc_span::SourceType; +use oxc_span::{GetSpan, SourceType}; use std::borrow::Cow; use std::collections::HashMap; +/// Numbers in vanilla-extract `style()` / `globalStyle()` / `keyframes()` rules outside a +/// stylesheet. Those calls become Devup UI's `css()` / `globalCss()` / `keyframes()`, which read a +/// number as its spacing scale, so it is rewritten to the `px` string +/// vanilla-extract makes of it. +struct VanillaNumbers<'n> { + /// Local name of each call and the index of its rules argument + calls: Vec<(&'n str, usize)>, + replacements: Vec<(usize, usize, String)>, +} + +impl VanillaNumbers<'_> { + fn pixelify(&mut self, rules: &Expression) { + match rules { + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(element) = element.as_expression() { + self.pixelify(element); + } + } + } + Expression::ObjectExpression(object) => { + for property in &object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && let Some(key) = get_str_by_property_key(&property.key) + { + if matches!(property.value, Expression::ObjectExpression(_)) { + if key != "vars" { + self.pixelify(&property.value); + } + } else if let Some(number) = js_number_literal(&property.value) + && number != 0.0 + && !is_unitless_key(&key) + { + let span = property.value.span(); + self.replacements.push(( + span.start as usize, + span.end as usize, + format!("\"{number}px\""), + )); + } + } + } + } + _ => {} + } + } +} + +impl<'a> Visit<'a> for VanillaNumbers<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if let Expression::Identifier(callee) = &call.callee + && let Some(&(_, index)) = self + .calls + .iter() + .find(|(name, _)| *name == callee.name.as_str()) + && let Some(rules) = call.arguments.get(index).and_then(Argument::as_expression) + { + self.pixelify(rules); + } + walk_call_expression(self, call); + } +} + /// Map an aliased package's export onto the `@devup-ui/react` export that implements the /// same behaviour, so the extractor consumes the call and drops the import entirely — no /// dependency on either package survives. @@ -87,9 +156,13 @@ pub fn transform_import_aliases<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); + let mut numbers = VanillaNumbers { + calls: Vec::new(), + replacements: Vec::new(), + }; for stmt in &program.body { - if let oxc_ast::ast::Statement::ImportDeclaration(import_decl) = stmt { + if let Statement::ImportDeclaration(import_decl) = stmt { let source_value = import_decl.source.value.as_str(); if let Some(alias) = import_aliases.get(source_value) { @@ -97,9 +170,26 @@ pub fn transform_import_aliases<'a>( let new_import = generate_transformed_import(import_decl, alias, package, redirect_every_name); transformations.push((span.start as usize, span.end as usize, new_import)); + if source_value == "@vanilla-extract/css" && !redirect_every_name { + for specifier in import_decl.specifiers.iter().flatten() { + if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { + let rules_index = match imported_name(&spec.imported).as_ref() { + "style" | "keyframes" => 0, + "globalStyle" => 1, + _ => continue, + }; + numbers.calls.push((spec.local.name.as_str(), rules_index)); + } + } + } } } } + if !numbers.calls.is_empty() { + numbers.visit_program(&program); + transformations.append(&mut numbers.replacements); + transformations.sort_unstable_by_key(|(start, ..)| *start); + } // Apply transformations in reverse order to preserve positions if transformations.is_empty() { @@ -501,6 +591,45 @@ export const container = style({ background: 'red' })", )); } + #[test] + fn test_vanilla_extract_numbers_become_px_outside_stylesheets() { + let code = r"import { style as s, globalStyle, keyframes, styleVariants } from '@vanilla-extract/css' +export const a = s({ padding: 8, top: -2, left: (1.5), width: 0, lineHeight: 1.5, vars: { '--x': 4 }, [key]: 5, ...rest, selectors: { '&:hover': { margin: 4 } } }) +export const b = s([a, { right: 3 }], 'debug') +globalStyle('body', { margin: 2 }) +keyframes({ from: { width: 10, opacity: 0 }, '50%': { width: 20 } }) +styleVariants({ small: { padding: 1 } }) +other({ padding: 8 }) +s()"; + assert_eq!( + transform_import_aliases( + code, + "test.tsx", + "@devup-ui/react", + &vanilla_extract_alias() + ), + r#"import { css as s, globalCss as globalStyle, keyframes } from '@devup-ui/react'; import { styleVariants } from '@vanilla-extract/css'; +export const a = s({ padding: "8px", top: "-2px", left: "1.5px", width: 0, lineHeight: 1.5, vars: { '--x': 4 }, [key]: 5, ...rest, selectors: { '&:hover': { margin: "4px" } } }) +export const b = s([a, { right: "3px" }], 'debug') +globalStyle('body', { margin: "2px" }) +keyframes({ from: { width: "10px", opacity: 0 }, '50%': { width: "20px" } }) +styleVariants({ small: { padding: 1 } }) +other({ padding: 8 }) +s()"# + ); + let stylesheet = + "import { style } from '@vanilla-extract/css'\nexport const a = style({ padding: 8 })"; + assert!( + transform_import_aliases( + stylesheet, + "a.css.ts", + "@devup-ui/react", + &vanilla_extract_alias() + ) + .ends_with("style({ padding: 8 })") + ); + } + #[test] fn test_multiple_imports_same_file() { assert_snapshot!(transform_import_aliases( diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 9b3759ae4..b287c1a8f 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -14746,6 +14746,132 @@ const styles = stylex.create({ )); } + fn static_values(output: &ExtractOutput) -> BTreeSet<(String, String)> { + output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(st) => { + Some((st.property().to_string(), st.value().to_string())) + } + ExtractStyleValue::Keyframes(keyframes) => keyframes + .keyframes + .values() + .flatten() + .next() + .map(|st| (st.property().to_string(), st.value().to_string())), + _ => None, + }) + .collect() + } + + #[test] + #[serial] + fn test_vanilla_extract_numbers_follow_vanilla_extract_units() { + reset_class_map(); + reset_file_map(); + let output = extract( + "numbers.css.ts", + r"import { style, globalStyle, keyframes, styleVariants } from '@devup-ui/react' +export const box = style({ + fontSize: 16, + top: -2, + width: 0, + lineHeight: 1.5, + zIndex: 2, + columnCount: 3, + strokeWidth: 2, + aspectRatio: 1.5, + selectors: { '&:hover': { marginTop: 4 } }, + '@media': { print: { left: 1.5 } }, +}) +export const spin = keyframes({ to: { height: 10 } }) +export const size = styleVariants({ small: { maxWidth: 20 } }) +globalStyle('body', { margin: 2 }) +", + ExtractOption::default(), + ) + .unwrap(); + let values = static_values(&output); + for (property, value) in [ + ("font-size", "16px"), + ("top", "-2px"), + ("width", "0"), + ("line-height", "1.5"), + ("z-index", "2"), + ("column-count", "3"), + ("stroke-width", "2"), + ("aspect-ratio", "1.5"), + ("margin-top", "4px"), + ("left", "1.5px"), + ("height", "10px"), + ("max-width", "20px"), + ("margin", "2px"), + ] { + assert!( + values.contains(&(property.to_string(), value.to_string())), + "{property}: {value} not in {values:?}" + ); + } + } + + #[test] + #[serial] + fn test_stylex_numbers_follow_stylex_units() { + reset_class_map(); + reset_file_map(); + let output = extract( + "test.tsx", + r"import stylex from '@stylexjs/stylex'; +const styles = stylex.create({ + base: { + fontSize: 16, + marginTop: -8, + width: -0, + height: '16', + lineHeight: 1.5, + transitionDuration: 300, + '--size': 4, + left: 1.33333, + top: { default: 2, ':hover': 4 }, + right: stylex.firstThatWorks(6, 'auto'), + }, + dynamic: (opacity, delay) => ({ opacity, transitionDelay: delay, bottom: 3 }), +}); +const result = stylex.props(styles.dynamic(o, d));", + ExtractOption::default(), + ) + .unwrap(); + let values = static_values(&output); + for (property, value) in [ + ("font-size", "16px"), + ("margin-top", "-8px"), + ("width", "0"), + ("height", "16"), + ("line-height", "1.5"), + ("transition-duration", "300ms"), + ("--size", "4"), + ("left", "1.3333px"), + ("top", "2px"), + ("top", "4px"), + ("right", "6px"), + ("bottom", "3px"), + ] { + assert!( + values.contains(&(property.to_string(), value.to_string())), + "{property}: {value} not in {values:?}" + ); + } + assert!(output.code.contains(r#""--a": o"#), "{}", output.code); + assert!( + output + .code + .contains(r#"((v) => typeof v === "number" ? v + "ms" : v)(d)"#), + "{}", + output.code + ); + } + #[test] #[serial] fn test_stylex_create_numeric_values() { diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap index d8a33c39a..af003b424 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "16", + value: "16px", level: 0, selector: None, style_order: None, @@ -47,7 +47,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "8", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap index 4478cd7c8..5a952aed5 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "16", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap index 0e64214a1..2432d59ed 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap @@ -14,5 +14,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b\" };\nconst result = {\n\tclassName: \"b\",\n\tstyle: { \"--a\": h }\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b\" };\nconst result = {\n\tclassName: \"b\",\n\tstyle: { \"--a\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(h) }\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap index 255d23b9e..d4c3552f1 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap @@ -24,5 +24,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b c\" };\nconst result = {\n\tclassName: \"b c\",\n\tstyle: { \"--a\": h }\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b c\" };\nconst result = {\n\tclassName: \"b c\",\n\tstyle: { \"--a\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(h) }\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap index af63ba3ef..81725cdab 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"c d\" };\nconst result = {\n\tclassName: \"c d\",\n\tstyle: {\n\t\t\"--a\": myH,\n\t\t\"--b\": myW\n\t}\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"c d\" };\nconst result = {\n\tclassName: \"c d\",\n\tstyle: {\n\t\t\"--a\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(myH),\n\t\t\"--b\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(myW)\n\t}\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap index 21272d366..d2c86cbbf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "16", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap index 28c1eda92..b04a3ed7c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "16", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap index 9f4b29a89..29976b6ec 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap @@ -71,7 +71,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap index 796a2aade..307ac06da 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -47,7 +47,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "48px", + value: "12px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap index 239a9d9a2..611abb463 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap index ad214187d..cbaf24c7f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "128px", + value: "32px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap index 1c1914d52..754ab6170 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap index e25bfd199..26b2f670b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap @@ -75,7 +75,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap index f6dafe73f..09eedf6be 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap @@ -75,7 +75,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap index 15a6ef98d..b37bcbadb 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap index e7f52c8e8..39460fa16 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap @@ -31,7 +31,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin-top", - value: "32px", + value: "8px", level: 0, selector: Some( Selector( diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap index c6d642b08..65fd54dcf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap @@ -31,7 +31,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin-top", - value: "32px", + value: "8px", level: 0, selector: Some( Selector( diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap index 6f0442004..143f62597 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap index 2fc830773..019824caf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap index 9ae96afb3..f3d821654 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "64px", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap index a546eb220..84a2b9346 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -57,7 +57,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "48px", + value: "12px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap index 204774f28..27943e415 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap @@ -37,7 +37,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -67,7 +67,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap index 31b0af989..1d2367b8e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "48px", + value: "12px", level: 0, selector: None, style_order: None, @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "64px", + value: "16px", level: 0, selector: None, style_order: None, @@ -37,7 +37,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "80px", + value: "20px", level: 0, selector: None, style_order: None, @@ -47,7 +47,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap index 32beafe5d..33392c10c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap @@ -75,7 +75,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap index 0379d0c0d..b718003f7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap @@ -24,7 +24,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap index ce2e9b896..e4a5505d8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "64px", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap index 474649ffd..cc83fec00 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap index 5a9821856..bb689c12b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "64px", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/stylex.rs b/libs/extractor/src/stylex.rs index 10cddf7c3..134360fdc 100644 --- a/libs/extractor/src/stylex.rs +++ b/libs/extractor/src/stylex.rs @@ -1,7 +1,11 @@ +use std::borrow::Cow; + use css::style_selector::{AtRuleKind, StyleSelector}; use oxc_ast::ast::{Expression, ObjectPropertyKind}; -use crate::utils::{get_string_by_literal_expression, get_string_by_property_key}; +use crate::utils::{ + get_string_by_literal_expression, get_string_by_property_key, js_number_literal, +}; /// Which `StyleX` function a named import refers to #[derive(Debug, Clone, PartialEq, Eq)] @@ -113,6 +117,248 @@ pub fn normalize_stylex_property(name: &str) -> String { css::utils::to_kebab_case(name).into_owned() } +/// Properties `StyleX` leaves unitless when given a number. +static UNITLESS_NUMBER_PROPERTIES: phf::Set<&'static str> = phf::phf_set! { + "webkit-line-clamp", + "animation-iteration-count", + "aspect-ratio", + "border-image-outset", + "border-image-slice", + "border-image-width", + "counter-set", + "counter-reset", + "column-count", + "flex", + "flex-grow", + "flex-shrink", + "flex-order", + "grid-row", + "grid-row-start", + "grid-row-end", + "grid-column", + "grid-column-start", + "grid-column-end", + "grid-area", + "font-size-adjust", + "font-weight", + "hyphenate-limit-chars", + "line-clamp", + "line-height", + "mask-border-outset", + "mask-border-slice", + "mask-border-width", + "opacity", + "order", + "orphans", + "tab-size", + "widows", + "z-index", + "fill-opacity", + "flood-opacity", + "rotate", + "scale", + "shape-image-threshold", + "stop-opacity", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-miterlimit", + "stroke-opacity", + "stroke-width", + "math-depth", + "zoom", +}; + +/// Properties whose numbers `StyleX` reads as milliseconds. +static TIME_PROPERTIES: phf::Set<&'static str> = phf::phf_set! { + "animation-delay", + "animation-duration", + "transition-delay", + "transition-duration", + "voice-duration", +}; + +/// Properties whose dynamic values `StyleX` gives a unit when they are numbers. +static LENGTH_PROPERTIES: phf::Set<&'static str> = phf::phf_set! { + "background-position-x", + "background-position-y", + "block-size", + "border-block-end-width", + "border-block-start-width", + "border-block-width", + "border-vertical-width", + "border-bottom-left-radius", + "border-bottom-right-radius", + "border-bottom-width", + "border-end-end-radius", + "border-end-start-radius", + "border-inline-end-width", + "border-end-width", + "border-inline-start-width", + "border-start-width", + "border-inline-width", + "border-horizontal-width", + "border-left-width", + "border-right-width", + "border-spacing", + "border-start-end-radius", + "border-start-start-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-top-width", + "bottom", + "column-gap", + "column-rule-width", + "column-width", + "contain-intrinsic-block-size", + "contain-intrinsic-height", + "contain-intrinsic-inline-size", + "contain-intrinsic-width", + "flex-basis", + "font-size", + "font-smooth", + "height", + "inline-size", + "inset-block-end", + "inset-block-start", + "inset-inline-end", + "inset-inline-start", + "left", + "letter-spacing", + "margin-block-end", + "margin-block-start", + "margin-bottom", + "margin-inline-end", + "margin-end", + "margin-inline-start", + "margin-start", + "margin-left", + "margin-right", + "margin-top", + "max-block-size", + "max-height", + "max-inline-size", + "max-width", + "min-block-size", + "min-height", + "min-inline-size", + "min-width", + "offset-distance", + "outline-offset", + "outline-width", + "overflow-clip-margin", + "padding-block-end", + "padding-block-start", + "padding-bottom", + "padding-inline-end", + "padding-end", + "padding-inline-start", + "padding-start", + "padding-left", + "padding-right", + "padding-top", + "perspective", + "right", + "row-gap", + "scroll-margin-block-end", + "scroll-margin-block-start", + "scroll-margin-bottom", + "scroll-margin-inline-end", + "scroll-margin-inline-start", + "scroll-margin-left", + "scroll-margin-right", + "scroll-margin-top", + "scroll-padding-block-end", + "scroll-padding-block-start", + "scroll-padding-bottom", + "scroll-padding-inline-end", + "scroll-padding-inline-start", + "scroll-padding-left", + "scroll-padding-right", + "scroll-padding-top", + "scroll-snap-margin-bottom", + "scroll-snap-margin-left", + "scroll-snap-margin-right", + "scroll-snap-margin-top", + "shape-margin", + "tab-size", + "text-decoration-thickness", + "text-indent", + "text-underline-offset", + "top", + "transform-origin", + "translate", + "vertical-align", + "width", + "word-spacing", + "border", + "border-block", + "border-block-end", + "border-block-start", + "border-bottom", + "border-left", + "border-radius", + "border-right", + "border-top", + "border-width", + "column-rule", + "contain-intrinsic-size", + "gap", + "inset", + "inset-block", + "inset-inline", + "margin", + "margin-block", + "margin-vertical", + "margin-inline", + "margin-horizontal", + "offset", + "outline", + "padding", + "padding-block", + "padding-vertical", + "padding-inline", + "padding-horizontal", + "scroll-margin", + "scroll-margin-block", + "scroll-margin-inline", + "scroll-padding", + "scroll-padding-block", + "scroll-padding-inline", + "scroll-snap-margin", +}; + +/// The unit `StyleX` appends to a number on `property`. +fn number_suffix(property: &str) -> &'static str { + if UNITLESS_NUMBER_PROPERTIES.contains(property) || property.starts_with("--") { + "" + } else if TIME_PROPERTIES.contains(property) { + "ms" + } else { + "px" + } +} + +/// The unit a dynamic value on `property` gets when it is a number at runtime. +pub fn dynamic_number_suffix(property: &str) -> &'static str { + if TIME_PROPERTIES.contains(property) || LENGTH_PROPERTIES.contains(property) { + number_suffix(property) + } else { + "" + } +} + +/// A literal `StyleX` value as CSS text: a number gets the unit `StyleX` gives it. +pub fn stylex_value<'a>(property: &str, value: &Expression<'a>) -> Option> { + js_number_literal(value).map_or_else( + || get_string_by_literal_expression(value), + |number| { + // `+ 0.0` turns `-0` into `0`, as JS prints it. + let rounded = (number * 10_000.0).round() / 10_000.0 + 0.0; + Some(Cow::Owned(format!("{rounded}{}", number_suffix(property)))) + }, + ) +} + /// Intermediate selector parts collected during recursion. #[derive(Debug, Clone)] pub enum SelectorPart { @@ -136,8 +382,8 @@ pub struct DecomposedStyle { pub struct StylexDynamicInfo { /// Combined class name string for all properties (static + dynamic) pub class_name: String, - /// Maps (`param_index`, `css_variable_name`) for each dynamic property - pub css_vars: Vec<(usize, String)>, + /// (`param_index`, `css_variable_name`, unit for a number) for each dynamic property + pub css_vars: Vec<(usize, String, &'static str)>, } /// A `StyleX` namespace entry — either static or dynamic (arrow function) @@ -163,8 +409,8 @@ pub fn decompose_value_conditions( value: &Expression, parent_selectors: &[SelectorPart], ) -> Vec { - // String literal → leaf - if let Some(s) = get_string_by_literal_expression(value) { + // String or number literal → leaf + if let Some(s) = stylex_value(css_property, value) { return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors) .into_iter() .collect(); @@ -184,7 +430,7 @@ pub fn decompose_value_conditions( let mut results = vec![]; for arg in call.arguments.iter().rev() { if let Some(arg_expr) = arg.as_expression() - && let Some(s) = get_string_by_literal_expression(arg_expr) + && let Some(s) = stylex_value(css_property, arg_expr) { results.extend(decomposed_leaf( css_property, @@ -201,7 +447,7 @@ pub fn decompose_value_conditions( && is_types_call(&call.callee) && let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression()) { - if let Some(s) = get_string_by_literal_expression(inner) { + if let Some(s) = stylex_value(css_property, inner) { return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors) .into_iter() .collect(); diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index d976d7ed6..3e2c88082 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -1,5 +1,7 @@ use std::borrow::Cow; +use crate::extract_style::constant::MAINTAIN_VALUE_PROPERTIES; +use css::utils::to_kebab_case; use oxc_allocator::{Allocator, CloneIn, GetAllocator}; use oxc_ast::{ ast::{ @@ -25,6 +27,31 @@ pub(super) fn is_vanilla_extract_file(filename: &str) -> bool { filename.ends_with(".css.ts") || filename.ends_with(".css.js") } +/// Whether vanilla-extract leaves a number on `key` without a unit: unitless +/// properties, custom properties, and array indices. +pub(super) fn is_unitless_key(key: &str) -> bool { + key.starts_with("--") + || key.starts_with("var(") + || key.bytes().all(|byte| byte.is_ascii_digit()) + || MAINTAIN_VALUE_PROPERTIES.contains(to_kebab_case(key).as_ref()) +} + +/// A JS number literal (`8`, `-8`, `(8)`), unlike a numeric string. +pub(super) fn js_number_literal(value: &Expression) -> Option { + match value { + Expression::NumericLiteral(number) => Some(number.value), + Expression::ParenthesizedExpression(inner) => js_number_literal(&inner.expression), + Expression::UnaryExpression(unary) => { + js_number_literal(&unary.argument).and_then(|number| match unary.operator { + UnaryOperator::UnaryNegation => Some(-number), + UnaryOperator::UnaryPlus => Some(number), + _ => None, + }) + } + _ => None, + } +} + /// Strip the wrappers that exist only in the source text: TypeScript's `as` / /// `satisfies` / `!` / explicit type arguments, plus redundant parentheses. Every /// one is erased before the code runs, so extraction must see through them — @@ -496,6 +523,30 @@ mod tests { assert!(matches!(convert_value("4"), Cow::Owned(_))); } + #[test] + fn test_js_number_literal() { + let allocator = Allocator::default(); + for (source, expected) in [ + ("8", Some(8.0)), + ("(8)", Some(8.0)), + ("+8", Some(8.0)), + ("-8", Some(-8.0)), + ("-(1.5)", Some(-1.5)), + ("!8", None), + ("'8'", None), + ("-x", None), + ] { + let expression = Parser::new(&allocator, source, SourceType::ts()) + .parse_expression() + .unwrap(); + assert_eq!(js_number_literal(&expression), expected, "{source}"); + } + assert!(is_unitless_key("lineHeight")); + assert!(is_unitless_key("--gap")); + assert!(is_unitless_key("0")); + assert!(!is_unitless_key("padding")); + } + #[test] fn test_get_number_by_literal_expression() { let allocator = Allocator::default(); diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 276369a98..4d8732b73 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -3,8 +3,10 @@ //! This module uses `boa_engine` to execute vanilla-extract style files //! and extract style definitions for processing by the existing extract logic. +use crate::utils::is_unitless_key; use boa_engine::{ - Context, JsArgs, JsValue, NativeFunction, Source, js_string, object::ObjectInitializer, + Context, JsArgs, JsResult, JsValue, NativeFunction, Source, js_string, + object::{FunctionObjectBuilder, ObjectInitializer}, property::Attribute, }; use css::file_map::get_file_num_by_filename; @@ -367,6 +369,38 @@ fn js_value_to_json(value: &JsValue, context: &mut Context) -> String { } } +/// `JSON.stringify` replacer adding `px` to numbers the way vanilla-extract does, +/// so they are not read as Devup UI's spacing scale. +fn pixelify(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let key = args.get_or_undefined(0).to_string(context)?; + let value = args.get_or_undefined(1); + Ok(match value.as_number() { + Some(number) if number != 0.0 && !is_unitless_key(&key.to_std_string_escaped()) => { + JsValue::from(js_string!(format!("{number}px"))) + } + _ => value.clone(), + }) +} + +/// Serialize a style rule object with vanilla-extract's number units. +fn style_to_json(value: &JsValue, context: &mut Context) -> String { + let realm = context.realm().clone(); + let replacer = + FunctionObjectBuilder::new(&realm, NativeFunction::from_fn_ptr(pixelify)).build(); + let json = context.intrinsics().objects().json(); + if let Ok(stringify) = json.get(js_string!("stringify"), context) + && let Some(callable) = stringify.as_callable() + && let Ok(result) = callable.call( + &JsValue::undefined(), + &[value.clone(), replacer.into()], + context, + ) + && let Some(s) = result.as_string() + { + return s.to_std_string_escaped(); + } + js_value_to_json(value, context) +} /// Execute vanilla-extract style file and collect styles pub fn execute_vanilla_extract( code: &str, @@ -825,7 +859,7 @@ fn register_vanilla_extract_apis( base_classes.push(base_str.to_std_string_escaped()); } else if elem.is_object() { // It's a style object - merge it - let elem_json = js_value_to_json(&elem, ctx); + let elem_json = style_to_json(&elem, ctx); // Strip outer braces and merge let inner = elem_json .trim() @@ -846,7 +880,7 @@ fn register_vanilla_extract_apis( (merged_styles, base_classes) } else { // No length property, just a style object - (js_value_to_json(style_obj, ctx), SmallVec::new()) + (style_to_json(style_obj, ctx), SmallVec::new()) }; collector_style.borrow_mut().styles.styles.insert( id.clone(), @@ -870,7 +904,7 @@ fn register_vanilla_extract_apis( .to_string(ctx)? .to_std_string_escaped(); let style_obj = args.get_or_undefined(1); - let json = js_value_to_json(style_obj, ctx); + let json = style_to_json(style_obj, ctx); collector_global .borrow_mut() @@ -887,7 +921,7 @@ fn register_vanilla_extract_apis( let keyframes_fn = unsafe { NativeFunction::from_closure(move |_this, args, ctx| { let keyframes_obj = args.get_or_undefined(0); - let json = js_value_to_json(keyframes_obj, ctx); + let json = style_to_json(keyframes_obj, ctx); let id = next_style_id(&collector_keyframes); collector_keyframes.borrow_mut().styles.keyframes.insert( @@ -1627,7 +1661,7 @@ fn parse_single_variant(value: &JsValue, context: &mut Context) -> StyleVariant if base_class.starts_with("__style_") || !base_class.contains('{') { // Get the style object (second element) if let Ok(style_obj) = obj.get(1, context) { - let json = js_value_to_json(&style_obj, context); + let json = style_to_json(&style_obj, context); return StyleVariant { base: Some(base_class), styles_json: json, @@ -1641,7 +1675,7 @@ fn parse_single_variant(value: &JsValue, context: &mut Context) -> StyleVariant // Not an array or not composition - treat as plain style object StyleVariant { base: None, - styles_json: js_value_to_json(value, context), + styles_json: style_to_json(value, context), } } @@ -3439,6 +3473,24 @@ export const box = style({ padding: 8 })"; assert!(code.contains("export const themeVars = {\"bg\":\"var(--bg)\"}")); } + #[test] + fn test_style_to_json_adds_vanilla_extract_units() { + let mut context = Context::default(); + let rule = context + .eval(Source::from_bytes( + "({ fontSize: 16, top: 0, lineHeight: 1.5, '--gap': 4, 'var(--x)': 2, fallback: [1, 2], ':hover': { width: 3 } })", + )) + .unwrap(); + assert_eq!( + super::style_to_json(&rule, &mut context), + r#"{"fontSize":"16px","top":0,"lineHeight":1.5,"--gap":4,"var(--x)":2,"fallback":[1,2],":hover":{"width":"3px"}}"# + ); + assert_eq!( + super::style_to_json(&JsValue::undefined(), &mut context), + "undefined" + ); + } + #[test] fn test_style_with_non_object_argument() { // Test style() with primitive value (covers line 749) diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 8850391ec..f6cfc066f 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -30,11 +30,12 @@ use oxc_ast::ast::ImportDeclarationSpecifier::{self, ImportSpecifier}; use oxc_ast::ast::JSXAttributeItem::Attribute; use oxc_ast::ast::JSXAttributeName::Identifier; use oxc_ast::ast::{ - Argument, BindingPattern, CallExpression, ChainElement, ComputedMemberExpression, Expression, - ExpressionStatement, FormalParameterKind, FormalParameters, IdentifierName, ImportDeclaration, - ImportOrExportKind, JSXAttributeItem, JSXAttributeValue, JSXChild, JSXClosingFragment, - JSXElement, JSXElementName, JSXExpressionContainer, JSXOpeningFragment, ObjectPropertyKind, - Program, PropertyKey, PropertyKind, Statement, StaticMemberExpression, Str, StringLiteral, + Argument, BinaryOperator, BindingPattern, CallExpression, ChainElement, + ComputedMemberExpression, Expression, ExpressionStatement, FormalParameter, + FormalParameterKind, FormalParameters, IdentifierName, ImportDeclaration, ImportOrExportKind, + JSXAttributeItem, JSXAttributeValue, JSXChild, JSXClosingFragment, JSXElement, JSXElementName, + JSXExpressionContainer, JSXOpeningFragment, ObjectPropertyKind, Program, PropertyKey, + PropertyKind, Statement, StaticMemberExpression, Str, StringLiteral, UnaryOperator, VariableDeclarator, }; use oxc_ast_visit::VisitMut; @@ -305,6 +306,71 @@ impl<'a> DevupVisitor<'a> { (class_exprs, style_props) } + /// `((v) => typeof v === "number" ? v + unit : v)(value)`: how `StyleX` gives a + /// dynamic number its unit without evaluating `value` twice. + fn with_number_unit(&self, value: Expression<'a>, unit: &'static str) -> Expression<'a> { + if unit.is_empty() { + return value; + } + let param = || Expression::new_identifier(SPAN, "v", &self.ast); + let body = Expression::new_conditional_expression( + SPAN, + Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression(SPAN, UnaryOperator::Typeof, param(), &self.ast), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "number", None, &self.ast), + &self.ast, + ), + Expression::new_binary_expression( + SPAN, + param(), + BinaryOperator::Addition, + Expression::new_string_literal(SPAN, unit, None, &self.ast), + &self.ast, + ), + param(), + &self.ast, + ); + let mut params = oxc_allocator::Vec::new_in(&self.ast); + params.push(FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(&self.ast), + BindingPattern::new_binding_identifier(SPAN, "v", &self.ast), + None, + None, + false, + None, + false, + false, + &self.ast, + )); + let arrow = Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + params, + None::>>, + &self.ast, + ), + None::>>, + body.into(), + &self.ast, + ); + let mut arguments = oxc_allocator::Vec::new_in(&self.ast); + arguments.push(Argument::from(value)); + Expression::new_call_expression( + SPAN, + Expression::new_parenthesized_expression(SPAN, arrow, &self.ast), + None::>>, + arguments, + false, + &self.ast, + ) + } /// Resolve a dynamic namespace call like `styles.bar(h)` to (className, `style_props`). fn resolve_stylex_dynamic_call( &self, @@ -324,11 +390,12 @@ impl<'a> DevupVisitor<'a> { ); let mut props = Vec::with_capacity(info.css_vars.len()); - for (param_idx, var_name) in &info.css_vars { + for (param_idx, var_name, unit) in &info.css_vars { if let Some(arg) = call.arguments.get(*param_idx) && let Some(arg_expr) = arg.as_expression() { - let arg_expr = arg_expr.clone_in(self.ast.allocator()); + let arg_expr = + self.with_number_unit(arg_expr.clone_in(self.ast.allocator()), unit); props.push(ObjectPropertyKind::new_object_property( SPAN, PropertyKind::Init, diff --git a/packages/next-plugin/src/__tests__/static-vanilla.test.ts b/packages/next-plugin/src/__tests__/static-vanilla.test.ts deleted file mode 100644 index 16da2ad98..000000000 --- a/packages/next-plugin/src/__tests__/static-vanilla.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'bun:test' - -import { transformStaticVanillaExtract } from '../static-vanilla' - -describe('transformStaticVanillaExtract', () => { - it('converts static style modules without evaluating JavaScript', () => { - const source = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red', padding: [4, null, 8] }) -export const hover = style({ selectors: { '&:hover': { opacity: 0.5 } } })` - - expect( - transformStaticVanillaExtract( - 'src/styles.css.ts', - source, - '@devup-ui/react', - ), - ).toBe(`import { css } from '@devup-ui/react' -export const box = css({ color: 'red', padding: [4, null, 8] }) -export const hover = css({ selectors: { '&:hover': { opacity: 0.5 } } })`) - }) - - it('rejects executable values and extra module statements', () => { - const dynamic = `import { style } from '@vanilla-extract/css' -const color = getColor() -export const box = style({ color })` - const spread = `import { style } from '@vanilla-extract/css' -export const box = style({ ...base, color: 'red' })` - - expect( - transformStaticVanillaExtract( - 'styles.css.ts', - dynamic, - '@devup-ui/react', - ), - ).toBeUndefined() - expect( - transformStaticVanillaExtract('styles.css.ts', spread, '@devup-ui/react'), - ).toBeUndefined() - expect( - transformStaticVanillaExtract('styles.ts', dynamic, '@devup-ui/react'), - ).toBeUndefined() - }) - - it('preserves comments and aliases in the static subset', () => { - const source = `/* import */ import { style as makeStyle } from '@vanilla-extract/css' -export const box = makeStyle({ - // responsive values remain source text - padding: [4, null, 8], -})` - - expect( - transformStaticVanillaExtract('styles.css.js', source, 'custom-package'), - ).toContain('export const box = css({') - }) - - it('rejects unsupported imports and malformed trailing comments', () => { - const multipleImports = `import { style, globalStyle } from '@vanilla-extract/css' -export const box = style({ color: 'red' })` - const unterminatedComment = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red' }) /*` - - expect( - transformStaticVanillaExtract( - 'styles.css.ts', - multipleImports, - '@devup-ui/react', - ), - ).toBeUndefined() - expect( - transformStaticVanillaExtract( - 'styles.css.ts', - unterminatedComment, - '@devup-ui/react', - ), - ).toBeUndefined() - }) -}) diff --git a/packages/next-plugin/src/static-vanilla.ts b/packages/next-plugin/src/static-vanilla.ts deleted file mode 100644 index 3bdd237c4..000000000 --- a/packages/next-plugin/src/static-vanilla.ts +++ /dev/null @@ -1,194 +0,0 @@ -function isIdentifierChar(char: string | undefined): boolean { - return char !== undefined && /[$\w]/.test(char) -} - -class StaticVanillaParser { - private index = 0 - private valid = true - - constructor( - private readonly source: string, - private readonly packageName: string, - ) {} - - parse(): string | undefined { - if (!this.word('import') || !this.char('{')) return undefined - const imported = this.identifier() - if (imported !== 'style') return undefined - let local = imported - if (this.word('as')) { - const alias = this.identifier() - if (alias === undefined) return undefined - local = alias - } - if ( - !this.char('}') || - !this.word('from') || - this.string() !== '@vanilla-extract/css' - ) { - return undefined - } - this.char(';') - - const declarations: string[] = [] - while (!this.done()) { - if (!this.word('export') || !this.word('const')) return undefined - const name = this.identifier() - if (name === undefined || !this.char('=') || !this.word(local)) { - return undefined - } - if (!this.char('(')) return undefined - this.skipTrivia() - const valueStart = this.index - if (!this.staticValue()) return undefined - const value = this.source.slice(valueStart, this.index) - if (!this.char(')')) return undefined - this.char(';') - declarations.push(`export const ${name} = css(${value})`) - } - - if (declarations.length === 0) return undefined - return `import { css } from '${this.packageName}'\n${declarations.join('\n')}` - } - - private staticValue(): boolean { - this.skipTrivia() - const char = this.source[this.index] - if (char === '{') return this.object() - if (char === '[') return this.array() - if (char === '"' || char === "'") return this.string() !== undefined - if (char === '+' || char === '-') this.index++ - const number = this.source - .slice(this.index) - .match(/^(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i) - if (number) { - this.index += number[0].length - return true - } - return this.word('true') || this.word('false') || this.word('null') - } - - private object(): boolean { - if (!this.char('{')) return false - if (this.char('}')) return true - while (true) { - this.skipTrivia() - const char = this.source[this.index] - const key = - char === '"' || char === "'" - ? this.string() - : (this.identifier() ?? this.numberKey()) - if (key === undefined || !this.char(':') || !this.staticValue()) { - return false - } - if (this.char('}')) return true - if (!this.char(',')) return false - if (this.char('}')) return true - } - } - - private array(): boolean { - if (!this.char('[')) return false - if (this.char(']')) return true - while (true) { - if (!this.staticValue()) return false - if (this.char(']')) return true - if (!this.char(',')) return false - if (this.char(']')) return true - } - } - - private numberKey(): string | undefined { - this.skipTrivia() - const match = this.source.slice(this.index).match(/^\d+(?:\.\d+)?/) - if (!match) return undefined - this.index += match[0].length - return match[0] - } - - private identifier(): string | undefined { - this.skipTrivia() - const match = this.source.slice(this.index).match(/^[$A-Z_a-z][$\w]*/) - if (!match) return undefined - this.index += match[0].length - return match[0] - } - - private string(): string | undefined { - this.skipTrivia() - const quote = this.source[this.index] - if (quote !== '"' && quote !== "'") return undefined - const start = ++this.index - while (this.index < this.source.length) { - const char = this.source[this.index++] - if (char === '\\') { - this.index++ - } else if (char === quote) { - return this.source.slice(start, this.index - 1) - } else if (char === '\n' || char === '\r') { - return undefined - } - } - return undefined - } - - private word(value: string): boolean { - this.skipTrivia() - if ( - !this.source.startsWith(value, this.index) || - isIdentifierChar(this.source[this.index - 1]) || - isIdentifierChar(this.source[this.index + value.length]) - ) { - return false - } - this.index += value.length - return true - } - - private char(value: string): boolean { - this.skipTrivia() - if (this.source[this.index] !== value) return false - this.index++ - return true - } - - private done(): boolean { - this.skipTrivia() - return this.valid && this.index === this.source.length - } - - private skipTrivia(): void { - while (this.index < this.source.length) { - if (/\s/.test(this.source[this.index])) { - this.index++ - } else if (this.source.startsWith('//', this.index)) { - const newline = this.source.indexOf('\n', this.index + 2) - this.index = newline === -1 ? this.source.length : newline + 1 - } else if (this.source.startsWith('/*', this.index)) { - const end = this.source.indexOf('*/', this.index + 2) - if (end === -1) { - this.valid = false - this.index = this.source.length - } else { - this.index = end + 2 - } - } else { - break - } - } - } -} - -/** - * Convert the deliberately small, statically provable subset of vanilla - * `style()` modules into Devup `css()` calls. Any executable expression or - * additional statement returns undefined and keeps the existing Boa path. - */ -export function transformStaticVanillaExtract( - filename: string, - source: string, - packageName: string, -): string | undefined { - if (!/\.css\.(?:ts|js)$/.test(filename)) return undefined - return new StaticVanillaParser(source, packageName).parse() -} From 089fc36f519f23f2bafeda8b0bd02c542e88822a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 04:20:41 +0900 Subject: [PATCH 10/12] fix(next-plugin): run every vanilla-extract stylesheet on the full engine Remove the static .css.ts fast path so development and production builds evaluate stylesheets the same way and produce the same class names and CSS. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/coordinator.test.ts | 47 ---------------- .../next-plugin/src/__tests__/plugin.test.ts | 55 +------------------ packages/next-plugin/src/coordinator.ts | 8 +-- packages/next-plugin/src/plugin.ts | 38 ++----------- 4 files changed, 6 insertions(+), 142 deletions(-) diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 6a23d2555..62dd85c80 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -215,53 +215,6 @@ describe('coordinator', () => { coordinator.close() }) - it('uses the static vanilla transform on a production cache miss', async () => { - const source = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red' })` - const extractOutput = { - code: 'transformed code', - map: undefined, - cssFile: undefined, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } - codeExtractWithoutSourceMapSpy.mockReturnValue(extractOutput) - const coordinator = startCoordinator( - makeOptions({ sourceMap: false, staticVanillaExtract: true }), - ) - await new Promise((resolve) => setTimeout(resolve, 100)) - const port = parseInt( - (writeFileSyncSpy.mock.calls[0] as [string, string])[1], - ) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/styles.css.ts', - code: source, - resourcePath: join(process.cwd(), 'src', 'styles.css.ts'), - }), - ) - - expect(res.status).toBe(200) - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( - 'src/styles.css.ts', - `import { css } from '@devup-ui/react' -export const box = css({ color: 'red' })`, - '@devup-ui/react', - './../.tmp-coordinator-test/css', - false, - false, - true, - {}, - ) - expect(extractOutput.free).toHaveBeenCalledTimes(1) - coordinator.close() - }) - it('reuses byte-identical singleCss prewarm output', async () => { const source = 'const x = ' const options = makeOptions({ diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index c6ffe926a..a79904cea 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -193,13 +193,6 @@ describe('DevupUINextPlugin', () => { expect( selectWasmVariant({ files: ['src/theme.css.js'] } as StaticImportGraph), ).toBe('full') - expect( - selectWasmVariant( - { files: ['src/theme.css.ts'] } as StaticImportGraph, - ['src/theme.css.ts'], - true, - ), - ).toBe('lite') expect( selectWasmVariant({ files: ['src/page.tsx'] } as StaticImportGraph, [ 'node_modules/design-system/theme.css.ts', @@ -675,10 +668,9 @@ describe('DevupUINextPlugin', () => { prewarmedFiles: expect.any(Array), prewarmedOutputs: expect.any(Map), sourceMap: false, - staticVanillaExtract: false, }) }) - it('uses the lite extractor for a provably static vanilla style module', () => { + it('extracts vanilla-extract modules as written with the full engine', () => { setNodeEnv('production') process.env.TURBOPACK = '1' const filename = resolve('src/styles.css.ts') @@ -696,47 +688,6 @@ export const box = style({ color: 'red' })` String(path).endsWith('styles.css.ts') ? source : '{}', ) - try { - DevupUI({}) - - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( - 'src/styles.css.ts', - `import { css } from '@devup-ui/react' -export const box = css({ color: 'red' })`, - '@devup-ui/react', - expect.any(String), - false, - false, - true, - expect.anything(), - ) - expect(startCoordinatorSpy).toHaveBeenCalledWith( - expect.objectContaining({ staticVanillaExtract: true }), - ) - } finally { - graphSpy.mockRestore() - compiledSpy.mockRestore() - } - }) - it('keeps the full extractor for dynamic vanilla style modules', () => { - setNodeEnv('production') - process.env.TURBOPACK = '1' - const filename = resolve('src/styles.css.ts') - const source = `import { style } from '@vanilla-extract/css' -const color = getColor() -export const box = style({ color })` - const graphSpy = spyOn( - importGraphModule, - 'buildStaticImportGraph', - ).mockReturnValue(createSingleFileGraph(filename)) - const compiledSpy = spyOn( - importGraphModule, - 'computeCompiledFiles', - ).mockReturnValue(['src/styles.css.ts']) - readFileSyncSpy.mockImplementation((path: fs.PathOrFileDescriptor) => - String(path).endsWith('styles.css.ts') ? source : '{}', - ) - try { DevupUI({}) @@ -750,9 +701,6 @@ export const box = style({ color })` true, expect.anything(), ) - expect(startCoordinatorSpy).toHaveBeenCalledWith( - expect.objectContaining({ staticVanillaExtract: false }), - ) } finally { graphSpy.mockRestore() compiledSpy.mockRestore() @@ -959,7 +907,6 @@ export const box = style({ color })` prewarmedFiles: [], prewarmedOutputs: expect.any(Map), sourceMap: true, - staticVanillaExtract: false, }) expect(codeExtractSpy).not.toHaveBeenCalled() expect(codeExtractWithoutSourceMapSpy).not.toHaveBeenCalled() diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 71bf53476..a6f6b4370 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -5,7 +5,6 @@ import { basename, dirname, join, relative } from 'node:path' import { getFileNumByFilename } from '@devup-ui/plugin-utils' import { elapsedMs, profileStart, reportProfile } from './profile' -import { transformStaticVanillaExtract } from './static-vanilla' import type { DevupWasm } from './wasm' export interface CoordinatorOptions { @@ -48,8 +47,6 @@ export interface CoordinatorOptions { prewarmedOutputs?: Map /** Generate transform source maps. Defaults to true for existing callers. */ sourceMap?: boolean - /** Production-only static `.css.ts` fast path selected with the lite WASM. */ - staticVanillaExtract?: boolean /** * Idle threshold (ms) for the base-css `/css` wait. Defaults to 2500. * FALLBACK ONLY — used when `expectedBaseFiles` is empty (no deterministic @@ -377,7 +374,6 @@ export function startCoordinator(options: CoordinatorOptions): { getCss, } = wasm const prewarmedOutputs = options.prewarmedOutputs ?? new Map() - const staticVanillaExtract = options.staticVanillaExtract ?? false const extract = options.sourceMap === false ? codeExtractWithoutSourceMap : codeExtract @@ -473,9 +469,7 @@ export function startCoordinator(options: CoordinatorOptions): { : takeExtractOutput( extract( filename, - (staticVanillaExtract - ? transformStaticVanillaExtract(filename, code, libPackage) - : undefined) ?? code, + code, libPackage, relCssDir, singleCss, diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index b520692ec..64d97b32e 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -31,7 +31,6 @@ import { } from './coordinator' import { collectProductionPrewarmFiles } from './prewarm' import { elapsedMs, profileStart, reportProfile } from './profile' -import { transformStaticVanillaExtract } from './static-vanilla' import { loadWasm, loadWebpackPlugin } from './wasm' /** Options accepted by the Next.js integration. */ @@ -118,11 +117,9 @@ export function resetTurboSetupCacheForTesting(): void { export function selectWasmVariant( graph: StaticImportGraph | undefined, candidateFiles: string[] = graph?.files ?? [], - staticVanillaExtract = false, ): 'lite' | 'full' { return graph && - (staticVanillaExtract || - !candidateFiles.some((filename) => /\.css\.(?:ts|js)$/.test(filename))) + !candidateFiles.some((filename) => /\.css\.(?:ts|js)$/.test(filename)) ? 'lite' : 'full' } @@ -248,31 +245,7 @@ export function DevupUI( }) : [] const candidateCollectMs = elapsedMs(candidateCollectStartedAt) - const staticVanillaSources = new Map< - string, - { code: string; source: string } - >() - const vanillaCandidates = wasmCandidateFiles.filter((filename) => - /\.css\.(?:ts|js)$/.test(filename), - ) - let staticVanillaExtract = - !watch && !sourceMap && vanillaCandidates.length > 0 - for (const filename of vanillaCandidates) { - if (!staticVanillaExtract) break - const source = readFileSync(resolve(process.cwd(), filename), 'utf-8') - const code = transformStaticVanillaExtract(filename, source, libPackage) - if (code === undefined) { - staticVanillaExtract = false - staticVanillaSources.clear() - break - } - staticVanillaSources.set(filename, { code, source }) - } - const wasmVariant = selectWasmVariant( - staticGraph, - wasmCandidateFiles, - staticVanillaExtract, - ) + const wasmVariant = selectWasmVariant(staticGraph, wasmCandidateFiles) const wasm = loadWasm(wasmVariant === 'lite') const { codeExtract, @@ -452,9 +425,7 @@ export function DevupUI( ).replaceAll('\\', '/')}` const readStartedAt = prewarmStartedAt === undefined ? undefined : performance.now() - const preparedVanilla = staticVanillaSources.get(filename) - const source = - preparedVanilla?.source ?? readFileSync(resourcePath, 'utf-8') + const source = readFileSync(resourcePath, 'utf-8') if (readStartedAt !== undefined) { prewarmReadMs += performance.now() - readStartedAt prewarmSourceBytes += Buffer.byteLength(source) @@ -464,7 +435,7 @@ export function DevupUI( const output = takeExtractOutput( extract( filename, - preparedVanilla?.code ?? source, + source, libPackage, relCssDir, singleCss, @@ -544,7 +515,6 @@ export function DevupUI( prewarmedFiles, prewarmedOutputs, sourceMap, - staticVanillaExtract, }) // Cleanup on exit From c6d33d6999d5e4e8d0a9e70ffbaadd005c848f42 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 04:22:40 +0900 Subject: [PATCH 11/12] docs: document library number units and the full vanilla-extract engine Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 2 +- README_ko.md | 2 +- SKILL.md | 4 ++++ apps/landing/src/app/(detail)/docs/api/box/page.mdx | 6 ++++++ apps/landing/src/app/(detail)/docs/api/button/page.mdx | 6 ++++++ apps/landing/src/app/(detail)/docs/api/center/page.mdx | 6 ++++++ apps/landing/src/app/(detail)/docs/api/grid/page.mdx | 6 ++++++ apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx | 6 ++++++ .../landing/src/app/(detail)/docs/migration/stylex/page.mdx | 2 ++ .../app/(detail)/docs/migration/vanilla-extract/page.mdx | 2 ++ apps/landing/src/app/(detail)/docs/overview/page.mdx | 2 +- packages/components/README.md | 2 +- packages/next-plugin/README.md | 2 +- packages/react/README.md | 2 +- packages/reset-css/README.md | 2 +- packages/rsbuild-plugin/README.md | 2 +- packages/vite-plugin/README.md | 2 +- packages/webpack-plugin/README.md | 2 +- 18 files changed, 48 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 14c0d1658..354d19968 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **5.46s** | **36,507,782 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **5.41s** | 36,586,883 bytes | -The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/README_ko.md b/README_ko.md index 1f97db94c..11c0caf7c 100644 --- a/README_ko.md +++ b/README_ko.md @@ -103,7 +103,7 @@ Turbopack 값은 실행 순서를 번갈아 측정한 cold build 6회의 중앙 | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -Turbopack 측정 범위는 서로 겹치므로 direct API 결과는 이 fixture에서 Tailwind와 사실상 동률입니다. static `.css.ts` 행은 `lite` WASM 빠른 경로를 사용하며, full Boa evaluator가 필요한 dynamic `.css.ts`는 해당 중앙값에 포함되지 않습니다. +Turbopack 측정 범위는 서로 겹치므로 direct API 결과는 이 fixture에서 Tailwind와 사실상 동률입니다. static `.css.ts` 행은 정적 모듈용 `lite` 빠른 경로가 있던 때에 측정한 값입니다. 지금은 모든 `.css.ts`를 full Boa evaluator로 처리하며, 첫 모듈에 약 20ms, 이후 모듈마다 1ms 미만이 더 듭니다. ## 작동 원리 diff --git a/SKILL.md b/SKILL.md index cdb07c166..1279357f5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -148,6 +148,10 @@ All standard CSS properties from `csstype` are also accepted directly (e.g., `di // padding: 20px (with unit = exact value) ``` +Unitless CSS properties keep the number as written: `lineHeight`, `zIndex`, `opacity`, `fontWeight`, `flexGrow`, `order`, `aspectRatio`, `columnCount`, `strokeWidth`, `zoom`, and the rest of the unitless list vanilla-extract uses. + +Styles from other libraries keep that library's number meaning instead of the scale: in `.css.ts` files, in vanilla-extract `style()`, `globalStyle()` and `keyframes()` calls inside ordinary modules, and in `stylex.create()`, `fontSize: 16` is `16px`. + ### Responsive Arrays (5 breakpoints) ```tsx diff --git a/apps/landing/src/app/(detail)/docs/api/box/page.mdx b/apps/landing/src/app/(detail)/docs/api/box/page.mdx index 748acafb2..f1fd2d529 100644 --- a/apps/landing/src/app/(detail)/docs/api/box/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/box/page.mdx @@ -65,6 +65,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit` ### Rendering as Another Element diff --git a/apps/landing/src/app/(detail)/docs/api/button/page.mdx b/apps/landing/src/app/(detail)/docs/api/button/page.mdx index 0cbc85daf..a79e01c5f 100644 --- a/apps/landing/src/app/(detail)/docs/api/button/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/button/page.mdx @@ -68,3 +68,9 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit` diff --git a/apps/landing/src/app/(detail)/docs/api/center/page.mdx b/apps/landing/src/app/(detail)/docs/api/center/page.mdx index 63783ba43..0ac65ae80 100644 --- a/apps/landing/src/app/(detail)/docs/api/center/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/center/page.mdx @@ -85,6 +85,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/grid/page.mdx b/apps/landing/src/app/(detail)/docs/api/grid/page.mdx index 035921d3c..01c06c307 100644 --- a/apps/landing/src/app/(detail)/docs/api/grid/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/grid/page.mdx @@ -77,6 +77,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx index 9133ae7f7..fb7b8b524 100644 --- a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx @@ -83,6 +83,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx b/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx index 2dc60054b..f800a8b0a 100644 --- a/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx @@ -33,6 +33,8 @@ const el =
Computed access such as `colors[color]` is resolved against the object the `create()` call was rewritten into, so every generated atom stays reachable. `stylex.attrs()` behaves the same but emits `class` instead of `className`. +Numbers get the unit StyleX gives them: `fontSize: 16` is `16px`, `transitionDuration: 300` is `300ms`, and unitless properties such as `lineHeight` stay as written. A number passed to a dynamic style gets the same unit at runtime. + Accepted argument shapes: ```tsx diff --git a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx index bb86dca45..09d5accc9 100644 --- a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx @@ -20,6 +20,8 @@ export const light = createTheme(vars, { colors: { bg: 'white' } }) export const box = style({ background: vars.colors.bg }) ``` +Numbers keep vanilla-extract's meaning, here and in ordinary modules: `padding: 8` is `8px`, and unitless properties such as `lineHeight` or `zIndex` stay as written. + ## Ordinary modules `style` and `globalStyle` also resolve in `.ts` / `.tsx`, mapped onto their Devup UI counterparts: diff --git a/apps/landing/src/app/(detail)/docs/overview/page.mdx b/apps/landing/src/app/(detail)/docs/overview/page.mdx index 9c337acd6..b186af1b8 100644 --- a/apps/landing/src/app/(detail)/docs/overview/page.mdx +++ b/apps/landing/src/app/(detail)/docs/overview/page.mdx @@ -235,7 +235,7 @@ Turbopack values are medians of six cold builds in alternating order. Build size -The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ### Get Started diff --git a/packages/components/README.md b/packages/components/README.md index 72d9da7e0..d586eff94 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/next-plugin/README.md b/packages/next-plugin/README.md index 5775ace8b..facbc7247 100644 --- a/packages/next-plugin/README.md +++ b/packages/next-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/react/README.md b/packages/react/README.md index bc91e68a5..ede5459d6 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/reset-css/README.md b/packages/reset-css/README.md index 72d9da7e0..d586eff94 100644 --- a/packages/reset-css/README.md +++ b/packages/reset-css/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md index 9f00bf49c..404fc2997 100644 --- a/packages/rsbuild-plugin/README.md +++ b/packages/rsbuild-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index 166b4784b..a943c028c 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md index 83029a18e..343ead564 100644 --- a/packages/webpack-plugin/README.md +++ b/packages/webpack-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works From 9a7a19a26db262380ae367f583acdb60c0dfb40c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 04:24:31 +0900 Subject: [PATCH 12/12] chore: add changepack for library number units and full engine Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_library_numbers_full_engine.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changepacks/changepack_log_library_numbers_full_engine.json diff --git a/.changepacks/changepack_log_library_numbers_full_engine.json b/.changepacks/changepack_log_library_numbers_full_engine.json new file mode 100644 index 000000000..248990056 --- /dev/null +++ b/.changepacks/changepack_log_library_numbers_full_engine.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/next-plugin/package.json": "Patch" + }, + "note": "Numbers in vanilla-extract and StyleX styles keep their library's meaning instead of Devup UI's spacing scale: vanilla-extract styles add `px` in `.css.ts` files and in `style()`, `globalStyle()` and `keyframes()` calls inside ordinary modules, StyleX adds `px`/`ms` to static values and to numbers passed to dynamic styles, and unitless properties such as `columnCount`, `strokeWidth`, `orphans` or `zoom` keep a bare number everywhere. The Next.js plugin now runs every `.css.ts` module on the full engine instead of a separate static fast path, so development and production builds produce the same output", + "date": "2026-09-27T00:00:00.000Z" +}