Hello
@@ -1190,9 +1232,20 @@ function Test ({ style, active, submit, disabled, titleStyle }) {
↓ ↓ ↓ ↓ ↓ ↓
import _css from "./index.styl";
-import { runtime as _runtime } from "cssxjs/runtime";
+import {
+ runtime as _runtime,
+ useCssxLayer as _useCssxLayer,
+} from "cssxjs/runtime";
+const _cssxLayer = _useCssxLayer;
const _cssx = _runtime;
function Test({ style, active, submit, disabled, titleStyle }) {
+ const _local = _cssxLayer(
+ typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__
+ );
+ const _global = _cssxLayer(
+ typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__
+ );
+ const _file__css = _cssxLayer(_css);
return (
);
@@ -1263,7 +1310,11 @@ const Test = ({ style, layout, cardStyle: myCardStyle, contentStyle, title, ...p
↓ ↓ ↓ ↓ ↓ ↓
import _css from "./index.styl";
-import { runtime as _runtime } from "cssxjs/runtime";
+import {
+ runtime as _runtime,
+ useCssxLayer as _useCssxLayer,
+} from "cssxjs/runtime";
+const _cssxLayer = _useCssxLayer;
const _cssx = _runtime;
const Test = ({
columnStyle: _columnStyle,
@@ -1275,41 +1326,36 @@ const Test = ({
title,
...props
}) => {
+ const _local = _cssxLayer(
+ typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__
+ );
+ const _global = _cssxLayer(
+ typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__
+ );
+ const _file__css = _cssxLayer(_css);
function render() {
return (
);
@@ -1341,7 +1387,11 @@ const Test = ({ style, cardStyle: myCardStyle, contentStyle, title, ...props })
↓ ↓ ↓ ↓ ↓ ↓
import _css from "./index.styl";
-import { runtime as _runtime } from "cssxjs/runtime";
+import {
+ runtime as _runtime,
+ useCssxLayer as _useCssxLayer,
+} from "cssxjs/runtime";
+const _cssxLayer = _useCssxLayer;
const _cssx = _runtime;
const Test = ({
activeStyle: _activeStyle,
@@ -1351,37 +1401,32 @@ const Test = ({
title,
...props
}) => {
+ const _local = _cssxLayer(
+ typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__
+ );
+ const _global = _cssxLayer(
+ typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__
+ );
+ const _file__css = _cssxLayer(_css);
function render() {
return (
);
@@ -1413,7 +1458,11 @@ const Test = ({ style, cardStyle: myCardStyle, contentStyle, title, ...props })
↓ ↓ ↓ ↓ ↓ ↓
import _css from "./index.styl";
-import { runtime as _runtime } from "cssxjs/runtime";
+import {
+ runtime as _runtime,
+ useCssxLayer as _useCssxLayer,
+} from "cssxjs/runtime";
+const _cssxLayer = _useCssxLayer;
const _cssx = _runtime;
const Test = ({
activeStyle: _activeStyle,
@@ -1423,40 +1472,35 @@ const Test = ({
title,
...props
}) => {
+ const _local = _cssxLayer(
+ typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__
+ );
+ const _global = _cssxLayer(
+ typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__
+ );
+ const _file__css = _cssxLayer(_css);
function render() {
return (
);
@@ -1482,12 +1526,12 @@ SyntaxError: unknown file:
'part' attribute only supports literal or string keys in object.
Dynamic keys or spreads are not supported.
-[0m [90m 2 |[39m [36mfunction[39m [33mTest[39m ({ variant }) {
- [90m 3 |[39m [36mreturn[39m (
-[31m[1m>[22m[39m[90m 4 |[39m [33m<[39m[33mCard[39m part[33m=[39m{{[variant][33m:[39m [36mtrue[39m}} [33m/[39m[33m>[39m
- [90m |[39m [31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m
- [90m 5 |[39m )
- [90m 6 |[39m }[0m
+ 2 | function Test ({ variant }) {
+ 3 | return (
+> 4 |
+ | ^^^^^^^^^^^^^^^
+ 5 | )
+ 6 | }
`;
@@ -1505,12 +1549,12 @@ function Test ({ variant }) {
SyntaxError: unknown file:
'part' attribute only supports static strings or objects inside an array.
-[0m [90m 2 |[39m [36mfunction[39m [33mTest[39m ({ variant }) {
- [90m 3 |[39m [36mreturn[39m (
-[31m[1m>[22m[39m[90m 4 |[39m [33m<[39m[33mCard[39m part[33m=[39m{[[32m'card'[39m[33m,[39m variant]} [33m/[39m[33m>[39m
- [90m |[39m [31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m
- [90m 5 |[39m )
- [90m 6 |[39m }[0m
+ 2 | function Test ({ variant }) {
+ 3 | return (
+> 4 |
+ | ^^^^^^^
+ 5 | )
+ 6 | }
`;
@@ -1533,12 +1577,12 @@ SyntaxError: unknown file:
Basically the rule is that the name of the part must be static so that
it is possible to determine at compile time which parts are being used.
-[0m [90m 2 |[39m [36mfunction[39m [33mTest[39m ({ variant }) {
- [90m 3 |[39m [36mreturn[39m (
-[31m[1m>[22m[39m[90m 4 |[39m [33m<[39m[33mCard[39m part[33m=[39m{variant} [33m/[39m[33m>[39m
- [90m |[39m [31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m
- [90m 5 |[39m )
- [90m 6 |[39m }[0m
+ 2 | function Test ({ variant }) {
+ 3 | return (
+> 4 |
+ | ^^^^^^^^^^^^^^
+ 5 | )
+ 6 | }
`;
@@ -1560,7 +1604,11 @@ function Test ({ title, style, ...props }) {
↓ ↓ ↓ ↓ ↓ ↓
import _css from "./index.styl";
-import { runtime as _runtime } from "cssxjs/runtime";
+import {
+ runtime as _runtime,
+ useCssxLayer as _useCssxLayer,
+} from "cssxjs/runtime";
+const _cssxLayer = _useCssxLayer;
const _cssx = _runtime;
function Test({
activeStyle: _activeStyle,
@@ -1569,36 +1617,31 @@ function Test({
style,
...props
}) {
+ const _local = _cssxLayer(
+ typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__
+ );
+ const _global = _cssxLayer(
+ typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__
+ );
+ const _file__css = _cssxLayer(_css);
return (
);
@@ -1625,7 +1668,11 @@ function Test ({ title, ...props }) {
↓ ↓ ↓ ↓ ↓ ↓
import _css from "./index.styl";
-import { runtime as _runtime } from "cssxjs/runtime";
+import {
+ runtime as _runtime,
+ useCssxLayer as _useCssxLayer,
+} from "cssxjs/runtime";
+const _cssxLayer = _useCssxLayer;
const _cssx = _runtime;
function Test({
activeStyle: _activeStyle,
@@ -1634,36 +1681,31 @@ function Test({
title,
...props
}) {
+ const _local = _cssxLayer(
+ typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__
+ );
+ const _global = _cssxLayer(
+ typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__
+ );
+ const _file__css = _cssxLayer(_css);
return (
);
@@ -1695,48 +1737,47 @@ function Test () {
↓ ↓ ↓ ↓ ↓ ↓
import _css from "./index.styl";
-import { runtime as _runtime } from "cssxjs/runtime";
+import {
+ runtime as _runtime,
+ useCssxLayer as _useCssxLayer,
+} from "cssxjs/runtime";
+const _cssxLayer = _useCssxLayer;
const _cssx = _runtime;
function Test(_props) {
+ const _local = _cssxLayer(
+ typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__
+ );
+ const _global = _cssxLayer(
+ typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__
+ );
+ const _file__css = _cssxLayer(_css);
return (
+
-
);
@@ -1766,7 +1807,11 @@ const Test = ({ style, cardStyle: myCardStyle, contentStyle, title, ...props })
↓ ↓ ↓ ↓ ↓ ↓
import _css from "./index.styl";
-import { runtime as _runtime } from "cssxjs/runtime";
+import {
+ runtime as _runtime,
+ useCssxLayer as _useCssxLayer,
+} from "cssxjs/runtime";
+const _cssxLayer = _useCssxLayer;
const _cssx = _runtime;
const Test = ({
activeStyle: _activeStyle,
@@ -1776,37 +1821,32 @@ const Test = ({
title,
...props
}) => {
+ const _local = _cssxLayer(
+ typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__
+ );
+ const _global = _cssxLayer(
+ typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__
+ );
+ const _file__css = _cssxLayer(_css);
function render() {
return (
);
@@ -1835,7 +1875,11 @@ function Test ({ style, cardStyle, title, ...props }) {
↓ ↓ ↓ ↓ ↓ ↓
import _css from "./index.styl";
-import { runtime as _runtime } from "cssxjs/runtime";
+import {
+ runtime as _runtime,
+ useCssxLayer as _useCssxLayer,
+} from "cssxjs/runtime";
+const _cssxLayer = _useCssxLayer;
const _cssx = _runtime;
function Test({
activeStyle: _activeStyle,
@@ -1845,36 +1889,31 @@ function Test({
title,
...props
}) {
+ const _local = _cssxLayer(
+ typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__
+ );
+ const _global = _cssxLayer(
+ typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__
+ );
+ const _file__css = _cssxLayer(_css);
return (
);
@@ -1901,39 +1940,38 @@ function Test (props) {
↓ ↓ ↓ ↓ ↓ ↓
import _css from "./index.styl";
-import { runtime as _runtime } from "cssxjs/runtime";
+import {
+ runtime as _runtime,
+ useCssxLayer as _useCssxLayer,
+} from "cssxjs/runtime";
+const _cssxLayer = _useCssxLayer;
const _cssx = _runtime;
function Test(props) {
+ const _local = _cssxLayer(
+ typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__
+ );
+ const _global = _cssxLayer(
+ typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__
+ );
+ const _file__css = _cssxLayer(_css);
return (
);
@@ -1968,7 +2006,11 @@ const Test = ({ style, active, variant, cardStyle: myCardStyle, contentStyle, ti
↓ ↓ ↓ ↓ ↓ ↓
import _css from "./index.styl";
-import { runtime as _runtime } from "cssxjs/runtime";
+import {
+ runtime as _runtime,
+ useCssxLayer as _useCssxLayer,
+} from "cssxjs/runtime";
+const _cssxLayer = _useCssxLayer;
const _cssx = _runtime;
const Test = ({
style,
@@ -1979,26 +2021,27 @@ const Test = ({
title,
...props
}) => {
+ const _local = _cssxLayer(
+ typeof __CSS_LOCAL__ !== "undefined" && __CSS_LOCAL__
+ );
+ const _global = _cssxLayer(
+ typeof __CSS_GLOBAL__ !== "undefined" && __CSS_GLOBAL__
+ );
+ const _file__css = _cssxLayer(_css);
function render() {
return (
diff --git a/packages/babel-plugin-rn-stylename-to-style/__tests__/index.spec.js b/packages/babel-plugin-rn-stylename-to-style/__tests__/index.spec.js
index 4648421..bd017a1 100644
--- a/packages/babel-plugin-rn-stylename-to-style/__tests__/index.spec.js
+++ b/packages/babel-plugin-rn-stylename-to-style/__tests__/index.spec.js
@@ -81,6 +81,18 @@ pluginTester({
)
})
`,
+ 'Provider style props are CSSX provider input, not RN style': /* js */`
+ import { observer, CssxProvider, StartupjsProvider } from 'startupjs'
+ export default observer(function Test ({ style }) {
+ return (
+
+
+
+
+
+ )
+ })
+ `,
'Regular string': /* js */`
import './index.styl'
function Test () {
@@ -119,6 +131,20 @@ pluginTester({
)
}
`,
+ 'Local css interpolation after hook': /* js */`
+ import { useThemeColor } from './theme'
+ import { View } from 'react-native'
+
+ function Card ({ ready, pad }) {
+ const color = useThemeColor('primary')
+ const __CSS_LOCAL__ = {
+ sheet: _localCssInstance,
+ values: [color, pad]
+ }
+ if (!ready) return
+ return
+ }
+ `,
'Puts compiled attribute to the end of attributes list': /* js */`
import './index.styl'
function Test ({ style, active, submit, disabled }) {
diff --git a/packages/babel-plugin-rn-stylename-to-style/index.js b/packages/babel-plugin-rn-stylename-to-style/index.js
index 9ba95bc..82d3fdd 100644
--- a/packages/babel-plugin-rn-stylename-to-style/index.js
+++ b/packages/babel-plugin-rn-stylename-to-style/index.js
@@ -3,7 +3,6 @@ const fs = require('fs')
const t = require('@babel/types')
const template = require('@babel/template').default
const parser = require('@babel/parser')
-const { GLOBAL_NAME, LOCAL_NAME } = require('@cssxjs/runtime/constants')
const { addNamed } = require('@babel/helper-module-imports')
const COMPILERS = require('@cssxjs/loaders/compilers')
@@ -14,12 +13,17 @@ const STYLE_NAME_REGEX = /(?:^s|S)tyleName$/
const STYLE_REGEX = /(?:^s|S)tyle$/
const ROOT_STYLE_PROP_NAME = 'style'
const RUNTIME_IMPORT_NAME = 'runtime'
+const RUNTIME_LAYER_HOOK_NAME = 'useCssxLayer'
const RUNTIME_FRIENDLY_NAME = 'cssx'
+const RUNTIME_LAYER_HOOK_FRIENDLY_NAME = 'cssxLayer'
+const GLOBAL_NAME = '__CSS_GLOBAL__'
+const LOCAL_NAME = '__CSS_LOCAL__'
const OPTIONS_CACHE = ['teamplay']
const OPTIONS_REACT_TYPES = ['react-native', 'web']
const DEFAULT_MAGIC_IMPORTS = ['cssxjs', 'startupjs']
const DEFAULT_OBSERVER_NAME = 'observer'
const DEFAULT_OBSERVER_IMPORTS = ['teamplay', 'startupjs']
+const PROVIDER_STYLE_COMPONENTS = new Set(['CssxProvider', 'StartupjsProvider'])
const buildSafeVar = template.expression(`
typeof %%variable%% !== 'undefined' && %%variable%%
@@ -41,6 +45,7 @@ module.exports = function (babel) {
let $program
let usedCompilers
let runtime
+ let useCssxLayer
function getOrCreateRuntime (state) {
if (runtime) return runtime
@@ -68,15 +73,49 @@ module.exports = function (babel) {
return runtime
}
- function getStyleFromExpression (expression, state) {
+ function getOrCreateUseCssxLayer (state) {
+ if (useCssxLayer) return useCssxLayer
+ const runtimePath = getRuntimePath($program, state, hasObserver)
+ const imported = addNamedImport($program, RUNTIME_LAYER_HOOK_NAME, runtimePath)
+ useCssxLayer = $program.scope.generateUidIdentifier(RUNTIME_LAYER_HOOK_FRIENDLY_NAME)
+
+ insertAfterImports($program, buildRuntimeVar({
+ name: useCssxLayer,
+ imported
+ }))
+
+ return useCssxLayer
+ }
+
+ function getStyleFromExpression ($path, expression, state) {
const cssStyles = cssIdentifier.name
const processCall = t.callExpression(
getOrCreateRuntime(state),
- [expression, t.identifier(cssStyles)]
+ [
+ expression,
+ getTrackedLayer($path, state, t.identifier(cssStyles), `file:${cssStyles}`)
+ ]
)
return processCall
}
+ function getTrackedLayer ($path, state, expression, key) {
+ const $fnComponent = findReactFnComponent($path)
+ if (!$fnComponent) return expression
+
+ const dataKey = `cssxTrackedLayer:${key}`
+ const existing = $fnComponent.getData(dataKey)
+ if (existing) return t.identifier(existing)
+
+ const identifier = $fnComponent.scope.generateUidIdentifier(key.replace(/[^a-zA-Z0-9_$]/g, '_'))
+ $fnComponent.setData(dataKey, identifier.name)
+ insertIntoFunctionBody($fnComponent, buildConst({
+ variable: identifier,
+ value: t.callExpression(getOrCreateUseCssxLayer(state), [expression])
+ }))
+ return identifier
+ }
+
function addPartStyleToProps ($jsxAttribute) {
const parts = getParts($jsxAttribute.get('value'))
const $fnComponent = findReactFnComponent($jsxAttribute)
@@ -139,9 +178,9 @@ module.exports = function (babel) {
const partStyle = styleHash[ROOT_STYLE_PROP_NAME]?.partStyle
const inlineStyles = []
- // Always process if 'observer' import is found in the file
- // which is needed for styles caching.
- // Otherwise, if no 'observer' found and no 'styleName' or 'part' found then skip
+ // Keep old observer-triggered behavior for files that relied on cached
+ // inline style prop normalization without styleName/part attributes.
+ // Normal styleName handling does not require observer().
if (!(hasObserver || styleName || partStyle)) return
// Check if styleName exists and if it can be processed
@@ -193,10 +232,25 @@ module.exports = function (babel) {
)
: t.stringLiteral(''),
cssIdentifier
- ? t.identifier(cssIdentifier.name)
+ ? getTrackedLayer(
+ jsxOpeningElementPath,
+ state,
+ t.identifier(cssIdentifier.name),
+ `file:${cssIdentifier.name}`
+ )
: t.objectExpression([]),
- buildSafeVar({ variable: t.identifier(GLOBAL_NAME) }),
- buildSafeVar({ variable: t.identifier(LOCAL_NAME) }),
+ getTrackedLayer(
+ jsxOpeningElementPath,
+ state,
+ buildSafeVar({ variable: t.identifier(GLOBAL_NAME) }),
+ 'global'
+ ),
+ getTrackedLayer(
+ jsxOpeningElementPath,
+ state,
+ buildSafeVar({ variable: t.identifier(LOCAL_NAME) }),
+ 'local'
+ ),
t.objectExpression(inlineStyles)
]
)
@@ -237,11 +291,11 @@ module.exports = function (babel) {
if (t.isStringLiteral(styleName.node.value)) {
expressions = [
- getStyleFromExpression(styleName.node.value, state)
+ getStyleFromExpression(styleName, styleName.node.value, state)
]
} else if (t.isJSXExpressionContainer(styleName.node.value)) {
expressions = [
- getStyleFromExpression(styleName.node.value.expression, state)
+ getStyleFromExpression(styleName, styleName.node.value.expression, state)
]
}
@@ -276,6 +330,7 @@ module.exports = function (babel) {
$program = undefined
usedCompilers = undefined
runtime = undefined
+ useCssxLayer = undefined
},
visitor: {
Program: {
@@ -394,7 +449,11 @@ module.exports = function (babel) {
styleHash[convertedName].styleName = $this
// Some react-native built-in stuff might have props like 'barStyle' which
// is a string. We skip those.
- } else if (STYLE_REGEX.test(name) && !$this.get('value').isStringLiteral()) {
+ } else if (
+ STYLE_REGEX.test(name) &&
+ !$this.get('value').isStringLiteral() &&
+ !isProviderStyleAttribute($this)
+ ) {
if (!styleHash[name]) styleHash[name] = {}
styleHash[name].style = $this
} else if (name === 'part') {
@@ -417,10 +476,25 @@ module.exports = function (babel) {
? $this.get('arguments.0').node
: t.stringLiteral(''),
cssIdentifier
- ? t.identifier(cssIdentifier.name)
+ ? getTrackedLayer(
+ $this,
+ state,
+ t.identifier(cssIdentifier.name),
+ `file:${cssIdentifier.name}`
+ )
: t.objectExpression([]),
- buildSafeVar({ variable: t.identifier(GLOBAL_NAME) }),
- buildSafeVar({ variable: t.identifier(LOCAL_NAME) }),
+ getTrackedLayer(
+ $this,
+ state,
+ buildSafeVar({ variable: t.identifier(GLOBAL_NAME) }),
+ 'global'
+ ),
+ getTrackedLayer(
+ $this,
+ state,
+ buildSafeVar({ variable: t.identifier(LOCAL_NAME) }),
+ 'local'
+ ),
$this.get('arguments.1')
? $this.get('arguments.1').node
: t.objectExpression([])
@@ -467,6 +541,19 @@ function validatePart ($jsxAttribute) {
`)
}
+function isProviderStyleAttribute ($jsxAttribute) {
+ const $openingElement = $jsxAttribute.findParent(path => path.isJSXOpeningElement())
+ if (!$openingElement) return false
+
+ return PROVIDER_STYLE_COMPONENTS.has(getJsxElementName($openingElement.node.name))
+}
+
+function getJsxElementName (name) {
+ if (t.isJSXIdentifier(name)) return name.name
+ if (t.isJSXMemberExpression(name)) return getJsxElementName(name.property)
+ return ''
+}
+
function validateDynamicPartObject ($object) {
for (const $property of $object.get('properties')) {
if (!$property.isObjectProperty() || $property.node.computed) {
@@ -520,7 +607,8 @@ function buildDynamicPart (expr, part) {
}
}
-// if cache is 'teamplay'
+// Legacy cache compatibility: observer imports still select the old
+// cssxjs/runtime/*-teamplay entrypoints, which now wrap the unified runtime.
function checkObserverImport ($import, state) {
const observerImports = state.opts.observerImports || DEFAULT_OBSERVER_IMPORTS
const observerName = state.opts.observerName || DEFAULT_OBSERVER_NAME
@@ -586,8 +674,8 @@ function getRuntimePath ($node, state, hasObserver) {
`Invalid cache option value: "${cache}". Supported values: ${OPTIONS_CACHE.join(', ')}`
)
}
- // If observer() is used in this file then we force cache to 'teamplay'
- // TODO: this is a bit of a hack, think of a better way to do this
+ // Preserve the old import path shape for codebases that still use observer().
+ // The runtime behind that path no longer imports Teamplay.
if (!cache && hasObserver) cache = 'teamplay'
const reactType = state.opts.reactType
if (reactType && !OPTIONS_REACT_TYPES.includes(reactType)) {
@@ -614,6 +702,31 @@ function addNamedImport ($program, name, sourceName) {
})
}
+function insertIntoFunctionBody ($function, statement) {
+ const $body = $function.get('body')
+ if (!$body.isBlockStatement()) {
+ $body.replaceWith(t.blockStatement([
+ t.returnStatement($body.node)
+ ]))
+ }
+
+ const body = $function.get('body')
+ const statements = body.get('body')
+ const localCssDeclaration = statements.find($statement => {
+ if (!$statement.isVariableDeclaration()) return false
+ return $statement.node.declarations.some(declaration => (
+ t.isIdentifier(declaration.id) &&
+ declaration.id.name === LOCAL_NAME
+ ))
+ })
+
+ if (localCssDeclaration) {
+ localCssDeclaration.insertAfter(statement)
+ } else {
+ body.unshiftContainer('body', statement)
+ }
+}
+
function insertAfterImports ($program, expressionStatement) {
const lastImport = $program
.get('body')
diff --git a/packages/babel-plugin-rn-stylename-to-style/package.json b/packages/babel-plugin-rn-stylename-to-style/package.json
index de3874e..e45ceb5 100644
--- a/packages/babel-plugin-rn-stylename-to-style/package.json
+++ b/packages/babel-plugin-rn-stylename-to-style/package.json
@@ -14,7 +14,7 @@
],
"main": "index.js",
"scripts": {
- "test": "jest"
+ "test": "NO_COLOR=1 FORCE_COLOR=0 jest"
},
"author": "Pavel Zhukov",
"license": "MIT",
@@ -26,8 +26,7 @@
"@babel/helper-module-imports": "^7.0.0",
"@babel/parser": "^7.0.0",
"@babel/template": "^7.4.0",
- "@babel/types": "^7.0.0",
- "@cssxjs/runtime": "^0.3.0"
+ "@babel/types": "^7.0.0"
},
"devDependencies": {
"@babel/plugin-syntax-jsx": "^7.0.0",
diff --git a/packages/babel-preset-cssxjs/index.js b/packages/babel-preset-cssxjs/index.js
index cb5d9a5..b0ef840 100644
--- a/packages/babel-preset-cssxjs/index.js
+++ b/packages/babel-preset-cssxjs/index.js
@@ -2,8 +2,8 @@
// On React Native this should be passed.
// reactType - force the React target platform (e.g. 'react-native', 'web'). Default: undefined.
// This shouldn't be needed in most cases since it will be automatically detected.
-// cache - force the CSS caching library instance (e.g. 'teamplay'). Default: undefined
-// This shouldn't be needed in most cases since it will be automatically detected.
+// cache - legacy compatibility option. 'teamplay' is still accepted but caching
+// is owned by cssxjs internally.
module.exports = (api, {
platform,
reactType,
@@ -45,6 +45,7 @@ module.exports = (api, {
transformCss && [require('@cssxjs/babel-plugin-rn-stylename-to-style'), {
useImport: true,
reactType,
+ platform,
cache
}]
].filter(Boolean)
diff --git a/packages/css-to-rn/package.json b/packages/css-to-rn/package.json
new file mode 100644
index 0000000..a29539b
--- /dev/null
+++ b/packages/css-to-rn/package.json
@@ -0,0 +1,80 @@
+{
+ "name": "@cssxjs/css-to-rn",
+ "version": "0.3.0",
+ "description": "Unified CSS to React Native style compiler and runtime resolver for CSSX",
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "cssx-ts": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./react": {
+ "react-native": {
+ "cssx-ts": "./src/react-native.ts",
+ "types": "./dist/react-native.d.ts",
+ "default": "./dist/react-native.js"
+ },
+ "cssx-ts": "./src/web.ts",
+ "types": "./dist/web.d.ts",
+ "default": "./dist/web.js"
+ },
+ "./react-native": {
+ "cssx-ts": "./src/react-native.ts",
+ "types": "./dist/react-native.d.ts",
+ "default": "./dist/react-native.js"
+ },
+ "./web": {
+ "cssx-ts": "./src/web.ts",
+ "types": "./dist/web.d.ts",
+ "default": "./dist/web.js"
+ }
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "test": "npm run test:engine && npm run test:react && npm run test:types",
+ "test:engine": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" mocha 'test/engine/**/*.test.ts'",
+ "test:react": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" mocha 'test/react/**/*.test.ts'",
+ "test:types": "tsc -p tsconfig.json --noEmit",
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
+ "prepublishOnly": "npm run build"
+ },
+ "files": [
+ "dist",
+ "src"
+ ],
+ "dependencies": {
+ "@colordx/core": "5.4.3",
+ "css": "^3.0.0",
+ "css-mediaquery": "^0.1.2",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@types/jsdom": "^28.0.3",
+ "@types/node": "^22.8.1",
+ "@types/react": "19.2.17",
+ "@types/react-dom": "19.2.3",
+ "jsdom": "^29.1.1",
+ "mocha": "^8.4.0",
+ "react": "19.2.7",
+ "react-dom": "19.2.7",
+ "typescript": "^6.0.3"
+ },
+ "license": "MIT"
+}
diff --git a/packages/css-to-rn/src/colors.ts b/packages/css-to-rn/src/colors.ts
new file mode 100644
index 0000000..5331f04
--- /dev/null
+++ b/packages/css-to-rn/src/colors.ts
@@ -0,0 +1,263 @@
+import { colordx, extend } from '@colordx/core'
+import names from '@colordx/core/plugins/names'
+
+extend([names])
+
+const COLOR_FUNCTIONS = ['color-mix', 'oklch', 'oklab']
+
+export function evaluateCssColors (input: string): string {
+ let output = ''
+ let index = 0
+
+ while (index < input.length) {
+ const next = findNextColorFunction(input, index)
+ if (next == null) {
+ output += input.slice(index)
+ break
+ }
+
+ output += input.slice(index, next.start)
+
+ const open = next.start + next.name.length
+ const close = findMatchingParen(input, open)
+ if (close === -1) {
+ output += input.slice(next.start)
+ break
+ }
+
+ const raw = input.slice(next.start, close + 1)
+ const body = input.slice(open + 1, close)
+ const replacement = next.name === 'color-mix'
+ ? evaluateColorMix(body)
+ : normalizeColor(raw)
+
+ output += replacement ?? raw
+ index = close + 1
+ }
+
+ return output
+}
+
+export function isCssColor (input: string): boolean {
+ const color = colordx(evaluateCssColors(input.trim()))
+ return color.isValid()
+}
+
+function evaluateColorMix (body: string): string | null {
+ const parts = splitTopLevelComma(body).map(part => part.trim()).filter(Boolean)
+ if (parts.length !== 3) return null
+
+ const spaceMatch = parts[0].match(/^in\s+([_a-zA-Z][_a-zA-Z0-9-]*)/i)
+ if (!spaceMatch) return null
+
+ const first = parseColorStop(parts[1])
+ const second = parseColorStop(parts[2])
+ const weights = normalizeWeights(first.weight, second.weight)
+ const colorA = colordx(evaluateCssColors(first.color))
+ const colorB = colordx(evaluateCssColors(second.color))
+
+ if (!colorA.isValid() || !colorB.isValid()) return null
+
+ const space = spaceMatch[1].toLowerCase()
+ if (space === 'oklch') {
+ return mixOklch(colorA, colorB, weights.first)
+ }
+
+ if (space === 'oklab') {
+ return mixOklab(colorA, colorB, weights.first)
+ }
+
+ if (space === 'srgb' || space === 'rgb') {
+ return mixRgb(colorA, colorB, weights.first)
+ }
+
+ return null
+}
+
+function parseColorStop (input: string): { color: string, weight?: number } {
+ const tokens = splitTopLevelWhitespace(input.trim())
+ const last = tokens[tokens.length - 1]
+ if (last?.endsWith('%')) {
+ const weight = Number(last.slice(0, -1))
+ if (Number.isFinite(weight)) {
+ return {
+ color: tokens.slice(0, -1).join(' '),
+ weight: weight / 100
+ }
+ }
+ }
+
+ return {
+ color: input.trim()
+ }
+}
+
+function normalizeWeights (
+ rawFirst: number | undefined,
+ rawSecond: number | undefined
+): { first: number, second: number } {
+ const first = rawFirst ?? (rawSecond == null ? 0.5 : 1 - rawSecond)
+ const second = rawSecond ?? (rawFirst == null ? 0.5 : 1 - rawFirst)
+ const total = first + second
+
+ if (!Number.isFinite(total) || total <= 0) {
+ return { first: 0.5, second: 0.5 }
+ }
+
+ return {
+ first: clamp(first / total, 0, 1),
+ second: clamp(second / total, 0, 1)
+ }
+}
+
+function mixRgb (
+ colorA: ReturnType,
+ colorB: ReturnType,
+ firstWeight: number
+): string {
+ const a = colorA.toRgb()
+ const b = colorB.toRgb()
+ const secondWeight = 1 - firstWeight
+ const alphaValue = alpha(a.alpha * firstWeight + b.alpha * secondWeight)
+
+ if (alphaValue === 0) {
+ return rgbaString({ r: 0, g: 0, b: 0, alpha: 0 })
+ }
+
+ return rgbaString({
+ r: round((a.r * a.alpha * firstWeight + b.r * b.alpha * secondWeight) / alphaValue),
+ g: round((a.g * a.alpha * firstWeight + b.g * b.alpha * secondWeight) / alphaValue),
+ b: round((a.b * a.alpha * firstWeight + b.b * b.alpha * secondWeight) / alphaValue),
+ alpha: alphaValue
+ })
+}
+
+function mixOklab (
+ colorA: ReturnType,
+ colorB: ReturnType,
+ firstWeight: number
+): string | null {
+ const a = colorA.toOklab()
+ const b = colorB.toOklab()
+ const secondWeight = 1 - firstWeight
+ return normalizeColor(
+ `oklab(${mix(a.l, b.l, firstWeight)} ${mix(a.a, b.a, firstWeight)} ${mix(a.b, b.b, firstWeight)} / ${mix(a.alpha, b.alpha, firstWeight, secondWeight)})`
+ )
+}
+
+function mixOklch (
+ colorA: ReturnType,
+ colorB: ReturnType,
+ firstWeight: number
+): string | null {
+ const a = colorA.toOklch()
+ const b = colorB.toOklch()
+ const secondWeight = 1 - firstWeight
+ const hue = mixHue(a.h, b.h, firstWeight)
+ return normalizeColor(
+ `oklch(${mix(a.l, b.l, firstWeight)} ${mix(a.c, b.c, firstWeight)} ${hue} / ${mix(a.alpha, b.alpha, firstWeight, secondWeight)})`
+ )
+}
+
+function normalizeColor (input: string): string | null {
+ const color = colordx(input)
+ return color.isValid() ? rgbaString(color.toRgb()) : null
+}
+
+function rgbaString (input: { r: number, g: number, b: number, alpha: number }): string {
+ return `rgba(${round(input.r)}, ${round(input.g)}, ${round(input.b)}, ${alpha(input.alpha)})`
+}
+
+function mix (
+ first: number,
+ second: number,
+ firstWeight: number,
+ secondWeight = 1 - firstWeight
+): number {
+ return first * firstWeight + second * secondWeight
+}
+
+function mixHue (first: number, second: number, firstWeight: number): number {
+ let delta = second - first
+ if (delta > 180) delta -= 360
+ if (delta < -180) delta += 360
+ return (first + delta * (1 - firstWeight) + 360) % 360
+}
+
+function round (value: number): number {
+ return Math.round(clamp(value, 0, 255))
+}
+
+function alpha (value: number): number {
+ return Math.round(clamp(value, 0, 1) * 1000) / 1000
+}
+
+function clamp (value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value))
+}
+
+function findNextColorFunction (
+ input: string,
+ fromIndex: number
+): { name: string, start: number } | null {
+ let best: { name: string, start: number } | null = null
+
+ for (const name of COLOR_FUNCTIONS) {
+ const start = input.indexOf(`${name}(`, fromIndex)
+ if (start === -1) continue
+ if (best == null || start < best.start) best = { name, start }
+ }
+
+ return best
+}
+
+function findMatchingParen (input: string, openIndex: number): number {
+ let depth = 0
+ for (let index = openIndex; index < input.length; index++) {
+ const char = input[index]
+ if (char === '(') depth++
+ if (char === ')') {
+ depth--
+ if (depth === 0) return index
+ }
+ }
+ return -1
+}
+
+function splitTopLevelComma (input: string): string[] {
+ const parts: string[] = []
+ let depth = 0
+ let start = 0
+
+ for (let index = 0; index < input.length; index++) {
+ const char = input[index]
+ if (char === '(') depth++
+ if (char === ')') depth--
+ if (char === ',' && depth === 0) {
+ parts.push(input.slice(start, index))
+ start = index + 1
+ }
+ }
+
+ parts.push(input.slice(start))
+ return parts
+}
+
+function splitTopLevelWhitespace (input: string): string[] {
+ const parts: string[] = []
+ let depth = 0
+ let start = 0
+
+ for (let index = 0; index < input.length; index++) {
+ const char = input[index]
+ if (char === '(') depth++
+ if (char === ')') depth--
+ if (/\s/.test(char) && depth === 0) {
+ if (start !== index) parts.push(input.slice(start, index))
+ start = index + 1
+ }
+ }
+
+ if (start < input.length) parts.push(input.slice(start))
+ return parts
+}
diff --git a/packages/css-to-rn/src/compiler.ts b/packages/css-to-rn/src/compiler.ts
new file mode 100644
index 0000000..f3e87e4
--- /dev/null
+++ b/packages/css-to-rn/src/compiler.ts
@@ -0,0 +1,676 @@
+import parseCss from 'css/lib/parse/index.js'
+import mediaQuery from 'css-mediaquery'
+import valueParser from 'postcss-value-parser'
+import { addDiagnostic, diagnostic } from './diagnostics.ts'
+import { cssxHash } from './hash.ts'
+import { parseSelector } from './selectors.ts'
+import { transformDeclarations } from './transform/index.ts'
+import { resolveCssValue } from './values.ts'
+import type {
+ CompileCssOptions,
+ CompileCssTemplateOptions,
+ CompileState,
+ CompiledCssSheet,
+ CssxDeclaration,
+ CssxDiagnostic,
+ CssxKeyframe,
+ CssxMetadata,
+ CssxRule,
+ CssxTarget
+} from './types.ts'
+
+const VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/
+const VAR_NAME_RE = /^--[A-Za-z0-9_-]+$/
+const THEME_ROOT_SELECTOR_RE = /^:root\.([A-Za-z0-9_-]+)$/
+const THEME_MEDIA_RE = /\(--theme-[A-Za-z0-9_-]+\)/
+const CUSTOM_MEDIA_NAME_RE = /^--[A-Za-z0-9_-]+$/
+const THEME_CUSTOM_MEDIA_NAME_RE = /^--theme-[A-Za-z0-9_-]+$/
+const MEDIA_RANGE_RE = /\((?:width|height)\s*(?:<=|>=|<|>)/
+const VIEWPORT_UNIT_RE = /(?:^|[^\w-])[-+]?(?:\d*\.)?\d+(?:vh|vw|vmin|vmax)\b/
+const DYNAMIC_SLOT_RE = /var\(\s*--__cssx_dynamic_(\d+)\b/g
+const ANIMATION_PROPS = new Set([
+ 'animation',
+ 'animation-name',
+ 'animation-duration',
+ 'animation-timing-function',
+ 'animation-delay',
+ 'animation-iteration-count',
+ 'animation-direction',
+ 'animation-fill-mode',
+ 'animation-play-state'
+])
+const TRANSITION_PROPS = new Set([
+ 'transition',
+ 'transition-property',
+ 'transition-duration',
+ 'transition-timing-function',
+ 'transition-delay'
+])
+
+export function compileCss (css: string, options: CompileCssOptions = {}): CompiledCssSheet {
+ return compileCssInternal(css, options)
+}
+
+export function compileCssTemplate (
+ css: string,
+ options: CompileCssTemplateOptions = {}
+): CompiledCssSheet {
+ return compileCssInternal(css, {
+ ...options,
+ sourceIdentity: options.sourceIdentity ?? options.id
+ }, true)
+}
+
+function compileCssInternal (
+ css: string,
+ options: CompileCssOptions,
+ isTemplate = false
+): CompiledCssSheet {
+ const mode = options.mode ?? 'runtime'
+ const state: CompileState = { mode, diagnostics: [] }
+ const contentHash = options.contentHash ?? cssxHash(css)
+ const sourceId = options.sourceId ?? (options.sourceIdentity ? cssxHash(options.sourceIdentity) : undefined)
+ const id = options.id ?? cssxHash(`${sourceId ?? 'runtime'}:${contentHash}`)
+ const empty = (): CompiledCssSheet => createSheet({
+ id,
+ sourceId,
+ contentHash,
+ diagnostics: state.diagnostics,
+ error: state.diagnostics.find(item => item.level === 'error')
+ })
+
+ let ast: CssAst
+ try {
+ ast = parseCss(css, { silent: false }) as CssAst
+ } catch (error) {
+ const err = error as Error & { line?: number, column?: number, reason?: string }
+ const item = diagnostic(
+ 'CSS_SYNTAX_ERROR',
+ err.reason ?? err.message,
+ 'error',
+ { line: err.line, column: err.column }
+ )
+ addDiagnostic(state, item)
+ return empty()
+ }
+
+ const rules: CssxRule[] = []
+ const keyframes: Record = {}
+ const rootVariables: Record = {}
+ const themeVariables: Record> = {}
+ const customMedia: Record = {}
+ const exports: Record = {}
+ let order = 0
+
+ for (const rule of ast.stylesheet?.rules ?? []) {
+ if (rule.type === 'rule') {
+ const styleRule = rule as CssStyleRuleAst
+ compileRuleList(styleRule.selectors ?? [], styleRule.declarations ?? [], null, rules, rootVariables, themeVariables, state, orderRef(() => order++), isTemplate, exports, options.target)
+ continue
+ }
+
+ if (rule.type === 'media') {
+ const mediaRule = rule as CssMediaAst
+ const media = `@media ${mediaRule.media ?? ''}`.trim()
+ const mediaIsValid = validateMedia(mediaRule, state, isTemplate)
+ if (!mediaIsValid && state.mode === 'build') continue
+ for (const child of mediaRule.rules ?? []) {
+ if (child.type !== 'rule') continue
+ compileRuleList(child.selectors ?? [], child.declarations ?? [], media, rules, rootVariables, themeVariables, state, orderRef(() => order++), isTemplate, exports, options.target)
+ }
+ continue
+ }
+
+ if (rule.type === 'keyframes') {
+ const keyframesRule = rule as CssKeyframesAst
+ const name = keyframesRule.name
+ if (!name) continue
+ keyframes[name] = compileKeyframes(keyframesRule, state, orderRef(() => order++), isTemplate, options.target)
+ continue
+ }
+
+ if (rule.type === 'custom-media') {
+ compileCustomMedia(rule as CssCustomMediaAst, customMedia, state, isTemplate)
+ continue
+ }
+
+ if (rule.type !== 'comment') {
+ addDiagnostic(state, diagnostic(
+ 'UNSUPPORTED_AT_RULE',
+ `Unsupported at-rule or CSS rule type "${rule.type}" ignored.`,
+ 'warning',
+ positionOf(rule)
+ ))
+ }
+ }
+
+ const metadata = buildMetadata(rules, keyframes, rootVariables, themeVariables, customMedia, isTemplate)
+ return createSheet({
+ id,
+ sourceId,
+ contentHash,
+ rules,
+ keyframes,
+ rootVariables: Object.keys(rootVariables).length > 0 ? rootVariables : undefined,
+ themeVariables: Object.keys(themeVariables).length > 0 ? themeVariables : undefined,
+ customMedia: Object.keys(customMedia).length > 0 ? customMedia : undefined,
+ exports: Object.keys(exports).length > 0 ? exports : undefined,
+ metadata,
+ diagnostics: state.diagnostics,
+ error: state.diagnostics.find(item => item.level === 'error')
+ })
+}
+
+function compileRuleList (
+ selectors: string[],
+ declarations: CssDeclarationAst[],
+ media: string | null,
+ output: CssxRule[],
+ rootVariables: Record,
+ themeVariables: Record>,
+ state: CompileState,
+ nextOrder: () => number,
+ isTemplate: boolean,
+ exports: Record,
+ target: CssxTarget | undefined
+): void {
+ let compiledDeclarations: CssxDeclaration[] | undefined
+
+ for (const selector of selectors) {
+ if (selector === ':export') {
+ compileExports(declarations, exports, state, isTemplate)
+ continue
+ }
+
+ if (selector.trim() === ':root') {
+ if (media != null) {
+ addDiagnostic(state, diagnostic(
+ 'UNSUPPORTED_SELECTOR',
+ `Unsupported selector "${selector}" inside media query ignored. CSSX provider root variables are unconditional.`,
+ 'warning'
+ ))
+ continue
+ }
+ compileRootVariables(declarations, rootVariables, state, ':root')
+ continue
+ }
+
+ const themeMatch = selector.trim().match(THEME_ROOT_SELECTOR_RE)
+ if (themeMatch) {
+ if (media != null) {
+ addDiagnostic(state, diagnostic(
+ 'UNSUPPORTED_SELECTOR',
+ `Unsupported selector "${selector}" inside media query ignored. CSSX provider theme variables are unconditional.`,
+ 'warning'
+ ))
+ continue
+ }
+ const themeName = themeMatch[1]
+ themeVariables[themeName] ??= {}
+ compileRootVariables(declarations, themeVariables[themeName], state, selector.trim())
+ continue
+ }
+
+ if (selector.trim().startsWith(':root')) {
+ addDiagnostic(state, diagnostic(
+ 'UNSUPPORTED_SELECTOR',
+ `Unsupported selector "${selector}" ignored. CSSX supports only :root and :root. for provider CSS variables.`,
+ 'warning'
+ ))
+ continue
+ }
+
+ const parsed = parseSelector(selector, positionOfDeclarationList(declarations))
+ if (parsed.diagnostic) {
+ addDiagnostic(state, parsed.diagnostic)
+ continue
+ }
+ if (!parsed.result) continue
+ compiledDeclarations ??= compileDeclarations(declarations, state, isTemplate, target)
+
+ output.push({
+ selector: parsed.result.selector,
+ tag: parsed.result.tag,
+ classes: parsed.result.classes,
+ part: parsed.result.part,
+ specificity: parsed.result.specificity,
+ order: nextOrder(),
+ media,
+ declarations: compiledDeclarations
+ })
+ }
+}
+
+function compileRootVariables (
+ declarations: CssDeclarationAst[],
+ rootVariables: Record,
+ state: CompileState,
+ selector: string
+): void {
+ for (const declaration of declarations) {
+ if (declaration.type !== 'declaration') continue
+ const property = declaration.property
+ if (!property) continue
+
+ if (!VAR_NAME_RE.test(property)) {
+ addDiagnostic(state, diagnostic(
+ 'INVALID_THEME_BLOCK',
+ `Only CSS custom properties are supported inside ${selector}. Declaration "${property}" ignored.`,
+ 'warning',
+ positionOf(declaration)
+ ))
+ continue
+ }
+
+ const value = declaration.value ?? ''
+ rootVariables[property] = value
+ }
+}
+
+function compileExports (
+ declarations: CssDeclarationAst[],
+ exports: Record,
+ state: CompileState,
+ isTemplate: boolean
+): void {
+ for (const declaration of declarations) {
+ if (declaration.type !== 'declaration') continue
+ if (isTemplate && hasDynamicSlots(declaration.value ?? '')) {
+ addDiagnostic(state, diagnostic(
+ 'UNSUPPORTED_INTERPOLATION_POSITION',
+ 'Interpolation is not supported inside :export blocks.',
+ 'error',
+ positionOf(declaration)
+ ))
+ continue
+ }
+ if (declaration.property) exports[declaration.property] = declaration.value ?? ''
+ }
+}
+
+function compileCustomMedia (
+ rule: CssCustomMediaAst,
+ customMedia: Record,
+ state: CompileState,
+ isTemplate: boolean
+): void {
+ const name = rule.name ?? ''
+ const media = rule.media ?? ''
+
+ if (!CUSTOM_MEDIA_NAME_RE.test(name)) {
+ addDiagnostic(state, diagnostic(
+ 'INVALID_CUSTOM_MEDIA',
+ `Invalid @custom-media name "${name}". Custom media names must start with "--".`,
+ 'warning',
+ positionOf(rule)
+ ))
+ return
+ }
+
+ if (THEME_CUSTOM_MEDIA_NAME_RE.test(name)) {
+ addDiagnostic(state, diagnostic(
+ 'INVALID_CUSTOM_MEDIA',
+ `Custom media name "${name}" is reserved by CSSX theme media aliases.`,
+ 'warning',
+ positionOf(rule)
+ ))
+ return
+ }
+
+ if (isTemplate && hasDynamicSlots(media)) {
+ addDiagnostic(state, diagnostic(
+ 'UNSUPPORTED_INTERPOLATION_POSITION',
+ 'Interpolation is not supported inside @custom-media queries.',
+ 'error',
+ positionOf(rule)
+ ))
+ return
+ }
+
+ customMedia[name] = media.trim()
+}
+
+function compileDeclarations (
+ declarations: CssDeclarationAst[],
+ state: CompileState,
+ isTemplate: boolean,
+ target: CssxTarget | undefined
+): CssxDeclaration[] {
+ const output: CssxDeclaration[] = []
+ let order = 0
+
+ for (const declaration of declarations) {
+ if (declaration.type !== 'declaration') continue
+ const property = declaration.property
+ const value = declaration.value ?? ''
+ if (!property) continue
+
+ if (property.startsWith('--')) {
+ addDiagnostic(state, diagnostic(
+ 'INVALID_DECLARATION',
+ `CSS custom property declaration "${property}" ignored. Use variables or setDefaultVariables() instead.`,
+ 'warning',
+ positionOf(declaration)
+ ))
+ continue
+ }
+
+ const dynamicSlots = isTemplate ? getDynamicSlots(value) : undefined
+ const compiledDeclaration: CssxDeclaration = {
+ property,
+ value,
+ raw: `${property}: ${value}`,
+ order: order++,
+ dynamicSlots,
+ line: declaration.position?.start?.line,
+ column: declaration.position?.start?.column
+ }
+
+ validateBuildDeclaration(compiledDeclaration, state, target)
+ output.push(compiledDeclaration)
+ }
+
+ return output
+}
+
+function compileKeyframes (
+ rule: CssKeyframesAst,
+ state: CompileState,
+ nextOrder: () => number,
+ isTemplate: boolean,
+ target: CssxTarget | undefined
+): CssxKeyframe[] {
+ const output: CssxKeyframe[] = []
+ for (const frame of rule.keyframes ?? []) {
+ output.push({
+ selector: (frame.values ?? []).join(', '),
+ declarations: compileDeclarations(frame.declarations ?? [], state, isTemplate, target),
+ order: nextOrder()
+ })
+ }
+ return output
+}
+
+function validateBuildDeclaration (
+ declaration: CssxDeclaration,
+ state: CompileState,
+ target: CssxTarget | undefined
+): void {
+ if (state.mode !== 'build') return
+
+ if (
+ declaration.dynamicSlots?.length ||
+ declaration.value.includes('var(')
+ ) {
+ return
+ }
+
+ const position = {
+ line: declaration.line,
+ column: declaration.column
+ }
+ const resolved = resolveCssValue(declaration.value, {
+ dimensions: {
+ width: 100,
+ height: 100
+ },
+ deprecateUUnits: true
+ })
+
+ if (resolved.valid) {
+ for (const item of resolved.diagnostics) {
+ addDiagnostic(state, diagnostic(
+ item.code,
+ item.message,
+ item.level,
+ position
+ ))
+ }
+ }
+
+ if (!resolved.valid) {
+ for (const item of resolved.diagnostics) {
+ addDiagnostic(state, diagnostic(
+ item.code,
+ item.message,
+ 'error',
+ position
+ ))
+ }
+ return
+ }
+
+ const transformed = transformDeclarations([{
+ property: declaration.property,
+ value: resolved.value,
+ raw: `${declaration.property}: ${resolved.value}`,
+ order: declaration.order
+ }], {
+ platform: target ?? 'react-native'
+ })
+
+ for (const item of transformed.diagnostics) {
+ addDiagnostic(state, diagnostic(
+ item.code,
+ item.message,
+ 'error',
+ position
+ ))
+ }
+}
+
+function validateMedia (
+ rule: CssMediaAst,
+ state: CompileState,
+ isTemplate: boolean
+): boolean {
+ if (isTemplate && hasDynamicSlots(rule.media ?? '')) {
+ addDiagnostic(state, diagnostic(
+ 'UNSUPPORTED_INTERPOLATION_POSITION',
+ 'Interpolation is not supported inside media queries.',
+ 'error',
+ positionOf(rule)
+ ))
+ return false
+ }
+
+ try {
+ if (THEME_MEDIA_RE.test(rule.media ?? '') || MEDIA_RANGE_RE.test(rule.media ?? '')) return true
+ mediaQuery.parse(rule.media ?? '')
+ return true
+ } catch (error) {
+ addDiagnostic(state, diagnostic(
+ 'UNSUPPORTED_AT_RULE',
+ `Unsupported media query "${rule.media ?? ''}" ignored: ${(error as Error).message}`,
+ 'warning',
+ positionOf(rule)
+ ))
+ return false
+ }
+}
+
+function buildMetadata (
+ rules: CssxRule[],
+ keyframes: Record,
+ rootVariables: Record,
+ themeVariables: Record>,
+ customMedia: Record,
+ isTemplate: boolean
+): CssxMetadata {
+ const vars = new Set()
+ let hasMedia = false
+ let hasViewportUnits = false
+ let hasAnimations = Object.keys(keyframes).length > 0
+ let hasTransitions = false
+ let hasInterpolations = isTemplate
+
+ for (const rule of rules) {
+ if (rule.media) hasMedia = true
+ scanDeclarations(rule.declarations)
+ }
+ for (const frames of Object.values(keyframes)) {
+ for (const frame of frames) scanDeclarations(frame.declarations)
+ }
+ for (const value of Object.values(rootVariables)) {
+ collectVars(value, vars)
+ if (VIEWPORT_UNIT_RE.test(value)) hasViewportUnits = true
+ }
+ for (const variables of Object.values(themeVariables)) {
+ for (const value of Object.values(variables)) {
+ collectVars(value, vars)
+ if (VIEWPORT_UNIT_RE.test(value)) hasViewportUnits = true
+ }
+ }
+ for (const value of Object.values(customMedia)) collectVars(value, vars)
+
+ function scanDeclarations (declarations: CssxDeclaration[]): void {
+ for (const declaration of declarations) {
+ collectVars(declaration.value, vars)
+ if (VIEWPORT_UNIT_RE.test(declaration.value)) hasViewportUnits = true
+ if (ANIMATION_PROPS.has(declaration.property)) hasAnimations = true
+ if (TRANSITION_PROPS.has(declaration.property)) hasTransitions = true
+ if (declaration.dynamicSlots && declaration.dynamicSlots.length > 0) hasInterpolations = true
+ }
+ }
+
+ return {
+ hasVars: vars.size > 0,
+ vars: Array.from(vars).sort(),
+ hasMedia,
+ hasViewportUnits,
+ hasInterpolations,
+ hasDynamicRuntimeDependencies: vars.size > 0 || hasMedia || hasViewportUnits || hasInterpolations,
+ hasAnimations,
+ hasTransitions,
+ hasThemes: Object.keys(themeVariables).length > 0,
+ hasCustomMedia: Object.keys(customMedia).length > 0
+ }
+}
+
+function collectVars (value: string, vars: Set): void {
+ const parsed = valueParser(value)
+ parsed.walk(node => {
+ if (node.type !== 'function' || node.value !== 'var') return
+ const first = node.nodes.find(child => child.type === 'word')
+ if (first?.value && VAR_RE.test(`var(${first.value})`)) vars.add(first.value)
+ })
+}
+
+function getDynamicSlots (value: string): number[] | undefined {
+ const slots: number[] = []
+ DYNAMIC_SLOT_RE.lastIndex = 0
+ let match: RegExpExecArray | null
+ while ((match = DYNAMIC_SLOT_RE.exec(value)) != null) {
+ slots.push(Number(match[1]))
+ }
+ return slots.length > 0 ? slots : undefined
+}
+
+function hasDynamicSlots (value: string): boolean {
+ DYNAMIC_SLOT_RE.lastIndex = 0
+ return DYNAMIC_SLOT_RE.test(value)
+}
+
+function createSheet (input: Partial & {
+ id: string
+ contentHash: string
+ diagnostics: CssxDiagnostic[]
+}): CompiledCssSheet {
+ return {
+ version: 1,
+ id: input.id,
+ sourceId: input.sourceId,
+ contentHash: input.contentHash,
+ rules: input.rules ?? [],
+ keyframes: input.keyframes ?? {},
+ rootVariables: input.rootVariables,
+ themeVariables: input.themeVariables,
+ customMedia: input.customMedia,
+ exports: input.exports,
+ metadata: input.metadata ?? {
+ hasVars: false,
+ vars: [],
+ hasMedia: false,
+ hasViewportUnits: false,
+ hasInterpolations: false,
+ hasDynamicRuntimeDependencies: false,
+ hasAnimations: false,
+ hasTransitions: false,
+ hasThemes: false,
+ hasCustomMedia: false
+ },
+ diagnostics: input.diagnostics,
+ error: input.error
+ }
+}
+
+function orderRef (next: () => number): () => number {
+ return next
+}
+
+function positionOf (node: CssPositioned): { line?: number, column?: number } {
+ return {
+ line: node.position?.start?.line,
+ column: node.position?.start?.column
+ }
+}
+
+function positionOfDeclarationList (declarations: CssDeclarationAst[]): { line?: number, column?: number } | undefined {
+ const first = declarations.find(item => item.position)
+ return first ? positionOf(first) : undefined
+}
+
+interface CssAst {
+ stylesheet?: {
+ rules?: CssRuleAst[]
+ }
+}
+
+type CssRuleAst = CssStyleRuleAst | CssMediaAst | CssKeyframesAst | CssCustomMediaAst | CssUnsupportedAst
+
+interface CssPositioned {
+ position?: {
+ start?: {
+ line?: number
+ column?: number
+ }
+ }
+}
+
+interface CssStyleRuleAst extends CssPositioned {
+ type: 'rule'
+ selectors?: string[]
+ declarations?: CssDeclarationAst[]
+}
+
+interface CssMediaAst extends CssPositioned {
+ type: 'media'
+ media?: string
+ rules?: CssStyleRuleAst[]
+}
+
+interface CssKeyframesAst extends CssPositioned {
+ type: 'keyframes'
+ name?: string
+ keyframes?: Array
+}
+
+interface CssCustomMediaAst extends CssPositioned {
+ type: 'custom-media'
+ name?: string
+ media?: string
+}
+
+interface CssDeclarationAst extends CssPositioned {
+ type: 'declaration' | string
+ property?: string
+ value?: string
+}
+
+interface CssUnsupportedAst extends CssPositioned {
+ type: string
+}
diff --git a/packages/css-to-rn/src/diagnostics.ts b/packages/css-to-rn/src/diagnostics.ts
new file mode 100644
index 0000000..3db8892
--- /dev/null
+++ b/packages/css-to-rn/src/diagnostics.ts
@@ -0,0 +1,24 @@
+import type { CompileState, CssxDiagnostic, CssxDiagnosticCode, CssxDiagnosticLevel } from './types.ts'
+
+export function diagnostic (
+ code: CssxDiagnosticCode,
+ message: string,
+ level: CssxDiagnosticLevel = 'warning',
+ position?: { line?: number, column?: number }
+): CssxDiagnostic {
+ return {
+ level,
+ code,
+ message,
+ line: position?.line,
+ column: position?.column
+ }
+}
+
+export function addDiagnostic (state: CompileState, item: CssxDiagnostic): void {
+ state.diagnostics.push(item)
+ if (state.mode === 'build' && item.level === 'error') {
+ const location = item.line == null ? '' : ` (${item.line}:${item.column ?? 1})`
+ throw new Error(`[cssx] ${item.code}${location}: ${item.message}`)
+ }
+}
diff --git a/packages/css-to-rn/src/hash.ts b/packages/css-to-rn/src/hash.ts
new file mode 100644
index 0000000..9c7a70d
--- /dev/null
+++ b/packages/css-to-rn/src/hash.ts
@@ -0,0 +1,11 @@
+// ref: https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0
+export function simpleNumericHash (value: string): number {
+ let i = 0
+ let h = 0
+ for (; i < value.length; i++) h = Math.imul(31, h) + value.charCodeAt(i) | 0
+ return h
+}
+
+export function cssxHash (value: string): string {
+ return `cssx_${Math.abs(simpleNumericHash(value)).toString(36)}`
+}
diff --git a/packages/css-to-rn/src/index.ts b/packages/css-to-rn/src/index.ts
new file mode 100644
index 0000000..fa6cf5f
--- /dev/null
+++ b/packages/css-to-rn/src/index.ts
@@ -0,0 +1,53 @@
+///
+
+export {
+ compileCss,
+ compileCssTemplate
+} from './compiler.ts'
+export {
+ cssxHash,
+ simpleNumericHash
+} from './hash.ts'
+export {
+ resolveCssValue
+} from './values.ts'
+export {
+ u
+} from './units.ts'
+export {
+ createCssxCache,
+ cssx,
+ resolveCssx
+} from './resolve.ts'
+
+export type {
+ CompileCssOptions,
+ CompileCssTemplateOptions,
+ CompileMode,
+ CompiledCssSheet,
+ CssxDeclaration,
+ CssxDiagnostic,
+ CssxDiagnosticCode,
+ CssxKeyframe,
+ CssxMetadata,
+ CssxRule,
+ CssxTarget
+} from './types.ts'
+export type {
+ InterpolationValue,
+ ResolveCssValueOptions,
+ ResolveCssValueResult
+} from './values.ts'
+export type {
+ CssxCache,
+ CssxDimensions,
+ CssxLayerInput,
+ CssxMediaQueryEvaluator,
+ InlineStyleInput,
+ ResolveCssxDependencies,
+ ResolveCssxLayer,
+ ResolveCssxOptions,
+ ResolveCssxResult,
+ ResolvedStyleProps,
+ StyleNameValue
+} from './resolve.ts'
diff --git a/packages/css-to-rn/src/react-native.ts b/packages/css-to-rn/src/react-native.ts
new file mode 100644
index 0000000..be5ab0e
--- /dev/null
+++ b/packages/css-to-rn/src/react-native.ts
@@ -0,0 +1,199 @@
+///
+
+export {
+ compileCss,
+ compileCssTemplate
+} from './compiler.ts'
+export {
+ resolveCssValue
+} from './values.ts'
+export {
+ u
+} from './units.ts'
+import {
+ resetUWarningForTests
+} from './units.ts'
+import {
+ cssx as baseCssx,
+ clearRawCssCacheForTests
+} from './react/cssx.ts'
+import {
+ useCssxLayer as baseUseCssxLayer,
+ useRuntimeCss as baseUseRuntimeCss,
+ useCssxSheet as baseUseCssxSheet,
+ useCssxTemplate as baseUseCssxTemplate
+} from './react/hooks.ts'
+import {
+ createTrackedCssxSheet
+} from './react/tracker.ts'
+import {
+ configureColorSchemeAdapter,
+ configureDimensionsAdapter,
+ configureMediaQueryAdapter,
+ defaultVariables,
+ flushMicrotasksForTests,
+ getRuntimeSubscriberCountForTests,
+ resetStoreForTests,
+ setColorSchemeForTests,
+ setDefaultVariables,
+ setDimensionsForTests,
+ subscribeVariablesForTests,
+ variables
+} from './react/store.ts'
+// @ts-ignore react-native is an optional peer for non-RN consumers.
+import { Dimensions } from 'react-native'
+// @ts-ignore react-native is an optional peer for non-RN consumers.
+import { Appearance } from 'react-native'
+
+export type {
+ CompileCssOptions,
+ CompileCssTemplateOptions,
+ CompiledCssSheet
+} from './types.ts'
+export type {
+ CssxResolvedProps,
+ CssxRuntimeOptions,
+ CssxStyleName
+} from './react/cssx.ts'
+export type {
+ CssxProviderStyleInput,
+ CssxProviderStyleLayer,
+ CssxProviderProps,
+ CssxReactConfig,
+ CssxRuntimeContextValue
+} from './react/config.ts'
+export type {
+ TrackedCssxSheetOptions
+} from './react/tracker.ts'
+export type {
+ CssxColorSchemeAdapter,
+ CssxVariableStore
+} from './react/store.ts'
+
+export {
+ CssxProvider,
+ configureCssx,
+ themed,
+ useCssxComponentTag,
+ useCssxConfig,
+ useCssxRuntimeContext
+} from './react/config.ts'
+export {
+ getCssColor,
+ getCssVariable,
+ getCssVariableRaw,
+ useMedia,
+ useCssColor,
+ useCssVariable,
+ useCssVariableRaw
+} from './react/hooks.ts'
+export type {
+ CssColorMixInput
+} from './react/hooks.ts'
+export {
+ TrackedCssxSheet,
+ isTrackedCssxSheet
+} from './react/tracker.ts'
+export {
+ defaultVariables,
+ setDefaultVariables,
+ variables
+}
+
+installReactNativeColorSchemeAdapter()
+installReactNativeDimensionsAdapter()
+
+export function cssx (
+ ...args: Parameters
+): ReturnType {
+ const [styleName, sheet, inlineStyleProps, options] = args
+ return baseCssx(styleName, sheet, inlineStyleProps, {
+ target: 'react-native',
+ ...(options ?? {})
+ })
+}
+
+export function useRuntimeCss (
+ ...args: Parameters
+): ReturnType {
+ const [input, options] = args
+ return baseUseRuntimeCss(input, {
+ target: 'react-native',
+ ...(options ?? {})
+ })
+}
+
+export function useCssxLayer (
+ ...args: Parameters
+): ReturnType {
+ const [input, options] = args
+ return baseUseCssxLayer(input, {
+ target: 'react-native',
+ ...(options ?? {})
+ })
+}
+
+export function useCssxSheet (
+ ...args: Parameters
+): ReturnType {
+ const [sheet, options] = args
+ return baseUseCssxSheet(sheet, {
+ target: 'react-native',
+ ...(options ?? {})
+ })
+}
+
+export function useCssxTemplate (
+ ...args: Parameters
+): ReturnType {
+ const [sheet, values, options] = args
+ return baseUseCssxTemplate(sheet, values, {
+ target: 'react-native',
+ ...(options ?? {})
+ })
+}
+
+export const __cssxInternals = {
+ clearRawCssCacheForTests,
+ configureColorSchemeAdapterForTests: configureColorSchemeAdapter,
+ configureDimensionsAdapterForTests: configureDimensionsAdapter,
+ configureMediaQueryAdapterForTests: configureMediaQueryAdapter,
+ createTrackedCssxSheet,
+ flushMicrotasksForTests,
+ getRuntimeSubscriberCountForTests,
+ resetStoreForTests,
+ resetUWarningForTests,
+ setColorSchemeForTests,
+ setDimensionsForTests,
+ subscribeVariablesForTests
+}
+
+function installReactNativeColorSchemeAdapter (): void {
+ configureColorSchemeAdapter({
+ get: () => Appearance.getColorScheme(),
+ subscribe: listener => {
+ const subscription = Appearance.addChangeListener(listener)
+ return () => {
+ subscription.remove()
+ }
+ }
+ })
+}
+
+function installReactNativeDimensionsAdapter (): void {
+ configureDimensionsAdapter({
+ get: () => {
+ const next = Dimensions.get('window')
+ return {
+ width: next.width,
+ height: next.height
+ }
+ },
+ subscribe: listener => {
+ const subscription = Dimensions.addEventListener('change', listener)
+ return () => {
+ subscription.remove()
+ }
+ }
+ })
+}
diff --git a/packages/css-to-rn/src/react/config.ts b/packages/css-to-rn/src/react/config.ts
new file mode 100644
index 0000000..5e40e25
--- /dev/null
+++ b/packages/css-to-rn/src/react/config.ts
@@ -0,0 +1,478 @@
+import {
+ createContext,
+ createElement,
+ type ComponentProps,
+ type ComponentType,
+ useEffect,
+ useContext,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useSyncExternalStore,
+ type ReactNode
+} from 'react'
+import { compileCss } from '../compiler.ts'
+import type { CompiledCssSheet } from '../types.ts'
+import {
+ getColorScheme,
+ getColorSchemeVersion,
+ getRuntimeConfig,
+ setRuntimeConfig,
+ subscribeColorScheme,
+ type CssxRuntimeConfig
+} from './store.ts'
+import {
+ isTrackedCssxSheet,
+ TrackedCssxSheet,
+ type TrackedCssxSheetOptions
+} from './tracker.ts'
+import type {
+ ResolveCssxLayer
+} from '../resolve.ts'
+import type { CssxMetadata } from '../types.ts'
+
+export interface CssxReactConfig extends CssxRuntimeConfig, TrackedCssxSheetOptions {}
+
+export type CssxProviderStyleInput =
+ | string
+ | CompiledCssSheet
+ | TrackedCssxSheet
+ | CssxProviderStyleLayer
+ | null
+ | undefined
+ | false
+ | readonly CssxProviderStyleInput[]
+
+export interface CssxProviderStyleLayer {
+ sheet: string | CompiledCssSheet | TrackedCssxSheet
+ values?: readonly unknown[]
+ cacheKey?: unknown
+}
+
+export interface CssxRuntimeContextValue {
+ config: CssxReactConfig
+ layers: CssxRuntimeLayerInput[]
+ scopedVariables: Record[]
+ customMedia: Record
+ componentTag: string | null
+ theme: string
+ themePreference: string
+ themeNames: string[]
+}
+
+export type CssxRuntimeLayerInput =
+ | string
+ | CompiledCssSheet
+ | TrackedCssxSheet
+ | ResolveCssxLayer
+
+export interface CssxProviderProps {
+ value?: CssxReactConfig
+ style?: CssxProviderStyleInput
+ theme?: string
+ children?: ReactNode
+}
+
+export const CssxRuntimeContext = createContext(null)
+const useCommitEffect = typeof window === 'undefined'
+ ? useEffect
+ : useLayoutEffect
+const EMPTY_METADATA: CssxMetadata = {
+ hasVars: false,
+ vars: [],
+ hasMedia: false,
+ hasViewportUnits: false,
+ hasInterpolations: false,
+ hasDynamicRuntimeDependencies: false,
+ hasAnimations: false,
+ hasTransitions: false,
+ hasThemes: false,
+ hasCustomMedia: false
+}
+const EMPTY_TRACKING_SHEET: CompiledCssSheet = {
+ version: 1,
+ id: 'cssx_theme_tracker',
+ contentHash: 'cssx_theme_tracker',
+ rules: [],
+ keyframes: {},
+ metadata: EMPTY_METADATA,
+ diagnostics: []
+}
+const DYNAMIC_ROOT_SLOT_RE = /var\(\s*--__cssx_dynamic_(\d+)\s*\)/g
+
+export function configureCssx (config: CssxReactConfig): void {
+ setRuntimeConfig(config)
+}
+
+export function CssxProvider (props: CssxProviderProps): ReactNode {
+ const parent = useContext(CssxRuntimeContext) ?? getDefaultCssxRuntimeContext()
+ const providerStyles = useMemo(
+ () => normalizeProviderStyles(props.style),
+ [props.style]
+ )
+ const layers = useMemo(
+ () => parent.layers.concat(providerStyles.layers),
+ [parent.layers, providerStyles.layers]
+ )
+ const themeNames = useMemo(
+ () => mergeThemeNames(parent.themeNames, providerStyles.themeNames),
+ [parent.themeNames, providerStyles.themeNames]
+ )
+ const themePreference = props.theme ?? parent.themePreference
+ const colorSchemeVersion = useAutoThemeColorSchemeVersion(themePreference)
+ const theme = useMemo(
+ () => resolveProviderTheme(themePreference, themeNames),
+ [themePreference, themeNames, colorSchemeVersion]
+ )
+ const scopedVariables = useMemo(() => {
+ const scopes = [...parent.scopedVariables]
+ collectProviderRootVariables(providerStyles.layers, scopes, theme)
+ return scopes
+ }, [parent.scopedVariables, providerStyles.layers, theme])
+ const customMedia = useMemo(
+ () => ({
+ ...parent.customMedia,
+ ...providerStyles.customMedia
+ }),
+ [parent.customMedia, providerStyles.customMedia]
+ )
+ const value = useMemo(() => ({
+ config: {
+ ...parent.config,
+ ...(props.value ?? {})
+ },
+ layers,
+ scopedVariables,
+ customMedia,
+ componentTag: parent.componentTag,
+ theme,
+ themePreference,
+ themeNames
+ }), [parent.config, parent.componentTag, props.value, layers, scopedVariables, customMedia, theme, themePreference, themeNames])
+
+ return createElement(CssxRuntimeContext.Provider, {
+ value
+ }, props.children)
+}
+
+export function useCssxConfig (): CssxReactConfig {
+ return useCssxRuntimeContext().config
+}
+
+export function useCssxRuntimeContext (): CssxRuntimeContextValue {
+ return useContext(CssxRuntimeContext) ?? getDefaultCssxRuntimeContext()
+}
+
+export function useCssxComponentTag (): string | null {
+ return useCssxRuntimeContext().componentTag
+}
+
+export function themed> (
+ componentTag: string,
+ Component: C
+): C {
+ function ThemedComponent (props: ComponentProps): ReactNode {
+ const parent = useCssxRuntimeContext()
+ const tracker = useCssxRenderTracker(parent.config)
+ const value = useMemo(() => ({
+ ...parent,
+ layers: parent.layers.concat(tracker),
+ componentTag
+ }), [parent, tracker])
+
+ return createElement(
+ CssxRuntimeContext.Provider,
+ { value },
+ createElement(Component, props)
+ )
+ }
+
+ ThemedComponent.displayName = `themed(${Component.displayName ?? Component.name ?? componentTag})`
+ return ThemedComponent as unknown as C
+}
+
+function useCssxRenderTracker (options: CssxReactConfig): TrackedCssxSheet {
+ const trackerRef = useRef(null)
+ const committedTracker = trackerRef.current
+ const tracker = committedTracker?.matches(EMPTY_TRACKING_SHEET, options)
+ ? committedTracker
+ : new TrackedCssxSheet(EMPTY_TRACKING_SHEET, options)
+ const renderDependencies = tracker.startRender()
+
+ useSyncExternalStore(
+ tracker.subscribe,
+ tracker.getSnapshot,
+ tracker.getServerSnapshot
+ )
+
+ useCommitEffect(() => {
+ tracker.commitRender(renderDependencies)
+ trackerRef.current = tracker
+ })
+
+ return tracker
+}
+
+export function getDefaultCssxRuntimeContext (): CssxRuntimeContextValue {
+ return {
+ config: getRuntimeConfig(),
+ layers: [],
+ scopedVariables: [],
+ customMedia: {},
+ componentTag: null,
+ theme: 'default',
+ themePreference: 'auto',
+ themeNames: []
+ }
+}
+
+function normalizeProviderStyles (
+ style: CssxProviderStyleInput
+): { layers: CssxRuntimeLayerInput[], themeNames: string[], customMedia: Record } {
+ const layers: CssxRuntimeLayerInput[] = []
+ const themeNames = new Set()
+ const customMedia: Record = {}
+
+ collectProviderStyle(style, layers, themeNames, customMedia)
+
+ return {
+ layers,
+ themeNames: Array.from(themeNames).sort(),
+ customMedia
+ }
+}
+
+function collectProviderStyle (
+ input: CssxProviderStyleInput,
+ layers: CssxRuntimeLayerInput[],
+ themeNames: Set,
+ customMedia: Record
+): void {
+ if (!input) return
+
+ if (Array.isArray(input)) {
+ for (const item of input) collectProviderStyle(item, layers, themeNames, customMedia)
+ return
+ }
+
+ if (typeof input === 'string') {
+ const sheet = compileCss(input, { mode: 'runtime' })
+ layers.push(sheet)
+ collectThemeNames(sheet, themeNames)
+ collectCustomMedia(sheet, customMedia)
+ return
+ }
+
+ if (isTrackedCssxSheet(input)) {
+ const sheet = input.getSheet()
+ layers.push({ sheet, cacheKey: input })
+ collectThemeNames(sheet, themeNames)
+ collectCustomMedia(sheet, customMedia)
+ return
+ }
+
+ if (isCompiledSheet(input)) {
+ layers.push(input)
+ collectThemeNames(input, themeNames)
+ collectCustomMedia(input, customMedia)
+ return
+ }
+
+ if (isProviderStyleLayer(input)) {
+ const layer = normalizeProviderStyleLayer(input)
+ layers.push(layer)
+ const sheet = typeof layer.sheet === 'string'
+ ? compileCss(layer.sheet, { mode: 'runtime' })
+ : layer.sheet
+ collectThemeNames(sheet, themeNames)
+ collectCustomMedia(sheet, customMedia)
+ }
+}
+
+function normalizeProviderStyleLayer (
+ input: CssxProviderStyleLayer
+): ResolveCssxLayer {
+ if (typeof input.sheet === 'string') {
+ return {
+ sheet: compileCss(input.sheet, { mode: 'runtime' }),
+ values: input.values,
+ cacheKey: input.cacheKey
+ }
+ }
+
+ if (isTrackedCssxSheet(input.sheet)) {
+ return {
+ sheet: input.sheet.getSheet(),
+ values: input.values,
+ cacheKey: input.cacheKey ?? input.sheet
+ }
+ }
+
+ return {
+ sheet: input.sheet,
+ values: input.values,
+ cacheKey: input.cacheKey
+ }
+}
+
+function collectProviderRootVariables (
+ layers: readonly CssxRuntimeLayerInput[],
+ scopedVariables: Record[],
+ theme: string
+): void {
+ for (const input of layers) {
+ const layer = normalizeRuntimeLayer(input)
+ if (layer == null) continue
+
+ if (layer.sheet.rootVariables != null) {
+ scopedVariables.push(applyLayerValuesToRootVariables(layer.sheet.rootVariables, layer.values))
+ }
+
+ const themeRootVariables = getThemeVariables(layer.sheet, theme)
+ if (themeRootVariables != null) {
+ scopedVariables.push(applyLayerValuesToRootVariables(themeRootVariables, layer.values))
+ }
+ }
+}
+
+function normalizeRuntimeLayer (
+ input: CssxRuntimeLayerInput
+): { sheet: CompiledCssSheet, values: readonly unknown[] } | null {
+ if (typeof input === 'string') {
+ return { sheet: compileCss(input, { mode: 'runtime' }), values: [] }
+ }
+
+ if (isTrackedCssxSheet(input)) {
+ return {
+ sheet: input.getSheet(),
+ values: input.getOptions().values ?? []
+ }
+ }
+
+ if (isCompiledSheet(input)) {
+ return { sheet: input, values: [] }
+ }
+
+ const sheet = typeof input.sheet === 'string'
+ ? compileCss(input.sheet, { mode: 'runtime' })
+ : input.sheet
+
+ return {
+ sheet,
+ values: input.values ?? []
+ }
+}
+
+function collectThemeNames (
+ sheet: CompiledCssSheet,
+ themeNames: Set
+): void {
+ if (sheet.themeVariables == null) return
+ for (const name of Object.keys(sheet.themeVariables)) themeNames.add(name)
+}
+
+function collectCustomMedia (
+ sheet: CompiledCssSheet,
+ customMedia: Record
+): void {
+ if (sheet.customMedia == null) return
+ Object.assign(customMedia, sheet.customMedia)
+}
+
+function getThemeVariables (
+ sheet: CompiledCssSheet,
+ theme: string
+): Record | undefined {
+ if (sheet.themeVariables == null) return undefined
+ if (theme === 'light') return sheet.themeVariables.light ?? sheet.themeVariables.default
+ if (theme === 'default') return sheet.themeVariables.default
+ return sheet.themeVariables[theme]
+}
+
+function mergeThemeNames (
+ parentNames: readonly string[],
+ providerNames: readonly string[]
+): string[] {
+ if (parentNames.length === 0) return [...providerNames]
+ if (providerNames.length === 0) return [...parentNames]
+ return Array.from(new Set([...parentNames, ...providerNames])).sort()
+}
+
+function useAutoThemeColorSchemeVersion (themePreference: string): number {
+ const shouldSubscribe = themePreference === 'auto'
+ return useSyncExternalStore(
+ shouldSubscribe ? subscribeColorScheme : noopSubscribe,
+ shouldSubscribe ? getColorSchemeVersion : zeroSnapshot,
+ zeroSnapshot
+ )
+}
+
+function resolveProviderTheme (
+ themePreference: string,
+ themeNames: readonly string[]
+): string {
+ const themeSet = new Set(themeNames)
+
+ if (themePreference === 'auto') {
+ return getColorScheme() === 'dark' && themeSet.has('dark')
+ ? 'dark'
+ : 'default'
+ }
+
+ if (themePreference === 'light') {
+ return themeSet.has('light') ? 'light' : 'default'
+ }
+
+ return themePreference || 'default'
+}
+
+function noopSubscribe (): () => void {
+ return noop
+}
+
+function zeroSnapshot (): number {
+ return 0
+}
+
+function noop (): void {}
+
+function applyLayerValuesToRootVariables (
+ rootVariables: Record,
+ values: readonly unknown[]
+): Record {
+ if (values.length === 0) return rootVariables
+
+ const output: Record = {}
+ for (const name of Object.keys(rootVariables)) {
+ const value = rootVariables[name]
+ let valid = true
+ const next = value.replace(DYNAMIC_ROOT_SLOT_RE, (_match, rawIndex: string) => {
+ const interpolation = values[Number(rawIndex)]
+ if (typeof interpolation === 'string') return interpolation
+ if (typeof interpolation === 'number') return String(interpolation)
+ valid = false
+ return ''
+ })
+ if (valid) output[name] = next
+ }
+ return output
+}
+
+function isProviderStyleLayer (value: unknown): value is CssxProviderStyleLayer {
+ return Boolean(
+ value &&
+ typeof value === 'object' &&
+ 'sheet' in value &&
+ !isCompiledSheet(value) &&
+ !isTrackedCssxSheet(value)
+ )
+}
+
+function isCompiledSheet (value: unknown): value is CompiledCssSheet {
+ return Boolean(
+ value &&
+ typeof value === 'object' &&
+ (value as { version?: unknown }).version === 1 &&
+ Array.isArray((value as { rules?: unknown }).rules)
+ )
+}
diff --git a/packages/css-to-rn/src/react/cssx.ts b/packages/css-to-rn/src/react/cssx.ts
new file mode 100644
index 0000000..4c45143
--- /dev/null
+++ b/packages/css-to-rn/src/react/cssx.ts
@@ -0,0 +1,252 @@
+import { use } from 'react'
+import type { CompiledCssSheet, CssxTarget } from '../types.ts'
+import {
+ clearCssxRuntimeCachesForTests,
+ resolveCssx,
+ type CssxCache,
+ type CssxLayerInput,
+ type InlineStyleInput,
+ type ResolvedStyleProps,
+ type ResolveCssxLayer,
+ type StyleNameValue
+} from '../resolve.ts'
+import {
+ CssxRuntimeContext,
+ getDefaultCssxRuntimeContext
+} from './config.ts'
+import {
+ evaluateMediaQuery,
+ getMediaQueryEvaluator,
+ getDefaultVariableValues,
+ getDimensions,
+ getDimensionsVersion,
+ getVariableValues,
+ getVariableVersion,
+ type CssxDependencyCollector
+} from './store.ts'
+import {
+ isTrackedCssxSheet,
+ type TrackedCssxSheet
+} from './tracker.ts'
+
+export type CssxStyleName = StyleNameValue
+export type CssxResolvedProps = ResolvedStyleProps
+
+export interface CssxRuntimeOptions {
+ target?: CssxTarget
+ values?: readonly unknown[]
+ cache?: boolean | CssxCache
+ componentTag?: string | null
+}
+
+export type CssxSheetInput =
+ | string
+ | CompiledCssSheet
+ | TrackedCssxSheet
+ | CssxReactLayer
+ | CssxOpaqueSheetRecord
+ | readonly CssxSheetInput[]
+
+export type CssxOpaqueSheetRecord = Record
+
+export interface CssxReactLayer {
+ sheet: string | CompiledCssSheet | TrackedCssxSheet
+ values?: readonly unknown[]
+ cacheKey?: unknown
+}
+
+interface NormalizedReactLayers {
+ layers: CssxLayerInput | CssxLayerInput[]
+ collectors: CssxDependencyCollector[]
+ cache?: boolean | CssxCache
+ target?: CssxTarget
+}
+
+export function cssx (
+ styleName: CssxStyleName,
+ sheetInput: CssxSheetInput,
+ inlineStyleProps?: InlineStyleInput,
+ options: CssxRuntimeOptions = {}
+): CssxResolvedProps {
+ const runtimeContext = readRuntimeContext()
+ const normalized = normalizeSheetInput([
+ runtimeContext.layers,
+ sheetInput
+ ], options)
+ const result = resolveCssx({
+ styleName,
+ layers: normalized.layers,
+ inlineStyleProps,
+ target: options.target ?? normalized.target ?? 'react-native',
+ componentTag: options.componentTag ?? runtimeContext.componentTag,
+ variables: getVariableValues(),
+ scopedVariables: runtimeContext.scopedVariables,
+ defaultVariables: getDefaultVariableValues(),
+ dimensions: getDimensions(),
+ mediaQueryEvaluator: getMediaQueryEvaluator(),
+ theme: runtimeContext.theme,
+ cache: options.cache ?? normalized.cache
+ })
+
+ for (const collector of normalized.collectors) {
+ recordDependencies(collector, result)
+ }
+
+ return result.props
+}
+
+function readRuntimeContext () {
+ try {
+ return use(CssxRuntimeContext) ?? getDefaultCssxRuntimeContext()
+ } catch {
+ return getDefaultCssxRuntimeContext()
+ }
+}
+
+export function clearRawCssCacheForTests (): void {
+ clearCssxRuntimeCachesForTests()
+}
+
+function normalizeSheetInput (
+ input: CssxSheetInput,
+ options: CssxRuntimeOptions
+): NormalizedReactLayers {
+ const rawLayers = Array.isArray(input) ? input : [input]
+ const layers: CssxLayerInput[] = []
+ const collectors: CssxDependencyCollector[] = []
+ let cache: boolean | CssxCache | undefined
+ let target: CssxTarget | undefined
+
+ for (const rawLayer of rawLayers) {
+ const normalized = normalizeLayer(rawLayer, options)
+ if (Array.isArray(normalized.layers)) layers.push(...normalized.layers)
+ else layers.push(normalized.layers)
+ collectors.push(...normalized.collectors)
+ cache ??= normalized.cache
+ target ??= normalized.target
+ }
+
+ return {
+ layers,
+ collectors,
+ cache,
+ target
+ }
+}
+
+function normalizeLayer (
+ input: CssxSheetInput,
+ options: CssxRuntimeOptions
+): NormalizedReactLayers {
+ if (Array.isArray(input)) return normalizeSheetInput(input, options)
+
+ if (isTrackedCssxSheet(input)) {
+ const trackerOptions = input.getOptions()
+ const layer: ResolveCssxLayer = {
+ sheet: input.getSheet(),
+ values: options.values ?? trackerOptions.values ?? [],
+ cacheKey: input
+ }
+
+ return {
+ layers: layer,
+ collectors: [input],
+ cache: options.cache ?? input.getCache(),
+ target: options.target ?? trackerOptions.target
+ }
+ }
+
+ if (isReactLayer(input)) {
+ const nested = normalizeLayer(input.sheet, options)
+ const baseLayers = Array.isArray(nested.layers)
+ ? nested.layers
+ : [nested.layers]
+ const layers = baseLayers.map(layer => {
+ if (typeof layer === 'string') {
+ return {
+ sheet: layer,
+ values: input.values ?? options.values ?? [],
+ cacheKey: input.cacheKey
+ }
+ }
+ if ('sheet' in layer) {
+ return {
+ ...layer,
+ values: input.values ?? layer.values ?? options.values ?? [],
+ cacheKey: input.cacheKey ?? layer.cacheKey
+ }
+ }
+ return {
+ sheet: layer,
+ values: input.values ?? options.values ?? [],
+ cacheKey: input.cacheKey
+ }
+ })
+
+ return {
+ ...nested,
+ layers
+ }
+ }
+
+ if (typeof input === 'string') {
+ return {
+ layers: input,
+ collectors: [],
+ cache: options.cache
+ }
+ }
+
+ if (isCompiledSheet(input)) {
+ return {
+ layers: {
+ sheet: input,
+ values: options.values ?? []
+ },
+ collectors: [],
+ cache: options.cache
+ }
+ }
+
+ return {
+ layers: [],
+ collectors: [],
+ cache: options.cache
+ }
+}
+
+function isReactLayer (value: unknown): value is CssxReactLayer {
+ return Boolean(
+ value &&
+ typeof value === 'object' &&
+ 'sheet' in value &&
+ !isTrackedCssxSheet(value) &&
+ !isCompiledSheet(value)
+ )
+}
+
+function isCompiledSheet (value: unknown): value is CompiledCssSheet {
+ return Boolean(
+ value &&
+ typeof value === 'object' &&
+ (value as { version?: unknown }).version === 1 &&
+ Array.isArray((value as { rules?: unknown }).rules)
+ )
+}
+
+function recordDependencies (
+ collector: CssxDependencyCollector,
+ result: { dependencies: { vars: string[], dimensions: boolean, media: string[], mediaMatches?: Record } }
+): void {
+ for (const name of result.dependencies.vars) {
+ collector.recordVariable(name, getVariableVersion(name))
+ }
+
+ if (result.dependencies.dimensions) {
+ collector.recordDimensions(getDimensionsVersion())
+ }
+
+ for (const query of result.dependencies.media) {
+ collector.recordMedia(query, result.dependencies.mediaMatches?.[query] ?? evaluateMediaQuery(query))
+ }
+}
diff --git a/packages/css-to-rn/src/react/hooks.ts b/packages/css-to-rn/src/react/hooks.ts
new file mode 100644
index 0000000..33f82fe
--- /dev/null
+++ b/packages/css-to-rn/src/react/hooks.ts
@@ -0,0 +1,586 @@
+import {
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useSyncExternalStore
+} from 'react'
+import { compileCss } from '../compiler.ts'
+import { isCssColor } from '../colors.ts'
+import { evaluateCssxMediaQuery } from '../resolve.ts'
+import type { CompiledCssSheet } from '../types.ts'
+import {
+ useCssxConfig,
+ useCssxRuntimeContext,
+ type CssxReactConfig
+} from './config.ts'
+import {
+ coerceCssValue,
+ resolveCssValue,
+ type ResolveCssValueResult
+} from '../values.ts'
+import {
+ createDependencySnapshot,
+ getDefaultVariableValues,
+ getDimensions,
+ getDimensionsVersion,
+ getMediaQueryEvaluator,
+ getRuntimeVersion,
+ getVariableValues,
+ getVariableVersion,
+ retainMediaQuery,
+ subscribeRuntimeStore,
+ type CssxDependencySnapshot
+} from './store.ts'
+import { TrackedCssxSheet } from './tracker.ts'
+
+const useCommitEffect = typeof window === 'undefined'
+ ? useEffect
+ : useLayoutEffect
+const CSS_VARIABLE_NAME_RE = /^--[A-Za-z0-9_-]+$/
+const CSS_COLOR_FUNCTION_RE = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(/i
+const CSS_COLOR_TOKEN_RE = /^[A-Za-z][A-Za-z0-9_-]*$/
+const DEFAULT_CUSTOM_MEDIA: Record = {
+ '--breakpoint-mobile': '(width < 48rem)',
+ '--breakpoint-tablet': '(width >= 48rem)',
+ '--breakpoint-desktop': '(width >= 64rem)',
+ '--breakpoint-wide': '(width >= 80rem)'
+}
+const EMPTY_METADATA = {
+ hasVars: false,
+ vars: [],
+ hasMedia: false,
+ hasViewportUnits: false,
+ hasInterpolations: false,
+ hasDynamicRuntimeDependencies: false,
+ hasAnimations: false,
+ hasTransitions: false,
+ hasThemes: false,
+ hasCustomMedia: false
+}
+const EMPTY_LAYER_SHEET: CompiledCssSheet = {
+ version: 1,
+ id: 'cssx_empty_layer',
+ contentHash: 'cssx_empty_layer',
+ rules: [],
+ keyframes: {},
+ metadata: EMPTY_METADATA,
+ diagnostics: []
+}
+
+export type CssxLayerHookInput =
+ | string
+ | CompiledCssSheet
+ | TrackedCssxSheet
+ | {
+ sheet: string | CompiledCssSheet | TrackedCssxSheet
+ values?: readonly unknown[]
+ }
+ | null
+ | undefined
+ | false
+
+export type CssxLayerHookOutput =
+ | string
+ | TrackedCssxSheet
+ | {
+ sheet: string | TrackedCssxSheet
+ values?: readonly unknown[]
+ }
+ | null
+ | undefined
+ | false
+
+export type CssColorMixInput =
+ | number
+ | string
+ | {
+ mix?: number | string
+ with?: string
+ }
+
+export function useCssxSheet (
+ sheet: CompiledCssSheet,
+ options: CssxReactConfig = {}
+): TrackedCssxSheet {
+ const context = useCssxConfig()
+ const trackerRef = useRef(null)
+ const mergedOptions = {
+ ...context,
+ ...options
+ }
+ const committedTracker = trackerRef.current
+ const tracker = committedTracker?.matches(sheet, mergedOptions)
+ ? committedTracker
+ : new TrackedCssxSheet(sheet, mergedOptions)
+ const renderDependencies = tracker.startRender()
+
+ useSyncExternalStore(
+ tracker.subscribe,
+ tracker.getSnapshot,
+ tracker.getServerSnapshot
+ )
+
+ useCommitEffect(() => {
+ tracker.commitRender(renderDependencies)
+ trackerRef.current = tracker
+ })
+
+ return tracker
+}
+
+export function useRuntimeCss (
+ input: string | CompiledCssSheet,
+ options: CssxReactConfig = {}
+): TrackedCssxSheet {
+ const context = useCssxConfig()
+ const target = options.target ?? context.target
+ const sheet = useMemo(() => {
+ if (typeof input !== 'string') return input
+ return compileCss(input, { target })
+ }, [input, target])
+
+ return useCssxSheet(sheet, options)
+}
+
+export function useCssxTemplate (
+ sheet: CompiledCssSheet,
+ values: readonly unknown[],
+ options: CssxReactConfig = {}
+): TrackedCssxSheet {
+ return useCssxSheet(sheet, {
+ ...options,
+ values
+ })
+}
+
+export function useCssxLayer (
+ input: CssxLayerHookInput,
+ options: CssxReactConfig = {}
+): CssxLayerHookOutput {
+ const context = useCssxConfig()
+ const target = options.target ?? context.target
+ const normalized = useMemo(
+ () => normalizeLayerHookInput(input, target),
+ [input, target]
+ )
+ const tracker = useCssxSheet(normalized.sheet, {
+ ...options,
+ values: normalized.values
+ })
+
+ switch (normalized.kind) {
+ case 'empty':
+ return input as null | undefined | false
+ case 'tracked':
+ return input as CssxLayerHookOutput
+ case 'layerTracked':
+ return input as CssxLayerHookOutput
+ case 'layerString': {
+ const layerInput = input as {
+ sheet: string | CompiledCssSheet | TrackedCssxSheet
+ values?: readonly unknown[]
+ }
+ return {
+ ...layerInput,
+ sheet: tracker
+ } as CssxLayerHookOutput
+ }
+ case 'compiled':
+ case 'string':
+ case 'layerCompiled':
+ return tracker
+ case 'unknown':
+ default:
+ return input as CssxLayerHookOutput
+ }
+}
+
+export function useCssVariableRaw (
+ name: string,
+ fallback?: unknown
+): string | undefined {
+ assertCssVariableName(name)
+ const context = useCssxRuntimeContext()
+ const committedDependenciesRef = useRef(createDependencySnapshot())
+ const result = resolveCssVariableRaw(name, fallback, context.scopedVariables)
+ const renderDependencies = createCssValueDependencySnapshot(result)
+
+ useSyncExternalStore(
+ listener => subscribeRuntimeStore(listener, () => committedDependenciesRef.current),
+ getRuntimeVersion,
+ getRuntimeVersion
+ )
+
+ useCommitEffect(() => {
+ committedDependenciesRef.current = renderDependencies
+ })
+
+ return result.value
+}
+
+export function useCssVariable (
+ name: string,
+ fallback?: unknown
+): unknown {
+ const value = useCssVariableRaw(name, fallback)
+ return value == null ? value : coerceCssValue(value)
+}
+
+export function useCssColor (
+ color: string,
+ mix?: CssColorMixInput
+): string | undefined {
+ const context = useCssxRuntimeContext()
+ const committedDependenciesRef = useRef(createDependencySnapshot())
+ const result = resolveCssColor(color, mix, context.scopedVariables)
+ const renderDependencies = createCssValueDependencySnapshot(result)
+
+ useSyncExternalStore(
+ listener => subscribeRuntimeStore(listener, () => committedDependenciesRef.current),
+ getRuntimeVersion,
+ getRuntimeVersion
+ )
+
+ useCommitEffect(() => {
+ committedDependenciesRef.current = renderDependencies
+ })
+
+ return result.value
+}
+
+export function getCssVariableRaw (
+ name: string,
+ fallback?: unknown
+): string | undefined {
+ assertCssVariableName(name)
+ return resolveCssVariableRaw(name, fallback).value
+}
+
+export function getCssVariable (
+ name: string,
+ fallback?: unknown
+): unknown {
+ const value = getCssVariableRaw(name, fallback)
+ return value == null ? value : coerceCssValue(value)
+}
+
+export function getCssColor (
+ color: string,
+ mix?: CssColorMixInput
+): string | undefined {
+ return resolveCssColor(color, mix).value
+}
+
+export function useMedia (): Record {
+ const context = useCssxRuntimeContext()
+ const committedDependenciesRef = useRef(createDependencySnapshot())
+ const mediaQueryReleasesRef = useRef void>>(new Map())
+ const media = {
+ ...DEFAULT_CUSTOM_MEDIA,
+ ...context.customMedia
+ }
+ const result = resolveMedia(media, context)
+ const renderDependencies = createMediaDependencySnapshot(result)
+
+ useSyncExternalStore(
+ listener => subscribeRuntimeStore(listener, () => committedDependenciesRef.current),
+ getRuntimeVersion,
+ getRuntimeVersion
+ )
+
+ useCommitEffect(() => {
+ committedDependenciesRef.current = renderDependencies
+ syncMediaQuerySubscriptions(mediaQueryReleasesRef.current, renderDependencies)
+ return () => {
+ releaseMediaQuerySubscriptions(mediaQueryReleasesRef.current)
+ }
+ })
+
+ return result.value
+}
+
+function isCompiledSheet (value: unknown): value is CompiledCssSheet {
+ return Boolean(
+ value &&
+ typeof value === 'object' &&
+ (value as { version?: unknown }).version === 1 &&
+ Array.isArray((value as { rules?: unknown }).rules)
+ )
+}
+
+function isLayerObject (value: unknown): value is {
+ sheet: string | CompiledCssSheet | TrackedCssxSheet
+ values?: readonly unknown[]
+} {
+ return Boolean(
+ value &&
+ typeof value === 'object' &&
+ 'sheet' in value
+ )
+}
+
+type NormalizedLayerHookInput =
+ | {
+ kind: 'empty' | 'unknown' | 'tracked' | 'layerTracked'
+ sheet: CompiledCssSheet
+ values?: readonly unknown[]
+ }
+ | {
+ kind: 'string' | 'compiled' | 'layerString' | 'layerCompiled'
+ sheet: CompiledCssSheet
+ values?: readonly unknown[]
+ }
+
+function normalizeLayerHookInput (
+ input: CssxLayerHookInput,
+ target: CssxReactConfig['target']
+): NormalizedLayerHookInput {
+ if (!input) {
+ return {
+ kind: 'empty',
+ sheet: EMPTY_LAYER_SHEET
+ }
+ }
+
+ if (typeof input === 'string') {
+ return {
+ kind: 'string',
+ sheet: compileCss(input, { target })
+ }
+ }
+
+ if (input instanceof TrackedCssxSheet) {
+ return {
+ kind: 'tracked',
+ sheet: EMPTY_LAYER_SHEET
+ }
+ }
+
+ if (isCompiledSheet(input)) {
+ return {
+ kind: 'compiled',
+ sheet: input
+ }
+ }
+
+ if (isLayerObject(input)) {
+ const sheet = input.sheet
+ if (typeof sheet === 'string') {
+ return {
+ kind: 'layerString',
+ sheet: compileCss(sheet, { target }),
+ values: input.values
+ }
+ }
+ if (sheet instanceof TrackedCssxSheet) {
+ return {
+ kind: 'layerTracked',
+ sheet: EMPTY_LAYER_SHEET
+ }
+ }
+ if (isCompiledSheet(sheet)) {
+ return {
+ kind: 'layerCompiled',
+ sheet,
+ values: input.values
+ }
+ }
+ }
+
+ return {
+ kind: 'unknown',
+ sheet: EMPTY_LAYER_SHEET
+ }
+}
+
+function resolveCssVariableRaw (
+ name: string,
+ fallback?: unknown,
+ scopedVariables?: readonly Record[]
+) {
+ const fallbackText = fallback == null ? '' : `, ${String(fallback)}`
+ return resolveCssValue(`var(${name}${fallbackText})`, {
+ variables: getVariableValues(),
+ scopedVariables,
+ defaultVariables: getDefaultVariableValues(),
+ dimensions: getDimensions()
+ })
+}
+
+function resolveCssColor (
+ color: string,
+ mix?: CssColorMixInput,
+ scopedVariables?: readonly Record[]
+): ResolveCssValueResult {
+ return resolveCssValue(createCssColorExpression(color, mix), {
+ variables: getVariableValues(),
+ scopedVariables,
+ defaultVariables: getDefaultVariableValues(),
+ dimensions: getDimensions()
+ })
+}
+
+function createCssValueDependencySnapshot (
+ result: ResolveCssValueResult
+): CssxDependencySnapshot {
+ const dependencies = createDependencySnapshot()
+ for (const name of result.dependencies.vars) {
+ dependencies.vars.set(name, getVariableVersion(name))
+ }
+ if (result.dependencies.dimensions) {
+ dependencies.dimensionsVersion = getDimensionsVersion()
+ }
+ return dependencies
+}
+
+function createCssColorExpression (
+ color: string,
+ mix?: CssColorMixInput
+): string {
+ const base = normalizeCssColorExpression(color)
+ const mixOptions = normalizeColorMix(mix)
+ if (mixOptions == null) return base
+
+ return `color-mix(in srgb, ${base} ${mixOptions.weight}, ${normalizeCssColorExpression(mixOptions.with)})`
+}
+
+function normalizeCssColorExpression (input: string): string {
+ const value = input.trim()
+ if (value === '') return value
+ if (/^var\(/i.test(value)) return value
+ if (value.startsWith('--')) {
+ throw new TypeError(`Ambiguous CSS color token "${input}". Use "var(${value})" or a semantic token such as "primary".`)
+ }
+ if (
+ CSS_COLOR_FUNCTION_RE.test(value) ||
+ isCssColor(value) ||
+ !CSS_COLOR_TOKEN_RE.test(value)
+ ) {
+ return value
+ }
+
+ return `var(--color-${value})`
+}
+
+function normalizeColorMix (
+ input: CssColorMixInput | undefined
+): { weight: string, with: string } | null {
+ if (input == null) return null
+ if (typeof input === 'number' || typeof input === 'string') {
+ return {
+ weight: normalizeMixWeight(input),
+ with: 'transparent'
+ }
+ }
+
+ if (input.mix == null) return null
+ return {
+ weight: normalizeMixWeight(input.mix),
+ with: input.with ?? 'transparent'
+ }
+}
+
+function normalizeMixWeight (input: number | string): string {
+ if (typeof input === 'string') {
+ const value = input.trim()
+ if (/^(?:\d+|\d*\.\d+)%$/.test(value)) return value
+ throw new TypeError(`Invalid CSS color mix weight "${input}". Expected a percentage string such as "15%".`)
+ }
+
+ if (!Number.isFinite(input) || input < 0 || input > 1) {
+ throw new TypeError(`Invalid CSS color mix weight "${input}". Expected a number from 0 to 1.`)
+ }
+
+ return `${input * 100}%`
+}
+
+function resolveMedia (
+ media: Record,
+ context: ReturnType
+): {
+ value: Record
+ dependencies: {
+ vars: string[]
+ dimensions: boolean
+ media: Record
+ }
+ } {
+ const value: Record = {}
+ const vars = new Set()
+ let dimensions = false
+ const mediaDependencies: Record = {}
+
+ for (const [name, query] of Object.entries(media)) {
+ const result = evaluateCssxMediaQuery(query, {
+ variables: getVariableValues(),
+ scopedVariables: context.scopedVariables,
+ defaultVariables: getDefaultVariableValues(),
+ customMedia: media,
+ dimensions: getDimensions(),
+ mediaQueryEvaluator: getMediaQueryEvaluator(),
+ theme: context.theme
+ })
+ value[normalizeMediaName(name)] = result.matches
+ for (const varName of result.dependencies.vars) vars.add(varName)
+ if (result.dependencies.dimensions) dimensions = true
+ mediaDependencies[query] = result.matches
+ }
+
+ return {
+ value,
+ dependencies: {
+ vars: Array.from(vars),
+ dimensions,
+ media: mediaDependencies
+ }
+ }
+}
+
+function createMediaDependencySnapshot (
+ result: ReturnType
+): CssxDependencySnapshot {
+ const dependencies = createDependencySnapshot()
+ for (const name of result.dependencies.vars) {
+ dependencies.vars.set(name, getVariableVersion(name))
+ }
+ dependencies.dimensionsVersion = getDimensionsVersion()
+ for (const [query, matches] of Object.entries(result.dependencies.media)) {
+ dependencies.media.set(query, matches)
+ }
+ return dependencies
+}
+
+function normalizeMediaName (name: string): string {
+ const trimmed = name.replace(/^--/, '').replace(/^breakpoint-/, '')
+ return trimmed.replace(/-([a-z0-9])/g, (_match, character: string) => character.toUpperCase())
+}
+
+function syncMediaQuerySubscriptions (
+ releases: Map void>,
+ dependencies: CssxDependencySnapshot
+): void {
+ const nextQueries = new Set(dependencies.media.keys())
+ for (const [query, release] of Array.from(releases)) {
+ if (nextQueries.has(query)) continue
+ release()
+ releases.delete(query)
+ }
+
+ for (const query of nextQueries) {
+ if (releases.has(query)) continue
+ releases.set(query, retainMediaQuery(query))
+ }
+}
+
+function releaseMediaQuerySubscriptions (
+ releases: Map void>
+): void {
+ for (const release of releases.values()) release()
+ releases.clear()
+}
+
+function assertCssVariableName (name: string): void {
+ if (CSS_VARIABLE_NAME_RE.test(name)) return
+ throw new TypeError(`Invalid CSS custom property name "${name}". CSSX variables must start with "--".`)
+}
diff --git a/packages/css-to-rn/src/react/index.ts b/packages/css-to-rn/src/react/index.ts
new file mode 100644
index 0000000..a07ee5e
--- /dev/null
+++ b/packages/css-to-rn/src/react/index.ts
@@ -0,0 +1,66 @@
+export {
+ cssx,
+ clearRawCssCacheForTests
+} from './cssx.ts'
+export {
+ CssxProvider,
+ configureCssx,
+ themed,
+ useCssxComponentTag,
+ useCssxConfig,
+ useCssxRuntimeContext
+} from './config.ts'
+export {
+ useCssxLayer,
+ getCssVariable,
+ getCssVariableRaw,
+ useCssVariable,
+ useCssVariableRaw,
+ useMedia,
+ useRuntimeCss,
+ useCssxSheet,
+ useCssxTemplate
+} from './hooks.ts'
+export {
+ TrackedCssxSheet,
+ createTrackedCssxSheet,
+ isTrackedCssxSheet
+} from './tracker.ts'
+export {
+ defaultVariables,
+ flushMicrotasksForTests,
+ configureColorSchemeAdapter,
+ getRuntimeSubscriberCountForTests,
+ resetStoreForTests,
+ setColorSchemeForTests,
+ setDefaultVariables,
+ setDimensionsForTests,
+ subscribeVariablesForTests,
+ variables
+} from './store.ts'
+
+export type {
+ CssxResolvedProps,
+ CssxRuntimeOptions,
+ CssxStyleName
+} from './cssx.ts'
+export type {
+ CssxProviderStyleInput,
+ CssxProviderStyleLayer,
+ CssxProviderProps,
+ CssxReactConfig,
+ CssxRuntimeContextValue
+} from './config.ts'
+export type {
+ CssxLayerHookInput,
+ CssxLayerHookOutput
+} from './hooks.ts'
+export type {
+ CssxDependencySnapshot,
+ CssxColorSchemeAdapter,
+ CssxVariableStore,
+ CssxRuntimeConfig
+} from './store.ts'
+export type {
+ TrackedCssxSheetOptions
+} from './tracker.ts'
diff --git a/packages/css-to-rn/src/react/store.ts b/packages/css-to-rn/src/react/store.ts
new file mode 100644
index 0000000..9b17b30
--- /dev/null
+++ b/packages/css-to-rn/src/react/store.ts
@@ -0,0 +1,699 @@
+import mediaQuery from 'css-mediaquery'
+
+export interface CssxRuntimeConfig {
+ dimensionsDebounceMs?: number
+}
+
+export interface CssxDimensionsSnapshot {
+ width: number
+ height: number
+}
+
+export interface CssxDimensionsAdapter {
+ get: () => CssxDimensionsSnapshot
+ subscribe: (listener: () => void) => () => void
+}
+
+export interface CssxMediaQueryAdapter {
+ evaluate: (query: string) => boolean
+ subscribe?: (query: string, listener: () => void) => () => void
+}
+
+export type CssxColorScheme = 'light' | 'dark'
+
+export interface CssxColorSchemeAdapter {
+ get: () => CssxColorScheme | null | undefined
+ subscribe: (listener: () => void) => () => void
+}
+
+export interface CssxDependencySnapshot {
+ vars: Map
+ media: Map
+ dimensionsVersion: number | null
+}
+
+export interface CssxDependencyCollector {
+ recordVariable: (name: string, version: number) => void
+ recordMedia: (query: string, matches: boolean) => void
+ recordDimensions: (version: number) => void
+}
+
+export interface RuntimeChangeSnapshot {
+ vars: readonly string[]
+ dimensions: boolean
+ media: boolean
+}
+
+export interface CssxVariableStore extends Record {
+ set: (next: Record) => void
+ assign: (next: Record) => void
+ clear: () => void
+}
+
+type RuntimeSubscriber = {
+ listener: (change: RuntimeChangeSnapshot) => void
+ getDependencies: () => CssxDependencySnapshot
+}
+
+const FALLBACK_DIMENSIONS = { width: 1024, height: 768 }
+const CSS_VARIABLE_NAME_RE = /^--[A-Za-z0-9_-]+$/
+
+const variableValues: Record = Object.create(null)
+const defaultVariableValues: Record = Object.create(null)
+const variableVersions = new Map()
+const runtimeSubscribers = new Set()
+const colorSchemeSubscribers = new Set<() => void>()
+const pendingVariableNames = new Set()
+const retainedMediaQueries = new Map void) | null
+}>()
+
+let runtimeConfig: Required = {
+ dimensionsDebounceMs: 0
+}
+let variableVersion = 0
+let dimensionsAdapter: CssxDimensionsAdapter | null = null
+let dimensionsAdapterUnsubscribe: (() => void) | null = null
+let mediaQueryAdapter: CssxMediaQueryAdapter | null = null
+let colorSchemeAdapter: CssxColorSchemeAdapter | null = null
+let colorSchemeAdapterUnsubscribe: (() => void) | null = null
+let colorScheme = readColorScheme()
+let colorSchemeVersion = 0
+let dimensions = readWindowDimensions()
+let dimensionsVersion = 0
+let pendingDimensionsChanged = false
+let pendingMediaChanged = false
+let notifyScheduled = false
+let resizeListener: (() => void) | null = null
+let resizeTimer: ReturnType | null = null
+
+export const variables = createVariableProxy(variableValues)
+export const defaultVariables = createVariableProxy(defaultVariableValues)
+
+export function setDefaultVariables (next: Record): void {
+ defaultVariables.set(next)
+}
+
+export function getVariableValues (): Record {
+ return variableValues
+}
+
+export function getDefaultVariableValues (): Record {
+ return defaultVariableValues
+}
+
+export function getVariableVersion (name: string): number {
+ return variableVersions.get(name) ?? 0
+}
+
+export function getRuntimeVersion (): number {
+ return variableVersion + dimensionsVersion
+}
+
+export function createDependencySnapshot (): CssxDependencySnapshot {
+ return {
+ vars: new Map(),
+ media: new Map(),
+ dimensionsVersion: null
+ }
+}
+
+export function getDimensions (): { width: number, height: number } {
+ return dimensions
+}
+
+export function getDimensionsVersion (): number {
+ return dimensionsVersion
+}
+
+export function setDimensionsForTests (next: { width: number, height: number }): void {
+ applyDimensions(next)
+}
+
+export function configureDimensionsAdapter (
+ adapter: CssxDimensionsAdapter | null
+): void {
+ if (dimensionsAdapter === adapter) return
+ removeWindowResizeListener()
+ dimensionsAdapter = adapter
+ refreshRetainedMediaQueryListeners()
+ applyDimensions(readWindowDimensions())
+ if (runtimeSubscribers.size > 0) ensureWindowResizeListener()
+}
+
+export function configureMediaQueryAdapter (
+ adapter: CssxMediaQueryAdapter | null
+): void {
+ if (mediaQueryAdapter === adapter) return
+ mediaQueryAdapter = adapter
+ refreshRetainedMediaQueryListeners()
+ markMediaChanged()
+}
+
+export function configureColorSchemeAdapter (
+ adapter: CssxColorSchemeAdapter | null
+): void {
+ if (colorSchemeAdapter === adapter) return
+ removeColorSchemeListener()
+ colorSchemeAdapter = adapter
+ applyColorScheme(readColorScheme())
+ if (colorSchemeSubscribers.size > 0) ensureColorSchemeListener()
+}
+
+export function getColorScheme (): CssxColorScheme {
+ return colorScheme
+}
+
+export function getColorSchemeVersion (): number {
+ return colorSchemeVersion
+}
+
+export function setColorSchemeForTests (next: CssxColorScheme): void {
+ applyColorScheme(next)
+}
+
+export function subscribeColorScheme (
+ listener: () => void
+): () => void {
+ colorSchemeSubscribers.add(listener)
+ ensureColorSchemeListener()
+
+ return () => {
+ colorSchemeSubscribers.delete(listener)
+ if (colorSchemeSubscribers.size === 0) removeColorSchemeListener()
+ }
+}
+
+export function getMediaQueryEvaluator (): (query: string) => boolean {
+ return query => evaluateMediaQuery(query)
+}
+
+export function evaluateMediaQuery (query: string): boolean {
+ const normalized = stripMediaPrefix(query)
+
+ if (mediaQueryAdapter != null) {
+ return mediaQueryAdapter.evaluate(normalized)
+ }
+
+ if (canUseBrowserMatchMedia()) {
+ return window.matchMedia(normalized).matches
+ }
+
+ try {
+ return mediaQuery.match(normalized, mediaValues(dimensions))
+ } catch {
+ return false
+ }
+}
+
+export function setRuntimeConfig (next: CssxRuntimeConfig): void {
+ runtimeConfig = {
+ ...runtimeConfig,
+ ...next
+ }
+}
+
+export function getRuntimeConfig (): Required {
+ return runtimeConfig
+}
+
+export function subscribeRuntimeStore (
+ listener: (change: RuntimeChangeSnapshot) => void,
+ getDependencies: () => CssxDependencySnapshot
+): () => void {
+ const subscriber = { listener, getDependencies }
+ runtimeSubscribers.add(subscriber)
+ ensureWindowResizeListener()
+
+ return () => {
+ runtimeSubscribers.delete(subscriber)
+ if (runtimeSubscribers.size === 0) removeWindowResizeListener()
+ }
+}
+
+export function retainMediaQuery (query: string): () => void {
+ const normalized = stripMediaPrefix(query)
+ let entry = retainedMediaQueries.get(normalized)
+
+ if (entry == null) {
+ entry = {
+ count: 0,
+ unsubscribe: subscribeToMediaQuery(normalized)
+ }
+ retainedMediaQueries.set(normalized, entry)
+ }
+
+ entry.count += 1
+
+ return () => {
+ const current = retainedMediaQueries.get(normalized)
+ if (current == null) return
+
+ current.count -= 1
+ if (current.count > 0) return
+
+ current.unsubscribe?.()
+ retainedMediaQueries.delete(normalized)
+ }
+}
+
+export function hasStaleDependencies (dependencies: CssxDependencySnapshot): boolean {
+ for (const [name, version] of dependencies.vars) {
+ if (getVariableVersion(name) !== version) return true
+ }
+
+ if (
+ dependencies.dimensionsVersion != null &&
+ dependencies.dimensionsVersion !== dimensionsVersion
+ ) {
+ return true
+ }
+
+ for (const [query, matches] of dependencies.media) {
+ if (evaluateMediaQuery(query) !== matches) return true
+ }
+
+ return false
+}
+
+export function subscribeVariablesForTests (
+ names: readonly string[],
+ listener: (changedNames: readonly string[]) => void
+): () => void {
+ const dependencies = createDependencySnapshot()
+ for (const name of names) {
+ dependencies.vars.set(name, getVariableVersion(name))
+ }
+ return subscribeRuntimeStore(
+ change => listener(change.vars),
+ () => dependencies
+ )
+}
+
+export function getRuntimeSubscriberCountForTests (): number {
+ return runtimeSubscribers.size
+}
+
+export async function flushMicrotasksForTests (): Promise {
+ await Promise.resolve()
+ await Promise.resolve()
+}
+
+export function resetStoreForTests (): void {
+ clearRecord(variableValues)
+ clearRecord(defaultVariableValues)
+ variableVersions.clear()
+ pendingVariableNames.clear()
+ variableVersion = 0
+ removeWindowResizeListener()
+ releaseAllRetainedMediaQueries()
+ dimensionsAdapter = null
+ mediaQueryAdapter = null
+ removeColorSchemeListener()
+ colorSchemeAdapter = null
+ colorScheme = 'light'
+ colorSchemeVersion = 0
+ colorSchemeSubscribers.clear()
+ dimensions = FALLBACK_DIMENSIONS
+ dimensionsVersion = 0
+ pendingDimensionsChanged = false
+ pendingMediaChanged = false
+ notifyScheduled = false
+ runtimeSubscribers.clear()
+}
+
+function createVariableProxy (target: Record): CssxVariableStore {
+ const methods = {
+ set (next: Record): void {
+ replaceVariables(target, next)
+ },
+ assign (next: Record): void {
+ assignVariables(target, next)
+ },
+ clear (): void {
+ replaceVariables(target, {})
+ }
+ }
+
+ return new Proxy(target, {
+ get (record, property, receiver) {
+ if (property === 'set') return methods.set
+ if (property === 'assign') return methods.assign
+ if (property === 'clear') return methods.clear
+ return Reflect.get(record, property, receiver)
+ },
+ has (record, property) {
+ return property === 'set' ||
+ property === 'assign' ||
+ property === 'clear' ||
+ Reflect.has(record, property)
+ },
+ set (record, property, value) {
+ if (typeof property !== 'string') {
+ return Reflect.set(record, property, value)
+ }
+ assertCssVariableName(property)
+ if (Object.is(record[property], value)) return true
+ record[property] = value
+ markVariablesChanged([property])
+ return true
+ },
+ deleteProperty (record, property) {
+ if (typeof property !== 'string') {
+ return Reflect.deleteProperty(record, property)
+ }
+ assertCssVariableName(property)
+ if (!Object.prototype.hasOwnProperty.call(record, property)) return true
+ delete record[property]
+ markVariablesChanged([property])
+ return true
+ }
+ }) as CssxVariableStore
+}
+
+function replaceVariables (
+ target: Record,
+ next: Record
+): void {
+ const changed = new Set()
+ for (const name of Object.keys(next)) assertCssVariableName(name)
+
+ for (const name of Object.keys(target)) {
+ if (!Object.prototype.hasOwnProperty.call(next, name)) {
+ delete target[name]
+ changed.add(name)
+ }
+ }
+
+ for (const [name, value] of Object.entries(next)) {
+ if (Object.is(target[name], value)) continue
+ target[name] = value
+ changed.add(name)
+ }
+
+ markVariablesChanged(Array.from(changed))
+}
+
+function assignVariables (
+ target: Record,
+ next: Record
+): void {
+ const changed = new Set()
+
+ for (const [name, value] of Object.entries(next)) {
+ assertCssVariableName(name)
+ if (Object.is(target[name], value)) continue
+ target[name] = value
+ changed.add(name)
+ }
+
+ markVariablesChanged(Array.from(changed))
+}
+
+function assertCssVariableName (name: string): void {
+ if (CSS_VARIABLE_NAME_RE.test(name)) return
+ throw new TypeError(`Invalid CSS custom property name "${name}". CSSX variables must start with "--".`)
+}
+
+function markVariablesChanged (names: readonly string[]): void {
+ if (names.length === 0) return
+
+ for (const name of names) {
+ variableVersion += 1
+ variableVersions.set(name, variableVersion)
+ pendingVariableNames.add(name)
+ }
+
+ scheduleNotification()
+}
+
+function applyDimensions (next: { width: number, height: number }): void {
+ if (
+ Object.is(dimensions.width, next.width) &&
+ Object.is(dimensions.height, next.height)
+ ) {
+ return
+ }
+
+ dimensions = next
+ dimensionsVersion += 1
+ pendingDimensionsChanged = true
+ scheduleNotification()
+}
+
+function applyColorScheme (next: CssxColorScheme | null | undefined): void {
+ const normalized = next === 'dark' ? 'dark' : 'light'
+ if (colorScheme === normalized) return
+ colorScheme = normalized
+ colorSchemeVersion += 1
+ for (const listener of Array.from(colorSchemeSubscribers)) {
+ listener()
+ }
+}
+
+function markMediaChanged (): void {
+ pendingMediaChanged = true
+ scheduleNotification()
+}
+
+function scheduleNotification (): void {
+ if (notifyScheduled) return
+ notifyScheduled = true
+
+ queueMicrotask(() => {
+ notifyScheduled = false
+ flushNotifications()
+ })
+}
+
+function flushNotifications (): void {
+ const vars = Array.from(pendingVariableNames)
+ const dimensionsChanged = pendingDimensionsChanged
+ const mediaChanged = pendingMediaChanged
+
+ pendingVariableNames.clear()
+ pendingDimensionsChanged = false
+ pendingMediaChanged = false
+
+ if (vars.length === 0 && !dimensionsChanged && !mediaChanged) return
+
+ const change = {
+ vars,
+ dimensions: dimensionsChanged,
+ media: mediaChanged
+ }
+
+ for (const subscriber of Array.from(runtimeSubscribers)) {
+ if (shouldNotifySubscriber(subscriber.getDependencies(), change)) {
+ subscriber.listener(change)
+ }
+ }
+}
+
+function shouldNotifySubscriber (
+ dependencies: CssxDependencySnapshot,
+ change: RuntimeChangeSnapshot
+): boolean {
+ for (const name of change.vars) {
+ if (dependencies.vars.has(name)) return true
+ }
+
+ if (change.dimensions && dependencies.dimensionsVersion != null) return true
+
+ if (change.dimensions || change.media) {
+ for (const [query, matches] of dependencies.media) {
+ if (evaluateMediaQuery(query) !== matches) return true
+ }
+ }
+
+ return false
+}
+
+function refreshRetainedMediaQueryListeners (): void {
+ for (const entry of retainedMediaQueries.values()) {
+ entry.unsubscribe?.()
+ entry.unsubscribe = null
+ }
+
+ for (const [query, entry] of retainedMediaQueries) {
+ if (entry.count > 0) entry.unsubscribe = subscribeToMediaQuery(query)
+ }
+}
+
+function releaseAllRetainedMediaQueries (): void {
+ for (const entry of retainedMediaQueries.values()) {
+ entry.unsubscribe?.()
+ }
+ retainedMediaQueries.clear()
+}
+
+function subscribeToMediaQuery (query: string): (() => void) | null {
+ if (mediaQueryAdapter?.subscribe != null) {
+ return mediaQueryAdapter.subscribe(query, markMediaChanged)
+ }
+
+ if (!canUseBrowserMatchMedia()) return null
+
+ const media = window.matchMedia(query)
+ const listener = () => {
+ markMediaChanged()
+ }
+
+ if (typeof media.addEventListener === 'function') {
+ media.addEventListener('change', listener)
+ return () => {
+ media.removeEventListener('change', listener)
+ }
+ }
+
+ media.addListener(listener)
+ return () => {
+ media.removeListener(listener)
+ }
+}
+
+function ensureWindowResizeListener (): void {
+ if (dimensionsAdapter != null) {
+ if (dimensionsAdapterUnsubscribe != null) return
+ dimensionsAdapterUnsubscribe = dimensionsAdapter.subscribe(() => {
+ applyDimensions(readWindowDimensions())
+ })
+ applyDimensions(readWindowDimensions())
+ return
+ }
+
+ if (resizeListener != null || typeof window === 'undefined') return
+
+ resizeListener = () => {
+ const hasPendingTrailingUpdate = resizeTimer != null
+ if (resizeTimer != null) clearTimeout(resizeTimer)
+
+ const delay = runtimeConfig.dimensionsDebounceMs
+ if (delay <= 0) {
+ applyDimensions(readWindowDimensions())
+ return
+ }
+
+ if (!hasPendingTrailingUpdate) {
+ applyDimensions(readWindowDimensions())
+ }
+
+ resizeTimer = setTimeout(() => {
+ resizeTimer = null
+ applyDimensions(readWindowDimensions())
+ }, delay)
+ }
+
+ window.addEventListener('resize', resizeListener)
+ applyDimensions(readWindowDimensions())
+}
+
+function removeWindowResizeListener (): void {
+ if (resizeTimer != null) {
+ clearTimeout(resizeTimer)
+ resizeTimer = null
+ }
+
+ if (dimensionsAdapterUnsubscribe != null) {
+ dimensionsAdapterUnsubscribe()
+ dimensionsAdapterUnsubscribe = null
+ }
+
+ if (resizeListener == null || typeof window === 'undefined') {
+ resizeListener = null
+ return
+ }
+
+ window.removeEventListener('resize', resizeListener)
+ resizeListener = null
+}
+
+function readWindowDimensions (): { width: number, height: number } {
+ if (dimensionsAdapter != null) return dimensionsAdapter.get()
+
+ if (typeof window === 'undefined') return FALLBACK_DIMENSIONS
+
+ return {
+ width: window.innerWidth || FALLBACK_DIMENSIONS.width,
+ height: window.innerHeight || FALLBACK_DIMENSIONS.height
+ }
+}
+
+function readColorScheme (): CssxColorScheme {
+ if (colorSchemeAdapter != null) {
+ return colorSchemeAdapter.get() === 'dark' ? 'dark' : 'light'
+ }
+
+ if (canUseBrowserMatchMedia() && window.matchMedia('(prefers-color-scheme: dark)').matches) {
+ return 'dark'
+ }
+
+ return 'light'
+}
+
+function ensureColorSchemeListener (): void {
+ if (colorSchemeAdapter != null) {
+ if (colorSchemeAdapterUnsubscribe != null) return
+ colorSchemeAdapterUnsubscribe = colorSchemeAdapter.subscribe(() => {
+ applyColorScheme(readColorScheme())
+ })
+ applyColorScheme(readColorScheme())
+ return
+ }
+
+ if (!canUseBrowserMatchMedia() || colorSchemeAdapterUnsubscribe != null) return
+
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
+ const listener = () => {
+ applyColorScheme(readColorScheme())
+ }
+
+ if (typeof media.addEventListener === 'function') {
+ media.addEventListener('change', listener)
+ colorSchemeAdapterUnsubscribe = () => {
+ media.removeEventListener('change', listener)
+ }
+ return
+ }
+
+ media.addListener(listener)
+ colorSchemeAdapterUnsubscribe = () => {
+ media.removeListener(listener)
+ }
+}
+
+function removeColorSchemeListener (): void {
+ if (colorSchemeAdapterUnsubscribe == null) return
+ colorSchemeAdapterUnsubscribe()
+ colorSchemeAdapterUnsubscribe = null
+}
+
+function canUseBrowserMatchMedia (): boolean {
+ return (
+ dimensionsAdapter == null &&
+ typeof window !== 'undefined' &&
+ typeof window.matchMedia === 'function'
+ )
+}
+
+function stripMediaPrefix (query: string): string {
+ return query.trim().replace(/^@media\s+/i, '').trim()
+}
+
+function mediaValues (next: { width: number, height: number }): Record {
+ return {
+ type: 'screen',
+ width: `${next.width}px`,
+ height: `${next.height}px`,
+ 'device-width': `${next.width}px`,
+ 'device-height': `${next.height}px`,
+ orientation: next.width >= next.height ? 'landscape' : 'portrait'
+ }
+}
+
+function clearRecord (record: Record): void {
+ for (const key of Object.keys(record)) {
+ delete record[key]
+ }
+}
diff --git a/packages/css-to-rn/src/react/tracker.ts b/packages/css-to-rn/src/react/tracker.ts
new file mode 100644
index 0000000..69bc48b
--- /dev/null
+++ b/packages/css-to-rn/src/react/tracker.ts
@@ -0,0 +1,233 @@
+import type { CompiledCssSheet } from '../types.ts'
+import {
+ createCssxCache,
+ type CssxCache
+} from '../resolve.ts'
+import {
+ createDependencySnapshot,
+ hasStaleDependencies,
+ retainMediaQuery,
+ subscribeRuntimeStore,
+ type CssxDependencyCollector,
+ type CssxDependencySnapshot,
+ type RuntimeChangeSnapshot
+} from './store.ts'
+
+const TRACKED_SHEET = Symbol.for('cssx.trackedSheet')
+
+export interface TrackedCssxSheetOptions {
+ target?: 'react-native' | 'web'
+ values?: readonly unknown[]
+ cacheMaxEntries?: number
+}
+
+export class TrackedCssxSheet implements CssxDependencyCollector {
+ readonly [TRACKED_SHEET] = true
+
+ private sheet: CompiledCssSheet
+ private options: TrackedCssxSheetOptions
+ private pendingDependencies: CssxDependencySnapshot | null = null
+ private committedDependencies = createDependencySnapshot()
+ private listeners = new Set<() => void>()
+ private unsubscribeRuntimeStore: (() => void) | null = null
+ private mediaQueryReleases = new Map void>()
+ private snapshotVersion = 0
+ private cache: CssxCache
+
+ constructor (sheet: CompiledCssSheet, options: TrackedCssxSheetOptions = {}) {
+ this.sheet = sheet
+ this.options = options
+ this.cache = createCssxCache({ maxEntries: options.cacheMaxEntries })
+ }
+
+ getSheet (): CompiledCssSheet {
+ return this.sheet
+ }
+
+ getOptions (): TrackedCssxSheetOptions {
+ return this.options
+ }
+
+ getCache (): CssxCache {
+ return this.cache
+ }
+
+ matches (sheet: CompiledCssSheet, options: TrackedCssxSheetOptions = {}): boolean {
+ return this.sheet === sheet && sameOptions(this.options, options)
+ }
+
+ update (sheet: CompiledCssSheet, options: TrackedCssxSheetOptions = {}): void {
+ this.sheet = sheet
+ this.options = options
+ if (options.cacheMaxEntries !== this.cache.maxEntries) {
+ this.cache.maxEntries = options.cacheMaxEntries ?? this.cache.maxEntries
+ }
+ }
+
+ startRender (): CssxDependencySnapshot {
+ this.pendingDependencies = createDependencySnapshot()
+ return this.pendingDependencies
+ }
+
+ commitRender (dependencies: CssxDependencySnapshot | null = this.pendingDependencies): void {
+ if (dependencies == null) return
+
+ if (this.pendingDependencies === dependencies) {
+ this.pendingDependencies = null
+ }
+ this.committedDependencies = dependencies
+ this.syncMediaQuerySubscriptions()
+
+ if (hasStaleDependencies(dependencies)) {
+ this.emitChange()
+ }
+ }
+
+ recordVariable (name: string, version: number): void {
+ this.pendingDependencies?.vars.set(name, version)
+ }
+
+ recordMedia (query: string, matches: boolean): void {
+ this.pendingDependencies?.media.set(query, matches)
+ }
+
+ recordDimensions (version: number): void {
+ if (this.pendingDependencies == null) return
+ this.pendingDependencies.dimensionsVersion = version
+ }
+
+ subscribe = (listener: () => void): (() => void) => {
+ this.listeners.add(listener)
+
+ if (this.unsubscribeRuntimeStore == null) {
+ this.unsubscribeRuntimeStore = subscribeRuntimeStore(
+ this.handleRuntimeChange,
+ () => this.committedDependencies
+ )
+ this.syncMediaQuerySubscriptions()
+ }
+
+ return () => {
+ this.listeners.delete(listener)
+
+ if (this.listeners.size === 0 && this.unsubscribeRuntimeStore != null) {
+ this.unsubscribeRuntimeStore()
+ this.unsubscribeRuntimeStore = null
+ this.releaseMediaQuerySubscriptions()
+ }
+ }
+ }
+
+ getSnapshot = (): number => {
+ return this.snapshotVersion
+ }
+
+ getServerSnapshot = (): number => {
+ return this.snapshotVersion
+ }
+
+ getCommittedDependenciesForTests (): CssxDependencySnapshot {
+ return cloneDependencySnapshot(this.committedDependencies)
+ }
+
+ getPendingDependenciesForTests (): CssxDependencySnapshot | null {
+ return this.pendingDependencies == null
+ ? null
+ : cloneDependencySnapshot(this.pendingDependencies)
+ }
+
+ private handleRuntimeChange = (_change: RuntimeChangeSnapshot): void => {
+ this.emitChange()
+ }
+
+ private emitChange (): void {
+ this.snapshotVersion += 1
+ for (const listener of Array.from(this.listeners)) {
+ listener()
+ }
+ }
+
+ private syncMediaQuerySubscriptions (): void {
+ if (this.unsubscribeRuntimeStore == null) return
+
+ const nextQueries = new Set(this.committedDependencies.media.keys())
+ for (const [query, release] of Array.from(this.mediaQueryReleases)) {
+ if (nextQueries.has(query)) continue
+ release()
+ this.mediaQueryReleases.delete(query)
+ }
+
+ for (const query of nextQueries) {
+ if (this.mediaQueryReleases.has(query)) continue
+ this.mediaQueryReleases.set(query, retainMediaQuery(query))
+ }
+ }
+
+ private releaseMediaQuerySubscriptions (): void {
+ for (const release of this.mediaQueryReleases.values()) {
+ release()
+ }
+ this.mediaQueryReleases.clear()
+ }
+}
+
+export function isTrackedCssxSheet (value: unknown): value is TrackedCssxSheet {
+ return Boolean(
+ value != null &&
+ typeof value === 'object' &&
+ (value as { [TRACKED_SHEET]?: true })[TRACKED_SHEET] === true
+ )
+}
+
+export function createTrackedCssxSheet (
+ sheet: CompiledCssSheet,
+ options: TrackedCssxSheetOptions = {}
+): TrackedCssxSheet {
+ return new TrackedCssxSheet(sheet, options)
+}
+
+function cloneDependencySnapshot (
+ input: CssxDependencySnapshot
+): CssxDependencySnapshot {
+ return {
+ vars: new Map(input.vars),
+ media: new Map(input.media),
+ dimensionsVersion: input.dimensionsVersion
+ }
+}
+
+function sameOptions (
+ left: TrackedCssxSheetOptions,
+ right: TrackedCssxSheetOptions
+): boolean {
+ const keys = new Set([
+ ...Object.keys(left),
+ ...Object.keys(right)
+ ])
+
+ for (const key of keys) {
+ const leftValue = left[key as keyof TrackedCssxSheetOptions]
+ const rightValue = right[key as keyof TrackedCssxSheetOptions]
+ if (key === 'values') {
+ if (!sameValues(leftValue as readonly unknown[] | undefined, rightValue as readonly unknown[] | undefined)) {
+ return false
+ }
+ continue
+ }
+ if (!Object.is(leftValue, rightValue)) return false
+ }
+
+ return true
+}
+
+function sameValues (
+ left: readonly unknown[] | undefined,
+ right: readonly unknown[] | undefined
+): boolean {
+ if (left == null || right == null) return left == null && right == null
+ if (left.length !== right.length) return false
+ for (let i = 0; i < left.length; i++) {
+ if (!Object.is(left[i], right[i])) return false
+ }
+ return true
+}
diff --git a/packages/css-to-rn/src/resolve.ts b/packages/css-to-rn/src/resolve.ts
new file mode 100644
index 0000000..5be9d8d
--- /dev/null
+++ b/packages/css-to-rn/src/resolve.ts
@@ -0,0 +1,1174 @@
+import mediaQuery from 'css-mediaquery'
+import { compileCss } from './compiler.ts'
+import { diagnostic } from './diagnostics.ts'
+import { simpleNumericHash } from './hash.ts'
+import { transformDeclarations } from './transform/index.ts'
+import type {
+ CssDeclaration,
+ TransformStyle,
+ TransformStyleValue
+} from './transform/index.ts'
+import { coerceCssValue, resolveCssValue } from './values.ts'
+import type {
+ CompiledCssSheet,
+ CssxDeclaration,
+ CssxDiagnostic,
+ CssxKeyframe,
+ CssxRule,
+ CssxTarget
+} from './types.ts'
+
+export type StyleNameValue =
+ | string
+ | number
+ | null
+ | undefined
+ | false
+ | Record
+ | readonly StyleNameValue[]
+
+export type CssxLayerInput =
+ | string
+ | CompiledCssSheet
+ | ResolveCssxLayer
+
+export interface ResolveCssxLayer {
+ sheet: CompiledCssSheet | string
+ values?: readonly unknown[]
+ cacheKey?: unknown
+}
+
+export interface ResolveCssxOptions {
+ styleName: StyleNameValue
+ layers?: CssxLayerInput | readonly CssxLayerInput[]
+ inlineStyleProps?: InlineStyleInput
+ variables?: Record
+ scopedVariables?: readonly Record[]
+ defaultVariables?: Record
+ dimensions?: CssxDimensions
+ mediaQueryEvaluator?: CssxMediaQueryEvaluator
+ target?: CssxTarget
+ componentTag?: string | null
+ theme?: string | null
+ cache?: boolean | CssxCache
+ cacheMaxEntries?: number
+}
+
+export interface CssxDimensions {
+ width?: number
+ height?: number
+ type?: string
+}
+
+export type CssxMediaQueryEvaluator = (
+ query: string,
+ dimensions: CssxDimensions | undefined
+) => boolean
+
+export type InlineStyleInput =
+ | TransformStyle
+ | ResolvedStyleProps
+ | null
+ | undefined
+ | false
+
+export interface ResolvedStyleProps {
+ [propName: string]: TransformStyleValue
+}
+
+export interface ResolveCssxResult {
+ props: ResolvedStyleProps
+ diagnostics: CssxDiagnostic[]
+ dependencies: ResolveCssxDependencies
+ cacheHit: boolean
+}
+
+export interface ResolveCssxDependencies {
+ vars: string[]
+ dimensions: boolean
+ media: string[]
+ mediaMatches?: Record
+ sheets: string[]
+}
+
+export interface CssxCache {
+ maxEntries: number
+ entries: Map
+}
+
+interface ResolveCacheEntry {
+ dynamicSignature: string
+ values: readonly unknown[]
+ result: ResolveCssxResult
+}
+
+interface NormalizedLayer {
+ sheet: CompiledCssSheet
+ values: readonly unknown[]
+ cacheKey?: unknown
+}
+
+interface MutableDependencies {
+ vars: Set
+ dimensions: boolean
+ media: Map
+ sheets: Set
+}
+
+interface ResolutionContext {
+ target: CssxTarget
+ variables?: Record
+ scopedVariables?: readonly Record[]
+ defaultVariables?: Record
+ customMedia?: Record
+ dimensions?: CssxDimensions
+ mediaQueryEvaluator?: CssxMediaQueryEvaluator
+ componentTag?: string | null
+ theme: string
+ dependencies: MutableDependencies
+ diagnostics: CssxDiagnostic[]
+}
+
+interface MatchedRule {
+ rule: CssxRule
+ layer: NormalizedLayer
+ layerIndex: number
+}
+
+let lastRawCss: string | undefined
+let lastRawSheet: CompiledCssSheet | undefined
+let unknownIdentityCounter = 0
+const unknownObjectIds = new WeakMap()
+const unknownPrimitiveIds = new Map()
+const defaultCache = createCssxCache()
+const DYNAMIC_ROOT_SLOT_RE = /var\(\s*--__cssx_dynamic_(\d+)\s*\)/g
+const THEME_MEDIA_RE = /^\(--theme-([A-Za-z0-9_-]+)\)$/
+const CUSTOM_MEDIA_RE = /^\((--[A-Za-z0-9_-]+)\)$/
+const RANGE_MEDIA_RE = /^\((width|height)\s*(<=|>=|<|>)\s*(.+)\)$/
+
+export function createCssxCache (options: { maxEntries?: number } = {}): CssxCache {
+ return {
+ maxEntries: options.maxEntries ?? 100,
+ entries: new Map()
+ }
+}
+
+export function clearCssxRuntimeCachesForTests (): void {
+ lastRawCss = undefined
+ lastRawSheet = undefined
+ defaultCache.entries.clear()
+ unknownPrimitiveIds.clear()
+}
+
+export function cssx (
+ styleName: StyleNameValue,
+ layers?: CssxLayerInput | readonly CssxLayerInput[],
+ inlineStyleProps?: InlineStyleInput,
+ options: Omit = {}
+): ResolvedStyleProps {
+ return resolveCssx({
+ ...options,
+ styleName,
+ layers,
+ inlineStyleProps
+ }).props
+}
+
+export function resolveCssx (options: ResolveCssxOptions): ResolveCssxResult {
+ const layers = normalizeLayers(options.layers)
+ const classNames = normalizeStyleName(options.styleName)
+ const inlineHash = hashInlineStyleProps(options.inlineStyleProps)
+ const values = flattenLayerValues(layers)
+ const cache = options.cache === false
+ ? undefined
+ : options.cache === true || options.cache == null
+ ? defaultCache
+ : options.cache
+ const stableKey = inlineHash == null
+ ? undefined
+ : createStableKey(options, classNames, layers, inlineHash)
+ const cached = cache && stableKey
+ ? cache.entries.get(stableKey)
+ : undefined
+
+ if (cached && sameValues(cached.values, values)) {
+ const currentSignature = createDynamicSignature(
+ cached.result.dependencies,
+ options,
+ layers
+ )
+ if (currentSignature === cached.dynamicSignature) {
+ return {
+ ...cached.result,
+ cacheHit: true
+ }
+ }
+ }
+
+ const result = resolveCssxUncached(options, layers, classNames)
+ const dynamicSignature = createDynamicSignature(result.dependencies, options, layers)
+
+ if (cache && stableKey) {
+ remember(cache, stableKey, {
+ dynamicSignature,
+ values,
+ result
+ })
+ }
+
+ return result
+}
+
+function resolveCssxUncached (
+ options: ResolveCssxOptions,
+ layers: readonly NormalizedLayer[],
+ classNames: readonly string[]
+): ResolveCssxResult {
+ const scopedVariables = collectScopedVariables(options.scopedVariables, layers, options.theme)
+ const customMedia = collectCustomMedia(layers)
+ const context: ResolutionContext = {
+ target: options.target ?? 'react-native',
+ variables: options.variables,
+ scopedVariables,
+ defaultVariables: options.defaultVariables,
+ customMedia,
+ dimensions: options.dimensions,
+ mediaQueryEvaluator: options.mediaQueryEvaluator,
+ componentTag: options.componentTag ?? null,
+ theme: normalizeTheme(options.theme),
+ dependencies: createDependencies(),
+ diagnostics: [],
+ }
+ const classSet = new Set(classNames)
+ const props: ResolvedStyleProps = {}
+
+ for (const layer of layers) {
+ context.dependencies.sheets.add(layer.sheet.id)
+ context.diagnostics.push(...layer.sheet.diagnostics)
+ }
+
+ const matchedRules = getMatchedRules(layers, classSet, context)
+ const byProp = new Map()
+ for (const matched of matchedRules) {
+ const propName = getPartPropName(matched.rule.part)
+ const rules = byProp.get(propName)
+ if (rules) rules.push(matched)
+ else byProp.set(propName, [matched])
+ }
+
+ for (const [propName, rules] of byProp) {
+ const style = resolvePropStyle(rules, context)
+ if (Object.keys(style).length > 0) mergeStyleProp(props, propName, style)
+ }
+
+ mergeInlineStyleProps(props, options.inlineStyleProps, context)
+
+ return {
+ props,
+ diagnostics: context.diagnostics,
+ dependencies: serializeDependencies(context.dependencies),
+ cacheHit: false
+ }
+}
+
+function getMatchedRules (
+ layers: readonly NormalizedLayer[],
+ classSet: ReadonlySet,
+ context: ResolutionContext
+): MatchedRule[] {
+ const matched: MatchedRule[] = []
+
+ layers.forEach((layer, layerIndex) => {
+ for (const rule of layer.sheet.rules) {
+ if (!ruleMatchesTag(rule, context.componentTag)) continue
+ if (!ruleMatchesClasses(rule, classSet)) continue
+ if (!ruleMatchesMedia(rule, context)) continue
+ matched.push({ rule, layer, layerIndex })
+ }
+ })
+
+ return matched.sort((left, right) =>
+ left.layerIndex - right.layerIndex ||
+ left.rule.specificity - right.rule.specificity ||
+ left.rule.order - right.rule.order
+ )
+}
+
+function resolvePropStyle (
+ rules: readonly MatchedRule[],
+ context: ResolutionContext
+): TransformStyle {
+ const declarations: CssDeclaration[] = []
+ const keyframeNames = new Set()
+ let order = 0
+
+ for (const matched of rules) {
+ for (const declaration of matched.rule.declarations) {
+ const resolved = resolveDeclarationValue(declaration, matched.layer, context)
+ if (!resolved) continue
+ declarations.push({
+ property: declaration.property,
+ value: resolved,
+ raw: `${declaration.property}: ${resolved}`,
+ order: order++
+ })
+ }
+ }
+
+ const transformed = transformDeclarations(declarations, {
+ platform: context.target,
+ keyframes: {},
+ })
+ context.diagnostics.push(...transformed.diagnostics.map(toCssxDiagnostic))
+
+ collectAnimationNames(transformed.style.animationName, keyframeNames)
+ if (keyframeNames.size > 0) {
+ const keyframes = resolveKeyframes(rules, keyframeNames, context)
+ inlineAnimationKeyframes(transformed.style, keyframes)
+ }
+
+ return transformed.style
+}
+
+function resolveDeclarationValue (
+ declaration: CssxDeclaration,
+ layer: NormalizedLayer,
+ context: ResolutionContext
+): string | undefined {
+ const result = resolveCssValue(declaration.value, {
+ values: layer.values,
+ variables: context.variables,
+ scopedVariables: context.scopedVariables,
+ defaultVariables: context.defaultVariables,
+ dimensions: context.dimensions
+ })
+
+ for (const varName of result.dependencies.vars) context.dependencies.vars.add(varName)
+ if (result.dependencies.dimensions) context.dependencies.dimensions = true
+ context.diagnostics.push(...result.diagnostics)
+
+ return result.valid ? result.value : undefined
+}
+
+function resolveKeyframes (
+ rules: readonly MatchedRule[],
+ keyframeNames: ReadonlySet,
+ context: ResolutionContext
+): Record {
+ const resolved: Record = {}
+ const seen = new Set()
+
+ for (let index = rules.length - 1; index >= 0; index--) {
+ const layer = rules[index].layer
+
+ for (const keyframeName of keyframeNames) {
+ if (seen.has(keyframeName)) continue
+ const keyframes = layer.sheet.keyframes[keyframeName]
+ if (!keyframes) continue
+ resolved[keyframeName] = resolveSingleKeyframes(keyframes, layer, context)
+ seen.add(keyframeName)
+ }
+ }
+
+ return resolved
+}
+
+function resolveSingleKeyframes (
+ keyframes: readonly CssxKeyframe[],
+ layer: NormalizedLayer,
+ context: ResolutionContext
+): TransformStyle {
+ const style: TransformStyle = {}
+
+ for (const frame of keyframes) {
+ const declarations: CssDeclaration[] = []
+ for (const declaration of frame.declarations) {
+ const resolved = resolveDeclarationValue(declaration, layer, context)
+ if (!resolved) continue
+ declarations.push({
+ property: declaration.property,
+ value: resolved,
+ raw: `${declaration.property}: ${resolved}`,
+ order: declaration.order
+ })
+ }
+
+ const transformed = transformDeclarations(declarations, {
+ platform: context.target,
+ keyframes: {},
+ })
+ context.diagnostics.push(...transformed.diagnostics.map(toCssxDiagnostic))
+ style[frame.selector] = transformed.style
+ }
+
+ return style
+}
+
+function inlineAnimationKeyframes (
+ style: TransformStyle,
+ keyframes: Record
+): void {
+ if (style.animationName == null) return
+
+ if (Array.isArray(style.animationName)) {
+ style.animationName = style.animationName.map(value =>
+ typeof value === 'string' && value !== 'none' && keyframes[value] != null
+ ? keyframes[value]
+ : value
+ )
+ return
+ }
+
+ if (
+ typeof style.animationName === 'string' &&
+ style.animationName !== 'none' &&
+ keyframes[style.animationName] != null
+ ) {
+ style.animationName = keyframes[style.animationName]
+ }
+}
+
+function collectAnimationNames (
+ value: TransformStyleValue,
+ output: Set
+): void {
+ if (typeof value === 'string') {
+ if (value !== 'none') output.add(value)
+ return
+ }
+
+ if (!Array.isArray(value)) return
+ for (const item of value) collectAnimationNames(item, output)
+}
+
+function ruleMatchesClasses (
+ rule: CssxRule,
+ classSet: ReadonlySet
+): boolean {
+ return rule.classes.every(className => classSet.has(className))
+}
+
+function ruleMatchesTag (
+ rule: CssxRule,
+ componentTag: string | null | undefined
+): boolean {
+ return rule.tag == null || rule.tag === componentTag
+}
+
+function ruleMatchesMedia (
+ rule: CssxRule,
+ context: ResolutionContext
+): boolean {
+ if (!rule.media) return true
+
+ const query = stripMediaPrefix(rule.media)
+ const result = evaluateCssxMediaQuery(query, {
+ variables: context.variables,
+ scopedVariables: context.scopedVariables,
+ defaultVariables: context.defaultVariables,
+ customMedia: context.customMedia,
+ dimensions: context.dimensions,
+ mediaQueryEvaluator: context.mediaQueryEvaluator,
+ theme: context.theme
+ })
+ for (const varName of result.dependencies.vars) context.dependencies.vars.add(varName)
+ if (result.dependencies.dimensions) context.dependencies.dimensions = true
+ context.diagnostics.push(...result.diagnostics)
+ for (const [mediaQuery, matches] of Object.entries(result.dependencies.media)) {
+ context.dependencies.media.set(mediaQuery, matches)
+ }
+ return result.matches
+}
+
+interface CssxMediaQueryEvaluationOptions {
+ variables?: Record
+ scopedVariables?: readonly Record[]
+ defaultVariables?: Record
+ customMedia?: Record
+ dimensions?: CssxDimensions
+ mediaQueryEvaluator?: CssxMediaQueryEvaluator
+ theme?: string | null
+}
+
+interface CssxMediaQueryEvaluationResult {
+ matches: boolean
+ dependencies: {
+ vars: string[]
+ dimensions: boolean
+ media: Record
+ }
+ diagnostics: CssxDiagnostic[]
+}
+
+export function evaluateCssxMediaQuery (
+ query: string,
+ options: CssxMediaQueryEvaluationOptions
+): CssxMediaQueryEvaluationResult {
+ const dependencies = {
+ vars: new Set(),
+ dimensions: false,
+ media: new Map()
+ }
+ const diagnostics: CssxDiagnostic[] = []
+ const matches = matchesMediaQueryBranchList(query, options, dependencies, diagnostics, [])
+
+ return {
+ matches,
+ dependencies: {
+ vars: Array.from(dependencies.vars).sort(),
+ dimensions: dependencies.dimensions,
+ media: Object.fromEntries(Array.from(dependencies.media.entries()).sort())
+ },
+ diagnostics
+ }
+}
+
+function matchesMediaQuery (
+ query: string,
+ dimensions: CssxDimensions | undefined,
+ evaluator?: CssxMediaQueryEvaluator,
+ theme?: string | null,
+ customMedia?: Record,
+ variables?: Record,
+ scopedVariables?: readonly Record[],
+ defaultVariables?: Record
+): boolean {
+ return evaluateCssxMediaQuery(query, {
+ dimensions,
+ mediaQueryEvaluator: evaluator,
+ theme,
+ customMedia,
+ variables,
+ scopedVariables,
+ defaultVariables
+ }).matches
+}
+
+function matchesMediaQueryBranchList (
+ query: string,
+ options: CssxMediaQueryEvaluationOptions,
+ dependencies: { vars: Set, dimensions: boolean, media: Map },
+ diagnostics: CssxDiagnostic[],
+ customMediaStack: string[]
+): boolean {
+ const normalized = stripMediaPrefix(query)
+ const branches = splitTopLevelComma(normalized)
+ if (branches.length > 1) {
+ return branches.some(branch => matchesSingleMediaQuery(branch, options, dependencies, diagnostics, customMediaStack))
+ }
+
+ return matchesSingleMediaQuery(normalized, options, dependencies, diagnostics, customMediaStack)
+}
+
+function matchesSingleMediaQuery (
+ query: string,
+ options: CssxMediaQueryEvaluationOptions,
+ dependencies: { vars: Set, dimensions: boolean, media: Map },
+ diagnostics: CssxDiagnostic[],
+ customMediaStack: string[]
+): boolean {
+ const parts = splitTopLevelAnd(query)
+ const rest: string[] = []
+
+ for (const part of parts) {
+ const trimmed = part.trim()
+ const themeMatch = trimmed.match(THEME_MEDIA_RE)
+ if (themeMatch) {
+ if (!matchesThemeName(themeMatch[1], normalizeTheme(options.theme))) return false
+ continue
+ }
+
+ const customMediaMatch = trimmed.match(CUSTOM_MEDIA_RE)
+ if (customMediaMatch && options.customMedia?.[customMediaMatch[1]] != null) {
+ const customMediaName = customMediaMatch[1]
+ if (customMediaStack.includes(customMediaName)) {
+ diagnostics.push(diagnostic(
+ 'INVALID_CUSTOM_MEDIA',
+ `Custom media cycle detected: ${customMediaStack.concat(customMediaName).join(' -> ')}.`,
+ 'warning'
+ ))
+ return false
+ }
+ if (!matchesMediaQueryBranchList(
+ options.customMedia[customMediaName],
+ options,
+ dependencies,
+ diagnostics,
+ customMediaStack.concat(customMediaName)
+ )) {
+ return false
+ }
+ continue
+ }
+
+ const rangeMatch = trimmed.match(RANGE_MEDIA_RE)
+ if (rangeMatch) {
+ const rangeMatches = evaluateRangeMedia(rangeMatch, options, dependencies, diagnostics)
+ if (!rangeMatches) return false
+ continue
+ }
+
+ if (trimmed) rest.push(trimmed)
+ }
+
+ if (rest.length === 0) return true
+
+ const restQuery = resolveMediaQueryValue(rest.join(' and '), options, dependencies, diagnostics)
+ if (restQuery == null) return false
+ const matches = options.mediaQueryEvaluator
+ ? options.mediaQueryEvaluator(restQuery, options.dimensions)
+ : matchesNativeMediaQuery(restQuery, options.dimensions)
+
+ dependencies.media.set(restQuery, matches)
+ return matches
+}
+
+function matchesNativeMediaQuery (
+ query: string,
+ dimensions: CssxDimensions | undefined
+): boolean {
+ try {
+ return mediaQuery.match(query, mediaValues(dimensions))
+ } catch {
+ return false
+ }
+}
+
+function evaluateRangeMedia (
+ match: RegExpMatchArray,
+ options: CssxMediaQueryEvaluationOptions,
+ dependencies: { vars: Set, dimensions: boolean, media: Map },
+ diagnostics: CssxDiagnostic[]
+): boolean {
+ dependencies.dimensions = true
+
+ const feature = match[1] as 'width' | 'height'
+ const operator = match[2]
+ const rawValue = match[3].trim()
+ const resolved = resolveMediaQueryValue(rawValue, options, dependencies, diagnostics)
+ const expected = resolved == null ? null : parseMediaLength(resolved)
+ if (expected == null) return false
+
+ const actual = feature === 'width'
+ ? options.dimensions?.width ?? 0
+ : options.dimensions?.height ?? 0
+
+ switch (operator) {
+ case '>=':
+ return actual >= expected
+ case '>':
+ return actual > expected
+ case '<=':
+ return actual <= expected
+ case '<':
+ return actual < expected
+ default:
+ return false
+ }
+}
+
+function resolveMediaQueryValue (
+ input: string,
+ options: CssxMediaQueryEvaluationOptions,
+ dependencies: { vars: Set, dimensions: boolean, media: Map },
+ diagnostics: CssxDiagnostic[]
+): string | null {
+ const result = resolveCssValue(input, {
+ variables: options.variables,
+ scopedVariables: options.scopedVariables,
+ defaultVariables: options.defaultVariables,
+ dimensions: options.dimensions
+ })
+
+ for (const varName of result.dependencies.vars) dependencies.vars.add(varName)
+ if (result.dependencies.dimensions) dependencies.dimensions = true
+ diagnostics.push(...result.diagnostics)
+
+ return result.valid ? result.value ?? input : null
+}
+
+function parseMediaLength (input: string): number | null {
+ const match = input.trim().match(/^([-+]?(?:\d*\.)?\d+)(px|rem|em|u)?$/i)
+ if (match == null) return null
+
+ const number = Number(match[1])
+ const unit = (match[2] ?? 'px').toLowerCase()
+ if (!Number.isFinite(number)) return null
+ if (unit === 'px') return number
+ if (unit === 'rem' || unit === 'em') return number * 16
+ if (unit === 'u') return number * 8
+ return null
+}
+
+function mediaValues (dimensions: CssxDimensions | undefined): Record {
+ const width = dimensions?.width ?? 0
+ const height = dimensions?.height ?? 0
+
+ return {
+ type: dimensions?.type ?? 'screen',
+ width: `${width}px`,
+ height: `${height}px`,
+ 'device-width': `${width}px`,
+ 'device-height': `${height}px`,
+ orientation: width >= height ? 'landscape' : 'portrait'
+ }
+}
+
+function stripMediaPrefix (media: string): string {
+ return media.replace(/^@media\s*/i, '').trim()
+}
+
+function splitTopLevelComma (input: string): string[] {
+ return splitTopLevelToken(input, ',')
+}
+
+function splitTopLevelAnd (input: string): string[] {
+ const parts: string[] = []
+ let depth = 0
+ let start = 0
+ let index = 0
+
+ while (index < input.length) {
+ const char = input[index]
+ if (char === '(') depth += 1
+ else if (char === ')') depth = Math.max(0, depth - 1)
+ else if (
+ depth === 0 &&
+ input.slice(index, index + 3).toLowerCase() === 'and' &&
+ isWordBoundary(input[index - 1]) &&
+ isWordBoundary(input[index + 3])
+ ) {
+ parts.push(input.slice(start, index))
+ index += 3
+ start = index
+ continue
+ }
+ index += 1
+ }
+
+ parts.push(input.slice(start))
+ return parts
+}
+
+function splitTopLevelToken (input: string, token: string): string[] {
+ const parts: string[] = []
+ let depth = 0
+ let start = 0
+
+ for (let index = 0; index < input.length; index++) {
+ const char = input[index]
+ if (char === '(') depth += 1
+ else if (char === ')') depth = Math.max(0, depth - 1)
+ else if (depth === 0 && char === token) {
+ parts.push(input.slice(start, index))
+ start = index + 1
+ }
+ }
+
+ parts.push(input.slice(start))
+ return parts
+}
+
+function isWordBoundary (char: string | undefined): boolean {
+ return char == null || !/[A-Za-z0-9_-]/.test(char)
+}
+
+function matchesThemeName (queryTheme: string, activeTheme: string): boolean {
+ if (queryTheme === 'default' || queryTheme === 'light') {
+ return activeTheme === 'default' || activeTheme === 'light'
+ }
+ return queryTheme === activeTheme
+}
+
+function normalizeTheme (theme: string | null | undefined): string {
+ return theme || 'default'
+}
+
+function getPartPropName (part: string | null): string {
+ return part && part !== 'root' ? `${part}Style` : 'style'
+}
+
+function normalizeLayers (
+ layers: CssxLayerInput | readonly CssxLayerInput[] | undefined
+): NormalizedLayer[] {
+ const input = layers == null
+ ? []
+ : Array.isArray(layers)
+ ? layers
+ : [layers]
+
+ return input.map(layer => {
+ if (typeof layer === 'string') {
+ return { sheet: compileRawCss(layer), values: [] }
+ }
+
+ if (isCompiledSheet(layer)) {
+ return { sheet: layer, values: [] }
+ }
+
+ const sheet = typeof layer.sheet === 'string'
+ ? compileRawCss(layer.sheet)
+ : layer.sheet
+
+ return {
+ sheet,
+ values: layer.values ?? [],
+ cacheKey: layer.cacheKey
+ }
+ })
+}
+
+function compileRawCss (css: string): CompiledCssSheet {
+ if (css === lastRawCss && lastRawSheet) return lastRawSheet
+ lastRawCss = css
+ lastRawSheet = compileCss(css, { mode: 'runtime' })
+ return lastRawSheet
+}
+
+function isCompiledSheet (value: unknown): value is CompiledCssSheet {
+ return Boolean(
+ value &&
+ typeof value === 'object' &&
+ (value as { version?: unknown }).version === 1 &&
+ Array.isArray((value as { rules?: unknown }).rules)
+ )
+}
+
+function normalizeStyleName (value: StyleNameValue): string[] {
+ const className = classcat(value)
+ return className.split(/\s+/).filter(Boolean).sort()
+}
+
+function classcat (value: StyleNameValue): string {
+ if (value == null || value === false) return ''
+ if (typeof value === 'string' || typeof value === 'number') return value ? String(value) : ''
+
+ if (Array.isArray(value)) {
+ let output = ''
+ for (const item of value) {
+ const nested = classcat(item)
+ if (nested) output += (output ? ' ' : '') + nested
+ }
+ return output
+ }
+
+ let output = ''
+ const record = value as Record
+ for (const key of Object.keys(record)) {
+ if (record[key]) output += (output ? ' ' : '') + key
+ }
+ return output
+}
+
+function mergeInlineStyleProps (
+ props: ResolvedStyleProps,
+ inlineStyleProps: InlineStyleInput,
+ context: ResolutionContext
+): void {
+ if (!inlineStyleProps) return
+
+ if (isStylePropsInput(inlineStyleProps)) {
+ for (const propName of Object.keys(inlineStyleProps)) {
+ mergeStyleProp(props, propName, resolveInlineStyleValue(inlineStyleProps[propName], context))
+ }
+ return
+ }
+
+ mergeStyleProp(props, 'style', resolveInlineStyleValue(inlineStyleProps, context))
+}
+
+function isStylePropsInput (value: TransformStyle | ResolvedStyleProps): value is ResolvedStyleProps {
+ return Object.keys(value).some(key => key === 'style' || key.endsWith('Style'))
+}
+
+function mergeStyleProp (
+ props: ResolvedStyleProps,
+ propName: string,
+ style: TransformStyleValue
+): void {
+ if (style == null || style === false) return
+
+ const current = props[propName]
+ const flattened: TransformStyle = {}
+ flattenStyleInto(current, flattened)
+ flattenStyleInto(style, flattened)
+ props[propName] = flattened
+}
+
+function flattenStyleInto (
+ value: TransformStyleValue,
+ output: TransformStyle
+): void {
+ if (value == null || value === false) return
+ if (Array.isArray(value)) {
+ for (const item of value) flattenStyleInto(item, output)
+ return
+ }
+ if (typeof value === 'object') Object.assign(output, value)
+}
+
+function resolveInlineStyleValue (
+ value: TransformStyleValue,
+ context: ResolutionContext
+): TransformStyleValue {
+ if (typeof value === 'string') {
+ const result = resolveCssValue(value, {
+ variables: context.variables,
+ scopedVariables: context.scopedVariables,
+ defaultVariables: context.defaultVariables,
+ dimensions: context.dimensions
+ })
+
+ for (const varName of result.dependencies.vars) context.dependencies.vars.add(varName)
+ if (result.dependencies.dimensions) context.dependencies.dimensions = true
+ context.diagnostics.push(...result.diagnostics)
+
+ return result.valid
+ ? coerceCssValue(result.value) as TransformStyleValue
+ : undefined
+ }
+
+ if (Array.isArray(value)) {
+ return value.map(item => resolveInlineStyleValue(item, context))
+ }
+
+ if (value && typeof value === 'object') {
+ const output: TransformStyle = {}
+ for (const [key, child] of Object.entries(value)) {
+ output[key] = resolveInlineStyleValue(child, context)
+ }
+ return output
+ }
+
+ return value
+}
+
+function createStableKey (
+ options: ResolveCssxOptions,
+ classNames: readonly string[],
+ layers: readonly NormalizedLayer[],
+ inlineHash: string
+): string {
+ return JSON.stringify({
+ target: options.target ?? 'react-native',
+ componentTag: options.componentTag ?? null,
+ styleName: classNames,
+ inline: inlineHash,
+ layers: layers.map(layer => ({
+ id: layer.sheet.id,
+ contentHash: layer.sheet.contentHash,
+ cacheKey: layer.cacheKey == null ? undefined : identityFor(layer.cacheKey)
+ }))
+ })
+}
+
+function createDynamicSignature (
+ dependencies: ResolveCssxDependencies,
+ options: ResolveCssxOptions,
+ layers: readonly NormalizedLayer[]
+): string {
+ const scopedVariables = collectScopedVariables(options.scopedVariables, layers, options.theme)
+ const customMedia = collectCustomMedia(layers)
+ return JSON.stringify({
+ theme: normalizeTheme(options.theme),
+ vars: dependencies.vars.map(name => [
+ name,
+ valueFromRecord(options.variables, name) ??
+ valueFromScopedRecords(scopedVariables, name) ??
+ valueFromRecord(options.defaultVariables, name)
+ ]),
+ dimensions: dependencies.dimensions
+ ? {
+ width: options.dimensions?.width ?? 0,
+ height: options.dimensions?.height ?? 0,
+ type: options.dimensions?.type ?? 'screen'
+ }
+ : undefined,
+ media: dependencies.media.map(query => [
+ query,
+ matchesMediaQuery(
+ query,
+ options.dimensions,
+ options.mediaQueryEvaluator,
+ options.theme,
+ customMedia,
+ options.variables,
+ scopedVariables,
+ options.defaultVariables
+ )
+ ])
+ })
+}
+
+function hashInlineStyleProps (inlineStyleProps: InlineStyleInput): string | undefined {
+ if (!inlineStyleProps) return '0'
+
+ try {
+ return String(simpleNumericHash(JSON.stringify(inlineStyleProps)))
+ } catch {
+ return undefined
+ }
+}
+
+function flattenLayerValues (layers: readonly NormalizedLayer[]): readonly unknown[] {
+ const values: unknown[] = []
+ for (const layer of layers) values.push(...layer.values)
+ return values
+}
+
+function collectScopedVariables (
+ explicitScopes: readonly Record[] | undefined,
+ layers: readonly NormalizedLayer[],
+ theme?: string | null
+): readonly Record[] | undefined {
+ const scopes: Record[] = explicitScopes ? [...explicitScopes] : []
+ const activeTheme = normalizeTheme(theme)
+
+ for (const layer of layers) {
+ if (layer.sheet.rootVariables != null) {
+ scopes.push(applyLayerValuesToRootVariables(layer.sheet.rootVariables, layer.values))
+ }
+ const themeRootVariables = getThemeVariables(layer.sheet, activeTheme)
+ if (themeRootVariables != null) {
+ scopes.push(applyLayerValuesToRootVariables(themeRootVariables, layer.values))
+ }
+ }
+
+ return scopes.length > 0 ? scopes : undefined
+}
+
+function collectCustomMedia (
+ layers: readonly NormalizedLayer[]
+): Record | undefined {
+ let customMedia: Record | undefined
+
+ for (const layer of layers) {
+ if (layer.sheet.customMedia == null) continue
+ customMedia ??= {}
+ Object.assign(customMedia, applyLayerValuesToRootVariables(layer.sheet.customMedia, layer.values))
+ }
+
+ return customMedia
+}
+
+function getThemeVariables (
+ sheet: CompiledCssSheet,
+ theme: string
+): Record | undefined {
+ if (sheet.themeVariables == null) return undefined
+ if (theme === 'light') return sheet.themeVariables.light ?? sheet.themeVariables.default
+ if (theme === 'default') return sheet.themeVariables.default
+ return sheet.themeVariables[theme]
+}
+
+function applyLayerValuesToRootVariables (
+ rootVariables: Record,
+ values: readonly unknown[]
+): Record {
+ if (values.length === 0) return rootVariables
+
+ const output: Record = {}
+ for (const name of Object.keys(rootVariables)) {
+ const value = rootVariables[name]
+ let valid = true
+ const next = value.replace(DYNAMIC_ROOT_SLOT_RE, (_match, rawIndex: string) => {
+ const interpolation = values[Number(rawIndex)]
+ if (typeof interpolation === 'string') return interpolation
+ if (typeof interpolation === 'number') return String(interpolation)
+ valid = false
+ return ''
+ })
+ if (valid) output[name] = next
+ }
+ return output
+}
+
+function sameValues (
+ left: readonly unknown[],
+ right: readonly unknown[]
+): boolean {
+ if (left.length !== right.length) return false
+ for (let index = 0; index < left.length; index++) {
+ if (!Object.is(left[index], right[index])) return false
+ }
+ return true
+}
+
+function remember (
+ cache: CssxCache,
+ key: string,
+ entry: ResolveCacheEntry
+): void {
+ cache.entries.delete(key)
+ cache.entries.set(key, entry)
+
+ while (cache.entries.size > cache.maxEntries) {
+ const oldestKey = cache.entries.keys().next().value
+ if (oldestKey == null) break
+ cache.entries.delete(oldestKey)
+ }
+}
+
+function identityFor (value: unknown): string {
+ if (value && (typeof value === 'object' || typeof value === 'function')) {
+ const object = value as object
+ const existing = unknownObjectIds.get(object)
+ if (existing != null) return `o:${existing}`
+ const id = ++unknownIdentityCounter
+ unknownObjectIds.set(object, id)
+ return `o:${id}`
+ }
+
+ const existing = unknownPrimitiveIds.get(value)
+ if (existing != null) return `p:${existing}`
+ const id = ++unknownIdentityCounter
+ unknownPrimitiveIds.set(value, id)
+ return `p:${id}`
+}
+
+function createDependencies (): MutableDependencies {
+ return {
+ vars: new Set(),
+ dimensions: false,
+ media: new Map(),
+ sheets: new Set()
+ }
+}
+
+function serializeDependencies (
+ dependencies: MutableDependencies
+): ResolveCssxDependencies {
+ return {
+ vars: Array.from(dependencies.vars).sort(),
+ dimensions: dependencies.dimensions,
+ media: Array.from(dependencies.media.keys()).sort(),
+ mediaMatches: Object.fromEntries(Array.from(dependencies.media.entries()).sort(([left], [right]) => left.localeCompare(right))),
+ sheets: Array.from(dependencies.sheets).sort()
+ }
+}
+
+function toCssxDiagnostic (item: {
+ code: CssxDiagnostic['code']
+ message: string
+}): CssxDiagnostic {
+ return diagnostic(item.code, item.message, 'warning')
+}
+
+function valueFromRecord (record: Record | undefined, key: string): unknown {
+ if (!record || !Object.prototype.hasOwnProperty.call(record, key)) return undefined
+ return record[key]
+}
+
+function valueFromScopedRecords (
+ records: readonly Record[] | undefined,
+ key: string
+): unknown {
+ if (!records) return undefined
+
+ for (let index = records.length - 1; index >= 0; index--) {
+ const value = valueFromRecord(records[index], key)
+ if (value !== undefined) return value
+ }
+
+ return undefined
+}
diff --git a/packages/css-to-rn/src/selectors.ts b/packages/css-to-rn/src/selectors.ts
new file mode 100644
index 0000000..3a12d5e
--- /dev/null
+++ b/packages/css-to-rn/src/selectors.ts
@@ -0,0 +1,90 @@
+import { diagnostic } from './diagnostics.ts'
+import type { CssxDiagnostic, SelectorParseResult } from './types.ts'
+
+const PART_RE = /::?part\(([^)]+)\)/
+const CLASS_RE = /\.([_a-zA-Z][-_a-zA-Z0-9]*)/g
+const TAG_RE = /^[_a-zA-Z][-_a-zA-Z0-9]*/
+const PSEUDO_PARTS: Record = {
+ ':hover': 'hover',
+ ':active': 'active'
+}
+
+export function parseSelector (selector: string, position?: { line?: number, column?: number }): {
+ result?: SelectorParseResult
+ diagnostic?: CssxDiagnostic
+} {
+ const original = selector.trim()
+ let current = original
+ let part: string | null = null
+
+ const partMatch = current.match(PART_RE)
+ if (partMatch) {
+ part = partMatch[1].trim()
+ current = (
+ current.slice(0, partMatch.index) +
+ current.slice((partMatch.index ?? 0) + partMatch[0].length)
+ ).trim()
+ } else {
+ for (const pseudo of Object.keys(PSEUDO_PARTS)) {
+ if (current.endsWith(pseudo)) {
+ part = PSEUDO_PARTS[pseudo]
+ current = current.slice(0, -pseudo.length).trim()
+ break
+ }
+ }
+ }
+
+ if (
+ current.includes(' ') ||
+ current.includes('>') ||
+ current.includes('+') ||
+ current.includes('~') ||
+ current.includes('[') ||
+ current.includes('#') ||
+ current.includes(':')
+ ) {
+ return unsupported(original, position)
+ }
+
+ const tagMatch = current.startsWith('.') ? null : current.match(TAG_RE)
+ const tag = tagMatch?.[0] ?? null
+ const classPart = tag == null ? current : current.slice(tag.length)
+
+ if (classPart && !classPart.startsWith('.')) {
+ return unsupported(original, position)
+ }
+
+ const classes: string[] = []
+ CLASS_RE.lastIndex = 0
+ let consumed = ''
+ let match: RegExpExecArray | null
+ while ((match = CLASS_RE.exec(classPart)) != null) {
+ classes.push(match[1])
+ consumed += match[0]
+ }
+
+ if (consumed !== classPart || (tag == null && classes.length === 0)) {
+ return unsupported(original, position)
+ }
+
+ return {
+ result: {
+ selector: original,
+ tag,
+ classes,
+ part,
+ specificity: classes.length
+ }
+ }
+}
+
+function unsupported (selector: string, position?: { line?: number, column?: number }) {
+ return {
+ diagnostic: diagnostic(
+ 'UNSUPPORTED_SELECTOR',
+ `Unsupported selector "${selector}" ignored. CSSX supports class selectors, component tag selectors, and :part()/:hover/:active only.`,
+ 'warning',
+ position
+ )
+ }
+}
diff --git a/packages/css-to-rn/src/transform/index.ts b/packages/css-to-rn/src/transform/index.ts
new file mode 100644
index 0000000..55d2d51
--- /dev/null
+++ b/packages/css-to-rn/src/transform/index.ts
@@ -0,0 +1,1567 @@
+export type CssPlatform = 'react-native' | 'web'
+
+export type TransformStyleValue =
+ | string
+ | number
+ | boolean
+ | null
+ | undefined
+ | TransformStyle
+ | TransformStyleValue[]
+
+export interface TransformStyle {
+ [property: string]: TransformStyleValue
+}
+
+export interface CssDeclaration {
+ property: string
+ raw?: string
+ value?: string
+ order?: number
+}
+
+export interface TransformDeclarationOptions {
+ platform?: CssPlatform
+ keyframes?: Record
+ onInvalid?: 'diagnose' | 'throw'
+ shorthandBlacklist?: readonly string[]
+}
+
+export type TransformDiagnosticCode =
+ | 'INVALID_DECLARATION'
+ | 'UNSUPPORTED_BACKGROUND_IMAGE'
+ | 'UNSUPPORTED_BACKGROUND_SHORTHAND'
+
+export interface TransformDiagnostic {
+ code: TransformDiagnosticCode
+ property: string
+ value: string
+ message: string
+ order?: number
+}
+
+export interface TransformDeclarationResult {
+ style: TransformStyle
+ diagnostics: TransformDiagnostic[]
+}
+
+interface PropertyTransformContext {
+ platform: CssPlatform
+ keyframes: Record
+}
+
+interface PropertyTransformResult {
+ style: TransformStyle
+ diagnostics?: TransformDiagnostic[]
+}
+
+type PropertyTransform = (
+ property: string,
+ value: string,
+ declaration: CssDeclaration,
+ context: PropertyTransformContext
+) => PropertyTransformResult
+
+const numberPattern =
+ '[+-]?(?:(?:\\d+\\.\\d+)|(?:\\d+\\.)|(?:\\.\\d+)|(?:\\d+))(?:e[+-]?\\d+)?'
+const numberRe = new RegExp(`^${numberPattern}$`, 'i')
+const numberOrLengthRe = new RegExp(`^(${numberPattern})([a-z%]*)$`, 'i')
+const timeRe = new RegExp(`^${numberPattern}(?:ms|s)$`, 'i')
+const angleRe = new RegExp(`^${numberPattern}(?:deg|rad|grad|turn)$`, 'i')
+const hexColorRe = /^(?:#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8}))$/i
+const colorFunctionRe =
+ /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|gray|color)\(/i
+const supportedLengthUnits = new Set([
+ 'ch',
+ 'cm',
+ 'em',
+ 'ex',
+ 'in',
+ 'mm',
+ 'pc',
+ 'pt',
+ 'rem',
+ 'vh',
+ 'vmax',
+ 'vmin',
+ 'vw',
+])
+const borderStyles = new Set([
+ 'solid',
+ 'dashed',
+ 'dotted',
+ 'double',
+ 'groove',
+ 'ridge',
+ 'inset',
+ 'outset',
+])
+const timingFunctionKeywords = new Set([
+ 'ease',
+ 'linear',
+ 'ease-in',
+ 'ease-out',
+ 'ease-in-out',
+ 'step-start',
+ 'step-end',
+])
+const animationDirectionKeywords = new Set([
+ 'normal',
+ 'reverse',
+ 'alternate',
+ 'alternate-reverse',
+])
+const animationFillModeKeywords = new Set([
+ 'none',
+ 'forwards',
+ 'backwards',
+ 'both',
+])
+const animationPlayStateKeywords = new Set(['running', 'paused'])
+const cssColorKeywords = new Set([
+ 'aliceblue',
+ 'antiquewhite',
+ 'aqua',
+ 'aquamarine',
+ 'azure',
+ 'beige',
+ 'bisque',
+ 'black',
+ 'blanchedalmond',
+ 'blue',
+ 'blueviolet',
+ 'brown',
+ 'burlywood',
+ 'cadetblue',
+ 'chartreuse',
+ 'chocolate',
+ 'coral',
+ 'cornflowerblue',
+ 'cornsilk',
+ 'crimson',
+ 'cyan',
+ 'darkblue',
+ 'darkcyan',
+ 'darkgoldenrod',
+ 'darkgray',
+ 'darkgreen',
+ 'darkgrey',
+ 'darkkhaki',
+ 'darkmagenta',
+ 'darkolivegreen',
+ 'darkorange',
+ 'darkorchid',
+ 'darkred',
+ 'darksalmon',
+ 'darkseagreen',
+ 'darkslateblue',
+ 'darkslategray',
+ 'darkslategrey',
+ 'darkturquoise',
+ 'darkviolet',
+ 'deeppink',
+ 'deepskyblue',
+ 'dimgray',
+ 'dimgrey',
+ 'dodgerblue',
+ 'firebrick',
+ 'floralwhite',
+ 'forestgreen',
+ 'fuchsia',
+ 'gainsboro',
+ 'ghostwhite',
+ 'gold',
+ 'goldenrod',
+ 'gray',
+ 'green',
+ 'greenyellow',
+ 'grey',
+ 'honeydew',
+ 'hotpink',
+ 'indianred',
+ 'indigo',
+ 'ivory',
+ 'khaki',
+ 'lavender',
+ 'lavenderblush',
+ 'lawngreen',
+ 'lemonchiffon',
+ 'lightblue',
+ 'lightcoral',
+ 'lightcyan',
+ 'lightgoldenrodyellow',
+ 'lightgray',
+ 'lightgreen',
+ 'lightgrey',
+ 'lightpink',
+ 'lightsalmon',
+ 'lightseagreen',
+ 'lightskyblue',
+ 'lightslategray',
+ 'lightslategrey',
+ 'lightsteelblue',
+ 'lightyellow',
+ 'lime',
+ 'limegreen',
+ 'linen',
+ 'magenta',
+ 'maroon',
+ 'mediumaquamarine',
+ 'mediumblue',
+ 'mediumorchid',
+ 'mediumpurple',
+ 'mediumseagreen',
+ 'mediumslateblue',
+ 'mediumspringgreen',
+ 'mediumturquoise',
+ 'mediumvioletred',
+ 'midnightblue',
+ 'mintcream',
+ 'mistyrose',
+ 'moccasin',
+ 'navajowhite',
+ 'navy',
+ 'oldlace',
+ 'olive',
+ 'olivedrab',
+ 'orange',
+ 'orangered',
+ 'orchid',
+ 'palegoldenrod',
+ 'palegreen',
+ 'paleturquoise',
+ 'palevioletred',
+ 'papayawhip',
+ 'peachpuff',
+ 'peru',
+ 'pink',
+ 'plum',
+ 'powderblue',
+ 'purple',
+ 'rebeccapurple',
+ 'red',
+ 'rosybrown',
+ 'royalblue',
+ 'saddlebrown',
+ 'salmon',
+ 'sandybrown',
+ 'seagreen',
+ 'seashell',
+ 'sienna',
+ 'silver',
+ 'skyblue',
+ 'slateblue',
+ 'slategray',
+ 'slategrey',
+ 'snow',
+ 'springgreen',
+ 'steelblue',
+ 'tan',
+ 'teal',
+ 'thistle',
+ 'tomato',
+ 'transparent',
+ 'turquoise',
+ 'violet',
+ 'wheat',
+ 'white',
+ 'whitesmoke',
+ 'yellow',
+ 'yellowgreen',
+])
+
+const shorthandTransforms: Record = {
+ animation: transformAnimation,
+ animationDelay: transformAnimationLonghand,
+ animationDirection: transformAnimationLonghand,
+ animationDuration: transformAnimationLonghand,
+ animationFillMode: transformAnimationLonghand,
+ animationIterationCount: transformAnimationLonghand,
+ animationName: transformAnimationLonghand,
+ animationPlayState: transformAnimationLonghand,
+ animationTimingFunction: transformAnimationLonghand,
+ background: transformBackground,
+ backgroundImage: transformBackgroundImage,
+ border: transformBorder,
+ borderColor: transformDirectionalColor,
+ borderRadius: transformBorderRadius,
+ borderStyle: transformDirectionalBorderStyle,
+ borderWidth: transformDirectionalWidth,
+ boxShadow: passthroughString,
+ filter: passthroughString,
+ margin: transformMargin,
+ padding: transformPadding,
+ textShadow: transformTextShadow,
+ transform: transformTransform,
+ transition: transformTransition,
+ transitionDelay: transformTransitionLonghand,
+ transitionDuration: transformTransitionLonghand,
+ transitionProperty: transformTransitionLonghand,
+ transitionTimingFunction: transformTransitionLonghand,
+}
+
+export function transformDeclarations (
+ declarations: readonly CssDeclaration[],
+ options: TransformDeclarationOptions = {}
+): TransformDeclarationResult {
+ const style: TransformStyle = {}
+ const diagnostics: TransformDiagnostic[] = []
+ const shorthandBlacklist = new Set(options.shorthandBlacklist ?? [])
+ const context: PropertyTransformContext = {
+ platform: options.platform ?? 'react-native',
+ keyframes: options.keyframes ?? {},
+ }
+
+ const orderedDeclarations = declarations
+ .map((declaration, index) => ({ declaration, index }))
+ .sort((left, right) => {
+ const leftOrder = left.declaration.order ?? left.index
+ const rightOrder = right.declaration.order ?? right.index
+ return leftOrder - rightOrder || left.index - right.index
+ })
+
+ for (const { declaration } of orderedDeclarations) {
+ const property = getPropertyName(declaration.property)
+ const value = getDeclarationValue(declaration)
+
+ if (property.startsWith('--')) continue
+ if (value.length === 0) continue
+
+ try {
+ const transformer = shorthandBlacklist.has(property)
+ ? undefined
+ : shorthandTransforms[property]
+ const result =
+ transformer == null
+ ? transformRawProperty(property, value)
+ : transformer(property, value, declaration, context)
+
+ Object.assign(style, result.style)
+ if (result.diagnostics != null) diagnostics.push(...result.diagnostics)
+ } catch (error) {
+ if (options.onInvalid === 'throw') throw error
+ diagnostics.push({
+ code: 'INVALID_DECLARATION',
+ property: declaration.property,
+ value,
+ message:
+ error instanceof Error
+ ? error.message
+ : `Failed to parse declaration "${declaration.property}: ${value}"`,
+ order: declaration.order,
+ })
+ }
+ }
+
+ inlineAnimationKeyframes(style, context.keyframes)
+
+ return { style, diagnostics }
+}
+
+export function getPropertyName (property: string): string {
+ const trimmed = property.trim()
+ if (trimmed.startsWith('--')) return trimmed
+
+ return trimmed.replace(/-([a-z])/g, (_, character: string) =>
+ character.toUpperCase()
+ )
+}
+
+export function transformRawValue (value: string): TransformStyleValue {
+ const trimmed = value.trim()
+ const numberMatch = trimmed.match(numberOrLengthRe)
+
+ if (numberMatch != null) {
+ const number = Number(numberMatch[1])
+ const unit = numberMatch[2].toLowerCase()
+
+ if (unit === '' || unit === 'px') return number
+ if (unit === 'u') return number * 8
+ }
+
+ if (/^(?:true|false)$/i.test(trimmed)) {
+ return trimmed.toLowerCase() === 'true'
+ }
+ if (/^null$/i.test(trimmed)) return null
+ if (/^undefined$/i.test(trimmed)) return undefined
+
+ return trimmed
+}
+
+function getDeclarationValue (declaration: CssDeclaration): string {
+ if (typeof declaration.value === 'string') return declaration.value.trim()
+ if (typeof declaration.raw === 'string') {
+ const raw = declaration.raw.trim()
+ const colonIndex = raw.indexOf(':')
+ if (colonIndex === -1) return raw
+ return raw.slice(colonIndex + 1).replace(/;$/, '').trim()
+ }
+ return ''
+}
+
+function transformRawProperty (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ return { style: { [property]: transformRawValue(value) } }
+}
+
+function passthroughString (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ return { style: { [property]: value.trim() } }
+}
+
+function transformMargin (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ return {
+ style: expandDirectionalValues({
+ directions: ['Top', 'Right', 'Bottom', 'Left'],
+ prefix: property,
+ values: parseDirectionalValues(value, valueToken =>
+ parseLength(valueToken, { allowAuto: true, allowPercent: true })
+ ),
+ }),
+ }
+}
+
+function transformPadding (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ return {
+ style: expandDirectionalValues({
+ directions: ['Top', 'Right', 'Bottom', 'Left'],
+ prefix: property,
+ values: parseDirectionalValues(value, valueToken =>
+ parseLength(valueToken, { allowPercent: true })
+ ),
+ }),
+ }
+}
+
+function transformDirectionalWidth (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ return {
+ style: expandDirectionalValues({
+ directions: ['Top', 'Right', 'Bottom', 'Left'],
+ prefix: 'border',
+ suffix: 'Width',
+ values: parseDirectionalValues(value, valueToken =>
+ parseLength(valueToken, { allowPercent: false })
+ ),
+ }),
+ }
+}
+
+function transformDirectionalColor (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ return {
+ style: expandDirectionalValues({
+ directions: ['Top', 'Right', 'Bottom', 'Left'],
+ prefix: 'border',
+ suffix: 'Color',
+ values: parseDirectionalValues(value, parseColor),
+ }),
+ }
+}
+
+function transformDirectionalBorderStyle (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ return {
+ style: expandDirectionalValues({
+ directions: ['Top', 'Right', 'Bottom', 'Left'],
+ prefix: 'border',
+ suffix: 'Style',
+ values: parseDirectionalValues(value, parseBorderStyle),
+ }),
+ }
+}
+
+function transformBorderRadius (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ if (value.includes('/')) {
+ throw new Error(`Unsupported elliptical border-radius "${value}"`)
+ }
+
+ return {
+ style: expandDirectionalValues({
+ directions: ['TopLeft', 'TopRight', 'BottomRight', 'BottomLeft'],
+ prefix: 'border',
+ suffix: 'Radius',
+ values: parseDirectionalValues(value, valueToken =>
+ parseLength(valueToken, { allowPercent: false })
+ ),
+ }),
+ }
+}
+
+function transformBorder (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ const trimmed = value.trim()
+ if (trimmed.toLowerCase() === 'none') {
+ return {
+ style: {
+ borderWidth: 0,
+ borderColor: 'black',
+ borderStyle: 'solid',
+ },
+ }
+ }
+
+ const tokens = splitByWhitespace(trimmed)
+ if (tokens.length === 0 || tokens.length > 3) {
+ throw new Error(`Unsupported border shorthand "${value}"`)
+ }
+
+ let borderWidth: TransformStyleValue | undefined
+ let borderColor: string | undefined
+ let borderStyle: string | undefined
+
+ for (const token of tokens) {
+ if (borderWidth === undefined && isLength(token, false)) {
+ borderWidth = parseLength(token, { allowPercent: false })
+ } else if (borderColor === undefined && isColor(token)) {
+ borderColor = token
+ } else if (
+ borderStyle === undefined &&
+ borderStyles.has(token.toLowerCase())
+ ) {
+ borderStyle = token.toLowerCase()
+ } else {
+ throw new Error(`Unsupported border shorthand "${value}"`)
+ }
+ }
+
+ return {
+ style: {
+ borderWidth: borderWidth ?? 1,
+ borderColor: borderColor ?? 'black',
+ borderStyle: borderStyle ?? 'solid',
+ },
+ }
+}
+
+function transformTransform (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ if (value.trim().toLowerCase() === 'none') {
+ return { style: { transform: [] } }
+ }
+
+ const parts = parseFunctionSequence(value)
+ const transforms: TransformStyleValue[] = []
+
+ for (const part of parts) {
+ const args = parseFunctionArguments(part.arguments)
+ const transformed = transformTransformFunction(part.name, args)
+ transforms.unshift(...transformed)
+ }
+
+ return { style: { transform: transforms } }
+}
+
+function transformTransformFunction (
+ name: string,
+ args: readonly string[]
+): TransformStyle[] {
+ if (name === 'perspective') {
+ expectArgumentCount(name, args, 1, 1)
+ return [{ perspective: parseNumber(args[0]) }]
+ }
+
+ if (name === 'scale') {
+ expectArgumentCount(name, args, 1, 2)
+ const x = parseNumber(args[0])
+ if (args.length === 1) return [{ scale: x }]
+ return [{ scaleY: parseNumber(args[1]) }, { scaleX: x }]
+ }
+
+ if (name === 'scaleX' || name === 'scaleY') {
+ expectArgumentCount(name, args, 1, 1)
+ return [{ [name]: parseNumber(args[0]) }]
+ }
+
+ if (name === 'translate') {
+ expectArgumentCount(name, args, 1, 2)
+ const x = parseLength(args[0], { allowPercent: true })
+ const y =
+ args.length === 2 ? parseLength(args[1], { allowPercent: true }) : 0
+ return [{ translateY: y }, { translateX: x }]
+ }
+
+ if (name === 'translateX' || name === 'translateY') {
+ expectArgumentCount(name, args, 1, 1)
+ return [{ [name]: parseLength(args[0], { allowPercent: true }) }]
+ }
+
+ if (
+ name === 'rotate' ||
+ name === 'rotateX' ||
+ name === 'rotateY' ||
+ name === 'rotateZ' ||
+ name === 'skewX' ||
+ name === 'skewY'
+ ) {
+ expectArgumentCount(name, args, 1, 1)
+ return [{ [name]: parseAngle(args[0]) }]
+ }
+
+ if (name === 'skew') {
+ expectArgumentCount(name, args, 1, 2)
+ return [
+ { skewY: args.length === 2 ? parseAngle(args[1]) : '0deg' },
+ { skewX: parseAngle(args[0]) },
+ ]
+ }
+
+ throw new Error(`Unsupported transform function "${name}"`)
+}
+
+function transformTextShadow (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ const trimmed = value.trim()
+ if (trimmed.toLowerCase() === 'none') {
+ return {
+ style: {
+ textShadowOffset: { width: 0, height: 0 },
+ textShadowRadius: 0,
+ textShadowColor: 'black',
+ },
+ }
+ }
+
+ const tokens = splitByWhitespace(trimmed)
+ let color: string | undefined
+ const lengths: TransformStyleValue[] = []
+
+ for (const token of tokens) {
+ if (color === undefined && isColor(token)) {
+ color = token
+ } else if (isLength(token, false)) {
+ lengths.push(parseLength(token, { allowPercent: false }))
+ } else {
+ throw new Error(`Unsupported text-shadow "${value}"`)
+ }
+ }
+
+ if (lengths.length < 2 || lengths.length > 3) {
+ throw new Error(`Unsupported text-shadow "${value}"`)
+ }
+
+ return {
+ style: {
+ textShadowOffset: { width: lengths[0], height: lengths[1] },
+ textShadowRadius: lengths[2] ?? 0,
+ textShadowColor: color ?? 'black',
+ },
+ }
+}
+
+function transformAnimation (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ const trimmed = value.trim()
+ if (trimmed.toLowerCase() === 'none') {
+ return {
+ style: {
+ animationName: 'none',
+ animationDuration: '0s',
+ animationTimingFunction: 'ease',
+ animationDelay: '0s',
+ animationIterationCount: 1,
+ animationDirection: 'normal',
+ animationFillMode: 'none',
+ animationPlayState: 'running',
+ },
+ }
+ }
+
+ const animations = splitTopLevel(trimmed, ',').map(parseSingleAnimation)
+ const isSingle = animations.length === 1
+
+ return {
+ style: {
+ animationName: singleOrArray(
+ animations.map(animation => animation.name),
+ isSingle
+ ),
+ animationDuration: singleOrArray(
+ animations.map(animation => animation.duration),
+ isSingle
+ ),
+ animationTimingFunction: singleOrArray(
+ animations.map(animation => animation.timingFunction),
+ isSingle
+ ),
+ animationDelay: singleOrArray(
+ animations.map(animation => animation.delay),
+ isSingle
+ ),
+ animationIterationCount: singleOrArray(
+ animations.map(animation => animation.iterationCount),
+ isSingle
+ ),
+ animationDirection: singleOrArray(
+ animations.map(animation => animation.direction),
+ isSingle
+ ),
+ animationFillMode: singleOrArray(
+ animations.map(animation => animation.fillMode),
+ isSingle
+ ),
+ animationPlayState: singleOrArray(
+ animations.map(animation => animation.playState),
+ isSingle
+ ),
+ },
+ }
+}
+
+function transformAnimationLonghand (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ if (property === 'animationName') {
+ return {
+ style: { animationName: parseCommaSeparated(value, parseIdentifier) },
+ }
+ }
+ if (property === 'animationDuration') {
+ return {
+ style: { animationDuration: parseCommaSeparated(value, parseTime) },
+ }
+ }
+ if (property === 'animationTimingFunction') {
+ return {
+ style: {
+ animationTimingFunction: parseCommaSeparated(
+ value,
+ parseTimingFunction
+ ),
+ },
+ }
+ }
+ if (property === 'animationDelay') {
+ return { style: { animationDelay: parseCommaSeparated(value, parseTime) } }
+ }
+ if (property === 'animationIterationCount') {
+ return {
+ style: {
+ animationIterationCount: parseCommaSeparated(
+ value,
+ parseIterationCount
+ ),
+ },
+ }
+ }
+ if (property === 'animationDirection') {
+ return {
+ style: {
+ animationDirection: parseCommaSeparated(value, valueToken =>
+ parseKeyword(valueToken, animationDirectionKeywords)
+ ),
+ },
+ }
+ }
+ if (property === 'animationFillMode') {
+ return {
+ style: {
+ animationFillMode: parseCommaSeparated(value, valueToken =>
+ parseKeyword(valueToken, animationFillModeKeywords)
+ ),
+ },
+ }
+ }
+ if (property === 'animationPlayState') {
+ return {
+ style: {
+ animationPlayState: parseCommaSeparated(value, valueToken =>
+ parseKeyword(valueToken, animationPlayStateKeywords)
+ ),
+ },
+ }
+ }
+
+ throw new Error(`Unsupported animation property "${property}"`)
+}
+
+function transformTransition (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ const trimmed = value.trim()
+ if (trimmed.toLowerCase() === 'none') {
+ return {
+ style: {
+ transitionProperty: 'none',
+ transitionDuration: '0s',
+ transitionTimingFunction: 'ease',
+ transitionDelay: '0s',
+ },
+ }
+ }
+
+ const transitions = splitTopLevel(trimmed, ',').map(parseSingleTransition)
+ const isSingle = transitions.length === 1
+
+ return {
+ style: {
+ transitionProperty: singleOrArray(
+ transitions.map(transition => transition.property),
+ isSingle
+ ),
+ transitionDuration: singleOrArray(
+ transitions.map(transition => transition.duration),
+ isSingle
+ ),
+ transitionTimingFunction: singleOrArray(
+ transitions.map(transition => transition.timingFunction),
+ isSingle
+ ),
+ transitionDelay: singleOrArray(
+ transitions.map(transition => transition.delay),
+ isSingle
+ ),
+ },
+ }
+}
+
+function transformTransitionLonghand (
+ property: string,
+ value: string
+): PropertyTransformResult {
+ if (property === 'transitionProperty') {
+ return {
+ style: {
+ transitionProperty: parseCommaSeparated(
+ value,
+ parseTransitionProperty
+ ),
+ },
+ }
+ }
+ if (property === 'transitionDuration') {
+ return {
+ style: { transitionDuration: parseCommaSeparated(value, parseTime) },
+ }
+ }
+ if (property === 'transitionTimingFunction') {
+ return {
+ style: {
+ transitionTimingFunction: parseCommaSeparated(
+ value,
+ parseTimingFunction
+ ),
+ },
+ }
+ }
+ if (property === 'transitionDelay') {
+ return { style: { transitionDelay: parseCommaSeparated(value, parseTime) } }
+ }
+
+ throw new Error(`Unsupported transition property "${property}"`)
+}
+
+function transformBackgroundImage (
+ property: string,
+ value: string,
+ declaration: CssDeclaration,
+ context: PropertyTransformContext
+): PropertyTransformResult {
+ const trimmed = value.trim()
+ if (!isSupportedBackgroundImageValue(trimmed)) {
+ return {
+ style: {},
+ diagnostics: [
+ createDiagnostic(
+ 'UNSUPPORTED_BACKGROUND_IMAGE',
+ property,
+ value,
+ `Unsupported background image "${value}"`,
+ declaration
+ ),
+ ],
+ }
+ }
+
+ return {
+ style: {
+ [backgroundImageProperty(context.platform)]: trimmed,
+ },
+ }
+}
+
+function transformBackground (
+ property: string,
+ value: string,
+ declaration: CssDeclaration,
+ context: PropertyTransformContext
+): PropertyTransformResult {
+ const trimmed = value.trim()
+
+ if (isColor(trimmed)) {
+ return { style: { backgroundColor: trimmed } }
+ }
+
+ if (isSupportedBackgroundImageValue(trimmed)) {
+ return {
+ style: { [backgroundImageProperty(context.platform)]: trimmed },
+ }
+ }
+
+ if (containsUnsupportedBackgroundImage(trimmed)) {
+ return {
+ style: {},
+ diagnostics: [
+ createDiagnostic(
+ 'UNSUPPORTED_BACKGROUND_IMAGE',
+ property,
+ value,
+ `Unsupported background image "${value}"`,
+ declaration
+ ),
+ ],
+ }
+ }
+
+ const tokens = splitByWhitespace(trimmed)
+ if (tokens.length === 2) {
+ const firstIsColor = isColor(tokens[0])
+ const secondIsColor = isColor(tokens[1])
+ const firstIsImage = isSupportedBackgroundImageValue(tokens[0])
+ const secondIsImage = isSupportedBackgroundImageValue(tokens[1])
+
+ if (firstIsColor && secondIsImage) {
+ return {
+ style: {
+ backgroundColor: tokens[0],
+ [backgroundImageProperty(context.platform)]: tokens[1],
+ },
+ }
+ }
+
+ if (firstIsImage && secondIsColor) {
+ return {
+ style: {
+ backgroundColor: tokens[1],
+ [backgroundImageProperty(context.platform)]: tokens[0],
+ },
+ }
+ }
+ }
+
+ return {
+ style: {},
+ diagnostics: [
+ createDiagnostic(
+ 'UNSUPPORTED_BACKGROUND_SHORTHAND',
+ property,
+ value,
+ `Unsupported background shorthand "${value}"`,
+ declaration
+ ),
+ ],
+ }
+}
+
+function parseSingleAnimation (value: string): {
+ name: string
+ duration: string
+ timingFunction: string
+ delay: string
+ iterationCount: string | number
+ direction: string
+ fillMode: string
+ playState: string
+} {
+ const tokens = splitByWhitespace(value)
+ let name: string | undefined
+ let duration: string | undefined
+ let timingFunction: string | undefined
+ let delay: string | undefined
+ let iterationCount: string | number | undefined
+ let direction: string | undefined
+ let fillMode: string | undefined
+ let playState: string | undefined
+
+ for (const token of tokens) {
+ const lower = token.toLowerCase()
+
+ if (isTime(token)) {
+ if (duration == null) duration = token
+ else if (delay == null) delay = token
+ else throw new Error(`Unsupported animation "${value}"`)
+ } else if (isTimingFunction(token)) {
+ timingFunction = token
+ } else if (animationDirectionKeywords.has(lower)) {
+ direction = lower
+ } else if (animationFillModeKeywords.has(lower)) {
+ fillMode = lower
+ } else if (animationPlayStateKeywords.has(lower)) {
+ playState = lower
+ } else if (lower === 'infinite') {
+ iterationCount = 'infinite'
+ } else if (numberRe.test(token)) {
+ iterationCount = Number(token)
+ } else {
+ name = token
+ }
+ }
+
+ return {
+ name: name ?? 'none',
+ duration: duration ?? '0s',
+ timingFunction: timingFunction ?? 'ease',
+ delay: delay ?? '0s',
+ iterationCount: iterationCount ?? 1,
+ direction: direction ?? 'normal',
+ fillMode: fillMode ?? 'none',
+ playState: playState ?? 'running',
+ }
+}
+
+function parseSingleTransition (value: string): {
+ property: string
+ duration: string
+ timingFunction: string
+ delay: string
+} {
+ const tokens = splitByWhitespace(value)
+ let property: string | undefined
+ let duration: string | undefined
+ let timingFunction: string | undefined
+ let delay: string | undefined
+
+ for (const token of tokens) {
+ if (isTime(token)) {
+ if (duration == null) duration = token
+ else if (delay == null) delay = token
+ else throw new Error(`Unsupported transition "${value}"`)
+ } else if (isTimingFunction(token)) {
+ timingFunction = token
+ } else {
+ property = token
+ }
+ }
+
+ return {
+ property: parseTransitionProperty(property ?? 'all'),
+ duration: duration ?? '0s',
+ timingFunction: timingFunction ?? 'ease',
+ delay: delay ?? '0s',
+ }
+}
+
+function parseDirectionalValues (
+ value: string,
+ parseValue: (value: string) => TransformStyleValue
+): TransformStyleValue[] {
+ const tokens = splitByWhitespace(value)
+ if (tokens.length < 1 || tokens.length > 4) {
+ throw new Error(`Expected 1 to 4 values, got "${value}"`)
+ }
+ return tokens.map(parseValue)
+}
+
+function expandDirectionalValues (options: {
+ directions: readonly string[]
+ prefix: string
+ suffix?: string
+ values: readonly TransformStyleValue[]
+}): TransformStyle {
+ const [top, right = top, bottom = top, left = right] = options.values
+ const suffix = options.suffix ?? ''
+ const values = [top, right, bottom, left]
+ const style: TransformStyle = {}
+
+ for (let index = 0; index < options.directions.length; index += 1) {
+ style[`${options.prefix}${options.directions[index]}${suffix}`] =
+ values[index]
+ }
+
+ return style
+}
+
+function parseLength (
+ value: string,
+ options: { allowAuto?: boolean; allowPercent?: boolean } = {}
+): TransformStyleValue {
+ const trimmed = value.trim()
+ const lower = trimmed.toLowerCase()
+
+ if (options.allowAuto === true && lower === 'auto') return 'auto'
+ if (isCalc(trimmed)) return trimmed
+
+ const match = trimmed.match(numberOrLengthRe)
+ if (match == null) {
+ throw new Error(`Expected length value, got "${value}"`)
+ }
+
+ const number = Number(match[1])
+ const unit = match[2].toLowerCase()
+
+ if (unit === '') {
+ if (number === 0) return 0
+ throw new Error(`Expected length unit in "${value}"`)
+ }
+ if (unit === 'px') return number
+ if (unit === 'u') return number * 8
+ if (unit === '%') {
+ if (options.allowPercent === true) return `${match[1]}%`
+ throw new Error(`Percentage is not supported in "${value}"`)
+ }
+ if (supportedLengthUnits.has(unit)) return trimmed
+
+ throw new Error(`Unsupported length unit in "${value}"`)
+}
+
+function parseNumber (value: string): number {
+ const trimmed = value.trim()
+ if (!numberRe.test(trimmed)) {
+ throw new Error(`Expected number value, got "${value}"`)
+ }
+ return Number(trimmed)
+}
+
+function parseAngle (value: string): string {
+ const trimmed = value.trim()
+ if (!angleRe.test(trimmed)) {
+ throw new Error(`Expected angle value, got "${value}"`)
+ }
+ return trimmed.toLowerCase()
+}
+
+function parseColor (value: string): string {
+ const trimmed = value.trim()
+ if (!isColor(trimmed)) throw new Error(`Expected color value, got "${value}"`)
+ return trimmed
+}
+
+function parseBorderStyle (value: string): string {
+ const lower = value.trim().toLowerCase()
+ if (!borderStyles.has(lower)) {
+ throw new Error(`Expected border style value, got "${value}"`)
+ }
+ return lower
+}
+
+function parseTime (value: string): string {
+ const trimmed = value.trim()
+ if (!isTime(trimmed)) throw new Error(`Expected time value, got "${value}"`)
+ return trimmed
+}
+
+function parseTimingFunction (value: string): string {
+ const trimmed = value.trim()
+ if (!isTimingFunction(trimmed)) {
+ throw new Error(`Expected timing function value, got "${value}"`)
+ }
+ return trimmed
+}
+
+function parseIterationCount (value: string): string | number {
+ const trimmed = value.trim()
+ if (trimmed.toLowerCase() === 'infinite') return 'infinite'
+ if (numberRe.test(trimmed)) return Number(trimmed)
+ throw new Error(`Expected iteration count value, got "${value}"`)
+}
+
+function parseIdentifier (value: string): string {
+ const trimmed = value.trim()
+ if (!/^[-_a-z][-_a-z0-9]*$/i.test(trimmed) && trimmed !== 'none') {
+ throw new Error(`Expected identifier value, got "${value}"`)
+ }
+ return trimmed
+}
+
+function parseKeyword (value: string, keywords: ReadonlySet): string {
+ const lower = value.trim().toLowerCase()
+ if (!keywords.has(lower)) {
+ throw new Error(`Expected one of ${Array.from(keywords).join(', ')}`)
+ }
+ return lower
+}
+
+function parseTransitionProperty (value: string): string {
+ const trimmed = value.trim()
+ if (trimmed === 'all' || trimmed === 'none') return trimmed
+ return getPropertyName(trimmed)
+}
+
+function parseCommaSeparated (
+ value: string,
+ parseValue: (value: string) => T
+): T | T[] {
+ const values = splitTopLevel(value, ',').map(parseValue)
+ return values.length === 1 ? values[0] : values
+}
+
+function singleOrArray (values: T[], isSingle: boolean): T | T[] {
+ return isSingle ? values[0] : values
+}
+
+function inlineAnimationKeyframes (
+ style: TransformStyle,
+ keyframes: Record
+): void {
+ if (style.animationName == null) return
+
+ if (Array.isArray(style.animationName)) {
+ style.animationName = style.animationName.map(value =>
+ typeof value === 'string' && value !== 'none' && keyframes[value] != null
+ ? keyframes[value]
+ : value
+ )
+ return
+ }
+
+ if (
+ typeof style.animationName === 'string' &&
+ style.animationName !== 'none' &&
+ keyframes[style.animationName] != null
+ ) {
+ style.animationName = keyframes[style.animationName]
+ }
+}
+
+function isLength (value: string, allowPercent: boolean): boolean {
+ try {
+ parseLength(value, { allowPercent })
+ return true
+ } catch {
+ return false
+ }
+}
+
+function isColor (value: string): boolean {
+ const trimmed = value.trim()
+ const lower = trimmed.toLowerCase()
+ return (
+ hexColorRe.test(trimmed) ||
+ colorFunctionRe.test(trimmed) ||
+ cssColorKeywords.has(lower) ||
+ lower === 'currentcolor'
+ )
+}
+
+function isTime (value: string): boolean {
+ return timeRe.test(value.trim())
+}
+
+function isTimingFunction (value: string): boolean {
+ const trimmed = value.trim()
+ const lower = trimmed.toLowerCase()
+
+ return (
+ timingFunctionKeywords.has(lower) ||
+ isFunctionToken(trimmed, 'cubic-bezier') ||
+ isFunctionToken(trimmed, 'steps') ||
+ isFunctionToken(trimmed, 'linear')
+ )
+}
+
+function isCalc (value: string): boolean {
+ return isFunctionToken(value.trim(), 'calc')
+}
+
+function isSupportedBackgroundImageValue (value: string): boolean {
+ const trimmed = value.trim()
+ if (trimmed.toLowerCase() === 'none') return true
+
+ const layers = splitTopLevel(trimmed, ',')
+ return (
+ layers.length > 0 &&
+ layers.every(
+ layer =>
+ isFunctionToken(layer, 'linear-gradient') ||
+ isFunctionToken(layer, 'radial-gradient')
+ )
+ )
+}
+
+function containsUnsupportedBackgroundImage (value: string): boolean {
+ return /\b(?:url|image-set|cross-fade|element|paint)\s*\(/i.test(value)
+}
+
+function backgroundImageProperty (platform: CssPlatform): string {
+ return platform === 'web' ? 'backgroundImage' : 'experimental_backgroundImage'
+}
+
+function isFunctionToken (value: string, functionName: string): boolean {
+ const trimmed = value.trim()
+ if (!trimmed.toLowerCase().startsWith(`${functionName.toLowerCase()}(`)) {
+ return false
+ }
+ const openIndex = trimmed.indexOf('(')
+ return findMatchingParen(trimmed, openIndex) === trimmed.length - 1
+}
+
+function parseFunctionSequence (
+ value: string
+): Array<{ name: string; arguments: string }> {
+ const functions: Array<{ name: string; arguments: string }> = []
+ let index = 0
+ const source = value.trim()
+
+ while (index < source.length) {
+ while (/\s/.test(source[index] ?? '')) index += 1
+ if (index >= source.length) break
+
+ const nameMatch = source.slice(index).match(/^[-_a-z][-_a-z0-9]*/i)
+ if (nameMatch == null) {
+ throw new Error(`Expected transform function in "${value}"`)
+ }
+
+ const name = nameMatch[0]
+ index += name.length
+ if (source[index] !== '(') {
+ throw new Error(`Expected "(" after transform function "${name}"`)
+ }
+
+ const closeIndex = findMatchingParen(source, index)
+ if (closeIndex === -1) {
+ throw new Error(`Unclosed transform function "${name}"`)
+ }
+
+ functions.push({
+ name,
+ arguments: source.slice(index + 1, closeIndex),
+ })
+ index = closeIndex + 1
+ }
+
+ if (functions.length === 0) {
+ throw new Error(`Expected transform value, got "${value}"`)
+ }
+
+ return functions
+}
+
+function parseFunctionArguments (value: string): string[] {
+ const commaParts = splitTopLevel(value, ',')
+ if (commaParts.length > 1) return commaParts
+ return splitByWhitespace(value)
+}
+
+function expectArgumentCount (
+ functionName: string,
+ args: readonly string[],
+ min: number,
+ max: number
+): void {
+ if (args.length < min || args.length > max) {
+ throw new Error(
+ `Expected ${functionName}() to have ${min === max ? min : `${min}-${max}`} arguments`
+ )
+ }
+}
+
+function splitByWhitespace (value: string): string[] {
+ const parts: string[] = []
+ let current = ''
+ let depth = 0
+ let quote: string | null = null
+ let escaped = false
+
+ for (let index = 0; index < value.length; index += 1) {
+ const character = value[index]
+
+ if (escaped) {
+ current += character
+ escaped = false
+ continue
+ }
+
+ if (character === '\\') {
+ current += character
+ escaped = true
+ continue
+ }
+
+ if (quote != null) {
+ current += character
+ if (character === quote) quote = null
+ continue
+ }
+
+ if (character === '"' || character === "'") {
+ current += character
+ quote = character
+ continue
+ }
+
+ if (character === '(') {
+ depth += 1
+ current += character
+ continue
+ }
+
+ if (character === ')') {
+ depth -= 1
+ if (depth < 0) throw new Error(`Unexpected ")" in "${value}"`)
+ current += character
+ continue
+ }
+
+ if (depth === 0 && /\s/.test(character)) {
+ if (current.length > 0) {
+ parts.push(current)
+ current = ''
+ }
+ continue
+ }
+
+ current += character
+ }
+
+ if (quote != null) throw new Error(`Unclosed string in "${value}"`)
+ if (depth !== 0) throw new Error(`Unclosed function in "${value}"`)
+ if (current.length > 0) parts.push(current)
+
+ return parts
+}
+
+function splitTopLevel (value: string, separator: string): string[] {
+ const parts: string[] = []
+ let current = ''
+ let depth = 0
+ let quote: string | null = null
+ let escaped = false
+
+ for (let index = 0; index < value.length; index += 1) {
+ const character = value[index]
+
+ if (escaped) {
+ current += character
+ escaped = false
+ continue
+ }
+
+ if (character === '\\') {
+ current += character
+ escaped = true
+ continue
+ }
+
+ if (quote != null) {
+ current += character
+ if (character === quote) quote = null
+ continue
+ }
+
+ if (character === '"' || character === "'") {
+ current += character
+ quote = character
+ continue
+ }
+
+ if (character === '(') {
+ depth += 1
+ current += character
+ continue
+ }
+
+ if (character === ')') {
+ depth -= 1
+ if (depth < 0) throw new Error(`Unexpected ")" in "${value}"`)
+ current += character
+ continue
+ }
+
+ if (depth === 0 && character === separator) {
+ const part = current.trim()
+ if (part.length === 0) throw new Error(`Empty value in "${value}"`)
+ parts.push(part)
+ current = ''
+ continue
+ }
+
+ current += character
+ }
+
+ if (quote != null) throw new Error(`Unclosed string in "${value}"`)
+ if (depth !== 0) throw new Error(`Unclosed function in "${value}"`)
+
+ const part = current.trim()
+ if (part.length === 0) throw new Error(`Empty value in "${value}"`)
+ parts.push(part)
+
+ return parts
+}
+
+function findMatchingParen (value: string, openIndex: number): number {
+ let depth = 0
+ let quote: string | null = null
+ let escaped = false
+
+ for (let index = openIndex; index < value.length; index += 1) {
+ const character = value[index]
+
+ if (escaped) {
+ escaped = false
+ continue
+ }
+
+ if (character === '\\') {
+ escaped = true
+ continue
+ }
+
+ if (quote != null) {
+ if (character === quote) quote = null
+ continue
+ }
+
+ if (character === '"' || character === "'") {
+ quote = character
+ continue
+ }
+
+ if (character === '(') {
+ depth += 1
+ continue
+ }
+
+ if (character === ')') {
+ depth -= 1
+ if (depth === 0) return index
+ if (depth < 0) return -1
+ }
+ }
+
+ return -1
+}
+
+function createDiagnostic (
+ code: TransformDiagnosticCode,
+ property: string,
+ value: string,
+ message: string,
+ declaration: CssDeclaration
+): TransformDiagnostic {
+ return {
+ code,
+ property,
+ value,
+ message,
+ order: declaration.order,
+ }
+}
diff --git a/packages/css-to-rn/src/types.ts b/packages/css-to-rn/src/types.ts
new file mode 100644
index 0000000..54bc03d
--- /dev/null
+++ b/packages/css-to-rn/src/types.ts
@@ -0,0 +1,112 @@
+export type CompileMode = 'runtime' | 'build'
+
+export type CssxDiagnosticLevel = 'warning' | 'error'
+
+export type CssxDiagnosticCode =
+ | 'CSS_SYNTAX_ERROR'
+ | 'UNSUPPORTED_SELECTOR'
+ | 'UNSUPPORTED_AT_RULE'
+ | 'INVALID_DECLARATION'
+ | 'UNRESOLVED_VARIABLE'
+ | 'VARIABLE_CYCLE'
+ | 'VARIABLE_DEPTH_LIMIT'
+ | 'UNSUPPORTED_INTERPOLATION_POSITION'
+ | 'INVALID_INTERPOLATION_VALUE'
+ | 'UNSUPPORTED_CALC'
+ | 'UNSUPPORTED_BACKGROUND_IMAGE'
+ | 'UNSUPPORTED_BACKGROUND_SHORTHAND'
+ | 'INVALID_THEME_BLOCK'
+ | 'INVALID_CUSTOM_MEDIA'
+ | 'DEPRECATED_UNIT'
+
+export interface CssxDiagnostic {
+ level: CssxDiagnosticLevel
+ code: CssxDiagnosticCode
+ message: string
+ line?: number
+ column?: number
+}
+
+export interface CompileCssOptions {
+ mode?: CompileMode
+ id?: string
+ sourceId?: string
+ contentHash?: string
+ sourceIdentity?: string
+ target?: CssxTarget
+}
+
+export interface CompileCssTemplateOptions extends CompileCssOptions {
+ dynamicSlotPrefix?: string
+}
+
+export type CssxTarget = 'react-native' | 'web'
+
+export interface CssxMetadata {
+ hasVars: boolean
+ vars: string[]
+ hasMedia: boolean
+ hasViewportUnits: boolean
+ hasInterpolations: boolean
+ hasDynamicRuntimeDependencies: boolean
+ hasAnimations: boolean
+ hasTransitions: boolean
+ hasThemes: boolean
+ hasCustomMedia: boolean
+}
+
+export interface CompiledCssSheet {
+ version: 1
+ id: string
+ sourceId?: string
+ contentHash: string
+ rules: CssxRule[]
+ keyframes: Record
+ rootVariables?: Record
+ themeVariables?: Record>
+ customMedia?: Record
+ exports?: Record
+ metadata: CssxMetadata
+ diagnostics: CssxDiagnostic[]
+ error?: CssxDiagnostic
+}
+
+export interface CssxRule {
+ selector: string
+ tag: string | null
+ classes: string[]
+ part: string | null
+ specificity: number
+ order: number
+ media: string | null
+ declarations: CssxDeclaration[]
+}
+
+export interface CssxDeclaration {
+ property: string
+ value: string
+ raw: string
+ order: number
+ dynamicSlots?: number[]
+ line?: number
+ column?: number
+}
+
+export interface CssxKeyframe {
+ selector: string
+ declarations: CssxDeclaration[]
+ order: number
+}
+
+export interface SelectorParseResult {
+ selector: string
+ tag: string | null
+ classes: string[]
+ part: string | null
+ specificity: number
+}
+
+export interface CompileState {
+ diagnostics: CssxDiagnostic[]
+ mode: CompileMode
+}
diff --git a/packages/css-to-rn/src/units.ts b/packages/css-to-rn/src/units.ts
new file mode 100644
index 0000000..f5e3bbe
--- /dev/null
+++ b/packages/css-to-rn/src/units.ts
@@ -0,0 +1,14 @@
+let warnedAboutU = false
+
+export function u (value: number): number {
+ if (!warnedAboutU && process.env.NODE_ENV !== 'production') {
+ warnedAboutU = true
+ console.warn('[cssx] u() is deprecated. Use rem, var(--spacing), or CSS instead. 1u equals 0.5rem or 8px.')
+ }
+
+ return value * 8
+}
+
+export function resetUWarningForTests (): void {
+ warnedAboutU = false
+}
diff --git a/packages/css-to-rn/src/values.ts b/packages/css-to-rn/src/values.ts
new file mode 100644
index 0000000..76a678d
--- /dev/null
+++ b/packages/css-to-rn/src/values.ts
@@ -0,0 +1,460 @@
+import { diagnostic } from './diagnostics.ts'
+import { evaluateCssColors } from './colors.ts'
+import type { CssxDiagnostic } from './types.ts'
+
+export type InterpolationValue = string | number | null | undefined | false
+
+export interface ResolveCssValueOptions {
+ values?: readonly unknown[]
+ variables?: Record
+ scopedVariables?: readonly Record[]
+ defaultVariables?: Record
+ dimensions?: {
+ width?: number
+ height?: number
+ }
+ maxVarDepth?: number
+ deprecateUUnits?: boolean
+}
+
+export interface ResolveCssValueResult {
+ value?: string
+ valid: boolean
+ dependencies: {
+ vars: string[]
+ dimensions: boolean
+ }
+ diagnostics: CssxDiagnostic[]
+}
+
+const DYNAMIC_SLOT_RE = /var\(\s*--__cssx_dynamic_(\d+)\s*\)/g
+const VAR_NAME_RE = /^--[A-Za-z0-9_-]+$/
+const REM_UNIT_RE = /(^|[^\w.-])([+-]?(?:\d*\.)?\d+)rem\b/g
+const VIEWPORT_UNIT_RE = /(^|[^\w.-])([+-]?(?:\d*\.)?\d+)(vh|vw|vmin|vmax)\b/g
+const U_UNIT_RE = /(^|[^\w.-])([+-]?(?:\d*\.)?\d+)u\b/g
+const CALC_RE = /calc\(/g
+
+export function resolveCssValue (
+ input: string,
+ options: ResolveCssValueOptions = {}
+): ResolveCssValueResult {
+ const diagnostics: CssxDiagnostic[] = []
+ const dependencies = {
+ vars: new Set(),
+ dimensions: false
+ }
+ const maxVarDepth = options.maxVarDepth ?? 20
+
+ const interpolation = replaceDynamicSlots(input, options.values ?? [], diagnostics)
+ if (!interpolation.valid) {
+ return invalid(diagnostics, dependencies)
+ }
+
+ const variableResolution = resolveVars(
+ interpolation.value,
+ options,
+ dependencies.vars,
+ diagnostics,
+ [],
+ maxVarDepth
+ )
+ if (!variableResolution.valid) {
+ return invalid(diagnostics, dependencies)
+ }
+
+ const units = resolveUnits(variableResolution.value, options, dependencies, diagnostics)
+ const calc = resolveCalcs(units.value, diagnostics)
+ if (!calc.valid) {
+ return invalid(diagnostics, dependencies)
+ }
+ const colors = evaluateCssColors(calc.value)
+
+ return {
+ value: colors.trim(),
+ valid: true,
+ dependencies: serializeDependencies(dependencies),
+ diagnostics
+ }
+}
+
+export function coerceCssValue (input: unknown): unknown {
+ if (typeof input !== 'string') return input
+
+ const value = evaluateCssColors(input.trim())
+ const number = Number(value)
+ if (value !== '' && Number.isFinite(number) && /^[-+]?(?:\d*\.)?\d+$/.test(value)) {
+ return number
+ }
+
+ const px = value.match(/^([-+]?(?:\d*\.)?\d+)px$/)
+ if (px) return Number(px[1])
+
+ return value
+}
+
+function replaceDynamicSlots (
+ input: string,
+ values: readonly unknown[],
+ diagnostics: CssxDiagnostic[]
+): { valid: true, value: string } | { valid: false } {
+ DYNAMIC_SLOT_RE.lastIndex = 0
+ let valid = true
+ const value = input.replace(DYNAMIC_SLOT_RE, (_match, rawIndex: string) => {
+ const index = Number(rawIndex)
+ const interpolation = values[index]
+ if (typeof interpolation === 'string') return interpolation
+ if (typeof interpolation === 'number') return String(interpolation)
+ if (interpolation === null || interpolation === undefined || interpolation === false) {
+ diagnostics.push(diagnostic(
+ 'INVALID_INTERPOLATION_VALUE',
+ `Interpolation slot ${index} resolved to an omitted value, so the declaration is invalid.`,
+ 'warning'
+ ))
+ valid = false
+ return ''
+ }
+
+ diagnostics.push(diagnostic(
+ 'INVALID_INTERPOLATION_VALUE',
+ `Interpolation slot ${index} resolved to unsupported value type "${typeof interpolation}".`,
+ 'warning'
+ ))
+ valid = false
+ return ''
+ })
+
+ return valid ? { valid: true, value } : { valid: false }
+}
+
+function resolveVars (
+ input: string,
+ options: ResolveCssValueOptions,
+ deps: Set,
+ diagnostics: CssxDiagnostic[],
+ stack: string[],
+ maxDepth: number
+): { valid: true, value: string } | { valid: false } {
+ if (stack.length > maxDepth) {
+ diagnostics.push(diagnostic(
+ 'VARIABLE_DEPTH_LIMIT',
+ `CSS variable resolution exceeded max depth ${maxDepth}.`,
+ 'warning'
+ ))
+ return { valid: false }
+ }
+
+ let output = input
+
+ while (true) {
+ const start = output.indexOf('var(')
+ if (start === -1) return { valid: true, value: output }
+
+ const open = start + 3
+ const close = findMatchingParen(output, open)
+ if (close === -1) {
+ diagnostics.push(diagnostic(
+ 'UNRESOLVED_VARIABLE',
+ 'Malformed var() expression.',
+ 'warning'
+ ))
+ return { valid: false }
+ }
+
+ const body = output.slice(open + 1, close)
+ const parts = splitTopLevelComma(body)
+ const name = parts[0]?.trim()
+ if (!name || !VAR_NAME_RE.test(name)) {
+ diagnostics.push(diagnostic(
+ 'UNRESOLVED_VARIABLE',
+ `Invalid CSS variable name "${name ?? ''}".`,
+ 'warning'
+ ))
+ return { valid: false }
+ }
+
+ deps.add(name)
+ if (stack.includes(name)) {
+ diagnostics.push(diagnostic(
+ 'VARIABLE_CYCLE',
+ `CSS variable cycle detected: ${stack.concat(name).join(' -> ')}.`,
+ 'warning'
+ ))
+ return { valid: false }
+ }
+
+ const fallback = parts.length > 1 ? parts.slice(1).join(',').trim() : undefined
+ const rawReplacement =
+ valueFromRecord(options.variables, name) ??
+ valueFromScopedRecords(options.scopedVariables, name) ??
+ valueFromRecord(options.defaultVariables, name) ??
+ fallback
+
+ if (rawReplacement === undefined) {
+ diagnostics.push(diagnostic(
+ 'UNRESOLVED_VARIABLE',
+ `CSS variable "${name}" is not defined and has no fallback.`,
+ 'warning'
+ ))
+ return { valid: false }
+ }
+
+ const nested = resolveVars(
+ String(rawReplacement),
+ options,
+ deps,
+ diagnostics,
+ stack.concat(name),
+ maxDepth
+ )
+ if (!nested.valid) return { valid: false }
+
+ output = output.slice(0, start) + nested.value + output.slice(close + 1)
+ }
+}
+
+function resolveUnits (
+ input: string,
+ options: ResolveCssValueOptions,
+ dependencies: { vars: Set, dimensions: boolean },
+ diagnostics: CssxDiagnostic[]
+): { value: string } {
+ const warnUUnits = options.deprecateUUnits && U_UNIT_RE.test(input)
+ U_UNIT_RE.lastIndex = 0
+ if (warnUUnits) {
+ diagnostics.push(diagnostic(
+ 'DEPRECATED_UNIT',
+ 'The CSSX "u" unit is deprecated. Use rem, var(--spacing), or calc(var(--spacing) * n).',
+ 'warning'
+ ))
+ }
+
+ let value = input.replace(U_UNIT_RE, (_match, prefix: string, rawNumber: string) => {
+ return `${prefix}${Number(rawNumber) * 8}px`
+ })
+
+ value = value.replace(REM_UNIT_RE, (_match, prefix: string, rawNumber: string) => {
+ return `${prefix}${Number(rawNumber) * 16}px`
+ })
+
+ const width = options.dimensions?.width ?? 0
+ const height = options.dimensions?.height ?? 0
+
+ value = value.replace(VIEWPORT_UNIT_RE, (_match, prefix: string, rawNumber: string, unit: string) => {
+ dependencies.dimensions = true
+ const number = Number(rawNumber)
+ const basis =
+ unit === 'vw'
+ ? width
+ : unit === 'vh'
+ ? height
+ : unit === 'vmin'
+ ? Math.min(width, height)
+ : Math.max(width, height)
+ return `${prefix}${number * basis / 100}px`
+ })
+
+ return { value }
+}
+
+function resolveCalcs (
+ input: string,
+ diagnostics: CssxDiagnostic[]
+): { valid: true, value: string } | { valid: false } {
+ let output = input
+ CALC_RE.lastIndex = 0
+
+ while (true) {
+ const start = output.indexOf('calc(')
+ if (start === -1) return { valid: true, value: output }
+ const open = start + 4
+ const close = findMatchingParen(output, open)
+ if (close === -1) {
+ diagnostics.push(diagnostic('UNSUPPORTED_CALC', 'Malformed calc() expression.', 'warning'))
+ return { valid: false }
+ }
+
+ const expression = output.slice(open + 1, close).trim()
+ const result = evaluateCalc(expression)
+ if (result == null) {
+ diagnostics.push(diagnostic(
+ 'UNSUPPORTED_CALC',
+ `Unsupported calc() expression "${expression}".`,
+ 'warning'
+ ))
+ return { valid: false }
+ }
+
+ output = output.slice(0, start) + String(result) + output.slice(close + 1)
+ }
+}
+
+function evaluateCalc (expression: string): string | null {
+ const unit = getCalcUnit(expression)
+ if (unit === false) return null
+
+ const normalized = expression.replace(/([+-]?(?:\d*\.)?\d+)(px\b|%)/g, (_match, number: string) => number)
+ if (!/^[0-9+\-*/().\s]+$/.test(normalized)) return null
+
+ let index = 0
+
+ const skipWhitespace = () => {
+ while (/\s/.test(normalized[index] ?? '')) index++
+ }
+
+ const parseNumber = (): number | null => {
+ skipWhitespace()
+ const match = normalized.slice(index).match(/^(?:(?:\d+\.\d+)|(?:\d+\.)|(?:\.\d+)|(?:\d+))/)
+ if (match == null) return null
+ index += match[0].length
+ return Number(match[0])
+ }
+
+ const parseFactor = (): number | null => {
+ skipWhitespace()
+
+ if (normalized[index] === '+') {
+ index++
+ return parseFactor()
+ }
+
+ if (normalized[index] === '-') {
+ index++
+ const value = parseFactor()
+ return value == null ? null : -value
+ }
+
+ if (normalized[index] === '(') {
+ index++
+ const value = parseAdditive()
+ skipWhitespace()
+ if (normalized[index] !== ')') return null
+ index++
+ return value
+ }
+
+ return parseNumber()
+ }
+
+ const parseMultiplicative = (): number | null => {
+ let value = parseFactor()
+ if (value == null) return null
+
+ while (true) {
+ skipWhitespace()
+ const operator = normalized[index]
+ if (operator !== '*' && operator !== '/') return value
+ index++
+
+ const right = parseFactor()
+ if (right == null) return null
+ value = operator === '*' ? value * right : value / right
+ }
+ }
+
+ function parseAdditive (): number | null {
+ let value = parseMultiplicative()
+ if (value == null) return null
+
+ while (true) {
+ skipWhitespace()
+ const operator = normalized[index]
+ if (operator !== '+' && operator !== '-') return value
+ index++
+
+ const right = parseMultiplicative()
+ if (right == null) return null
+ value = operator === '+' ? value + right : value - right
+ }
+ }
+
+ const result = parseAdditive()
+ skipWhitespace()
+
+ return result != null && index === normalized.length && Number.isFinite(result)
+ ? unit ? `${roundCalc(result)}${unit}` : String(roundCalc(result))
+ : null
+}
+
+function getCalcUnit (expression: string): 'px' | '%' | '' | false {
+ const units = new Set()
+ expression.replace(/(?:^|[^\w.-])[+-]?(?:\d*\.)?\d+(px\b|%)/g, (_match, unit: string) => {
+ units.add(unit === '%' ? '%' : 'px')
+ return ''
+ })
+
+ if (units.size > 1) return false
+ return (units.values().next().value ?? '') as 'px' | '%' | ''
+}
+
+function roundCalc (value: number): number {
+ return Math.round(value * 1000000) / 1000000
+}
+
+function findMatchingParen (input: string, openIndex: number): number {
+ let depth = 0
+ for (let index = openIndex; index < input.length; index++) {
+ const char = input[index]
+ if (char === '(') depth++
+ if (char === ')') {
+ depth--
+ if (depth === 0) return index
+ }
+ }
+ return -1
+}
+
+function splitTopLevelComma (input: string): string[] {
+ const parts: string[] = []
+ let depth = 0
+ let start = 0
+
+ for (let index = 0; index < input.length; index++) {
+ const char = input[index]
+ if (char === '(') depth++
+ if (char === ')') depth--
+ if (char === ',' && depth === 0) {
+ parts.push(input.slice(start, index))
+ start = index + 1
+ }
+ }
+
+ parts.push(input.slice(start))
+ return parts
+}
+
+function valueFromRecord (record: Record | undefined, key: string): unknown {
+ if (!record || !Object.prototype.hasOwnProperty.call(record, key)) return undefined
+ return record[key]
+}
+
+function valueFromScopedRecords (
+ records: readonly Record[] | undefined,
+ key: string
+): unknown {
+ if (!records) return undefined
+
+ for (let index = records.length - 1; index >= 0; index--) {
+ const value = valueFromRecord(records[index], key)
+ if (value !== undefined) return value
+ }
+
+ return undefined
+}
+
+function serializeDependencies (dependencies: { vars: Set, dimensions: boolean }) {
+ return {
+ vars: Array.from(dependencies.vars).sort(),
+ dimensions: dependencies.dimensions
+ }
+}
+
+function invalid (
+ diagnostics: CssxDiagnostic[],
+ dependencies: { vars: Set, dimensions: boolean }
+): ResolveCssValueResult {
+ return {
+ valid: false,
+ dependencies: serializeDependencies(dependencies),
+ diagnostics
+ }
+}
diff --git a/packages/css-to-rn/src/vendor.d.ts b/packages/css-to-rn/src/vendor.d.ts
new file mode 100644
index 0000000..58bf215
--- /dev/null
+++ b/packages/css-to-rn/src/vendor.d.ts
@@ -0,0 +1,24 @@
+declare module 'css/lib/parse/index.js' {
+ export default function parseCss (css: string, options?: unknown): unknown
+}
+
+declare module 'css-mediaquery' {
+ interface MediaQueryExpression {
+ modifier?: string
+ feature: string
+ value?: string
+ }
+
+ interface MediaQuery {
+ inverse: boolean
+ type: string
+ expressions: MediaQueryExpression[]
+ }
+
+ const mediaQuery: {
+ parse(query: string): MediaQuery[]
+ match(query: string, values: Record): boolean
+ }
+
+ export default mediaQuery
+}
diff --git a/packages/css-to-rn/src/web.ts b/packages/css-to-rn/src/web.ts
new file mode 100644
index 0000000..7856bc8
--- /dev/null
+++ b/packages/css-to-rn/src/web.ts
@@ -0,0 +1,162 @@
+///
+
+export {
+ compileCss,
+ compileCssTemplate
+} from './compiler.ts'
+export {
+ resolveCssValue
+} from './values.ts'
+export {
+ u
+} from './units.ts'
+import {
+ resetUWarningForTests
+} from './units.ts'
+import {
+ cssx as baseCssx,
+ clearRawCssCacheForTests
+} from './react/cssx.ts'
+import {
+ useCssxLayer as baseUseCssxLayer,
+ useRuntimeCss as baseUseRuntimeCss,
+ useCssxSheet as baseUseCssxSheet,
+ useCssxTemplate as baseUseCssxTemplate
+} from './react/hooks.ts'
+import {
+ createTrackedCssxSheet
+} from './react/tracker.ts'
+import {
+ configureColorSchemeAdapter,
+ configureDimensionsAdapter,
+ configureMediaQueryAdapter,
+ defaultVariables,
+ flushMicrotasksForTests,
+ getRuntimeSubscriberCountForTests,
+ resetStoreForTests,
+ setColorSchemeForTests,
+ setDefaultVariables,
+ setDimensionsForTests,
+ subscribeVariablesForTests,
+ variables
+} from './react/store.ts'
+
+export type {
+ CompileCssOptions,
+ CompileCssTemplateOptions,
+ CompiledCssSheet
+} from './types.ts'
+export type {
+ CssxResolvedProps,
+ CssxRuntimeOptions,
+ CssxStyleName
+} from './react/cssx.ts'
+export type {
+ CssxProviderStyleInput,
+ CssxProviderStyleLayer,
+ CssxProviderProps,
+ CssxReactConfig,
+ CssxRuntimeContextValue
+} from './react/config.ts'
+export type {
+ TrackedCssxSheetOptions
+} from './react/tracker.ts'
+export type {
+ CssxColorSchemeAdapter,
+ CssxVariableStore
+} from './react/store.ts'
+
+export {
+ CssxProvider,
+ configureCssx,
+ themed,
+ useCssxComponentTag,
+ useCssxConfig,
+ useCssxRuntimeContext
+} from './react/config.ts'
+export {
+ getCssColor,
+ getCssVariable,
+ getCssVariableRaw,
+ useMedia,
+ useCssColor,
+ useCssVariable,
+ useCssVariableRaw
+} from './react/hooks.ts'
+export type {
+ CssColorMixInput
+} from './react/hooks.ts'
+export {
+ TrackedCssxSheet,
+ isTrackedCssxSheet
+} from './react/tracker.ts'
+export {
+ defaultVariables,
+ setDefaultVariables,
+ variables
+}
+
+export function cssx (
+ ...args: Parameters
+): ReturnType {
+ const [styleName, sheet, inlineStyleProps, options] = args
+ return baseCssx(styleName, sheet, inlineStyleProps, {
+ target: 'web',
+ ...(options ?? {})
+ })
+}
+
+export function useRuntimeCss (
+ ...args: Parameters
+): ReturnType {
+ const [input, options] = args
+ return baseUseRuntimeCss(input, {
+ target: 'web',
+ ...(options ?? {})
+ })
+}
+
+export function useCssxLayer (
+ ...args: Parameters
+): ReturnType {
+ const [input, options] = args
+ return baseUseCssxLayer(input, {
+ target: 'web',
+ ...(options ?? {})
+ })
+}
+
+export function useCssxSheet (
+ ...args: Parameters
+): ReturnType {
+ const [sheet, options] = args
+ return baseUseCssxSheet(sheet, {
+ target: 'web',
+ ...(options ?? {})
+ })
+}
+
+export function useCssxTemplate (
+ ...args: Parameters
+): ReturnType {
+ const [sheet, values, options] = args
+ return baseUseCssxTemplate(sheet, values, {
+ target: 'web',
+ ...(options ?? {})
+ })
+}
+
+export const __cssxInternals = {
+ clearRawCssCacheForTests,
+ configureColorSchemeAdapterForTests: configureColorSchemeAdapter,
+ configureDimensionsAdapterForTests: configureDimensionsAdapter,
+ configureMediaQueryAdapterForTests: configureMediaQueryAdapter,
+ createTrackedCssxSheet,
+ flushMicrotasksForTests,
+ getRuntimeSubscriberCountForTests,
+ resetStoreForTests,
+ resetUWarningForTests,
+ setColorSchemeForTests,
+ setDimensionsForTests,
+ subscribeVariablesForTests
+}
diff --git a/packages/css-to-rn/test/engine/compiler.test.ts b/packages/css-to-rn/test/engine/compiler.test.ts
new file mode 100644
index 0000000..41653c4
--- /dev/null
+++ b/packages/css-to-rn/test/engine/compiler.test.ts
@@ -0,0 +1,236 @@
+import assert from 'node:assert/strict'
+import { compileCss, compileCssTemplate } from '../../src/index.ts'
+
+describe('@cssxjs/css-to-rn compiler IR', () => {
+ it('compiles class selectors into canonical rules', () => {
+ const sheet = compileCss(`
+ .root {
+ color: red;
+ padding: 8px 16px;
+ }
+ .root.active:part(label) {
+ color: var(--label-color, blue);
+ }
+ `, { mode: 'build', sourceIdentity: 'Button.tsx:0' })
+
+ assert.equal(sheet.version, 1)
+ assert.equal(sheet.rules.length, 2)
+ assert.deepEqual(sheet.rules[0].classes, ['root'])
+ assert.equal(sheet.rules[0].tag, null)
+ assert.equal(sheet.rules[0].part, null)
+ assert.equal(sheet.rules[0].specificity, 1)
+ assert.equal(sheet.rules[0].declarations[0].property, 'color')
+ assert.deepEqual(sheet.rules[1].classes, ['root', 'active'])
+ assert.equal(sheet.rules[1].part, 'label')
+ assert.deepEqual(sheet.metadata.vars, ['--label-color'])
+ assert.equal(sheet.metadata.hasDynamicRuntimeDependencies, true)
+ assert.match(sheet.id, /^cssx_/)
+ assert.match(sheet.sourceId ?? '', /^cssx_/)
+ })
+
+ it('compiles component tag selectors and scoped root variables', () => {
+ const sheet = compileCss(`
+ :root {
+ --button-color: oklch(62% 0.18 250 / 0.5);
+ }
+ Button {
+ color: var(--button-color);
+ }
+ Button.primary::part(label) {
+ color: white;
+ }
+ Button:part(icon).large {
+ opacity: 0.5;
+ }
+ `, { mode: 'build' })
+
+ assert.deepEqual(sheet.rootVariables, {
+ '--button-color': 'oklch(62% 0.18 250 / 0.5)'
+ })
+ assert.equal(sheet.rules[0].tag, 'Button')
+ assert.deepEqual(sheet.rules[0].classes, [])
+ assert.equal(sheet.rules[1].tag, 'Button')
+ assert.deepEqual(sheet.rules[1].classes, ['primary'])
+ assert.equal(sheet.rules[1].part, 'label')
+ assert.equal(sheet.rules[2].tag, 'Button')
+ assert.deepEqual(sheet.rules[2].classes, ['large'])
+ assert.equal(sheet.rules[2].part, 'icon')
+ assert.deepEqual(sheet.metadata.vars, ['--button-color'])
+ })
+
+ it('compiles named theme root variables', () => {
+ const sheet = compileCss(`
+ :root {
+ --surface: white;
+ }
+ :root.dark {
+ --surface: black;
+ color: white;
+ }
+ .root {
+ color: var(--surface);
+ }
+ `, { mode: 'build' })
+
+ assert.deepEqual(sheet.rootVariables, {
+ '--surface': 'white'
+ })
+ assert.deepEqual(sheet.themeVariables, {
+ dark: {
+ '--surface': 'black'
+ }
+ })
+ assert.equal(sheet.metadata.hasThemes, true)
+ assert.deepEqual(sheet.metadata.vars, ['--surface'])
+ assert.equal(sheet.diagnostics[0].code, 'INVALID_THEME_BLOCK')
+ })
+
+ it('stores custom media aliases and rejects theme alias collisions', () => {
+ const sheet = compileCss(`
+ @custom-media --breakpoint-tablet (width >= 48rem);
+ @custom-media --theme-dark (prefers-color-scheme: dark);
+ @media (--breakpoint-tablet) {
+ .root { color: red; }
+ }
+ `, { mode: 'build' })
+
+ assert.deepEqual(sheet.customMedia, {
+ '--breakpoint-tablet': '(width >= 48rem)'
+ })
+ assert.equal(sheet.metadata.hasCustomMedia, true)
+ assert.equal(sheet.diagnostics[0].code, 'INVALID_CUSTOM_MEDIA')
+ })
+
+ it('maps hover and active pseudos to logical part aliases', () => {
+ const sheet = compileCss(`
+ .root:hover { color: red; }
+ .root.active:active { color: blue; }
+ `, { mode: 'build' })
+
+ assert.equal(sheet.rules[0].part, 'hover')
+ assert.equal(sheet.rules[1].part, 'active')
+ })
+
+ it('keeps media conditions on matching rules', () => {
+ const sheet = compileCss(`
+ @media (min-width: 600px) {
+ .root { width: 50vw; }
+ }
+ `, { mode: 'build' })
+
+ assert.equal(sheet.rules.length, 1)
+ assert.equal(sheet.rules[0].media, '@media (min-width: 600px)')
+ assert.equal(sheet.metadata.hasMedia, true)
+ assert.equal(sheet.metadata.hasViewportUnits, true)
+ })
+
+ it('stores keyframes as declaration IR and marks animation metadata', () => {
+ const sheet = compileCss(`
+ .root { animation: fade 200ms ease; }
+ @keyframes fade {
+ from { opacity: 0; }
+ to { opacity: var(--target-opacity, 1); }
+ }
+ `, { mode: 'build' })
+
+ assert.equal(sheet.metadata.hasAnimations, true)
+ assert.deepEqual(sheet.metadata.vars, ['--target-opacity'])
+ assert.equal(sheet.keyframes.fade.length, 2)
+ assert.equal(sheet.keyframes.fade[0].selector, 'from')
+ assert.equal(sheet.keyframes.fade[1].declarations[0].property, 'opacity')
+ })
+
+ it('returns structured diagnostics instead of throwing in runtime mode', () => {
+ const sheet = compileCss('.root { color red; }')
+
+ assert.equal(sheet.rules.length, 0)
+ assert.equal(sheet.error?.code, 'CSS_SYNTAX_ERROR')
+ assert.equal(sheet.diagnostics[0].level, 'error')
+ })
+
+ it('throws syntax diagnostics in build mode', () => {
+ assert.throws(
+ () => compileCss('.root { color red; }', { mode: 'build' }),
+ /CSS_SYNTAX_ERROR/
+ )
+ })
+
+ it('throws unsupported static declaration diagnostics in build mode', () => {
+ assert.throws(
+ () => compileCss('.root { width: calc(100% - 16px); }', { mode: 'build' }),
+ /UNSUPPORTED_CALC/
+ )
+ assert.throws(
+ () => compileCss('.root { transform: translate3d(1px, 2px, 3px); }', { mode: 'build' }),
+ /INVALID_DECLARATION/
+ )
+ assert.throws(
+ () => compileCss('.root { background-image: url(hero.png); }', { mode: 'build' }),
+ /UNSUPPORTED_BACKGROUND_IMAGE/
+ )
+ })
+
+ it('defers dynamic declarations to runtime validation in build mode', () => {
+ const sheet = compileCssTemplate(`
+ .root {
+ width: var(--width);
+ transform: var(--__cssx_dynamic_0);
+ }
+ `, { mode: 'build' })
+
+ assert.equal(sheet.rules.length, 1)
+ assert.equal(sheet.error, undefined)
+ })
+
+ it('warns about deprecated u units in build mode', () => {
+ const sheet = compileCss('.root { padding: 1u; }', { mode: 'build' })
+
+ assert.equal(sheet.error, undefined)
+ assert.equal(sheet.diagnostics[0].code, 'DEPRECATED_UNIT')
+ assert.equal(sheet.diagnostics[0].level, 'warning')
+ })
+
+ it('warns and ignores unsupported selectors in runtime mode', () => {
+ const sheet = compileCss(`
+ .root .child { color: red; }
+ .root { color: blue; }
+ `)
+
+ assert.equal(sheet.rules.length, 1)
+ assert.equal(sheet.diagnostics[0].code, 'UNSUPPORTED_SELECTOR')
+ })
+
+ it('records interpolation slots in template mode', () => {
+ const sheet = compileCssTemplate(`
+ .root {
+ color: var(--__cssx_dynamic_0);
+ padding: var(--__cssx_dynamic_1) 2u;
+ }
+ `, { mode: 'build' })
+
+ assert.equal(sheet.metadata.hasInterpolations, true)
+ assert.deepEqual(sheet.rules[0].declarations[0].dynamicSlots, [0])
+ assert.deepEqual(sheet.rules[0].declarations[1].dynamicSlots, [1])
+ })
+
+ it('rejects interpolation inside media queries in build mode', () => {
+ assert.throws(
+ () => compileCssTemplate(`
+ @media (min-width: var(--__cssx_dynamic_0)) {
+ .root { color: red; }
+ }
+ `, { mode: 'build' }),
+ /UNSUPPORTED_INTERPOLATION_POSITION/
+ )
+ })
+
+ it('keeps :export static-only', () => {
+ const sheet = compileCss(`
+ :export {
+ color: red;
+ }
+ `, { mode: 'build' })
+
+ assert.deepEqual(sheet.exports, { color: 'red' })
+ })
+})
diff --git a/packages/css-to-rn/test/engine/resolve.test.ts b/packages/css-to-rn/test/engine/resolve.test.ts
new file mode 100644
index 0000000..b809194
--- /dev/null
+++ b/packages/css-to-rn/test/engine/resolve.test.ts
@@ -0,0 +1,642 @@
+import assert from 'node:assert/strict'
+
+import {
+ compileCss,
+ compileCssTemplate,
+ createCssxCache,
+ resolveCssx
+} from '../../src/index.ts'
+
+describe('@cssxjs/css-to-rn resolver', () => {
+ it('resolves matched root and part styles with specificity and inline overrides', () => {
+ const sheet = compileCss(`
+ .button { color: red; padding: 1u; }
+ .button.primary { color: blue; }
+ .button:part(root) { background-color: yellow; }
+ .button:part(label) { color: white; }
+ .button:hover { opacity: 0.5; }
+ `)
+
+ const result = resolveCssx({
+ styleName: ['button', { primary: true }],
+ layers: sheet,
+ inlineStyleProps: { color: 'green' }
+ })
+
+ assert.deepEqual(result.props, {
+ style: {
+ color: 'green',
+ backgroundColor: 'yellow',
+ paddingTop: 8,
+ paddingRight: 8,
+ paddingBottom: 8,
+ paddingLeft: 8
+ },
+ labelStyle: { color: 'white' },
+ hoverStyle: { opacity: 0.5 }
+ })
+ })
+
+ it('applies later layers after earlier layers', () => {
+ const base = compileCss('.button { color: red; padding: 8px; }')
+ const local = compileCss('.button { color: blue; }')
+
+ const result = resolveCssx({
+ styleName: 'button',
+ layers: [base, local]
+ })
+
+ assert.deepEqual(result.props, {
+ style: {
+ color: 'blue',
+ paddingTop: 8,
+ paddingRight: 8,
+ paddingBottom: 8,
+ paddingLeft: 8
+ }
+ })
+ })
+
+ it('returns compile diagnostics from runtime sheet inputs', () => {
+ const result = resolveCssx({
+ styleName: 'button',
+ layers: `
+ #ignored { color: red; }
+ .button { color: blue; }
+ `
+ })
+
+ assert.deepEqual(result.props, { style: { color: 'blue' } })
+ assert.equal(result.diagnostics[0].code, 'UNSUPPORTED_SELECTOR')
+ })
+
+ it('drops only invalid dynamic declarations and keeps fallback declarations', () => {
+ const sheet = compileCss(`
+ .button {
+ color: red;
+ color: var(--button-color);
+ border: var(--border-width, 2px) solid var(--border-color, blue);
+ }
+ `)
+
+ const result = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ variables: { '--border-color': 'green' }
+ })
+
+ assert.deepEqual(result.props, {
+ style: {
+ color: 'red',
+ borderWidth: 2,
+ borderColor: 'green',
+ borderStyle: 'solid'
+ }
+ })
+ assert.deepEqual(result.dependencies.vars, [
+ '--border-color',
+ '--border-width',
+ '--button-color'
+ ])
+ assert.equal(result.diagnostics[0].code, 'UNRESOLVED_VARIABLE')
+ })
+
+ it('does not subscribe to variables in inactive media rules', () => {
+ const sheet = compileCss(`
+ .button { color: red; }
+ @media (min-width: 600px) {
+ .button { color: var(--wide-color); }
+ }
+ `)
+
+ const result = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ variables: { '--wide-color': 'blue' },
+ dimensions: { width: 320, height: 640 }
+ })
+
+ assert.deepEqual(result.props, { style: { color: 'red' } })
+ assert.deepEqual(result.dependencies.vars, [])
+ assert.deepEqual(result.dependencies.media, ['(min-width: 600px)'])
+ })
+
+ it('activates media rules and resolves viewport units from dimensions', () => {
+ const sheet = compileCss(`
+ .button { width: 10vw; }
+ @media (min-width: 600px) {
+ .button { width: calc(20vw + 1u); }
+ }
+ `)
+
+ const result = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ dimensions: { width: 800, height: 600 }
+ })
+
+ assert.deepEqual(result.props, { style: { width: 168 } })
+ assert.equal(result.dependencies.dimensions, true)
+ assert.deepEqual(result.dependencies.media, ['(min-width: 600px)'])
+ })
+
+ it('resolves active theme variables and invalidates cache by theme', () => {
+ const sheet = compileCss(`
+ :root { --surface: white; }
+ :root.dark { --surface: black; }
+ .button { color: var(--surface); }
+ `)
+ const cache = createCssxCache()
+
+ const light = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ theme: 'default',
+ cache
+ })
+ const dark = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ theme: 'dark',
+ cache
+ })
+ const darkAgain = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ theme: 'dark',
+ cache
+ })
+
+ assert.deepEqual(light.props, { style: { color: 'white' } })
+ assert.deepEqual(dark.props, { style: { color: 'black' } })
+ assert.notEqual(dark.props, light.props)
+ assert.equal(darkAgain.cacheHit, true)
+ assert.equal(darkAgain.props, dark.props)
+ })
+
+ it('matches built-in theme media aliases', () => {
+ const sheet = compileCss(`
+ .button { color: red; }
+ @media (--theme-dark) {
+ .button { color: white; }
+ }
+ @media (--theme-dark) and (min-width: 600px) {
+ .button { padding: 2u; }
+ }
+ `)
+
+ const light = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ theme: 'default',
+ dimensions: { width: 800, height: 600 }
+ })
+ const darkNarrow = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ theme: 'dark',
+ dimensions: { width: 320, height: 600 }
+ })
+ const darkWide = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ theme: 'dark',
+ dimensions: { width: 800, height: 600 }
+ })
+
+ assert.deepEqual(light.props, { style: { color: 'red' } })
+ assert.deepEqual(darkNarrow.props, { style: { color: 'white' } })
+ assert.deepEqual(darkWide.props, {
+ style: {
+ color: 'white',
+ paddingTop: 16,
+ paddingRight: 16,
+ paddingBottom: 16,
+ paddingLeft: 16
+ }
+ })
+ assert.deepEqual(darkWide.dependencies.media, ['(min-width: 600px)'])
+ })
+
+ it('expands custom media aliases with provider variables', () => {
+ const sheet = compileCss(`
+ :root { --tablet: 40rem; }
+ @custom-media --breakpoint-tablet (width >= var(--tablet));
+ .button { color: red; }
+ @media (--breakpoint-tablet) {
+ .button { color: blue; }
+ }
+ `)
+
+ const narrow = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ dimensions: { width: 600, height: 800 }
+ })
+ const wide = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ dimensions: { width: 700, height: 800 }
+ })
+
+ assert.deepEqual(narrow.props, { style: { color: 'red' } })
+ assert.deepEqual(wide.props, { style: { color: 'blue' } })
+ assert.deepEqual(wide.dependencies.vars, ['--tablet'])
+ assert.equal(wide.dependencies.dimensions, true)
+ assert.deepEqual(wide.dependencies.media, [])
+ assert.deepEqual(wide.dependencies.mediaMatches, {})
+ })
+
+ it('evaluates width and height range media syntax', () => {
+ const sheet = compileCss(`
+ .button { color: red; }
+ @media (width >= 48rem) {
+ .button { color: blue; }
+ }
+ @media (height < 40rem) {
+ .button { opacity: 0.5; }
+ }
+ `)
+
+ const small = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ dimensions: { width: 767, height: 640 }
+ })
+ const large = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ dimensions: { width: 768, height: 639 }
+ })
+
+ assert.deepEqual(small.props, { style: { color: 'red' } })
+ assert.deepEqual(large.props, { style: { color: 'blue', opacity: 0.5 } })
+ })
+
+ it('resolves template interpolation values through one cache slot', () => {
+ const sheet = compileCssTemplate('.button { color: var(--__cssx_dynamic_0); }')
+ const cache = createCssxCache()
+
+ const red = resolveCssx({
+ styleName: 'button',
+ layers: { sheet, values: ['red'] },
+ cache
+ })
+ const redAgain = resolveCssx({
+ styleName: 'button',
+ layers: { sheet, values: ['red'] },
+ cache
+ })
+ const green = resolveCssx({
+ styleName: 'button',
+ layers: { sheet, values: ['green'] },
+ cache
+ })
+ const greenAgain = resolveCssx({
+ styleName: 'button',
+ layers: { sheet, values: ['green'] },
+ cache
+ })
+ const redAfterGreen = resolveCssx({
+ styleName: 'button',
+ layers: { sheet, values: ['red'] },
+ cache
+ })
+
+ assert.equal(redAgain.cacheHit, true)
+ assert.equal(redAgain.props, red.props)
+ assert.notEqual(green.props, red.props)
+ assert.equal(greenAgain.cacheHit, true)
+ assert.equal(greenAgain.props, green.props)
+ assert.notEqual(redAfterGreen.props, red.props)
+ assert.equal(cache.entries.size, 1)
+ })
+
+ it('reuses cached references for equal inline style values', () => {
+ const sheet = compileCss('.button { color: red; }')
+ const cache = createCssxCache()
+
+ const first = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ inlineStyleProps: { opacity: 0.5 },
+ cache
+ })
+ const second = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ inlineStyleProps: { opacity: 0.5 },
+ cache
+ })
+
+ assert.equal(second.cacheHit, true)
+ assert.equal(second.props, first.props)
+ assert.equal(second.props.style, first.props.style)
+ })
+
+ it('does not invalidate cache when unused variables change', () => {
+ const sheet = compileCss('.button { color: var(--text); }')
+ const cache = createCssxCache()
+
+ const first = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ variables: { '--text': 'red', '--unused': 1 },
+ cache
+ })
+ const second = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ variables: { '--text': 'red', '--unused': 2 },
+ cache
+ })
+ const changed = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ variables: { '--text': 'green', '--unused': 2 },
+ cache
+ })
+
+ assert.equal(second.cacheHit, true)
+ assert.equal(second.props, first.props)
+ assert.notEqual(changed.props, first.props)
+ assert.deepEqual(changed.props, { style: { color: 'green' } })
+ })
+
+ it('keeps separate cache entries for different elements', () => {
+ const sheet = compileCss(`
+ .button { color: red; }
+ .label { color: blue; }
+ `)
+ const cache = createCssxCache()
+
+ const button = resolveCssx({ styleName: 'button', layers: sheet, cache })
+ const label = resolveCssx({ styleName: 'label', layers: sheet, cache })
+ const buttonAgain = resolveCssx({ styleName: 'button', layers: sheet, cache })
+ const labelAgain = resolveCssx({ styleName: 'label', layers: sheet, cache })
+
+ assert.equal(buttonAgain.props, button.props)
+ assert.equal(labelAgain.props, label.props)
+ assert.notEqual(button.props, label.props)
+ assert.equal(cache.entries.size, 2)
+ })
+
+ it('matches component tag selectors and resolves sheet root variables', () => {
+ const sheet = compileCss(`
+ :root {
+ --button-color: oklch(62% 0.18 250 / 0.5);
+ }
+ Button { color: var(--button-color); }
+ Button.primary:part(label) { color: white; }
+ Link { color: green; }
+ .utility { padding: 1u; }
+ `)
+
+ const result = resolveCssx({
+ componentTag: 'Button',
+ styleName: ['primary', 'utility'],
+ layers: sheet
+ })
+
+ assert.deepEqual(result.props, {
+ style: {
+ color: 'rgba(0, 137, 237, 0.5)',
+ paddingTop: 8,
+ paddingRight: 8,
+ paddingBottom: 8,
+ paddingLeft: 8
+ },
+ labelStyle: { color: 'white' }
+ })
+ assert.deepEqual(result.dependencies.vars, ['--button-color'])
+ })
+
+ it('keeps component tag and scoped variables in cache invalidation', () => {
+ const sheet = compileCss(`
+ Button { color: var(--color); }
+ Link { color: var(--color); }
+ `)
+ const cache = createCssxCache()
+ const button = resolveCssx({
+ componentTag: 'Button',
+ styleName: '',
+ layers: sheet,
+ scopedVariables: [{ '--color': 'red' }],
+ cache
+ })
+ const link = resolveCssx({
+ componentTag: 'Link',
+ styleName: '',
+ layers: sheet,
+ scopedVariables: [{ '--color': 'red' }],
+ cache
+ })
+ const buttonAgain = resolveCssx({
+ componentTag: 'Button',
+ styleName: '',
+ layers: sheet,
+ scopedVariables: [{ '--color': 'red' }],
+ cache
+ })
+ const buttonChanged = resolveCssx({
+ componentTag: 'Button',
+ styleName: '',
+ layers: sheet,
+ scopedVariables: [{ '--color': 'blue' }],
+ cache
+ })
+
+ assert.equal(buttonAgain.cacheHit, true)
+ assert.equal(buttonAgain.props, button.props)
+ assert.notEqual(link.props, button.props)
+ assert.notEqual(buttonChanged.props, button.props)
+ assert.deepEqual(buttonChanged.props, { style: { color: 'blue' } })
+ })
+
+ it('resolves variables in inline style props', () => {
+ const sheet = compileCss('.button { color: red; }')
+ const result = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ inlineStyleProps: {
+ style: {
+ color: 'var(--inline-color)',
+ paddingTop: 'var(--inline-space)'
+ }
+ },
+ variables: {
+ '--inline-color': 'oklch(62% 0.18 250 / 0.5)',
+ '--inline-space': '2u'
+ }
+ })
+
+ assert.deepEqual(result.props, {
+ style: {
+ color: 'rgba(0, 137, 237, 0.5)',
+ paddingTop: 16
+ }
+ })
+ assert.deepEqual(result.dependencies.vars, ['--inline-color', '--inline-space'])
+ })
+
+ it('resolves partial variables inside complex property values', () => {
+ const sheet = compileCss(`
+ .button {
+ box-shadow: var(--shadow-x, 0) 2px var(--shadow-blur, 4px) var(--shadow-color);
+ filter: blur(var(--blur, 2px)) brightness(var(--brightness, 0.8));
+ text-shadow: var(--text-x, 1px) 2px 3px var(--text-color, red);
+ transform: translateX(var(--tx, 4px)) scale(var(--scale, 2));
+ background: var(--bg-color, red) var(--bg-image, radial-gradient(circle, white, black));
+ }
+ `)
+
+ const result = resolveCssx({
+ styleName: 'button',
+ layers: sheet,
+ variables: {
+ '--shadow-x': '1px',
+ '--shadow-blur': '8px',
+ '--shadow-color': 'rgba(0,0,0,.2)',
+ '--blur': '4px',
+ '--brightness': 0.9,
+ '--text-x': '5px',
+ '--text-color': 'blue',
+ '--tx': '10px',
+ '--scale': 1.5,
+ '--bg-color': 'green',
+ '--bg-image': 'linear-gradient(90deg, white, black)'
+ }
+ })
+
+ assert.deepEqual(result.dependencies.vars, [
+ '--bg-color',
+ '--bg-image',
+ '--blur',
+ '--brightness',
+ '--scale',
+ '--shadow-blur',
+ '--shadow-color',
+ '--shadow-x',
+ '--text-color',
+ '--text-x',
+ '--tx'
+ ])
+ assert.deepEqual(result.props, {
+ style: {
+ boxShadow: '1px 2px 8px rgba(0,0,0,.2)',
+ filter: 'blur(4px) brightness(0.9)',
+ textShadowOffset: { width: 5, height: 2 },
+ textShadowRadius: 3,
+ textShadowColor: 'blue',
+ transform: [
+ { scale: 1.5 },
+ { translateX: 10 }
+ ],
+ backgroundColor: 'green',
+ experimental_backgroundImage: 'linear-gradient(90deg, white, black)'
+ }
+ })
+ })
+
+ it('evicts raw CSS resolved cache entries when a caller requests a single cache slot', () => {
+ const cache = createCssxCache({ maxEntries: 1 })
+ const redCss = '.root { color: red; }'
+ const greenCss = '.root { color: green; }'
+
+ const red = resolveCssx({ styleName: 'root', layers: redCss, cache })
+ const redAgain = resolveCssx({ styleName: 'root', layers: redCss, cache })
+ const green = resolveCssx({ styleName: 'root', layers: greenCss, cache })
+ const redAfterGreen = resolveCssx({ styleName: 'root', layers: redCss, cache })
+
+ assert.equal(redAgain.cacheHit, true)
+ assert.equal(redAgain.props, red.props)
+ assert.equal(green.cacheHit, false)
+ assert.equal(redAfterGreen.cacheHit, false)
+ assert.notEqual(redAfterGreen.props, red.props)
+ assert.equal(cache.entries.size, 1)
+ })
+
+ it('inlines only keyframes used by matched animation styles', () => {
+ const sheet = compileCss(`
+ @keyframes fade {
+ from { opacity: var(--from-opacity, 0); }
+ to { opacity: 1; }
+ }
+ @keyframes unused {
+ from { color: var(--unused-color); }
+ to { color: black; }
+ }
+ .button { animation: fade 200ms ease; }
+ `)
+
+ const result = resolveCssx({
+ styleName: 'button',
+ layers: sheet
+ })
+
+ assert.deepEqual(result.dependencies.vars, ['--from-opacity'])
+ assert.deepEqual(result.props.style, {
+ animationName: {
+ from: { opacity: 0 },
+ to: { opacity: 1 }
+ },
+ animationDuration: '200ms',
+ animationTimingFunction: 'ease',
+ animationDelay: '0s',
+ animationIterationCount: 1,
+ animationDirection: 'normal',
+ animationFillMode: 'none',
+ animationPlayState: 'running'
+ })
+ })
+
+ it('resolves variables and interpolation inside animation and transition values', () => {
+ const sheet = compileCssTemplate(`
+ @keyframes fade {
+ from { opacity: var(--from-opacity, 0); }
+ to { opacity: var(--target-opacity, 1); }
+ }
+ .button {
+ animation: var(--animation-name, fade) var(--__cssx_dynamic_0) ease;
+ transition: opacity var(--transition-duration, 150ms);
+ }
+ `)
+
+ const result = resolveCssx({
+ styleName: 'button',
+ layers: {
+ sheet,
+ values: ['300ms']
+ },
+ variables: {
+ '--from-opacity': 0.25,
+ '--target-opacity': 0.75,
+ '--transition-duration': '250ms'
+ }
+ })
+
+ assert.deepEqual(result.dependencies.vars, [
+ '--animation-name',
+ '--from-opacity',
+ '--target-opacity',
+ '--transition-duration'
+ ])
+ assert.deepEqual(result.props.style, {
+ animationName: {
+ from: { opacity: 0.25 },
+ to: { opacity: 0.75 }
+ },
+ animationDuration: '300ms',
+ animationTimingFunction: 'ease',
+ animationDelay: '0s',
+ animationIterationCount: 1,
+ animationDirection: 'normal',
+ animationFillMode: 'none',
+ animationPlayState: 'running',
+ transitionProperty: 'opacity',
+ transitionDuration: '250ms',
+ transitionTimingFunction: 'ease',
+ transitionDelay: '0s'
+ })
+ })
+})
diff --git a/packages/css-to-rn/test/engine/transform.test.ts b/packages/css-to-rn/test/engine/transform.test.ts
new file mode 100644
index 0000000..814e304
--- /dev/null
+++ b/packages/css-to-rn/test/engine/transform.test.ts
@@ -0,0 +1,183 @@
+import assert from 'node:assert/strict'
+
+import { transformDeclarations } from '../../src/transform/index.ts'
+import type {
+ CssDeclaration,
+ TransformDeclarationOptions,
+} from '../../src/transform/index.ts'
+
+function declarations (
+ input: ReadonlyArray
+): CssDeclaration[] {
+ return input.map(([property, value], order) => ({
+ property,
+ value,
+ raw: `${property}: ${value}`,
+ order,
+ }))
+}
+
+function transform (
+ input: ReadonlyArray,
+ options?: TransformDeclarationOptions
+) {
+ return transformDeclarations(declarations(input), options)
+}
+
+describe('@cssxjs/css-to-rn declaration transformer', () => {
+ it('normalizes raw declarations and expands margin, padding, and border shorthands', () => {
+ const result = transform([
+ ['opacity', '0.5'],
+ ['display', 'flex'],
+ ['margin', '1px 2px auto 4px'],
+ ['padding', '2u 8px'],
+ ['border', '2px dashed #f00'],
+ ['border-radius', '4px 8px 12px 16px'],
+ ['border-width', '1px 2px 3px 4px'],
+ ['border-color', 'red green blue black'],
+ ])
+
+ assert.deepEqual(result.diagnostics, [])
+ assert.deepEqual(result.style, {
+ opacity: 0.5,
+ display: 'flex',
+ marginTop: 1,
+ marginRight: 2,
+ marginBottom: 'auto',
+ marginLeft: 4,
+ paddingTop: 16,
+ paddingRight: 8,
+ paddingBottom: 16,
+ paddingLeft: 8,
+ borderWidth: 2,
+ borderColor: '#f00',
+ borderStyle: 'dashed',
+ borderTopLeftRadius: 4,
+ borderTopRightRadius: 8,
+ borderBottomRightRadius: 12,
+ borderBottomLeftRadius: 16,
+ borderTopWidth: 1,
+ borderRightWidth: 2,
+ borderBottomWidth: 3,
+ borderLeftWidth: 4,
+ borderTopColor: 'red',
+ borderRightColor: 'green',
+ borderBottomColor: 'blue',
+ borderLeftColor: 'black',
+ })
+ })
+
+ it('transforms transform and text-shadow values', () => {
+ const result = transform([
+ ['transform', 'scale(2, 3) translate(4px, 50%) rotate(5deg)'],
+ ['text-shadow', '10px 20px 30px rgba(0, 0, 0, 0.4)'],
+ ])
+
+ assert.deepEqual(result.diagnostics, [])
+ assert.deepEqual(result.style, {
+ transform: [
+ { rotate: '5deg' },
+ { translateY: '50%' },
+ { translateX: 4 },
+ { scaleY: 3 },
+ { scaleX: 2 },
+ ],
+ textShadowOffset: { width: 10, height: 20 },
+ textShadowRadius: 30,
+ textShadowColor: 'rgba(0, 0, 0, 0.4)',
+ })
+ })
+
+ it('transforms none into an empty transform list', () => {
+ const result = transform([
+ ['transform', 'none'],
+ ])
+
+ assert.deepEqual(result.diagnostics, [])
+ assert.deepEqual(result.style, {
+ transform: [],
+ })
+ })
+
+ it('passes through box-shadow and filter strings', () => {
+ const result = transform([
+ ['box-shadow', '0 2px 8px rgba(0,0,0,.2), 0 1px 2px #333'],
+ ['filter', 'blur(4px) brightness(0.8)'],
+ ])
+
+ assert.deepEqual(result.diagnostics, [])
+ assert.deepEqual(result.style, {
+ boxShadow: '0 2px 8px rgba(0,0,0,.2), 0 1px 2px #333',
+ filter: 'blur(4px) brightness(0.8)',
+ })
+ })
+
+ it('maps background-image by platform and supports limited background shorthand', () => {
+ const nativeResult = transform([
+ ['background-image', 'linear-gradient(90deg, red, blue)'],
+ ['background', 'red radial-gradient(circle, white, black)'],
+ ])
+ const webResult = transform(
+ [['background-image', 'linear-gradient(90deg, red, blue)']],
+ { platform: 'web' }
+ )
+
+ assert.deepEqual(nativeResult.diagnostics, [])
+ assert.deepEqual(nativeResult.style, {
+ experimental_backgroundImage: 'radial-gradient(circle, white, black)',
+ backgroundColor: 'red',
+ })
+ assert.deepEqual(webResult.style, {
+ backgroundImage: 'linear-gradient(90deg, red, blue)',
+ })
+ })
+
+ it('diagnoses unsupported background images without emitting style', () => {
+ const result = transform([
+ ['background-image', 'url(foo.png)'],
+ ['background', 'no-repeat center/cover red'],
+ ])
+
+ assert.deepEqual(result.style, {})
+ assert.deepEqual(
+ result.diagnostics.map(diagnostic => diagnostic.code),
+ ['UNSUPPORTED_BACKGROUND_IMAGE', 'UNSUPPORTED_BACKGROUND_SHORTHAND']
+ )
+ })
+
+ it('transforms animations, transitions, and animation keyframe names', () => {
+ const result = transform(
+ [
+ ['animation', 'fadeIn 300ms ease, slideIn 500ms ease-out 100ms'],
+ [
+ 'transition',
+ 'background-color 200ms linear, opacity 1s ease-in 50ms',
+ ],
+ ],
+ {
+ keyframes: {
+ fadeIn: { from: { opacity: 0 }, to: { opacity: 1 } },
+ },
+ }
+ )
+
+ assert.deepEqual(result.diagnostics, [])
+ assert.deepEqual(result.style, {
+ animationName: [
+ { from: { opacity: 0 }, to: { opacity: 1 } },
+ 'slideIn',
+ ],
+ animationDuration: ['300ms', '500ms'],
+ animationTimingFunction: ['ease', 'ease-out'],
+ animationDelay: ['0s', '100ms'],
+ animationIterationCount: [1, 1],
+ animationDirection: ['normal', 'normal'],
+ animationFillMode: ['none', 'none'],
+ animationPlayState: ['running', 'running'],
+ transitionProperty: ['backgroundColor', 'opacity'],
+ transitionDuration: ['200ms', '1s'],
+ transitionTimingFunction: ['linear', 'ease-in'],
+ transitionDelay: ['0s', '50ms'],
+ })
+ })
+})
diff --git a/packages/css-to-rn/test/engine/values.test.ts b/packages/css-to-rn/test/engine/values.test.ts
new file mode 100644
index 0000000..8cab53f
--- /dev/null
+++ b/packages/css-to-rn/test/engine/values.test.ts
@@ -0,0 +1,142 @@
+import assert from 'node:assert/strict'
+import { resolveCssValue, u } from '../../src/index.ts'
+import { resetUWarningForTests } from '../../src/units.ts'
+
+describe('@cssxjs/css-to-rn value resolver', () => {
+ it('resolves runtime variables, defaults, and inline fallbacks by priority', () => {
+ assert.equal(resolveCssValue('var(--color, red)', {
+ defaultVariables: { '--color': 'blue' },
+ variables: { '--color': 'green' }
+ }).value, 'green')
+
+ assert.equal(resolveCssValue('var(--color, red)', {
+ defaultVariables: { '--color': 'blue' }
+ }).value, 'blue')
+
+ assert.equal(resolveCssValue('var(--color, red)').value, 'red')
+ })
+
+ it('resolves nested var fallbacks and records dependencies', () => {
+ const result = resolveCssValue('var(--a, var(--b, red))', {
+ defaultVariables: { '--b': 'blue' }
+ })
+
+ assert.equal(result.valid, true)
+ assert.equal(result.value, 'blue')
+ assert.deepEqual(result.dependencies.vars, ['--a', '--b'])
+ })
+
+ it('invalidates unresolved variables', () => {
+ const result = resolveCssValue('1px solid var(--missing)')
+
+ assert.equal(result.valid, false)
+ assert.equal(result.diagnostics[0].code, 'UNRESOLVED_VARIABLE')
+ assert.deepEqual(result.dependencies.vars, ['--missing'])
+ })
+
+ it('detects variable cycles', () => {
+ const result = resolveCssValue('var(--a)', {
+ defaultVariables: {
+ '--a': 'var(--b)',
+ '--b': 'var(--a)'
+ }
+ })
+
+ assert.equal(result.valid, false)
+ assert.equal(result.diagnostics[0].code, 'VARIABLE_CYCLE')
+ })
+
+ it('replaces interpolation slots before resolving variables', () => {
+ const result = resolveCssValue('color-mix(in srgb, var(--__cssx_dynamic_0), white)', {
+ values: ['var(--color, red)'],
+ variables: { '--color': 'green' }
+ })
+
+ assert.equal(result.valid, true)
+ assert.equal(result.value, 'rgba(128, 192, 128, 1)')
+ assert.deepEqual(result.dependencies.vars, ['--color'])
+ })
+
+ it('resolves scoped variables before defaults', () => {
+ const result = resolveCssValue('var(--color)', {
+ scopedVariables: [
+ { '--color': 'red' },
+ { '--color': 'oklch(62% 0.18 250 / 0.5)' }
+ ],
+ defaultVariables: { '--color': 'blue' }
+ })
+
+ assert.equal(result.valid, true)
+ assert.equal(result.value, 'rgba(0, 137, 237, 0.5)')
+ assert.deepEqual(result.dependencies.vars, ['--color'])
+ })
+
+ it('invalidates omitted interpolation values', () => {
+ const result = resolveCssValue('var(--__cssx_dynamic_0)', {
+ values: [false]
+ })
+
+ assert.equal(result.valid, false)
+ assert.equal(result.diagnostics[0].code, 'INVALID_INTERPOLATION_VALUE')
+ })
+
+ it('resolves u, rem, and viewport units', () => {
+ const result = resolveCssValue('calc(10vw + 2u + 0.25rem)', {
+ dimensions: { width: 200, height: 100 }
+ })
+
+ assert.equal(result.valid, true)
+ assert.equal(result.value, '40px')
+ assert.equal(result.dependencies.dimensions, true)
+ })
+
+ it('resolves rem variables inside calc expressions', () => {
+ const result = resolveCssValue('calc(var(--spacing) * 2)', {
+ variables: { '--spacing': '0.25rem' }
+ })
+
+ assert.equal(result.valid, true)
+ assert.equal(result.value, '8px')
+ assert.deepEqual(result.dependencies.vars, ['--spacing'])
+ })
+
+ it('keeps deprecated JS u helper with one warning', () => {
+ resetUWarningForTests()
+ const originalWarn = console.warn
+ const warnings: unknown[][] = []
+ console.warn = (...args: unknown[]) => {
+ warnings.push(args)
+ }
+
+ try {
+ assert.equal(u(1), 8)
+ assert.equal(u(2.5), 20)
+ } finally {
+ console.warn = originalWarn
+ resetUWarningForTests()
+ }
+
+ assert.equal(warnings.length, 1)
+ assert.match(String(warnings[0][0]), /u\(\) is deprecated/)
+ })
+
+ it('resolves percentage and unitless calc expressions for color channels', () => {
+ assert.equal(resolveCssValue('calc(50% + 10%)').value, '60%')
+ assert.equal(resolveCssValue('calc(0.2 * 0.5)').value, '0.1')
+ assert.equal(resolveCssValue('oklch(calc(50% + 10%) calc(0.2 * 0.5) 250)').value, 'rgba(79, 132, 186, 1)')
+ })
+
+ it('mixes srgb colors with transparent using premultiplied alpha', () => {
+ assert.equal(
+ resolveCssValue('color-mix(in srgb, rgb(24 107 236) 5%, transparent)').value,
+ 'rgba(24, 107, 236, 0.05)'
+ )
+ })
+
+ it('rejects unsupported calc expressions', () => {
+ const result = resolveCssValue('calc(100% - 16px)')
+
+ assert.equal(result.valid, false)
+ assert.equal(result.diagnostics[0].code, 'UNSUPPORTED_CALC')
+ })
+})
diff --git a/packages/css-to-rn/test/react/tracking.test.ts b/packages/css-to-rn/test/react/tracking.test.ts
new file mode 100644
index 0000000..1b92470
--- /dev/null
+++ b/packages/css-to-rn/test/react/tracking.test.ts
@@ -0,0 +1,1285 @@
+import assert from 'node:assert/strict'
+import { JSDOM } from 'jsdom'
+import React, { Suspense, act, createElement } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import {
+ __cssxInternals,
+ compileCss,
+ compileCssTemplate,
+ CssxProvider,
+ cssx,
+ defaultVariables,
+ getCssColor,
+ getCssVariable,
+ getCssVariableRaw,
+ setDefaultVariables,
+ themed,
+ useCssColor,
+ useCssVariable,
+ useCssVariableRaw,
+ useCssxLayer,
+ useCssxTemplate,
+ useMedia,
+ variables
+} from '../../src/web.ts'
+
+(globalThis as typeof globalThis & {
+ IS_REACT_ACT_ENVIRONMENT?: boolean
+}).IS_REACT_ACT_ENVIRONMENT = true
+
+const dom = new JSDOM('')
+Object.assign(globalThis, {
+ window: dom.window,
+ document: dom.window.document,
+ HTMLElement: dom.window.HTMLElement,
+ Node: dom.window.Node
+})
+
+describe('@cssxjs/css-to-rn React tracking prototype', () => {
+ function reset (): void {
+ __cssxInternals.resetStoreForTests()
+ __cssxInternals.clearRawCssCacheForTests()
+ }
+
+ it('batches variable notifications in one microtask', async () => {
+ reset()
+ const calls: string[][] = []
+ const unsubscribe = __cssxInternals.subscribeVariablesForTests(
+ ['--bg', '--text'],
+ names => calls.push([...names].sort())
+ )
+
+ variables['--bg'] = 'black'
+ Object.assign(variables, {
+ '--text': 'white'
+ })
+
+ assert.equal(calls.length, 0)
+ await __cssxInternals.flushMicrotasksForTests()
+
+ assert.deepEqual(calls, [['--bg', '--text']])
+ unsubscribe()
+ reset()
+ })
+
+ it('records dependencies only for matched active selectors', () => {
+ reset()
+ const sheet = compileCss(`
+ .root { color: var(--root-color, red); }
+ .label { color: var(--label-color, blue); }
+ `)
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' })
+
+ tracked.startRender()
+ const props = cssx('root', tracked)
+ const dependencies = tracked.getPendingDependenciesForTests()
+
+ assert.deepEqual(props, {
+ style: {
+ color: 'red'
+ }
+ })
+ assert.deepEqual(
+ Array.from(dependencies?.vars.keys() ?? []),
+ ['--root-color']
+ )
+ reset()
+ })
+
+ it('notifies tracked wrappers only for committed variable dependencies', async () => {
+ reset()
+ const sheet = compileCss(`
+ .root { color: var(--root-color, red); }
+ .root.active { background-color: var(--active-bg, blue); }
+ `)
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' })
+ let calls = 0
+ const unsubscribe = tracked.subscribe(() => {
+ calls += 1
+ })
+
+ tracked.startRender()
+ cssx('root', tracked)
+ tracked.commitRender()
+
+ variables['--active-bg'] = 'green'
+ await __cssxInternals.flushMicrotasksForTests()
+ assert.equal(calls, 0)
+
+ variables['--root-color'] = 'black'
+ await __cssxInternals.flushMicrotasksForTests()
+ assert.equal(calls, 1)
+
+ unsubscribe()
+ assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0)
+ reset()
+ })
+
+ it('unions dependencies from multiple cssx calls in one render', () => {
+ reset()
+ const sheet = compileCss(`
+ .root { color: var(--root-color, red); }
+ .label { color: var(--label-color, blue); }
+ `)
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' })
+
+ tracked.startRender()
+ cssx('root', tracked)
+ cssx('label', tracked)
+
+ assert.deepEqual(
+ Array.from(tracked.getPendingDependenciesForTests()?.vars.keys() ?? []),
+ ['--root-color', '--label-color']
+ )
+ reset()
+ })
+
+ it('does not subscribe to dependencies collected by an aborted render', async () => {
+ reset()
+ const sheet = compileCss(`
+ .root { color: var(--root-color, red); }
+ .root.active { background-color: var(--active-bg, blue); }
+ `)
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' })
+ let calls = 0
+ const unsubscribe = tracked.subscribe(() => {
+ calls += 1
+ })
+
+ tracked.startRender()
+ cssx('root', tracked)
+ tracked.commitRender()
+
+ tracked.startRender()
+ cssx(['root', 'active'], tracked)
+
+ variables['--active-bg'] = 'green'
+ await __cssxInternals.flushMicrotasksForTests()
+ assert.equal(calls, 0)
+
+ variables['--root-color'] = 'black'
+ await __cssxInternals.flushMicrotasksForTests()
+ assert.equal(calls, 1)
+
+ unsubscribe()
+ reset()
+ })
+
+ it('commits the dependency snapshot captured for that render', async () => {
+ reset()
+ const sheet = compileCss(`
+ .root { color: var(--root-color, red); }
+ .root.active { background-color: var(--active-bg, blue); }
+ `)
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' })
+ let calls = 0
+ const unsubscribe = tracked.subscribe(() => {
+ calls += 1
+ })
+
+ const rootRender = tracked.startRender()
+ cssx('root', tracked)
+
+ tracked.startRender()
+ cssx(['root', 'active'], tracked)
+
+ tracked.commitRender(rootRender)
+
+ variables['--active-bg'] = 'green'
+ await __cssxInternals.flushMicrotasksForTests()
+ assert.equal(calls, 0)
+
+ variables['--root-color'] = 'black'
+ await __cssxInternals.flushMicrotasksForTests()
+ assert.equal(calls, 1)
+
+ unsubscribe()
+ reset()
+ })
+
+ it('reuses tracked cache references for identical render inputs', () => {
+ reset()
+ const sheet = compileCss('.root { color: var(--root-color, red); }')
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' })
+
+ tracked.startRender()
+ const first = cssx('root', tracked, { style: { opacity: 0.5 } })
+ tracked.commitRender()
+
+ tracked.startRender()
+ const second = cssx('root', tracked, { style: { opacity: 0.5 } })
+ tracked.commitRender()
+
+ assert.equal(second, first)
+ assert.equal(second.style, first.style)
+ reset()
+ })
+
+ it('passes tracked template values into the shared resolver', () => {
+ reset()
+ const sheet = compileCssTemplate('.root { color: var(--__cssx_dynamic_0); }')
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, {
+ target: 'web',
+ values: ['red']
+ })
+
+ tracked.startRender()
+ const red = cssx('root', tracked)
+ tracked.commitRender()
+
+ tracked.update(sheet, {
+ target: 'web',
+ values: ['green']
+ })
+ tracked.startRender()
+ const green = cssx('root', tracked)
+ tracked.commitRender()
+
+ assert.deepEqual(red, { style: { color: 'red' } })
+ assert.deepEqual(green, { style: { color: 'green' } })
+ assert.notEqual(green, red)
+ reset()
+ })
+
+ it('notifies default variable replacements and removed defaults', async () => {
+ reset()
+ const sheet = compileCss('.root { color: var(--root-color, red); }')
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' })
+ let calls = 0
+ const unsubscribe = tracked.subscribe(() => {
+ calls += 1
+ })
+
+ setDefaultVariables({ '--root-color': 'blue' })
+ tracked.startRender()
+ assert.deepEqual(cssx('root', tracked), { style: { color: 'blue' } })
+ tracked.commitRender()
+
+ setDefaultVariables({ '--other': 'green' })
+ await __cssxInternals.flushMicrotasksForTests()
+ assert.equal(calls, 1)
+
+ tracked.startRender()
+ assert.deepEqual(cssx('root', tracked), { style: { color: 'red' } })
+ tracked.commitRender()
+
+ unsubscribe()
+ reset()
+ })
+
+ it('supports variable store bulk methods and validation', () => {
+ reset()
+
+ variables.assign({
+ '--text': 'red',
+ '--space': '2u'
+ })
+ assert.equal(variables['--text'], 'red')
+ assert.equal(getCssVariable('--space'), 16)
+
+ variables.set({
+ '--text': 'blue'
+ })
+ assert.equal(variables['--text'], 'blue')
+ assert.equal(variables['--space'], undefined)
+
+ variables.clear()
+ assert.equal(variables['--text'], undefined)
+
+ defaultVariables.set({ '--fallback': 'oklch(62% 0.18 250 / 0.5)' })
+ assert.equal(getCssVariableRaw('--fallback'), 'rgba(0, 137, 237, 0.5)')
+
+ assert.throws(() => {
+ variables.assign({ color: 'red' })
+ }, /Invalid CSS custom property name/)
+ assert.throws(() => {
+ variables.color = 'red'
+ }, /Invalid CSS custom property name/)
+
+ reset()
+ })
+
+ it('resolves CSS colors from semantic tokens, var() expressions, and mixes', () => {
+ reset()
+
+ variables.set({
+ '--color-primary': 'red',
+ '--color-secondary': 'blue',
+ '--custom': 'oklch(62% 0.18 250 / 0.5)'
+ })
+
+ assert.equal(getCssColor('primary'), 'red')
+ assert.equal(getCssColor('var(--custom)'), 'rgba(0, 137, 237, 0.5)')
+ assert.equal(getCssColor('primary', 0.5), 'rgba(255, 0, 0, 0.5)')
+ assert.equal(
+ getCssColor('primary', { mix: '25%', with: 'secondary' }),
+ 'rgba(64, 0, 191, 1)'
+ )
+ assert.equal(getCssColor('white'), 'white')
+
+ assert.throws(() => {
+ getCssColor('--primary')
+ }, /Ambiguous CSS color token/)
+ assert.throws(() => {
+ getCssColor('primary', 2)
+ }, /Expected a number from 0 to 1/)
+
+ reset()
+ })
+
+ it('resolves provider styles and themed component tag selectors', async () => {
+ reset()
+ let latest: unknown
+ let latestVar: unknown
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ const Button = themed('Button', function Button (): React.ReactNode {
+ latest = cssx(['primary', 'utility'], [])
+ latestVar = useCssVariable('--brand')
+ return createElement('div')
+ })
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(
+ CssxProvider,
+ {
+ style: `
+ :root { --brand: oklch(62% 0.18 250 / 0.5); }
+ Button { color: var(--brand); }
+ Button.primary:part(label) { color: white; }
+ Link { color: green; }
+ .utility { padding: 1u; }
+ `
+ },
+ createElement(Button)
+ ))
+ })
+
+ assert.deepEqual(latest, {
+ style: {
+ color: 'rgba(0, 137, 237, 0.5)',
+ paddingTop: 8,
+ paddingRight: 8,
+ paddingBottom: 8,
+ paddingLeft: 8
+ },
+ labelStyle: { color: 'white' }
+ })
+ assert.equal(latestVar, 'rgba(0, 137, 237, 0.5)')
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ reset()
+ })
+
+ it('resolves useCssColor through provider variables', async () => {
+ reset()
+ let latest: unknown
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ function Component (): React.ReactNode {
+ latest = useCssColor('primary')
+ return createElement('div')
+ }
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(
+ CssxProvider,
+ {
+ style: `
+ :root {
+ --primary: oklch(62% 0.18 250 / 0.5);
+ --color-primary: var(--primary);
+ }
+ `
+ },
+ createElement(Component)
+ ))
+ })
+
+ assert.equal(latest, 'rgba(0, 137, 237, 0.5)')
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ reset()
+ })
+
+ it('uses nearest provider root variables over outer provider roots', async () => {
+ reset()
+ let latest: unknown
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ function Component (): React.ReactNode {
+ latest = useCssVariable('--space')
+ return createElement('div')
+ }
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(
+ CssxProvider,
+ { style: ':root { --space: 1u; }' },
+ createElement(
+ CssxProvider,
+ { style: ':root { --space: 3u; }' },
+ createElement(Component)
+ )
+ ))
+ })
+
+ assert.equal(latest, 24)
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ reset()
+ })
+
+ it('updates auto provider theme from color scheme changes', async () => {
+ reset()
+ __cssxInternals.setColorSchemeForTests('light')
+ let latest: unknown
+ let latestVar: unknown
+ let renders = 0
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ function Component (): React.ReactNode {
+ renders += 1
+ latest = cssx('root', [])
+ latestVar = useCssVariable('--surface')
+ return createElement('div')
+ }
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(
+ CssxProvider,
+ {
+ style: `
+ :root { --surface: white; }
+ :root.dark { --surface: black; }
+ .root { color: var(--surface); }
+ `
+ },
+ createElement(Component)
+ ))
+ })
+
+ assert.deepEqual(latest, { style: { color: 'white' } })
+ assert.equal(latestVar, 'white')
+ assert.equal(renders, 1)
+
+ await act(async () => {
+ __cssxInternals.setColorSchemeForTests('dark')
+ })
+
+ assert.deepEqual(latest, { style: { color: 'black' } })
+ assert.equal(latestVar, 'black')
+ assert.equal(renders, 2)
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ reset()
+ })
+
+ it('resolves provider custom media aliases in useMedia', async () => {
+ reset()
+ let dimensions = { width: 600, height: 800 }
+ const listeners = new Set<() => void>()
+ __cssxInternals.configureDimensionsAdapterForTests({
+ get: () => dimensions,
+ subscribe: listener => {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+ }
+ })
+ let latest: Record | undefined
+ let renders = 0
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ function Component (): React.ReactNode {
+ renders += 1
+ latest = useMedia()
+ return createElement('div')
+ }
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(
+ CssxProvider,
+ {
+ style: `
+ :root { --compact-width: 40rem; }
+ @custom-media --compact (width < var(--compact-width));
+ `
+ },
+ createElement(Component)
+ ))
+ })
+
+ assert.equal(latest?.compact, true)
+ assert.equal(latest?.tablet, false)
+ assert.equal(renders, 1)
+
+ await act(async () => {
+ dimensions = { width: 800, height: 800 }
+ for (const listener of Array.from(listeners)) listener()
+ await __cssxInternals.flushMicrotasksForTests()
+ })
+
+ assert.equal(latest?.compact, false)
+ assert.equal(latest?.tablet, true)
+ assert.equal(renders, 2)
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ reset()
+ })
+
+ it('resolves provider root variables from compiled layers and template values', async () => {
+ reset()
+ const providerSheet = compileCss(':root { --tone: blue; }')
+ const providerTemplate = compileCssTemplate(':root { --space: var(--__cssx_dynamic_0); }')
+ let renders = 0
+ let latestTone: unknown
+ let latestSpace: unknown
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ setDefaultVariables({
+ '--tone': 'red',
+ '--space': '1u'
+ })
+
+ function Component (): React.ReactNode {
+ renders += 1
+ latestTone = useCssVariable('--tone')
+ latestSpace = useCssVariable('--space')
+ return createElement('div')
+ }
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(
+ CssxProvider,
+ {
+ style: [
+ providerSheet,
+ {
+ sheet: providerTemplate,
+ values: ['2u']
+ }
+ ]
+ },
+ createElement(Component)
+ ))
+ })
+
+ assert.equal(renders, 1)
+ assert.equal(latestTone, 'blue')
+ assert.equal(latestSpace, 16)
+
+ variables['--tone'] = 'green'
+ await act(async () => {
+ await __cssxInternals.flushMicrotasksForTests()
+ })
+
+ assert.equal(renders, 2)
+ assert.equal(latestTone, 'green')
+ assert.equal(latestSpace, 16)
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ reset()
+ })
+
+ it('tracks provider style dependencies from themed components without local sheets', async () => {
+ reset()
+ let renders = 0
+ let latest: unknown
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ variables['--brand'] = 'red'
+
+ const Button = themed('Button', function Button (): React.ReactNode {
+ renders += 1
+ latest = cssx('', [])
+ return createElement('div')
+ })
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(
+ CssxProvider,
+ { style: 'Button { color: var(--brand); }' },
+ createElement(Button)
+ ))
+ })
+
+ assert.equal(renders, 1)
+ assert.deepEqual(latest, { style: { color: 'red' } })
+
+ variables['--brand'] = 'blue'
+ await act(async () => {
+ await __cssxInternals.flushMicrotasksForTests()
+ })
+
+ assert.equal(renders, 2)
+ assert.deepEqual(latest, { style: { color: 'blue' } })
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0)
+ reset()
+ })
+
+ it('subscribes useCssVariable only to variables it resolves', async () => {
+ reset()
+ let renders = 0
+ let latest: unknown
+ let latestRaw: unknown
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ variables.set({
+ '--space': '2u',
+ '--tone': 'oklch(62% 0.18 250 / 0.5)',
+ '--unused': 'red'
+ })
+
+ function Component (): React.ReactNode {
+ renders += 1
+ latest = useCssVariable('--space')
+ latestRaw = useCssVariableRaw('--tone')
+ return createElement('div')
+ }
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(Component))
+ })
+
+ assert.equal(renders, 1)
+ assert.equal(latest, 16)
+ assert.equal(latestRaw, 'rgba(0, 137, 237, 0.5)')
+
+ variables['--unused'] = 'blue'
+ await act(async () => {
+ await __cssxInternals.flushMicrotasksForTests()
+ })
+ assert.equal(renders, 1)
+
+ variables['--space'] = '3u'
+ await act(async () => {
+ await __cssxInternals.flushMicrotasksForTests()
+ })
+ assert.equal(renders, 2)
+ assert.equal(latest, 24)
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0)
+ reset()
+ })
+
+ it('subscribes useCssColor only to variables it resolves', async () => {
+ reset()
+ let renders = 0
+ let latest: unknown
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ variables.set({
+ '--color-primary': 'red',
+ '--color-secondary': 'blue',
+ '--unused': 'black'
+ })
+
+ function Component (): React.ReactNode {
+ renders += 1
+ latest = useCssColor('primary', { mix: '25%', with: 'secondary' })
+ return createElement('div')
+ }
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(Component))
+ })
+
+ assert.equal(renders, 1)
+ assert.equal(latest, 'rgba(64, 0, 191, 1)')
+
+ variables['--unused'] = 'white'
+ await act(async () => {
+ await __cssxInternals.flushMicrotasksForTests()
+ })
+ assert.equal(renders, 1)
+
+ variables['--color-secondary'] = 'white'
+ await act(async () => {
+ await __cssxInternals.flushMicrotasksForTests()
+ })
+ assert.equal(renders, 2)
+ assert.equal(latest, 'rgba(255, 191, 191, 1)')
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0)
+ reset()
+ })
+
+ it('uses dimension adapter values for media queries and viewport units', async () => {
+ reset()
+ let dimensions = { width: 320, height: 640 }
+ const listeners = new Set<() => void>()
+
+ __cssxInternals.configureDimensionsAdapterForTests({
+ get: () => dimensions,
+ subscribe: listener => {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+ }
+ })
+
+ const sheet = compileCss(`
+ .root {
+ width: 100vw;
+ height: 50vh;
+ }
+ @media (max-width: 480px) {
+ .root { color: red; }
+ }
+ @media (orientation: portrait) {
+ .root { background-color: blue; }
+ }
+ `)
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' })
+ let calls = 0
+ const unsubscribe = tracked.subscribe(() => {
+ calls += 1
+ })
+
+ tracked.startRender()
+ assert.deepEqual(cssx('root', tracked), {
+ style: {
+ width: 320,
+ height: 320,
+ color: 'red',
+ backgroundColor: 'blue'
+ }
+ })
+ tracked.commitRender()
+
+ dimensions = { width: 800, height: 400 }
+ for (const listener of Array.from(listeners)) listener()
+ await __cssxInternals.flushMicrotasksForTests()
+ assert.equal(calls, 1)
+
+ tracked.startRender()
+ assert.deepEqual(cssx('root', tracked), {
+ style: {
+ width: 800,
+ height: 200
+ }
+ })
+ tracked.commitRender()
+
+ unsubscribe()
+ reset()
+ })
+
+ it('invalidates media dependencies using the same dimensions as resolution', async () => {
+ reset()
+ let dimensions = { width: 320, height: 640 }
+ const listeners = new Set<() => void>()
+
+ __cssxInternals.configureDimensionsAdapterForTests({
+ get: () => dimensions,
+ subscribe: listener => {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+ }
+ })
+
+ const sheet = compileCss(`
+ .root { color: black; }
+ @media (orientation: portrait) {
+ .root { color: red; }
+ }
+ `)
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' })
+ let calls = 0
+ const unsubscribe = tracked.subscribe(() => {
+ calls += 1
+ })
+
+ tracked.startRender()
+ assert.deepEqual(cssx('root', tracked), {
+ style: {
+ color: 'red'
+ }
+ })
+ tracked.commitRender()
+
+ dimensions = { width: 800, height: 400 }
+ for (const listener of Array.from(listeners)) listener()
+ await __cssxInternals.flushMicrotasksForTests()
+ assert.equal(calls, 1)
+
+ tracked.startRender()
+ assert.deepEqual(cssx('root', tracked), {
+ style: {
+ color: 'black'
+ }
+ })
+ tracked.commitRender()
+
+ unsubscribe()
+ reset()
+ })
+
+ it('invalidates matchMedia-only dependencies through the media adapter', async () => {
+ reset()
+ let scheme = 'light'
+ const listeners = new Map void>>()
+
+ __cssxInternals.configureMediaQueryAdapterForTests({
+ evaluate: query => query === '(prefers-color-scheme: dark)' && scheme === 'dark',
+ subscribe: (query, listener) => {
+ let queryListeners = listeners.get(query)
+ if (queryListeners == null) {
+ queryListeners = new Set()
+ listeners.set(query, queryListeners)
+ }
+ queryListeners.add(listener)
+ return () => {
+ queryListeners?.delete(listener)
+ if (queryListeners?.size === 0) listeners.delete(query)
+ }
+ }
+ })
+
+ const sheet = compileCss(`
+ .root { color: black; }
+ @media (prefers-color-scheme: dark) {
+ .root { color: white; }
+ }
+ `)
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' })
+ let calls = 0
+ const unsubscribe = tracked.subscribe(() => {
+ calls += 1
+ })
+
+ tracked.startRender()
+ assert.deepEqual(cssx('root', tracked), {
+ style: {
+ color: 'black'
+ }
+ })
+ tracked.commitRender()
+ assert.equal(listeners.get('(prefers-color-scheme: dark)')?.size, 1)
+
+ scheme = 'dark'
+ for (const listener of Array.from(listeners.get('(prefers-color-scheme: dark)') ?? [])) {
+ listener()
+ }
+ await __cssxInternals.flushMicrotasksForTests()
+ assert.equal(calls, 1)
+
+ tracked.startRender()
+ assert.deepEqual(cssx('root', tracked), {
+ style: {
+ color: 'white'
+ }
+ })
+ tracked.commitRender()
+
+ unsubscribe()
+ assert.equal(listeners.size, 0)
+ reset()
+ })
+
+ it('invalidates custom media aliases through expanded matchMedia dependencies', async () => {
+ reset()
+ let canHover = false
+ const listeners = new Map void>>()
+
+ __cssxInternals.configureMediaQueryAdapterForTests({
+ evaluate: query => query === '(hover: hover)' && canHover,
+ subscribe: (query, listener) => {
+ let queryListeners = listeners.get(query)
+ if (queryListeners == null) {
+ queryListeners = new Set()
+ listeners.set(query, queryListeners)
+ }
+ queryListeners.add(listener)
+ return () => {
+ queryListeners?.delete(listener)
+ if (queryListeners?.size === 0) listeners.delete(query)
+ }
+ }
+ })
+
+ const sheet = compileCss(`
+ @custom-media --can-hover (hover: hover);
+ .root { color: black; }
+ @media (--can-hover) {
+ .root { color: red; }
+ }
+ `)
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' })
+ let calls = 0
+ const unsubscribe = tracked.subscribe(() => {
+ calls += 1
+ })
+
+ tracked.startRender()
+ assert.deepEqual(cssx('root', tracked), {
+ style: {
+ color: 'black'
+ }
+ })
+ tracked.commitRender()
+ assert.equal(listeners.get('(hover: hover)')?.size, 1)
+ assert.equal(listeners.has('(--can-hover)'), false)
+
+ canHover = true
+ for (const listener of Array.from(listeners.get('(hover: hover)') ?? [])) {
+ listener()
+ }
+ await __cssxInternals.flushMicrotasksForTests()
+ assert.equal(calls, 1)
+
+ tracked.startRender()
+ assert.deepEqual(cssx('root', tracked), {
+ style: {
+ color: 'red'
+ }
+ })
+ tracked.commitRender()
+
+ unsubscribe()
+ assert.equal(listeners.size, 0)
+ reset()
+ })
+
+ it('does not retain media query listeners from aborted renders', () => {
+ reset()
+ const listeners = new Map void>>()
+
+ __cssxInternals.configureMediaQueryAdapterForTests({
+ evaluate: () => true,
+ subscribe: (query, listener) => {
+ let queryListeners = listeners.get(query)
+ if (queryListeners == null) {
+ queryListeners = new Set()
+ listeners.set(query, queryListeners)
+ }
+ queryListeners.add(listener)
+ return () => {
+ queryListeners?.delete(listener)
+ if (queryListeners?.size === 0) listeners.delete(query)
+ }
+ }
+ })
+
+ const sheet = compileCss(`
+ @media (hover: hover) {
+ .root { color: red; }
+ }
+ `)
+ const tracked = __cssxInternals.createTrackedCssxSheet(sheet, { target: 'web' })
+ const unsubscribe = tracked.subscribe(() => {})
+
+ tracked.startRender()
+ cssx('root', tracked)
+
+ assert.equal(listeners.size, 0)
+
+ unsubscribe()
+ reset()
+ })
+
+ it('subscribes React hook users only to committed dependencies', async () => {
+ reset()
+ const sheet = compileCss(`
+ .root { color: var(--root-color, red); }
+ .root.active { background-color: var(--active-bg, blue); }
+ `)
+ let renders = 0
+ let latest: unknown
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ function Component (props: { active?: boolean }): React.ReactNode {
+ renders += 1
+ const layer = useCssxLayer(sheet, { target: 'web' })
+ latest = cssx(['root', { active: props.active }], layer as Parameters[1])
+ return createElement('div', latest as Record)
+ }
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(Component))
+ })
+
+ assert.deepEqual(latest, {
+ style: {
+ color: 'red'
+ }
+ })
+
+ variables['--active-bg'] = 'green'
+ await act(async () => {
+ await __cssxInternals.flushMicrotasksForTests()
+ })
+ assert.equal(renders, 1)
+
+ variables['--root-color'] = 'black'
+ await act(async () => {
+ await __cssxInternals.flushMicrotasksForTests()
+ })
+ assert.equal(renders, 2)
+ assert.deepEqual(latest, {
+ style: {
+ color: 'black'
+ }
+ })
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0)
+ reset()
+ })
+
+ it('does not subscribe React hook dependencies from a Suspense-aborted initial render', async () => {
+ reset()
+ const pending = new Promise(() => {})
+ const sheet = compileCss(`
+ .root { color: var(--root-color, red); }
+ .root.active { background-color: var(--active-bg, blue); }
+ `)
+ let renders = 0
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ function Suspender (): React.ReactNode {
+ renders += 1
+ const layer = useCssxLayer(sheet, { target: 'web' })
+ cssx(['root', 'active'], layer as Parameters[1])
+ throw pending
+ }
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(
+ Suspense,
+ { fallback: createElement('span', null, 'loading') },
+ createElement(Suspender)
+ ))
+ })
+
+ assert.equal(container.textContent, 'loading')
+ assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0)
+ const rendersAfterFallback = renders
+
+ variables['--active-bg'] = 'green'
+ await act(async () => {
+ await __cssxInternals.flushMicrotasksForTests()
+ })
+ assert.equal(renders, rendersAfterFallback)
+ assert.equal(__cssxInternals.getRuntimeSubscriberCountForTests(), 0)
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ reset()
+ })
+
+ it('does not promote template values from a Suspense-aborted update', async () => {
+ reset()
+ const pending = new Promise(() => {})
+ const sheet = compileCssTemplate('.root { color: var(--__cssx_dynamic_0); }')
+ let latest: unknown
+ let committedLayer: Parameters[1] | undefined
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ function Component (props: { color: string, suspend?: boolean }): React.ReactNode {
+ const layer = useCssxTemplate(sheet, [props.color], { target: 'web' })
+ latest = cssx('root', layer)
+ React.useLayoutEffect(() => {
+ committedLayer = layer
+ }, [layer])
+ if (props.suspend) throw pending
+ return createElement('div')
+ }
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(
+ Suspense,
+ { fallback: createElement('span', null, 'loading') },
+ createElement(Component, { color: 'red' })
+ ))
+ })
+
+ assert.deepEqual(latest, { style: { color: 'red' } })
+ assert.deepEqual(cssx('root', committedLayer!), { style: { color: 'red' } })
+
+ await act(async () => {
+ root?.render(createElement(
+ Suspense,
+ { fallback: createElement('span', null, 'loading') },
+ createElement(Component, { color: 'green', suspend: true })
+ ))
+ })
+
+ assert.deepEqual(latest, { style: { color: 'green' } })
+ assert.deepEqual(cssx('root', committedLayer!), { style: { color: 'red' } })
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ reset()
+ })
+
+ it('keeps useCssVariable dependencies from a Suspense-aborted update uncommitted', async () => {
+ reset()
+ const pending = new Promise(() => {})
+ let renders = 0
+ let latest: unknown
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ variables.set({
+ '--root': 'red',
+ '--active': 'blue'
+ })
+
+ function Component (props: { name: string, suspend?: boolean }): React.ReactNode {
+ renders += 1
+ latest = useCssVariable(props.name)
+ if (props.suspend) throw pending
+ return createElement('div')
+ }
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(
+ Suspense,
+ { fallback: createElement('span', null, 'loading') },
+ createElement(Component, { name: '--root' })
+ ))
+ })
+
+ assert.equal(latest, 'red')
+
+ await act(async () => {
+ root?.render(createElement(
+ Suspense,
+ { fallback: createElement('span', null, 'loading') },
+ createElement(Component, { name: '--active', suspend: true })
+ ))
+ })
+
+ const rendersAfterAbortedUpdate = renders
+
+ variables['--active'] = 'green'
+ await act(async () => {
+ await __cssxInternals.flushMicrotasksForTests()
+ })
+ assert.equal(renders, rendersAfterAbortedUpdate)
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ reset()
+ })
+
+ it('keeps useCssxLayer hook order stable when disabled input toggles', async () => {
+ reset()
+ const sheet = compileCss('.root { color: red; }')
+ let latest: unknown
+ let root: Root | undefined
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ function Component (props: { enabled: boolean }): React.ReactNode {
+ const layer = useCssxLayer(props.enabled ? sheet : false, { target: 'web' })
+ latest = props.enabled ? cssx('root', layer as Parameters[1]) : null
+ return createElement('div')
+ }
+
+ await act(async () => {
+ root = createRoot(container)
+ root.render(createElement(Component, { enabled: false }))
+ })
+ assert.equal(latest, null)
+
+ await act(async () => {
+ root?.render(createElement(Component, { enabled: true }))
+ })
+ assert.deepEqual(latest, { style: { color: 'red' } })
+
+ await act(async () => {
+ root?.render(createElement(Component, { enabled: false }))
+ })
+ assert.equal(latest, null)
+
+ await act(async () => {
+ root?.unmount()
+ })
+ container.remove()
+ reset()
+ })
+})
diff --git a/packages/css-to-rn/test/types.d.ts b/packages/css-to-rn/test/types.d.ts
new file mode 100644
index 0000000..038853c
--- /dev/null
+++ b/packages/css-to-rn/test/types.d.ts
@@ -0,0 +1,2 @@
+declare function describe (name: string, fn: () => void): void
+declare function it (name: string, fn: () => void): void
diff --git a/packages/css-to-rn/test/types/react-api.test.ts b/packages/css-to-rn/test/types/react-api.test.ts
new file mode 100644
index 0000000..c0f8cc5
--- /dev/null
+++ b/packages/css-to-rn/test/types/react-api.test.ts
@@ -0,0 +1,19 @@
+import type { ComponentType, ReactNode } from 'react'
+import { themed } from '../../src/react/config.ts'
+import { cssx, type CssxSheetInput } from '../../src/react/cssx.ts'
+
+interface ButtonProps {
+ label: string
+}
+
+function Button (props: ButtonProps): ReactNode {
+ return props.label
+}
+
+const ThemedButton = themed('Button', Button)
+export const typedButton: ComponentType = ThemedButton
+
+const importedSheet: Record = {}
+const cssxSheet: CssxSheetInput = importedSheet
+
+cssx('root', cssxSheet)
diff --git a/packages/css-to-rn/tsconfig.build.json b/packages/css-to-rn/tsconfig.build.json
new file mode 100644
index 0000000..297b615
--- /dev/null
+++ b/packages/css-to-rn/tsconfig.build.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "declaration": true,
+ "declarationMap": false,
+ "sourceMap": false,
+ "noEmit": false,
+ "allowImportingTsExtensions": false
+ },
+ "include": [
+ "src/**/*.ts"
+ ]
+}
diff --git a/packages/css-to-rn/tsconfig.json b/packages/css-to-rn/tsconfig.json
new file mode 100644
index 0000000..ad929e1
--- /dev/null
+++ b/packages/css-to-rn/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "rewriteRelativeImportExtensions": true,
+ "erasableSyntaxOnly": true,
+ "verbatimModuleSyntax": true,
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "allowImportingTsExtensions": true,
+ "customConditions": [
+ "cssx-ts"
+ ],
+ "types": [
+ "node"
+ ]
+ },
+ "include": [
+ "src/**/*.ts",
+ "test/**/*.ts"
+ ]
+}
diff --git a/packages/cssxjs/index.d.ts b/packages/cssxjs/index.d.ts
index 06b60fb..951e684 100644
--- a/packages/cssxjs/index.d.ts
+++ b/packages/cssxjs/index.d.ts
@@ -1,4 +1,43 @@
import type React from 'react'
+export {
+ CssxProvider,
+ TrackedCssxSheet,
+ configureCssx,
+ cssx,
+ defaultVariables,
+ getCssColor,
+ getCssVariable,
+ getCssVariableRaw,
+ isTrackedCssxSheet,
+ setDefaultVariables,
+ themed,
+ u,
+ useCssColor,
+ useCssVariable,
+ useCssVariableRaw,
+ useCssxLayer,
+ useRuntimeCss,
+ useCssxComponentTag,
+ useCssxConfig,
+ useCssxRuntimeContext,
+ useCssxSheet,
+ useCssxTemplate,
+ useMedia,
+ variables
+} from '@cssxjs/css-to-rn/react'
+export type {
+ CssColorMixInput,
+ CssxProviderStyleInput,
+ CssxProviderStyleLayer,
+ CssxProviderProps,
+ CssxReactConfig,
+ CssxResolvedProps,
+ CssxRuntimeContextValue,
+ CssxRuntimeOptions,
+ CssxStyleName,
+ CssxVariableStore,
+ TrackedCssxSheetOptions
+} from '@cssxjs/css-to-rn/react'
export type CssxjsSimpleValue =
| string
@@ -27,3 +66,10 @@ export function styl (
inlineStyleProps?: Record
): any
export function pug (pug: TemplateStringsArray): React.ReactNode
+export function matcher (
+ styleName: StyleNameValue,
+ fileStyles?: Record,
+ globalStyles?: Record,
+ localStyles?: Record,
+ inlineStyleProps?: Record
+): any
diff --git a/packages/cssxjs/index.js b/packages/cssxjs/index.js
index 38ae353..89fc328 100644
--- a/packages/cssxjs/index.js
+++ b/packages/cssxjs/index.js
@@ -1,7 +1,31 @@
-export { default as variables } from '@cssxjs/runtime/variables'
-export { defaultVariables, setDefaultVariables } from '@cssxjs/runtime/variables'
-export { default as dimensions } from '@cssxjs/runtime/dimensions'
-export { default as matcher } from '@cssxjs/runtime/matcher'
+export {
+ CssxProvider,
+ TrackedCssxSheet,
+ configureCssx,
+ cssx,
+ defaultVariables,
+ getCssColor,
+ getCssVariable,
+ getCssVariableRaw,
+ isTrackedCssxSheet,
+ setDefaultVariables,
+ themed,
+ u,
+ useCssColor,
+ useCssVariable,
+ useCssVariableRaw,
+ useCssxLayer,
+ useRuntimeCss,
+ useCssxComponentTag,
+ useCssxConfig,
+ useCssxRuntimeContext,
+ useCssxSheet,
+ useCssxTemplate,
+ useMedia,
+ variables
+} from '@cssxjs/css-to-rn/react'
+
+export { default as matcher } from './matcher.js'
export function css (cssString) {
throw Error('[cssxjs] Unprocessed \'css\' template string. Bundler (Babel / Metro) did not process this file correctly.')
diff --git a/packages/cssxjs/matcher.js b/packages/cssxjs/matcher.js
new file mode 100644
index 0000000..82c481e
--- /dev/null
+++ b/packages/cssxjs/matcher.js
@@ -0,0 +1,97 @@
+const ROOT_STYLE_PROP_NAME = 'style'
+const PART_REGEX = /::?part\(([^)]+)\)/
+const isArray = Array.isArray
+
+// Backward-compatibility export for libraries built against the old cssxjs
+// runtime surface. New code should use cssx()/useRuntimeCss().
+export default function matcher (
+ styleName,
+ fileStyles,
+ globalStyles,
+ localStyles,
+ inlineStyleProps
+) {
+ const legacy = !inlineStyleProps
+ const classNames = toClassName(styleName).split(' ').filter(Boolean)
+ const result = getStyleProps(classNames, fileStyles, legacy)
+
+ if (legacy) return result[ROOT_STYLE_PROP_NAME]
+
+ appendStyleProps(result, getStyleProps(classNames, globalStyles))
+ appendStyleProps(result, getStyleProps(classNames, localStyles))
+ appendStyleProps(result, inlineStyleProps)
+ return result
+}
+
+function appendStyleProps (target, appendProps) {
+ if (!appendProps) return
+
+ for (const propName in appendProps) {
+ if (target[propName]) {
+ if (isArray(appendProps[propName])) {
+ target[propName] = target[propName].concat(appendProps[propName])
+ } else {
+ target[propName].push(appendProps[propName])
+ }
+ } else {
+ target[propName] = appendProps[propName]
+ }
+ }
+}
+
+function getStyleProps (classNames, styles, legacyRootOnly) {
+ const result = {}
+ if (!styles) return result
+
+ for (const selector in styles) {
+ const match = selector.match(PART_REGEX)
+ const propName = match ? getPropName(match[1]) : ROOT_STYLE_PROP_NAME
+ if (legacyRootOnly && propName !== ROOT_STYLE_PROP_NAME) continue
+
+ const pureSelector = selector.replace(PART_REGEX, '')
+ const cssClasses = pureSelector.split('.')
+ if (!classesContainedInClasses(cssClasses, classNames)) continue
+
+ const specificity = cssClasses.length - 1
+ result[propName] ??= []
+ result[propName][specificity] ??= []
+ result[propName][specificity].push(styles[selector])
+ }
+
+ return result
+}
+
+function getPropName (name) {
+ return `${name}Style`
+}
+
+function classesContainedInClasses (cssClasses, classNames) {
+ for (let i = 0; i < cssClasses.length; i++) {
+ if (classNames.indexOf(cssClasses[i]) === -1) return false
+ }
+ return true
+}
+
+function toClassName (names) {
+ let i
+ let tmp
+ let output = ''
+
+ tmp = typeof names
+ if (tmp === 'string' || tmp === 'number') return names || ''
+
+ if (isArray(names) && names.length > 0) {
+ for (i = 0; i < names.length; i++) {
+ tmp = toClassName(names[i])
+ if (tmp !== '') output += (output && ' ') + tmp
+ }
+ } else if (names && typeof names === 'object') {
+ for (i in names) {
+ if (Object.prototype.hasOwnProperty.call(names, i) && names[i]) {
+ output += (output && ' ') + i
+ }
+ }
+ }
+
+ return output
+}
diff --git a/packages/cssxjs/package.json b/packages/cssxjs/package.json
index b72c121..f1f3894 100644
--- a/packages/cssxjs/package.json
+++ b/packages/cssxjs/package.json
@@ -27,20 +27,22 @@
"./runtime/web": "./runtime/web.js",
"./runtime/react-native": "./runtime/react-native.js",
"./runtime/web-teamplay": "./runtime/web-teamplay.js",
- "./runtime/react-native-teamplay": "./runtime/react-native-teamplay.js"
+ "./runtime/react-native-teamplay": "./runtime/react-native-teamplay.js",
+ "./themes/tailwind": "./themes/tailwind.js",
+ "./themes/shadcn": "./themes/shadcn.js"
},
"publishConfig": {
"access": "public"
},
"scripts": {
- "test": "echo 'No tests yet' && exit 0"
+ "test": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" node test/smoke.mjs"
},
"dependencies": {
"@cssxjs/babel-plugin-rn-stylename-inline": "^0.3.0",
"@cssxjs/babel-plugin-rn-stylename-to-style": "^0.3.0",
"@cssxjs/bundler": "^0.3.0",
+ "@cssxjs/css-to-rn": "^0.3.0",
"@cssxjs/loaders": "^0.3.0",
- "@cssxjs/runtime": "^0.3.0",
"@react-pug/babel-plugin-react-pug": "^0.1.18",
"@react-pug/check-types": "^0.1.18",
"babel-preset-cssxjs": "^0.3.0"
diff --git a/packages/cssxjs/runtime/react-native-teamplay.js b/packages/cssxjs/runtime/react-native-teamplay.js
index 61f15cc..7f689f8 100644
--- a/packages/cssxjs/runtime/react-native-teamplay.js
+++ b/packages/cssxjs/runtime/react-native-teamplay.js
@@ -1,2 +1,10 @@
-export { default } from '@cssxjs/runtime/entrypoints/react-native-teamplay'
-export { default as runtime } from '@cssxjs/runtime/entrypoints/react-native-teamplay'
+// Backward-compatibility entrypoint for older Babel configs that selected
+// `cache: 'teamplay'`. Runtime caching/subscriptions are now implemented by
+// @cssxjs/css-to-rn; this file intentionally just re-exports the normal React
+// Native runtime and does not import Teamplay.
+export {
+ default,
+ runtime
+} from './react-native.js'
+
+export * from './react-native.js'
diff --git a/packages/cssxjs/runtime/react-native.js b/packages/cssxjs/runtime/react-native.js
index d3fc080..56c14aa 100644
--- a/packages/cssxjs/runtime/react-native.js
+++ b/packages/cssxjs/runtime/react-native.js
@@ -1,2 +1,65 @@
-export { default } from '@cssxjs/runtime/entrypoints/react-native'
-export { default as runtime } from '@cssxjs/runtime/entrypoints/react-native'
+import {
+ cssx
+} from '@cssxjs/css-to-rn/react-native'
+
+export {
+ CssxProvider,
+ TrackedCssxSheet,
+ configureCssx,
+ cssx,
+ defaultVariables,
+ getCssColor,
+ getCssVariable,
+ getCssVariableRaw,
+ isTrackedCssxSheet,
+ setDefaultVariables,
+ themed,
+ useCssColor,
+ useCssVariable,
+ useCssVariableRaw,
+ useCssxLayer,
+ useRuntimeCss,
+ useCssxComponentTag,
+ useCssxConfig,
+ useCssxRuntimeContext,
+ useCssxSheet,
+ useCssxTemplate,
+ useMedia,
+ variables
+} from '@cssxjs/css-to-rn/react-native'
+
+export { default as matcher } from '../matcher.js'
+
+export function runtime (
+ styleName,
+ fileStyles,
+ globalStyles,
+ localStyles,
+ inlineStyleProps
+) {
+ return cssx(
+ styleName,
+ collectLayers(fileStyles, globalStyles, localStyles),
+ inlineStyleProps
+ )
+}
+
+export default runtime
+
+function collectLayers (...layers) {
+ return layers.filter(isLayer)
+}
+
+function isLayer (layer) {
+ return Boolean(
+ typeof layer === 'string' ||
+ (
+ layer &&
+ typeof layer === 'object' &&
+ (
+ layer.version === 1 ||
+ Object.prototype.hasOwnProperty.call(layer, 'sheet')
+ )
+ )
+ )
+}
diff --git a/packages/cssxjs/runtime/web-teamplay.js b/packages/cssxjs/runtime/web-teamplay.js
index b1956ea..4911329 100644
--- a/packages/cssxjs/runtime/web-teamplay.js
+++ b/packages/cssxjs/runtime/web-teamplay.js
@@ -1,2 +1,10 @@
-export { default } from '@cssxjs/runtime/entrypoints/web-teamplay'
-export { default as runtime } from '@cssxjs/runtime/entrypoints/web-teamplay'
+// Backward-compatibility entrypoint for older Babel configs that selected
+// `cache: 'teamplay'`. Runtime caching/subscriptions are now implemented by
+// @cssxjs/css-to-rn; this file intentionally just re-exports the normal web
+// runtime and does not import Teamplay.
+export {
+ default,
+ runtime
+} from './web.js'
+
+export * from './web.js'
diff --git a/packages/cssxjs/runtime/web.js b/packages/cssxjs/runtime/web.js
index 081b11e..520854e 100644
--- a/packages/cssxjs/runtime/web.js
+++ b/packages/cssxjs/runtime/web.js
@@ -1,2 +1,65 @@
-export { default } from '@cssxjs/runtime/entrypoints/web'
-export { default as runtime } from '@cssxjs/runtime/entrypoints/web'
+import {
+ cssx
+} from '@cssxjs/css-to-rn/web'
+
+export {
+ CssxProvider,
+ TrackedCssxSheet,
+ configureCssx,
+ cssx,
+ defaultVariables,
+ getCssColor,
+ getCssVariable,
+ getCssVariableRaw,
+ isTrackedCssxSheet,
+ setDefaultVariables,
+ themed,
+ useCssColor,
+ useCssVariable,
+ useCssVariableRaw,
+ useCssxLayer,
+ useRuntimeCss,
+ useCssxComponentTag,
+ useCssxConfig,
+ useCssxRuntimeContext,
+ useCssxSheet,
+ useCssxTemplate,
+ useMedia,
+ variables
+} from '@cssxjs/css-to-rn/web'
+
+export { default as matcher } from '../matcher.js'
+
+export function runtime (
+ styleName,
+ fileStyles,
+ globalStyles,
+ localStyles,
+ inlineStyleProps
+) {
+ return cssx(
+ styleName,
+ collectLayers(fileStyles, globalStyles, localStyles),
+ inlineStyleProps
+ )
+}
+
+export default runtime
+
+function collectLayers (...layers) {
+ return layers.filter(isLayer)
+}
+
+function isLayer (layer) {
+ return Boolean(
+ typeof layer === 'string' ||
+ (
+ layer &&
+ typeof layer === 'object' &&
+ (
+ layer.version === 1 ||
+ Object.prototype.hasOwnProperty.call(layer, 'sheet')
+ )
+ )
+ )
+}
diff --git a/packages/cssxjs/test/smoke.mjs b/packages/cssxjs/test/smoke.mjs
new file mode 100644
index 0000000..a3e4191
--- /dev/null
+++ b/packages/cssxjs/test/smoke.mjs
@@ -0,0 +1,77 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { compileCss } from '@cssxjs/css-to-rn'
+import {
+ CssxProvider,
+ cssx,
+ getCssColor,
+ getCssVariable,
+ matcher,
+ themed,
+ u,
+ useCssColor,
+ useCssVariable,
+ useCssxLayer,
+ useRuntimeCss
+} from 'cssxjs'
+import {
+ cssx as runtimeCssx,
+ getCssColor as runtimeGetCssColor,
+ useCssColor as runtimeUseCssColor
+} from 'cssxjs/runtime/web'
+
+assert.equal(typeof CssxProvider, 'function')
+assert.equal(typeof cssx, 'function')
+assert.equal(typeof getCssColor, 'function')
+assert.equal(typeof getCssVariable, 'function')
+assert.equal(typeof matcher, 'function')
+assert.equal(typeof themed, 'function')
+assert.equal(typeof u, 'function')
+assert.equal(typeof useCssColor, 'function')
+assert.equal(typeof useCssVariable, 'function')
+assert.equal(typeof useCssxLayer, 'function')
+assert.equal(typeof useRuntimeCss, 'function')
+assert.equal(typeof runtimeCssx, 'function')
+assert.equal(typeof runtimeGetCssColor, 'function')
+assert.equal(typeof runtimeUseCssColor, 'function')
+
+assert.deepEqual(
+ matcher('root active', {
+ root: { color: 'red' },
+ active: { opacity: 0.5 }
+ }),
+ [[{ color: 'red' }, { opacity: 0.5 }]]
+)
+
+assert.deepEqual(
+ matcher(['root', { active: true }], {
+ root: { color: 'red' },
+ active: { opacity: 0.5 },
+ 'root:part(icon)': { color: 'blue' }
+ }, undefined, undefined, {
+ style: { marginTop: 4 },
+ iconStyle: { marginLeft: 8 }
+ }),
+ {
+ style: [[{ color: 'red' }, { opacity: 0.5 }], { marginTop: 4 }],
+ iconStyle: [[{ color: 'blue' }], { marginLeft: 8 }]
+ }
+)
+
+const packageDir = dirname(dirname(fileURLToPath(import.meta.url)))
+
+for (const name of ['tailwind', 'shadcn']) {
+ const source = readFileSync(join(packageDir, 'themes', `${name}.cssx.css`), 'utf8')
+ assert.equal(source.includes('@theme'), false, `${name} theme must not use Tailwind @theme syntax`)
+
+ const sheet = compileCss(source, { mode: 'build', sourceId: `cssxjs/themes/${name}` })
+ assert.equal(sheet.error, undefined, `${name} theme should compile without fatal errors`)
+ assert.deepEqual(
+ sheet.diagnostics.filter(diagnostic => diagnostic.level === 'error'),
+ [],
+ `${name} theme should compile without errors`
+ )
+ assert.equal(sheet.metadata.hasVars, true, `${name} theme should expose CSS variables`)
+}
diff --git a/packages/cssxjs/themes/shadcn.cssx.css b/packages/cssxjs/themes/shadcn.cssx.css
new file mode 100644
index 0000000..a0072d7
--- /dev/null
+++ b/packages/cssxjs/themes/shadcn.cssx.css
@@ -0,0 +1,130 @@
+/*
+ * shadcn/ui theme variables adapted for CSSX.
+ * Source: https://ui.shadcn.com/docs/theming
+ *
+ * The default theme is represented by :root. The dark variant is represented
+ * by :root.dark so CssxProvider theme='auto' can select it on dark systems.
+ */
+
+:root {
+ --radius: 0.625rem;
+
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.985 0 0);
+ --success: oklch(0.723 0.219 149.579);
+ --success-foreground: oklch(0.985 0 0);
+ --warning: oklch(0.769 0.188 70.08);
+ --warning-foreground: oklch(0.205 0 0);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-success: var(--success);
+ --color-success-foreground: var(--success-foreground);
+ --color-warning: var(--warning);
+ --color-warning-foreground: var(--warning-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+:root.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --destructive-foreground: oklch(0.985 0 0);
+ --success: oklch(0.696 0.17 162.48);
+ --success-foreground: oklch(0.145 0 0);
+ --warning: oklch(0.769 0.188 70.08);
+ --warning-foreground: oklch(0.145 0 0);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
diff --git a/packages/cssxjs/themes/shadcn.d.ts b/packages/cssxjs/themes/shadcn.d.ts
new file mode 100644
index 0000000..e375dd2
--- /dev/null
+++ b/packages/cssxjs/themes/shadcn.d.ts
@@ -0,0 +1,4 @@
+import type { CompiledCssSheet } from '@cssxjs/css-to-rn'
+
+declare const theme: CompiledCssSheet
+export default theme
diff --git a/packages/cssxjs/themes/shadcn.js b/packages/cssxjs/themes/shadcn.js
new file mode 100644
index 0000000..30df567
--- /dev/null
+++ b/packages/cssxjs/themes/shadcn.js
@@ -0,0 +1,3 @@
+import theme from './shadcn.cssx.css'
+
+export default theme
diff --git a/packages/cssxjs/themes/tailwind.cssx.css b/packages/cssxjs/themes/tailwind.cssx.css
new file mode 100644
index 0000000..3f7feb7
--- /dev/null
+++ b/packages/cssxjs/themes/tailwind.cssx.css
@@ -0,0 +1,527 @@
+/*
+ * Tailwind CSS theme variables adapted for CSSX.
+ * Source: https://raw.githubusercontent.com/tailwindlabs/tailwindcss/main/packages/tailwindcss/theme.css
+ *
+ * Tailwind's non-standard theme blocks are represented as plain :root
+ * custom-property blocks. Nested keyframes are lifted to top-level CSS.
+ */
+
+:root {
+ --font-sans:
+ ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+ 'Noto Color Emoji';
+ --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
+ --font-mono:
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
+ monospace;
+
+ --color-red-50: oklch(97.1% 0.013 17.38);
+ --color-red-100: oklch(93.6% 0.032 17.717);
+ --color-red-200: oklch(88.5% 0.062 18.334);
+ --color-red-300: oklch(80.8% 0.114 19.571);
+ --color-red-400: oklch(70.4% 0.191 22.216);
+ --color-red-500: oklch(63.7% 0.237 25.331);
+ --color-red-600: oklch(57.7% 0.245 27.325);
+ --color-red-700: oklch(50.5% 0.213 27.518);
+ --color-red-800: oklch(44.4% 0.177 26.899);
+ --color-red-900: oklch(39.6% 0.141 25.723);
+ --color-red-950: oklch(25.8% 0.092 26.042);
+
+ --color-orange-50: oklch(98% 0.016 73.684);
+ --color-orange-100: oklch(95.4% 0.038 75.164);
+ --color-orange-200: oklch(90.1% 0.076 70.697);
+ --color-orange-300: oklch(83.7% 0.128 66.29);
+ --color-orange-400: oklch(75% 0.183 55.934);
+ --color-orange-500: oklch(70.5% 0.213 47.604);
+ --color-orange-600: oklch(64.6% 0.222 41.116);
+ --color-orange-700: oklch(55.3% 0.195 38.402);
+ --color-orange-800: oklch(47% 0.157 37.304);
+ --color-orange-900: oklch(40.8% 0.123 38.172);
+ --color-orange-950: oklch(26.6% 0.079 36.259);
+
+ --color-amber-50: oklch(98.7% 0.022 95.277);
+ --color-amber-100: oklch(96.2% 0.059 95.617);
+ --color-amber-200: oklch(92.4% 0.12 95.746);
+ --color-amber-300: oklch(87.9% 0.169 91.605);
+ --color-amber-400: oklch(82.8% 0.189 84.429);
+ --color-amber-500: oklch(76.9% 0.188 70.08);
+ --color-amber-600: oklch(66.6% 0.179 58.318);
+ --color-amber-700: oklch(55.5% 0.163 48.998);
+ --color-amber-800: oklch(47.3% 0.137 46.201);
+ --color-amber-900: oklch(41.4% 0.112 45.904);
+ --color-amber-950: oklch(27.9% 0.077 45.635);
+
+ --color-yellow-50: oklch(98.7% 0.026 102.212);
+ --color-yellow-100: oklch(97.3% 0.071 103.193);
+ --color-yellow-200: oklch(94.5% 0.129 101.54);
+ --color-yellow-300: oklch(90.5% 0.182 98.111);
+ --color-yellow-400: oklch(85.2% 0.199 91.936);
+ --color-yellow-500: oklch(79.5% 0.184 86.047);
+ --color-yellow-600: oklch(68.1% 0.162 75.834);
+ --color-yellow-700: oklch(55.4% 0.135 66.442);
+ --color-yellow-800: oklch(47.6% 0.114 61.907);
+ --color-yellow-900: oklch(42.1% 0.095 57.708);
+ --color-yellow-950: oklch(28.6% 0.066 53.813);
+
+ --color-lime-50: oklch(98.6% 0.031 120.757);
+ --color-lime-100: oklch(96.7% 0.067 122.328);
+ --color-lime-200: oklch(93.8% 0.127 124.321);
+ --color-lime-300: oklch(89.7% 0.196 126.665);
+ --color-lime-400: oklch(84.1% 0.238 128.85);
+ --color-lime-500: oklch(76.8% 0.233 130.85);
+ --color-lime-600: oklch(64.8% 0.2 131.684);
+ --color-lime-700: oklch(53.2% 0.157 131.589);
+ --color-lime-800: oklch(45.3% 0.124 130.933);
+ --color-lime-900: oklch(40.5% 0.101 131.063);
+ --color-lime-950: oklch(27.4% 0.072 132.109);
+
+ --color-green-50: oklch(98.2% 0.018 155.826);
+ --color-green-100: oklch(96.2% 0.044 156.743);
+ --color-green-200: oklch(92.5% 0.084 155.995);
+ --color-green-300: oklch(87.1% 0.15 154.449);
+ --color-green-400: oklch(79.2% 0.209 151.711);
+ --color-green-500: oklch(72.3% 0.219 149.579);
+ --color-green-600: oklch(62.7% 0.194 149.214);
+ --color-green-700: oklch(52.7% 0.154 150.069);
+ --color-green-800: oklch(44.8% 0.119 151.328);
+ --color-green-900: oklch(39.3% 0.095 152.535);
+ --color-green-950: oklch(26.6% 0.065 152.934);
+
+ --color-emerald-50: oklch(97.9% 0.021 166.113);
+ --color-emerald-100: oklch(95% 0.052 163.051);
+ --color-emerald-200: oklch(90.5% 0.093 164.15);
+ --color-emerald-300: oklch(84.5% 0.143 164.978);
+ --color-emerald-400: oklch(76.5% 0.177 163.223);
+ --color-emerald-500: oklch(69.6% 0.17 162.48);
+ --color-emerald-600: oklch(59.6% 0.145 163.225);
+ --color-emerald-700: oklch(50.8% 0.118 165.612);
+ --color-emerald-800: oklch(43.2% 0.095 166.913);
+ --color-emerald-900: oklch(37.8% 0.077 168.94);
+ --color-emerald-950: oklch(26.2% 0.051 172.552);
+
+ --color-teal-50: oklch(98.4% 0.014 180.72);
+ --color-teal-100: oklch(95.3% 0.051 180.801);
+ --color-teal-200: oklch(91% 0.096 180.426);
+ --color-teal-300: oklch(85.5% 0.138 181.071);
+ --color-teal-400: oklch(77.7% 0.152 181.912);
+ --color-teal-500: oklch(70.4% 0.14 182.503);
+ --color-teal-600: oklch(60% 0.118 184.704);
+ --color-teal-700: oklch(51.1% 0.096 186.391);
+ --color-teal-800: oklch(43.7% 0.078 188.216);
+ --color-teal-900: oklch(38.6% 0.063 188.416);
+ --color-teal-950: oklch(27.7% 0.046 192.524);
+
+ --color-cyan-50: oklch(98.4% 0.019 200.873);
+ --color-cyan-100: oklch(95.6% 0.045 203.388);
+ --color-cyan-200: oklch(91.7% 0.08 205.041);
+ --color-cyan-300: oklch(86.5% 0.127 207.078);
+ --color-cyan-400: oklch(78.9% 0.154 211.53);
+ --color-cyan-500: oklch(71.5% 0.143 215.221);
+ --color-cyan-600: oklch(60.9% 0.126 221.723);
+ --color-cyan-700: oklch(52% 0.105 223.128);
+ --color-cyan-800: oklch(45% 0.085 224.283);
+ --color-cyan-900: oklch(39.8% 0.07 227.392);
+ --color-cyan-950: oklch(30.2% 0.056 229.695);
+
+ --color-sky-50: oklch(97.7% 0.013 236.62);
+ --color-sky-100: oklch(95.1% 0.026 236.824);
+ --color-sky-200: oklch(90.1% 0.058 230.902);
+ --color-sky-300: oklch(82.8% 0.111 230.318);
+ --color-sky-400: oklch(74.6% 0.16 232.661);
+ --color-sky-500: oklch(68.5% 0.169 237.323);
+ --color-sky-600: oklch(58.8% 0.158 241.966);
+ --color-sky-700: oklch(50% 0.134 242.749);
+ --color-sky-800: oklch(44.3% 0.11 240.79);
+ --color-sky-900: oklch(39.1% 0.09 240.876);
+ --color-sky-950: oklch(29.3% 0.066 243.157);
+
+ --color-blue-50: oklch(97% 0.014 254.604);
+ --color-blue-100: oklch(93.2% 0.032 255.585);
+ --color-blue-200: oklch(88.2% 0.059 254.128);
+ --color-blue-300: oklch(80.9% 0.105 251.813);
+ --color-blue-400: oklch(70.7% 0.165 254.624);
+ --color-blue-500: oklch(62.3% 0.214 259.815);
+ --color-blue-600: oklch(54.6% 0.245 262.881);
+ --color-blue-700: oklch(48.8% 0.243 264.376);
+ --color-blue-800: oklch(42.4% 0.199 265.638);
+ --color-blue-900: oklch(37.9% 0.146 265.522);
+ --color-blue-950: oklch(28.2% 0.091 267.935);
+
+ --color-indigo-50: oklch(96.2% 0.018 272.314);
+ --color-indigo-100: oklch(93% 0.034 272.788);
+ --color-indigo-200: oklch(87% 0.065 274.039);
+ --color-indigo-300: oklch(78.5% 0.115 274.713);
+ --color-indigo-400: oklch(67.3% 0.182 276.935);
+ --color-indigo-500: oklch(58.5% 0.233 277.117);
+ --color-indigo-600: oklch(51.1% 0.262 276.966);
+ --color-indigo-700: oklch(45.7% 0.24 277.023);
+ --color-indigo-800: oklch(39.8% 0.195 277.366);
+ --color-indigo-900: oklch(35.9% 0.144 278.697);
+ --color-indigo-950: oklch(25.7% 0.09 281.288);
+
+ --color-violet-50: oklch(96.9% 0.016 293.756);
+ --color-violet-100: oklch(94.3% 0.029 294.588);
+ --color-violet-200: oklch(89.4% 0.057 293.283);
+ --color-violet-300: oklch(81.1% 0.111 293.571);
+ --color-violet-400: oklch(70.2% 0.183 293.541);
+ --color-violet-500: oklch(60.6% 0.25 292.717);
+ --color-violet-600: oklch(54.1% 0.281 293.009);
+ --color-violet-700: oklch(49.1% 0.27 292.581);
+ --color-violet-800: oklch(43.2% 0.232 292.759);
+ --color-violet-900: oklch(38% 0.189 293.745);
+ --color-violet-950: oklch(28.3% 0.141 291.089);
+
+ --color-purple-50: oklch(97.7% 0.014 308.299);
+ --color-purple-100: oklch(94.6% 0.033 307.174);
+ --color-purple-200: oklch(90.2% 0.063 306.703);
+ --color-purple-300: oklch(82.7% 0.119 306.383);
+ --color-purple-400: oklch(71.4% 0.203 305.504);
+ --color-purple-500: oklch(62.7% 0.265 303.9);
+ --color-purple-600: oklch(55.8% 0.288 302.321);
+ --color-purple-700: oklch(49.6% 0.265 301.924);
+ --color-purple-800: oklch(43.8% 0.218 303.724);
+ --color-purple-900: oklch(38.1% 0.176 304.987);
+ --color-purple-950: oklch(29.1% 0.149 302.717);
+
+ --color-fuchsia-50: oklch(97.7% 0.017 320.058);
+ --color-fuchsia-100: oklch(95.2% 0.037 318.852);
+ --color-fuchsia-200: oklch(90.3% 0.076 319.62);
+ --color-fuchsia-300: oklch(83.3% 0.145 321.434);
+ --color-fuchsia-400: oklch(74% 0.238 322.16);
+ --color-fuchsia-500: oklch(66.7% 0.295 322.15);
+ --color-fuchsia-600: oklch(59.1% 0.293 322.896);
+ --color-fuchsia-700: oklch(51.8% 0.253 323.949);
+ --color-fuchsia-800: oklch(45.2% 0.211 324.591);
+ --color-fuchsia-900: oklch(40.1% 0.17 325.612);
+ --color-fuchsia-950: oklch(29.3% 0.136 325.661);
+
+ --color-pink-50: oklch(97.1% 0.014 343.198);
+ --color-pink-100: oklch(94.8% 0.028 342.258);
+ --color-pink-200: oklch(89.9% 0.061 343.231);
+ --color-pink-300: oklch(82.3% 0.12 346.018);
+ --color-pink-400: oklch(71.8% 0.202 349.761);
+ --color-pink-500: oklch(65.6% 0.241 354.308);
+ --color-pink-600: oklch(59.2% 0.249 0.584);
+ --color-pink-700: oklch(52.5% 0.223 3.958);
+ --color-pink-800: oklch(45.9% 0.187 3.815);
+ --color-pink-900: oklch(40.8% 0.153 2.432);
+ --color-pink-950: oklch(28.4% 0.109 3.907);
+
+ --color-rose-50: oklch(96.9% 0.015 12.422);
+ --color-rose-100: oklch(94.1% 0.03 12.58);
+ --color-rose-200: oklch(89.2% 0.058 10.001);
+ --color-rose-300: oklch(81% 0.117 11.638);
+ --color-rose-400: oklch(71.2% 0.194 13.428);
+ --color-rose-500: oklch(64.5% 0.246 16.439);
+ --color-rose-600: oklch(58.6% 0.253 17.585);
+ --color-rose-700: oklch(51.4% 0.222 16.935);
+ --color-rose-800: oklch(45.5% 0.188 13.697);
+ --color-rose-900: oklch(41% 0.159 10.272);
+ --color-rose-950: oklch(27.1% 0.105 12.094);
+
+ --color-slate-50: oklch(98.4% 0.003 247.858);
+ --color-slate-100: oklch(96.8% 0.007 247.896);
+ --color-slate-200: oklch(92.9% 0.013 255.508);
+ --color-slate-300: oklch(86.9% 0.022 252.894);
+ --color-slate-400: oklch(70.4% 0.04 256.788);
+ --color-slate-500: oklch(55.4% 0.046 257.417);
+ --color-slate-600: oklch(44.6% 0.043 257.281);
+ --color-slate-700: oklch(37.2% 0.044 257.287);
+ --color-slate-800: oklch(27.9% 0.041 260.031);
+ --color-slate-900: oklch(20.8% 0.042 265.755);
+ --color-slate-950: oklch(12.9% 0.042 264.695);
+
+ --color-gray-50: oklch(98.5% 0.002 247.839);
+ --color-gray-100: oklch(96.7% 0.003 264.542);
+ --color-gray-200: oklch(92.8% 0.006 264.531);
+ --color-gray-300: oklch(87.2% 0.01 258.338);
+ --color-gray-400: oklch(70.7% 0.022 261.325);
+ --color-gray-500: oklch(55.1% 0.027 264.364);
+ --color-gray-600: oklch(44.6% 0.03 256.802);
+ --color-gray-700: oklch(37.3% 0.034 259.733);
+ --color-gray-800: oklch(27.8% 0.033 256.848);
+ --color-gray-900: oklch(21% 0.034 264.665);
+ --color-gray-950: oklch(13% 0.028 261.692);
+
+ --color-zinc-50: oklch(98.5% 0 0);
+ --color-zinc-100: oklch(96.7% 0.001 286.375);
+ --color-zinc-200: oklch(92% 0.004 286.32);
+ --color-zinc-300: oklch(87.1% 0.006 286.286);
+ --color-zinc-400: oklch(70.5% 0.015 286.067);
+ --color-zinc-500: oklch(55.2% 0.016 285.938);
+ --color-zinc-600: oklch(44.2% 0.017 285.786);
+ --color-zinc-700: oklch(37% 0.013 285.805);
+ --color-zinc-800: oklch(27.4% 0.006 286.033);
+ --color-zinc-900: oklch(21% 0.006 285.885);
+ --color-zinc-950: oklch(14.1% 0.005 285.823);
+
+ --color-neutral-50: oklch(98.5% 0 0);
+ --color-neutral-100: oklch(97% 0 0);
+ --color-neutral-200: oklch(92.2% 0 0);
+ --color-neutral-300: oklch(87% 0 0);
+ --color-neutral-400: oklch(70.8% 0 0);
+ --color-neutral-500: oklch(55.6% 0 0);
+ --color-neutral-600: oklch(43.9% 0 0);
+ --color-neutral-700: oklch(37.1% 0 0);
+ --color-neutral-800: oklch(26.9% 0 0);
+ --color-neutral-900: oklch(20.5% 0 0);
+ --color-neutral-950: oklch(14.5% 0 0);
+
+ --color-stone-50: oklch(98.5% 0.001 106.423);
+ --color-stone-100: oklch(97% 0.001 106.424);
+ --color-stone-200: oklch(92.3% 0.003 48.717);
+ --color-stone-300: oklch(86.9% 0.005 56.366);
+ --color-stone-400: oklch(70.9% 0.01 56.259);
+ --color-stone-500: oklch(55.3% 0.013 58.071);
+ --color-stone-600: oklch(44.4% 0.011 73.639);
+ --color-stone-700: oklch(37.4% 0.01 67.558);
+ --color-stone-800: oklch(26.8% 0.007 34.298);
+ --color-stone-900: oklch(21.6% 0.006 56.043);
+ --color-stone-950: oklch(14.7% 0.004 49.25);
+
+ --color-mauve-50: oklch(98.5% 0 0);
+ --color-mauve-100: oklch(96% 0.003 325.6);
+ --color-mauve-200: oklch(92.2% 0.005 325.62);
+ --color-mauve-300: oklch(86.5% 0.012 325.68);
+ --color-mauve-400: oklch(71.1% 0.019 323.02);
+ --color-mauve-500: oklch(54.2% 0.034 322.5);
+ --color-mauve-600: oklch(43.5% 0.029 321.78);
+ --color-mauve-700: oklch(36.4% 0.029 323.89);
+ --color-mauve-800: oklch(26.3% 0.024 320.12);
+ --color-mauve-900: oklch(21.2% 0.019 322.12);
+ --color-mauve-950: oklch(14.5% 0.008 326);
+
+ --color-olive-50: oklch(98.8% 0.003 106.5);
+ --color-olive-100: oklch(96.6% 0.005 106.5);
+ --color-olive-200: oklch(93% 0.007 106.5);
+ --color-olive-300: oklch(88% 0.011 106.6);
+ --color-olive-400: oklch(73.7% 0.021 106.9);
+ --color-olive-500: oklch(58% 0.031 107.3);
+ --color-olive-600: oklch(46.6% 0.025 107.3);
+ --color-olive-700: oklch(39.4% 0.023 107.4);
+ --color-olive-800: oklch(28.6% 0.016 107.4);
+ --color-olive-900: oklch(22.8% 0.013 107.4);
+ --color-olive-950: oklch(15.3% 0.006 107.1);
+
+ --color-mist-50: oklch(98.7% 0.002 197.1);
+ --color-mist-100: oklch(96.3% 0.002 197.1);
+ --color-mist-200: oklch(92.5% 0.005 214.3);
+ --color-mist-300: oklch(87.2% 0.007 219.6);
+ --color-mist-400: oklch(72.3% 0.014 214.4);
+ --color-mist-500: oklch(56% 0.021 213.5);
+ --color-mist-600: oklch(45% 0.017 213.2);
+ --color-mist-700: oklch(37.8% 0.015 216);
+ --color-mist-800: oklch(27.5% 0.011 216.9);
+ --color-mist-900: oklch(21.8% 0.008 223.9);
+ --color-mist-950: oklch(14.8% 0.004 228.8);
+
+ --color-taupe-50: oklch(98.6% 0.002 67.8);
+ --color-taupe-100: oklch(96% 0.002 17.2);
+ --color-taupe-200: oklch(92.2% 0.005 34.3);
+ --color-taupe-300: oklch(86.8% 0.007 39.5);
+ --color-taupe-400: oklch(71.4% 0.014 41.2);
+ --color-taupe-500: oklch(54.7% 0.021 43.1);
+ --color-taupe-600: oklch(43.8% 0.017 39.3);
+ --color-taupe-700: oklch(36.7% 0.016 35.7);
+ --color-taupe-800: oklch(26.8% 0.011 36.5);
+ --color-taupe-900: oklch(21.4% 0.009 43.1);
+ --color-taupe-950: oklch(14.7% 0.004 49.3);
+
+ --color-black: #000;
+ --color-white: #fff;
+
+ --spacing: 0.25rem;
+
+ --breakpoint-sm: 40rem;
+ --breakpoint-md: 48rem;
+ --breakpoint-lg: 64rem;
+ --breakpoint-xl: 80rem;
+ --breakpoint-2xl: 96rem;
+
+ --container-3xs: 16rem;
+ --container-2xs: 18rem;
+ --container-xs: 20rem;
+ --container-sm: 24rem;
+ --container-md: 28rem;
+ --container-lg: 32rem;
+ --container-xl: 36rem;
+ --container-2xl: 42rem;
+ --container-3xl: 48rem;
+ --container-4xl: 56rem;
+ --container-5xl: 64rem;
+ --container-6xl: 72rem;
+ --container-7xl: 80rem;
+
+ --text-xs: 0.75rem;
+ --text-xs--line-height: calc(1 / 0.75);
+ --text-sm: 0.875rem;
+ --text-sm--line-height: calc(1.25 / 0.875);
+ --text-base: 1rem;
+ --text-base--line-height: calc(1.5 / 1);
+ --text-lg: 1.125rem;
+ --text-lg--line-height: calc(1.75 / 1.125);
+ --text-xl: 1.25rem;
+ --text-xl--line-height: calc(1.75 / 1.25);
+ --text-2xl: 1.5rem;
+ --text-2xl--line-height: calc(2 / 1.5);
+ --text-3xl: 1.875rem;
+ --text-3xl--line-height: calc(2.25 / 1.875);
+ --text-4xl: 2.25rem;
+ --text-4xl--line-height: calc(2.5 / 2.25);
+ --text-5xl: 3rem;
+ --text-5xl--line-height: 1;
+ --text-6xl: 3.75rem;
+ --text-6xl--line-height: 1;
+ --text-7xl: 4.5rem;
+ --text-7xl--line-height: 1;
+ --text-8xl: 6rem;
+ --text-8xl--line-height: 1;
+ --text-9xl: 8rem;
+ --text-9xl--line-height: 1;
+
+ --font-weight-thin: 100;
+ --font-weight-extralight: 200;
+ --font-weight-light: 300;
+ --font-weight-normal: 400;
+ --font-weight-medium: 500;
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+ --font-weight-extrabold: 800;
+ --font-weight-black: 900;
+
+ --tracking-tighter: -0.05em;
+ --tracking-tight: -0.025em;
+ --tracking-normal: 0em;
+ --tracking-wide: 0.025em;
+ --tracking-wider: 0.05em;
+ --tracking-widest: 0.1em;
+
+ --leading-tight: 1.25;
+ --leading-snug: 1.375;
+ --leading-normal: 1.5;
+ --leading-relaxed: 1.625;
+ --leading-loose: 2;
+
+ --radius-xs: 0.125rem;
+ --radius-sm: 0.25rem;
+ --radius-md: 0.375rem;
+ --radius-lg: 0.5rem;
+ --radius-xl: 0.75rem;
+ --radius-2xl: 1rem;
+ --radius-3xl: 1.5rem;
+ --radius-4xl: 2rem;
+
+ --shadow-2xs: 0 1px rgb(0 0 0 / 0.05);
+ --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+ --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
+ --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
+
+ --inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05);
+ --inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05);
+ --inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05);
+
+ --drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05);
+ --drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15);
+ --drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12);
+ --drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15);
+ --drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1);
+ --drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15);
+
+ --text-shadow-2xs: 0px 1px 0px rgb(0 0 0 / 0.15);
+ --text-shadow-xs: 0px 1px 1px rgb(0 0 0 / 0.2);
+ --text-shadow-sm:
+ 0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075), 0px 2px 2px rgb(0 0 0 / 0.075);
+ --text-shadow-md:
+ 0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1), 0px 2px 4px rgb(0 0 0 / 0.1);
+ --text-shadow-lg:
+ 0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1), 0px 4px 8px rgb(0 0 0 / 0.1);
+
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
+ --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+
+ --animate-spin: spin 1s linear infinite;
+ --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
+ --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+ --animate-bounce: bounce 1s infinite;
+
+
+
+
+
+
+
+
+
+ --blur-xs: 4px;
+ --blur-sm: 8px;
+ --blur-md: 12px;
+ --blur-lg: 16px;
+ --blur-xl: 24px;
+ --blur-2xl: 40px;
+ --blur-3xl: 64px;
+
+ --perspective-dramatic: 100px;
+ --perspective-near: 300px;
+ --perspective-normal: 500px;
+ --perspective-midrange: 800px;
+ --perspective-distant: 1200px;
+
+ --aspect-video: 16 / 9;
+
+ --default-transition-duration: 150ms;
+ --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-font-family: var(--font-sans, initial);
+ --default-font-feature-settings: var(--font-sans--font-feature-settings, initial);
+ --default-font-variation-settings: var(--font-sans--font-variation-settings, initial);
+ --default-mono-font-family: var(--font-mono, initial);
+ --default-mono-font-feature-settings: var(--font-mono--font-feature-settings, initial);
+ --default-mono-font-variation-settings: var(--font-mono--font-variation-settings, initial);
+}
+
+/* Deprecated */
+
+:root {
+ --blur: 8px;
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
+ --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
+ --drop-shadow: 0 1px 2px rgb(0 0 0 / 0.1), 0 1px 1px rgb(0 0 0 / 0.06);
+ --radius: 0.25rem;
+ --max-width-prose: 65ch;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes ping {
+ 75%,
+ 100% {
+ transform: scale(2);
+ opacity: 0;
+ }
+}
+
+@keyframes pulse {
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+@keyframes bounce {
+ 0%,
+ 100% {
+ transform: translateY(-25%);
+ animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
+ }
+
+ 50% {
+ transform: none;
+ animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
+ }
+}
diff --git a/packages/cssxjs/themes/tailwind.d.ts b/packages/cssxjs/themes/tailwind.d.ts
new file mode 100644
index 0000000..e375dd2
--- /dev/null
+++ b/packages/cssxjs/themes/tailwind.d.ts
@@ -0,0 +1,4 @@
+import type { CompiledCssSheet } from '@cssxjs/css-to-rn'
+
+declare const theme: CompiledCssSheet
+export default theme
diff --git a/packages/cssxjs/themes/tailwind.js b/packages/cssxjs/themes/tailwind.js
new file mode 100644
index 0000000..876d5af
--- /dev/null
+++ b/packages/cssxjs/themes/tailwind.js
@@ -0,0 +1,3 @@
+import theme from './tailwind.cssx.css'
+
+export default theme
diff --git a/packages/loaders/compilers/css.js b/packages/loaders/compilers/css.js
index e430135..78963e1 100644
--- a/packages/loaders/compilers/css.js
+++ b/packages/loaders/compilers/css.js
@@ -3,11 +3,13 @@ const cssLoader = require('../cssToReactNativeLoader.js')
const callLoader = require('../callLoader.js')
const { stripExport } = require('./helpers')
-module.exports = function compileCss (src) {
+module.exports = function compileCss (src, filename, options) {
return stripExport(
callLoader(
cssLoader,
- src
+ src,
+ filename,
+ options
)
)
}
diff --git a/packages/loaders/compilers/styl.js b/packages/loaders/compilers/styl.js
index a92dd13..fe4c1a5 100644
--- a/packages/loaders/compilers/styl.js
+++ b/packages/loaders/compilers/styl.js
@@ -10,5 +10,5 @@ module.exports = function compileStyl (src, filename, options) {
filename,
options
)
- return compileCss(src)
+ return compileCss(src, filename, options)
}
diff --git a/packages/loaders/cssToReactNativeLoader.js b/packages/loaders/cssToReactNativeLoader.js
index d2b0589..153af24 100644
--- a/packages/loaders/cssToReactNativeLoader.js
+++ b/packages/loaders/cssToReactNativeLoader.js
@@ -1,33 +1,127 @@
-// ref: https://github.com/kristerkari/react-native-css-transformer
-const css2rn = require('@startupjs/css-to-react-native-transform').default
+const { spawnSync } = require('child_process')
+const { existsSync } = require('fs')
+const { createRequire } = require('module')
+const { join } = require('path')
+const { pathToFileURL } = require('url')
+const cssToRn = requireCssToRn()
+const { compileCss, compileCssTemplate } = cssToRn
+const resolveCssx = cssToRn.resolveCssx
+const hashCssObject = cssToRn.simpleNumericHash ?? simpleNumericHash
const EXPORT_REGEX = /:export\s*\{/
-// Match var() anywhere in a string value (not just at the start)
-const VAR_NAMES_REGEX = /var\(\s*(--[A-Za-z0-9_-]+)/g
module.exports = function cssToReactNative (source) {
source = escapeExport(source)
- const cssObject = css2rn(source, {
- parseMediaQueries: true,
- parsePartSelectors: true,
- parseKeyframes: true
+ const compile = this.query?.template ? compileCssTemplate : compileCss
+ const cssObject = compile(source, {
+ mode: 'build',
+ target: this.query?.platform,
+ sourceIdentity: this.resourcePath
})
- for (const key in cssObject.__exportProps || {}) {
- cssObject[key] = parseStylValue(cssObject.__exportProps[key])
+ for (const key in cssObject.exports || {}) {
+ cssObject[key] = parseStylValue(cssObject.exports[key])
}
+ addLegacyStaticStyles(cssObject, this.query?.platform)
const stringifiedCss = JSON.stringify(cssObject)
- // save hash to use with the caching system of @startupjs/cache
- cssObject.__hash__ = simpleNumericHash(stringifiedCss)
- // OPTIMIZATION: save vars used in the styles for later replacement in runtime
- // and also to determine whether we need to listen for variable changes
- const vars = getVariableNames(stringifiedCss)
- if (vars) cssObject.__vars = vars
- // OPTIMIZATION: indicate whether @media queries are used.
- // This is later used in runtime to determine whether we need to listen for dimension changes
- if (hasMedia(cssObject)) cssObject.__hasMedia = true
+ // save hash to keep compatibility with existing generated code and tests
+ cssObject.__hash__ = hashCssObject(stringifiedCss)
return 'module.exports = ' + JSON.stringify(cssObject)
}
+function addLegacyStaticStyles (cssObject, target) {
+ if (typeof resolveCssx !== 'function') return
+
+ for (const className of getLegacyStaticClassNames(cssObject)) {
+ if (Object.prototype.hasOwnProperty.call(cssObject, className)) continue
+
+ const style = resolveCssx({
+ styleName: className,
+ layers: cssObject,
+ target,
+ cache: false
+ }).props.style
+
+ if (style && typeof style === 'object' && Object.keys(style).length > 0) {
+ cssObject[className] = style
+ }
+ }
+}
+
+function getLegacyStaticClassNames (cssObject) {
+ const classNames = new Set()
+
+ for (const rule of cssObject.rules || []) {
+ if (rule.part || rule.media || rule.classes?.length !== 1) continue
+ classNames.add(rule.classes[0])
+ }
+
+ return classNames
+}
+
+function requireCssToRn () {
+ const nativeRequire = createRequire(__filename)
+ try {
+ return nativeRequire('@cssxjs/css-to-rn')
+ } catch (error) {
+ const sourceEntrypoint = join(__dirname, '../css-to-rn/src/index.ts')
+ if (
+ existsSync(sourceEntrypoint) &&
+ (
+ error.code === 'MODULE_NOT_FOUND' ||
+ error instanceof SyntaxError ||
+ /Must use import to load ES Module/.test(error.message)
+ )
+ ) {
+ return createChildCompiler(sourceEntrypoint)
+ }
+ throw error
+ }
+}
+
+function createChildCompiler (sourceEntrypoint) {
+ return {
+ compileCss: (source, options) =>
+ compileInChildProcess('compileCss', sourceEntrypoint, source, options),
+ compileCssTemplate: (source, options) =>
+ compileInChildProcess('compileCssTemplate', sourceEntrypoint, source, options),
+ simpleNumericHash
+ }
+}
+
+function compileInChildProcess (method, sourceEntrypoint, source, options) {
+ const script = `
+ import { ${method} } from ${JSON.stringify(pathToFileURL(sourceEntrypoint).href)}
+ let input = ''
+ process.stdin.setEncoding('utf8')
+ for await (const chunk of process.stdin) input += chunk
+ const payload = JSON.parse(input)
+ process.stdout.write(JSON.stringify(${method}(payload.source, payload.options)))
+ `
+ const result = spawnSync(process.execPath, [
+ '-C',
+ 'cssx-ts',
+ '--input-type=module',
+ '--eval',
+ script
+ ], {
+ input: JSON.stringify({ source, options }),
+ encoding: 'utf8'
+ })
+
+ if (result.status !== 0) {
+ throw new Error(result.stderr || result.stdout)
+ }
+
+ return JSON.parse(result.stdout)
+}
+
+// ref: https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0?permalink_comment_id=2694461#gistcomment-269461
+function simpleNumericHash (s) {
+ let i, h
+ for (i = 0, h = 0; i < s.length; i++) h = Math.imul(31, h) + s.charCodeAt(i) | 0
+ return h
+}
+
function parseStylValue (value) {
if (typeof value !== 'string') return value
// strip single quotes (stylus adds it for the topmost value)
@@ -92,25 +186,3 @@ function escapeExport (source) {
return source
}
-
-// ref: https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0?permalink_comment_id=2694461#gistcomment-2694461
-function simpleNumericHash (s) {
- let i, h
- for (i = 0, h = 0; i < s.length; i++) h = Math.imul(31, h) + s.charCodeAt(i) | 0
- return h
-}
-
-function getVariableNames (cssString) {
- const matches = [...cssString.matchAll(VAR_NAMES_REGEX)]
- if (!matches.length) return
- const res = matches.map(m => m[1]) // extract capture group (variable name)
- return [...new Set(res)].sort() // remove duplicates and sort
-}
-
-function hasMedia (styles = {}) {
- for (const selector in styles) {
- if (/^@media/.test(selector)) {
- return true
- }
- }
-}
diff --git a/packages/loaders/package.json b/packages/loaders/package.json
index 39cd918..bc129bb 100644
--- a/packages/loaders/package.json
+++ b/packages/loaders/package.json
@@ -12,7 +12,7 @@
"access": "public"
},
"scripts": {
- "test": "echo 'No tests yet' && exit 0"
+ "test": "node test/cssToReactNativeLoader.test.cjs"
},
"author": {
"name": "Pavel Zhukov",
@@ -20,7 +20,7 @@
},
"license": "MIT",
"dependencies": {
- "@startupjs/css-to-react-native-transform": "2.1.0-3",
+ "@cssxjs/css-to-rn": "^0.3.0",
"stylus": "0.64.0"
}
}
diff --git a/packages/loaders/test/cssToReactNativeLoader.test.cjs b/packages/loaders/test/cssToReactNativeLoader.test.cjs
new file mode 100644
index 0000000..85c3dbc
--- /dev/null
+++ b/packages/loaders/test/cssToReactNativeLoader.test.cjs
@@ -0,0 +1,29 @@
+const assert = require('assert')
+const loader = require('../cssToReactNativeLoader.js')
+
+const output = loader.call(
+ { query: { platform: 'web' }, resourcePath: 'smoke.css' },
+ `
+ .root { color: red; }
+ .years-item { height: 36px; padding: 8px; }
+ .root.active { opacity: 0.5; }
+ .root:part(icon) { color: blue; }
+ :export { spacing: 2u; }
+ `
+)
+
+assert(output.startsWith('module.exports = '), 'loader must emit a CommonJS export')
+
+const sheet = JSON.parse(output.replace(/^module\.exports = /, ''))
+
+assert.equal(sheet.version, 1)
+assert.equal(typeof sheet.__hash__, 'number')
+assert.equal(sheet.spacing, 2)
+assert.equal(sheet.root.color, 'red')
+assert.equal(sheet['years-item'].height, 36)
+assert.equal(sheet['years-item'].paddingTop, 8)
+assert.equal(sheet['years-item'].paddingRight, 8)
+assert.equal(sheet['years-item'].paddingBottom, 8)
+assert.equal(sheet['years-item'].paddingLeft, 8)
+assert.equal(sheet.active, undefined, 'multi-class selectors should stay rule-only')
+assert.equal(sheet.icon, undefined, 'part selectors should stay rule-only')
diff --git a/packages/runtime/.npmignore b/packages/runtime/.npmignore
deleted file mode 100644
index 0f8eb33..0000000
--- a/packages/runtime/.npmignore
+++ /dev/null
@@ -1,2 +0,0 @@
-__tests__/
-test/
diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md
deleted file mode 100644
index 4f38e84..0000000
--- a/packages/runtime/CHANGELOG.md
+++ /dev/null
@@ -1,159 +0,0 @@
-# Change Log
-
-All notable changes to this project will be documented in this file.
-See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
-
-# [0.3.0](https://github.com/startupjs/startupjs/compare/v0.2.33...v0.3.0) (2026-05-03)
-
-
-### Features
-
-* [BREAKING] [v0.3] Allow writing styles inside pug's `style(lang='styl')` tag; move to new `react-pug` compilation pipeline and linting (fully TS-compatible) ([#4](https://github.com/startupjs/startupjs/issues/4)) ([fca2e90](https://github.com/startupjs/startupjs/commit/fca2e908f2d94ea966bb88f36308677f20709f58))
-
-
-
-
-
-# [0.3.0-alpha.0](https://github.com/startupjs/startupjs/compare/v0.2.33...v0.3.0-alpha.0) (2026-03-25)
-
-**Note:** Version bump only for package @cssxjs/runtime
-
-
-
-
-
-## [0.2.32](https://github.com/startupjs/startupjs/compare/v0.2.31...v0.2.32) (2026-01-25)
-
-
-### Bug Fixes
-
-* **runtime:** support var() in shorthand values and in various complex cases ([4483f54](https://github.com/startupjs/startupjs/commit/4483f54d9507ebb38eb5f056de3fcac39862cb30))
-
-
-
-
-
-## [0.2.31](https://github.com/startupjs/startupjs/compare/v0.2.30...v0.2.31) (2026-01-23)
-
-
-### Bug Fixes
-
-* **runtime:** improve performance of substituting var() in css ([282cb46](https://github.com/startupjs/startupjs/commit/282cb461369cdb951cc873973a2d0da97a682b9b))
-
-
-
-
-
-## [0.2.30](https://github.com/startupjs/startupjs/compare/v0.2.29...v0.2.30) (2026-01-18)
-
-
-### Features
-
-* support animation and transition (the way it's expected by Reanimated v4) ([44a1f77](https://github.com/startupjs/startupjs/commit/44a1f778074f1f65a8ccd76994a6bf1a3eb5e4a7))
-
-
-
-
-
-## [0.2.29](https://github.com/startupjs/startupjs/compare/v0.2.28...v0.2.29) (2025-12-26)
-
-
-### Bug Fixes
-
-* **runtime:** show warning about missing window just once ([b2f07d7](https://github.com/startupjs/startupjs/commit/b2f07d7a6b4f203477057db61c8a2456660d9e87))
-
-
-
-
-
-## [0.2.27](https://github.com/startupjs/startupjs/compare/v0.2.26...v0.2.27) (2025-12-16)
-
-**Note:** Version bump only for package @cssxjs/runtime
-
-
-
-
-
-# v0.2.11 (Fri Nov 07 2025)
-
-#### 🐛 Bug Fix
-
-- fix: make pug reconstruct bindings; add extra options to babel preset; implement reactive update of @media for web and RN ([@cray0000](https://github.com/cray0000))
-
-#### Authors: 1
-
-- Pavel Zhukov ([@cray0000](https://github.com/cray0000))
-
----
-
-# v0.2.10 (Wed Nov 05 2025)
-
-#### 🐛 Bug Fix
-
-- fix: export matcher, variables, dimensions from @cssxjs/runtime and from the main cssxjs ([@cray0000](https://github.com/cray0000))
-
-#### Authors: 1
-
-- Pavel Zhukov ([@cray0000](https://github.com/cray0000))
-
----
-
-# v0.2.9 (Wed Nov 05 2025)
-
-#### 🐛 Bug Fix
-
-- fix(runtime): don't process styles when undefined, fix mediaQuery call ([@cray0000](https://github.com/cray0000))
-
-#### Authors: 1
-
-- Pavel Zhukov ([@cray0000](https://github.com/cray0000))
-
----
-
-# v0.2.5 (Wed Nov 05 2025)
-
-#### 🐛 Bug Fix
-
-- fix: force 'px' unit for lineHegiht in pure React on web ([@cray0000](https://github.com/cray0000))
-
-#### Authors: 1
-
-- Pavel Zhukov ([@cray0000](https://github.com/cray0000))
-
----
-
-# v0.2.4 (Wed Nov 05 2025)
-
-#### 🚀 Enhancement
-
-- feat: add 'u' unit support to the 'style' prop: 1u = 8px ([@cray0000](https://github.com/cray0000))
-
-#### Authors: 1
-
-- Pavel Zhukov ([@cray0000](https://github.com/cray0000))
-
----
-
-# v0.2.2 (Tue Nov 04 2025)
-
-#### 🐛 Bug Fix
-
-- fix: support dynamic css var() for colors ([@cray0000](https://github.com/cray0000))
-
-#### Authors: 1
-
-- Pavel Zhukov ([@cray0000](https://github.com/cray0000))
-
----
-
-# v0.2.0 (Tue Nov 04 2025)
-
-#### 🚀 Enhancement
-
-- feat: add TypeScript support, write a more comprehensive example in TSX ([@cray0000](https://github.com/cray0000))
-- feat(runtime): implement support for both React Native and pure Web ([@cray0000](https://github.com/cray0000))
-- feat: make it work for pure web through a babel plugin [#2](https://github.com/startupjs/cssx/pull/2) ([@cray0000](https://github.com/cray0000))
-
-#### Authors: 1
-
-- Pavel Zhukov ([@cray0000](https://github.com/cray0000))
diff --git a/packages/runtime/constants.cjs b/packages/runtime/constants.cjs
deleted file mode 100644
index de9f7dd..0000000
--- a/packages/runtime/constants.cjs
+++ /dev/null
@@ -1,4 +0,0 @@
-module.exports = {
- GLOBAL_NAME: '__CSS_GLOBAL__',
- LOCAL_NAME: '__CSS_LOCAL__'
-}
diff --git a/packages/runtime/dimensions.js b/packages/runtime/dimensions.js
deleted file mode 100644
index c745153..0000000
--- a/packages/runtime/dimensions.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { observable } from '@nx-js/observer-util'
-
-let dimensionsInitialized = false
-
-export function setDimensionsInitialized (value) {
- dimensionsInitialized = value
-}
-
-export function getDimensionsInitialized () {
- return dimensionsInitialized
-}
-
-export default observable({
- width: 0
-})
diff --git a/packages/runtime/entrypoints/react-native-teamplay.js b/packages/runtime/entrypoints/react-native-teamplay.js
deleted file mode 100644
index 6fe4e75..0000000
--- a/packages/runtime/entrypoints/react-native-teamplay.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import * as platformHelpers from '../platformHelpers/react-native.js'
-import { setPlatformHelpers } from '../platformHelpers/index.js'
-import { process } from '../processCached.js'
-
-setPlatformHelpers(platformHelpers)
-platformHelpers.initDimensionsUpdater()
-
-export default process
diff --git a/packages/runtime/entrypoints/react-native.js b/packages/runtime/entrypoints/react-native.js
deleted file mode 100644
index b1c9bf7..0000000
--- a/packages/runtime/entrypoints/react-native.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import * as platformHelpers from '../platformHelpers/react-native.js'
-import { setPlatformHelpers } from '../platformHelpers/index.js'
-import { process } from '../process.js'
-
-setPlatformHelpers(platformHelpers)
-platformHelpers.initDimensionsUpdater()
-
-export default process
diff --git a/packages/runtime/entrypoints/web-teamplay.js b/packages/runtime/entrypoints/web-teamplay.js
deleted file mode 100644
index cae627d..0000000
--- a/packages/runtime/entrypoints/web-teamplay.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { setPlatformHelpers } from '../platformHelpers/index.js'
-import * as platformHelpers from '../platformHelpers/web.js'
-import { process } from '../processCached.js'
-
-setPlatformHelpers(platformHelpers)
-platformHelpers.initDimensionsUpdater()
-
-export default process
diff --git a/packages/runtime/entrypoints/web.js b/packages/runtime/entrypoints/web.js
deleted file mode 100644
index 3e721e6..0000000
--- a/packages/runtime/entrypoints/web.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { setPlatformHelpers } from '../platformHelpers/index.js'
-import * as platformHelpers from '../platformHelpers/web.js'
-import { process } from '../process.js'
-
-setPlatformHelpers(platformHelpers)
-platformHelpers.initDimensionsUpdater()
-
-export default process
diff --git a/packages/runtime/matcher.js b/packages/runtime/matcher.js
deleted file mode 100644
index e7c7aaf..0000000
--- a/packages/runtime/matcher.js
+++ /dev/null
@@ -1,127 +0,0 @@
-const ROOT_STYLE_PROP_NAME = 'style'
-const PART_REGEX = /::?part\(([^)]+)\)/
-
-const isArray = Array.isArray || function (arg) {
- return Object.prototype.toString.call(arg) === '[object Array]'
-}
-
-export default function matcher (
- styleName,
- fileStyles,
- globalStyles,
- localStyles,
- inlineStyleProps
-) {
- // inlineStyleProps is used as an implicit indication of:
- // w/ inlineStyleProps -- process all styles and return an object with style props
- // w/o inlineStyleProps -- default inline styles addition is done externally,
- // return styles object directly
- const legacy = !inlineStyleProps
-
- // Process styleName through the `classnames`-like function.
- // This allows to specify styleName as an array or an object,
- // not just the string.
- styleName = cc(styleName)
-
- const htmlClasses = (styleName || '').split(' ').filter(Boolean)
- const resProps = getStyleProps(htmlClasses, fileStyles, legacy)
-
- // In the legacy mode, return root styles right away
- if (legacy) return resProps[ROOT_STYLE_PROP_NAME]
-
- // 1. Add global styles
- appendStyleProps(resProps, getStyleProps(htmlClasses, globalStyles))
-
- // 2. Add local styles
- appendStyleProps(resProps, getStyleProps(htmlClasses, localStyles))
-
- // 3. Add inline styles
- appendStyleProps(resProps, inlineStyleProps)
- return resProps
-}
-
-function appendStyleProps (target, appendProps) {
- for (const propName in appendProps) {
- if (target[propName]) {
- if (isArray(appendProps[propName])) {
- target[propName] = target[propName].concat(appendProps[propName])
- } else {
- target[propName].push(appendProps[propName])
- }
- } else {
- target[propName] = appendProps[propName]
- }
- }
-}
-
-// Process all styles, including the ::part() ones.
-function getStyleProps (htmlClasses, styles, legacyRootOnly) {
- const res = {}
- for (const selector in styles) {
- // Find out which part (or root) this selector is targeting
- const match = selector.match(PART_REGEX)
- const attr = match ? getPropName(match[1]) : ROOT_STYLE_PROP_NAME
-
- // Don't process part if legacyRootOnly is specified
- if (legacyRootOnly && attr !== ROOT_STYLE_PROP_NAME) continue
-
- // Strip ::part() if it exists
- const pureSelector = selector.replace(PART_REGEX, '')
-
- // Check if the selector is matching our list of existing classes
- const cssClasses = pureSelector.split('.')
- if (!arrayContainedInArray(cssClasses, htmlClasses)) continue
-
- // Push selector's style to the according part's array of styles.
- // We have a nested array structure here to account for the selector specificity.
- // This way styles for selector with 3 classes take priority
- // over selectors with 2 classes, etc.
-
- // Note: Specificity here does not strictly equal the standard
- // since we only use classes to increase the specificity.
- // In future this might change when we add support for tags, but for now
- // it is a single digit increment starting from 0 and equalling the amount
- // of classes in the selector.
- const specificity = cssClasses.length - 1
- if (!res[attr]) res[attr] = []
- if (!res[attr][specificity]) res[attr][specificity] = []
- res[attr][specificity].push(styles[selector])
- }
- return res
-}
-
-function getPropName (name) {
- return name + 'Style'
-}
-
-function arrayContainedInArray (cssClasses, htmlClasses) {
- for (let i = 0; i < cssClasses.length; i++) {
- if (htmlClasses.indexOf(cssClasses[i]) === -1) return false
- }
- return true
-};
-
-// classcat 4.0.2
-// https://github.com/jorgebucaran/classcat
-
-function cc (names) {
- let i
- let len
- let tmp = typeof names
- let out = ''
-
- if (tmp === 'string' || tmp === 'number') return names || ''
-
- if (isArray(names) && names.length > 0) {
- for (i = 0, len = names.length; i < len; i++) {
- if ((tmp = cc(names[i])) !== '') out += (out && ' ') + tmp
- }
- } else {
- for (i in names) {
- // eslint-disable-next-line no-prototype-builtins
- if (names.hasOwnProperty(i) && names[i]) out += (out && ' ') + i
- }
- }
-
- return out
-}
diff --git a/packages/runtime/package.json b/packages/runtime/package.json
deleted file mode 100644
index cfe9d31..0000000
--- a/packages/runtime/package.json
+++ /dev/null
@@ -1,58 +0,0 @@
-{
- "name": "@cssxjs/runtime",
- "version": "0.3.0",
- "publishConfig": {
- "access": "public"
- },
- "description": "Dynamically resolve styleName in RN with support for multi-class selectors (for easier modifiers)",
- "keywords": [
- "babel",
- "babel-plugin",
- "react-native",
- "stylename",
- "style"
- ],
- "exports": {
- "./entrypoints/web": "./entrypoints/web.js",
- "./entrypoints/react-native": "./entrypoints/react-native.js",
- "./entrypoints/web-teamplay": "./entrypoints/web-teamplay.js",
- "./entrypoints/react-native-teamplay": "./entrypoints/react-native-teamplay.js",
- "./constants": "./constants.cjs",
- "./dimensions": "./dimensions.js",
- "./variables": "./variables.js",
- "./matcher": "./matcher.js"
- },
- "type": "module",
- "scripts": {
- "test": "mocha"
- },
- "author": "Pavel Zhukov",
- "license": "MIT",
- "repository": {
- "type": "git",
- "url": "https://github.com/startupjs/startupjs"
- },
- "dependencies": {
- "@nx-js/observer-util": "^4.1.3",
- "css-viewport-units-transform": "^0.10.2",
- "deepmerge": "^3.2.0",
- "micro-memoize": "^3.0.1"
- },
- "devDependencies": {
- "@cssxjs/loaders": "^0.3.0",
- "@startupjs/css-to-react-native-transform": "2.1.0-3",
- "mocha": "^8.1.1"
- },
- "peerDependencies": {
- "react-native": "*",
- "teamplay": "*"
- },
- "peerDependenciesMeta": {
- "react-native": {
- "optional": true
- },
- "teamplay": {
- "optional": true
- }
- }
-}
diff --git a/packages/runtime/platformHelpers/index.js b/packages/runtime/platformHelpers/index.js
deleted file mode 100644
index 9f5f814..0000000
--- a/packages/runtime/platformHelpers/index.js
+++ /dev/null
@@ -1,50 +0,0 @@
-// injection of platformHelpers
-
-let platformHelpers
-
-export function setPlatformHelpers (newPlatformHelpers) {
- if (platformHelpers === newPlatformHelpers) return
- platformHelpers = newPlatformHelpers
-}
-
-export function getPlatformHelpers () {
- return platformHelpers
-}
-
-// facades to call the currently injected platform helper functions
-
-export function getDimensions (...args) {
- try {
- return platformHelpers.getDimensions(...args)
- } catch (err) {
- console.error('[cssxjs] platform helpers \'getDimensions\' is not specified. Babel is probably misconfigured')
- throw err
- }
-}
-
-export function getPlatform (...args) {
- try {
- return platformHelpers.getPlatform(...args)
- } catch (err) {
- console.error('[cssxjs] platform helpers \'getPlatform\' is not specified. Babel is probably misconfigured')
- throw err
- }
-}
-
-export function isPureReact (...args) {
- try {
- return platformHelpers.isPureReact(...args)
- } catch (err) {
- console.error('[cssxjs] platform helpers \'isPureReact\' is not specified. Babel is probably misconfigured')
- throw err
- }
-}
-
-export function initDimensionsUpdater (...args) {
- try {
- return platformHelpers.initDimensionsUpdater(...args)
- } catch (err) {
- console.error('[cssxjs] platform helpers \'initDimensionsUpdater\' is not specified. Babel is probably misconfigured')
- throw err
- }
-}
diff --git a/packages/runtime/platformHelpers/react-native.js b/packages/runtime/platformHelpers/react-native.js
deleted file mode 100644
index 64d5e59..0000000
--- a/packages/runtime/platformHelpers/react-native.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { Dimensions, Platform } from 'react-native'
-import dimensions, { getDimensionsInitialized, setDimensionsInitialized } from '../dimensions.js'
-
-export function getDimensions () {
- return Dimensions.get('window')
-}
-
-export function getPlatform () {
- return Platform.OS
-}
-
-export function isPureReact () {
- return false
-}
-
-// this is needed to trigger components rerendering to update @media queries
-export function initDimensionsUpdater () {
- if (getDimensionsInitialized()) return
- setDimensionsInitialized(true)
- dimensions.width = Dimensions.get('window').width
- console.log('> Init dimensions updater for React Native. Initial width:', dimensions.width)
-
- // debounce by 200ms to avoid too many updates in a short time
- let timeoutId
- Dimensions.addEventListener('change', ({ window }) => {
- if (timeoutId) clearTimeout(timeoutId)
- timeoutId = setTimeout(() => {
- if (dimensions.width !== window.width) {
- console.log('> update window width:', window.width)
- dimensions.width = window.width
- }
- timeoutId = undefined
- }, 200)
- })
-}
diff --git a/packages/runtime/platformHelpers/web.js b/packages/runtime/platformHelpers/web.js
deleted file mode 100644
index 3e6a282..0000000
--- a/packages/runtime/platformHelpers/web.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import dimensions, { getDimensionsInitialized, setDimensionsInitialized } from '../dimensions.js'
-
-let shownWarningGetDimensions = false
-let shownWarningInitDimensionsUpdater = false
-
-export function getDimensions () {
- if (typeof window === 'undefined' || !window.innerWidth || !window.innerHeight) {
- if (!shownWarningGetDimensions) {
- console.warn('[cssx] No "window" global variable. Falling back to constant window width and height of 1024x768')
- shownWarningGetDimensions = true
- }
- return { width: 1024, height: 768 }
- }
- return {
- width: window.innerWidth,
- height: window.innerHeight
- }
-}
-
-export function getPlatform () {
- return 'web'
-}
-
-export function isPureReact () {
- return true
-}
-
-// this is needed to trigger components rerendering to update @media queries
-export function initDimensionsUpdater () {
- if (getDimensionsInitialized()) return
- setDimensionsInitialized(true)
- if (typeof window === 'undefined' || !window.innerWidth || !window.addEventListener) {
- if (!shownWarningInitDimensionsUpdater) {
- console.warn('[cssx] No "window" global variable. Setting default window width to 1024 and skipping updater.')
- shownWarningInitDimensionsUpdater = true
- }
- dimensions.width = 1024
- return
- }
- dimensions.width = window.innerWidth
- console.log('> Init dimensions updater for Web. Initial width:', dimensions.width)
-
- // debounce by 200ms to avoid too many updates in a short time
- let timeoutId
- window.addEventListener('resize', () => {
- if (timeoutId) clearTimeout(timeoutId)
- timeoutId = setTimeout(() => {
- if (dimensions.width !== window.innerWidth) {
- console.log('> update window width:', window.innerWidth)
- dimensions.width = window.innerWidth
- }
- timeoutId = undefined
- }, 200)
- })
-}
diff --git a/packages/runtime/process.js b/packages/runtime/process.js
deleted file mode 100644
index 5ee689e..0000000
--- a/packages/runtime/process.js
+++ /dev/null
@@ -1,137 +0,0 @@
-import { process as dynamicProcess } from './vendor/react-native-dynamic-style-processor/index.js'
-import dimensions from './dimensions.js'
-import singletonVariables, { defaultVariables } from './variables.js'
-import matcher from './matcher.js'
-import { isPureReact } from './platformHelpers/index.js'
-
-// Regex to match var() anywhere within a string value (handles both full and partial)
-const VARS_REGEX = /var\(\s*(--[A-Za-z0-9_-]+)\s*,?\s*([^)]*)\s*\)/g
-const SUPPORT_UNIT = true
-
-export function process (
- styleName,
- fileStyles,
- globalStyles,
- localStyles,
- inlineStyleProps
-) {
- fileStyles = transformStyles(fileStyles)
- globalStyles = transformStyles(globalStyles)
- localStyles = transformStyles(localStyles)
-
- const res = matcher(
- styleName, fileStyles, globalStyles, localStyles, inlineStyleProps
- )
- for (const propName in res) {
- // flatten styles into single objects
- if (Array.isArray(res[propName])) {
- res[propName] = res[propName].flat(10)
- res[propName] = Object.assign({}, ...res[propName])
- }
- if (typeof res[propName] !== 'object') continue
- // force transform to 'px' some units in pure React environment
- if (isPureReact()) {
- // atm it's only 'lineHeight' property
- if (typeof res[propName].lineHeight === 'number') {
- res[propName].lineHeight = `${res[propName].lineHeight}px`
- }
- }
- // add 'u' unit support (1u = 8px)
- // replace in string values `{NUMBER}u` with the `{NUMBER*8}`
- // (pure number without any units - which will be treated as 'px' by React Native and pure React)
- if (SUPPORT_UNIT) {
- for (const property in res[propName]) {
- if (typeof res[propName][property] !== 'string') continue
- if (!/\du/.test(res[propName][property])) continue // quick check for potential presence of 'u' unit
- while (true) {
- const match = res[propName][property].match(/(\(|,| |^)([+-]?(?:\d*\.)?\d+)u(\)|,| |$)/)
- if (!match) break
- const fullMatch = match[0]
- const number = parseFloat(match[2])
- const replacedValue = number * 8
- // if left and right don't exist (pure value), then assign the pure number
- if (!match[1] && !match[3]) {
- res[propName][property] = replacedValue
- break
- }
- res[propName][property] = res[propName][property].replace(fullMatch, `${match[1]}${replacedValue}${match[3]}`)
- }
- }
- }
- }
- return res
-}
-
-function replaceVariablesInObject (obj) {
- if (obj === null || obj === undefined) return obj
- if (Array.isArray(obj)) {
- return obj.map(item => replaceVariablesInObject(item))
- }
- if (typeof obj === 'object') {
- const result = {}
- for (const key of Object.keys(obj)) {
- result[key] = replaceVariablesInObject(obj[key])
- }
- return result
- }
- if (typeof obj === 'string' && obj.includes('var(')) {
- return replaceVariablesInString(obj)
- }
- return obj
-}
-
-function replaceVariablesInString (str) {
- // Replace all var() occurrences in the string
- const result = str.replace(VARS_REGEX, (match, varName, varDefault) => {
- let res = singletonVariables[varName] ?? defaultVariables[varName] ?? varDefault
- if (typeof res === 'string') {
- res = res.trim()
- // sometimes compiler returns wrapped brackets. Remove them
- const bracketsCount = res.match(/^\(+/)?.[0]?.length || 0
- res = res.substring(bracketsCount, res.length - bracketsCount)
- }
- return res
- })
-
- // After all replacements, check if the result is a pure numeric value
- // If so, convert it to a number (stripping 'px' suffix if present)
- const trimmed = result.trim()
- const withoutPx = trimmed.replace(/px$/, '')
- if (isNumeric(withoutPx)) {
- return parseFloat(withoutPx)
- }
-
- return result
-}
-
-function transformStyles (styles) {
- if (!styles) return {}
-
- // Dynamically process css variables.
- // This will also auto-trigger rerendering on variable change when cache is not used
- if (styles.__vars) {
- styles = replaceVariablesInObject(styles)
- }
-
- // trigger rerender when cache is NOT used
- if (styles.__hasMedia) listenForDimensionsChange()
-
- // dynamically process @media queries and vh/vw units
- styles = dynamicProcess(styles)
-
- return styles
-}
-
-// If @media is used, force trigger access to the observable value.
-// `dimensions` is an observed Proxy so
-// whenever its value changes the according components will
-// automatically rerender.
-// The change is triggered globally in startupjs/plugins/cssMediaUpdater.plugin.js
-export function listenForDimensionsChange () {
- // eslint-disable-next-line no-unused-expressions
- if (dimensions.width) true
-}
-
-function isNumeric (num) {
- return (typeof num === 'number' || (typeof num === 'string' && num.trim() !== '')) && !isNaN(num)
-}
diff --git a/packages/runtime/processCached.js b/packages/runtime/processCached.js
deleted file mode 100644
index 41cd573..0000000
--- a/packages/runtime/processCached.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import { singletonMemoize } from 'teamplay/cache'
-import dimensions from './dimensions.js'
-import singletonVariables from './variables.js'
-import { process as _process, listenForDimensionsChange } from './process.js'
-
-export const process = singletonMemoize(_process, {
- cacheName: 'styles',
- // IMPORTANT: This should be the same as the ones which go into the singletonMemoize function
- normalizer: (styleName, fileStyles, globalStyles, localStyles, inlineStyleProps) => simpleNumericHash(JSON.stringify([
- styleName,
- fileStyles?.__hash__ || fileStyles,
- globalStyles?.__hash__ || globalStyles,
- localStyles?.__hash__ || localStyles,
- inlineStyleProps
- ])),
- // IMPORTANT: This should be the same as the ones which go into the singletonMemoize function
- forceUpdateWhenChanged: (styleName, fileStyles, globalStyles, localStyles, inlineStyleProps) => {
- const args = {}
- const watchWidthChange = fileStyles?.__hasMedia || globalStyles?.__hasMedia || localStyles?.__hasMedia
- if (watchWidthChange) {
- // trigger rerender when cache is used
- listenForDimensionsChange()
- // Return the dimensionsWidth value itself to force
- // the affected cache to recalculate
- args.dimensionsWidth = dimensions.width
- }
- if (fileStyles?.__vars || globalStyles?.__vars || localStyles?.__vars) {
- const variableNames = getVariableNames(fileStyles, globalStyles, localStyles)
- // trigger rerender when cache is used
- listenForVariablesChange(variableNames)
- // Return the variable values themselves to force
- // the affected cache to recalculate
- for (const variableName of variableNames) {
- args['VAR_' + variableName] = singletonVariables[variableName]
- }
- }
- return simpleNumericHash(JSON.stringify(args))
- }
-})
-
-function getVariableNames (...styleObjects) {
- const vars = []
- for (const styleObject of styleObjects) {
- if (!styleObject?.__vars) continue
- for (const varName of styleObject.__vars) {
- if (!vars.includes(varName)) vars.push(varName)
- }
- }
- return vars.sort()
-}
-
-// If var() is used, force trigger access to the observable value.
-// `singletonVariables` is an observed Proxy so
-// whenever its value changes the according components will
-// automatically rerender.
-function listenForVariablesChange (variables = []) {
- for (const variable of variables) {
- // eslint-disable-next-line no-unused-expressions
- if (singletonVariables[variable]) true
- }
-}
-
-// ref: https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0?permalink_comment_id=2694461#gistcomment-2694461
-function simpleNumericHash (s) {
- let i, h
- for (i = 0, h = 0; i < s.length; i++) h = Math.imul(31, h) + s.charCodeAt(i) | 0
- return h
-}
diff --git a/packages/runtime/test/matcher.mjs b/packages/runtime/test/matcher.mjs
deleted file mode 100644
index 57f24d2..0000000
--- a/packages/runtime/test/matcher.mjs
+++ /dev/null
@@ -1,485 +0,0 @@
-/* global describe, it */
-import css2rn from '@startupjs/css-to-react-native-transform'
-import assert from 'assert'
-import matcher from '../matcher.js'
-
-function p ({ styleName, fileStyles, globalStyles, localStyles, inlineStyleProps, legacy }) {
- if (!legacy) inlineStyleProps = inlineStyleProps || {}
- return matcher(
- styleName,
- fileStyles && css2rn.default(fileStyles, { parseMediaQueries: true, parsePartSelectors: true, parseKeyframes: true }),
- globalStyles && css2rn.default(globalStyles, { parseMediaQueries: true, parsePartSelectors: true, parseKeyframes: true }),
- localStyles && css2rn.default(localStyles, { parseMediaQueries: true, parsePartSelectors: true, parseKeyframes: true }),
- inlineStyleProps
- )
-}
-
-describe('Pure usage without attributes', () => {
- it('simple', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: /* css */`
- .root {
- color: red;
- font-weight: bold;
- padding-left: 10px;
- }
- .dummy {
- color: green;
- }
- .root.dummy {
- color: red;
- }
- `,
- legacy: true
- }), [
- [{ // specificity 0 selectors (same as specificity 10 in CSS)
- color: 'red',
- fontWeight: 'bold',
- paddingLeft: 10
- }]
- ])
- })
-})
-
-describe('Root styles only', () => {
- it('simple', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: /* css */`
- .root {
- color: red;
- font-weight: bold;
- padding-left: 10px;
- }
- .dummy {
- color: green;
- }
- .root.dummy {
- color: red;
- }
- `
- }), {
- style: [
- [{ // specificity 0 selectors (same as specificity 10 in CSS)
- color: 'red',
- fontWeight: 'bold',
- paddingLeft: 10
- }]
- ]
- })
- })
- it('with inline styles', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: /* css */`
- .root {
- color: red;
- font-weight: bold;
- padding-left: 10px;
- }
- .dummy {
- color: green;
- }
- .root.dummy {
- color: red;
- }
- `,
- inlineStyleProps: {
- style: {
- marginLeft: 10
- }
- }
- }), {
- style: [
- [{ // specificity 0
- color: 'red',
- fontWeight: 'bold',
- paddingLeft: 10
- }],
- { // inline styles
- marginLeft: 10
- }
- ]
- })
- })
- it('empty root. Pipe inline styles only', () => {
- assert.deepStrictEqual(p({
- styleName: '',
- fileStyles: /* css */`
- .root {
- color: red;
- font-weight: bold;
- padding-left: 10px;
- }
- .dummy {
- color: green;
- }
- .root.dummy {
- color: red;
- }
- `,
- inlineStyleProps: {
- style: [
- {
- marginLeft: 10
- }, {
- marginRight: 20
- }
- ],
- cardStyle: {
- marginRight: 10
- }
- }
- }), {
- style: [
- // inline styles
- {
- marginLeft: 10
- }, {
- marginRight: 20
- }
- ],
- cardStyle: {
- marginRight: 10
- }
- })
- })
- it('empty everything. Pipe inline styles only', () => {
- assert.deepStrictEqual(p({
- styleName: '',
- inlineStyleProps: {
- style: {
- marginLeft: 10
- }
- }
- }), {
- style: {
- marginLeft: 10
- }
- })
- })
- it('pass inline styles as is if it\'s a string', () => {
- assert.deepStrictEqual(p({
- styleName: '',
- inlineStyleProps: {
- style: 'my-magic-style',
- barStyle: 'magic-bar-style'
- }
- }), {
- style: 'my-magic-style',
- barStyle: 'magic-bar-style'
- })
- })
- it('multiple classes', () => {
- assert.deepStrictEqual(p({
- styleName: 'root active card',
- fileStyles: /* css */`
- .active {
- opacity: 0.8;
- }
- .card {
- border-radius: 8px;
- }
- .card.active {
- opacity: 0.9;
- }
- .root {
- color: red;
- font-weight: bold;
- padding-left: 10px;
- }
- .root.active {
- opacity: 1;
- }
- .root.card.active {
- color: green;
- }
- .dummy {
- color: green;
- }
- .root.dummy {
- color: red;
- }
- .root.card.dummy {
- color: red;
- }
- `,
- inlineStyleProps: {
- style: {
- marginLeft: 10
- }
- }
- }), {
- style: [
- [{ // specificity 0 (1 class)
- opacity: 0.8
- }, {
- borderRadius: 8
- }, {
- color: 'red',
- fontWeight: 'bold',
- paddingLeft: 10
- }],
- [{ // specificity 1 (2 classes)
- opacity: 0.9
- }, {
- opacity: 1
- }],
- [{ // specificity 2 (3 classes)
- color: 'green'
- }],
- { // inline styles
- marginLeft: 10
- }
- ]
- })
- })
-})
-
-describe('Parts', () => {
- it('simple', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: /* css */`
- .root {
- color: red;
- font-weight: bold;
- padding-left: 10px;
- }
- .root::part(input) {
- background-color: black;
- color: blue;
- }
- `
- }), {
- style: [
- [{
- color: 'red',
- fontWeight: 'bold',
- paddingLeft: 10
- }]
- ],
- inputStyle: [
- [{
- backgroundColor: 'black',
- color: 'blue'
- }]
- ]
- })
- })
- it('multiple classes', () => {
- assert.deepStrictEqual(p({
- styleName: 'root active card',
- fileStyles: /* css */`
- .active {
- opacity: 0.8;
- }
- .card {
- border-radius: 8px;
- }
- .card.active {
- opacity: 0.9;
- }
- .card::part(header) {
- background-color: green;
- }
- .card.active::part(header) {
- background-color: red;
- }
- .card.active::part(footer) {
- color: orange;
- }
- .root {
- color: red;
- font-weight: bold;
- padding-left: 10px;
- }
- .root::part(header) {
- font-size: 20px;
- }
- .root::part(footer) {
- font-size: 22px;
- }
- .root.active {
- opacity: 1;
- }
- .root.active::part(footer) {
- background-color: pink;
- }
- .root.card.active {
- color: green;
- }
- .root.card.active::part(footer) {
- background-color: violet;
- }
- .dummy {
- color: green;
- }
- .dummy::part(header) {
- color: magenta;
- }
- .root.dummy {
- color: red;
- }
- .root.card.dummy {
- color: red;
- }
- `,
- inlineStyleProps: {
- style: {
- marginLeft: 10
- },
- headerStyle: {
- marginLeft: 12
- },
- footerStyle: {
- marginLeft: 14
- },
- dummyStyle: {
- marginLeft: 16
- }
- }
- }), {
- style: [
- [{ // specificity 0 (1 class)
- opacity: 0.8
- }, {
- borderRadius: 8
- }, {
- color: 'red',
- fontWeight: 'bold',
- paddingLeft: 10
- }],
- [{ // specificity 1 (2 classes)
- opacity: 0.9
- }, {
- opacity: 1
- }],
- [{ // specificity 2 (3 classes)
- color: 'green'
- }],
- { // inline styles
- marginLeft: 10
- }
- ],
- headerStyle: [
- [{ // specificity 0
- backgroundColor: 'green'
- }, {
- fontSize: 20
- }],
- [{ // specificity 1
- backgroundColor: 'red'
- }],
- { // inline styles
- marginLeft: 12
- }
- ],
- footerStyle: [
- [{ // specificity 0
- fontSize: 22
- }],
- [{ // specificity 1
- color: 'orange'
- }, {
- backgroundColor: 'pink'
- }],
- [{ // specificity 2
- backgroundColor: 'violet'
- }],
- { // inline styles
- marginLeft: 14
- }
- ],
- dummyStyle: {
- marginLeft: 16
- }
- })
- })
-})
-
-describe('External and global and local styles', () => {
- it('inline > local > global > external. No matter the specificity', () => {
- assert.deepStrictEqual(p({
- styleName: 'root active',
- fileStyles: /* css */`
- .root {
- color: red;
- font-weight: bold;
- padding-left: 10px;
- padding-right: 10px;
- }
- .root.active {
- color: yellow;
- padding-right: 20px;
- }
- .dummy {
- color: green;
- }
- .root.dummy {
- color: red;
- }
- `,
- globalStyles: /* css */`
- .root {
- color: blue;
- padding-left: 15px;
- padding-right: 15px;
- }
- .root.active {
- color: white;
- }
- .dummy {
- padding-left: 50px;
- }
- `,
- localStyles: /* css */`
- .root {
- color: violet;
- }
- .root.active {
- padding-right: 20px;
- }
- .dummy {
- padding-top: 10px;
- }
- `,
- inlineStyleProps: {
- style: {
- marginLeft: 10
- }
- }
- }), {
- style: [
- [{ // external specificity 0
- color: 'red',
- fontWeight: 'bold',
- paddingLeft: 10,
- paddingRight: 10
- }],
- [{ // external specificity 1
- color: 'yellow',
- paddingRight: 20
- }],
- [{ // global specificity 0
- color: 'blue',
- paddingLeft: 15,
- paddingRight: 15
- }],
- [{ // global specificity 1
- color: 'white'
- }],
- [{ // local specificity 0
- color: 'violet'
- }],
- [{ // local specificity 1
- paddingRight: 20
- }],
- { // inline styles
- marginLeft: 10
- }
- ]
- })
- })
-})
diff --git a/packages/runtime/test/process.mjs b/packages/runtime/test/process.mjs
deleted file mode 100644
index 20c686a..0000000
--- a/packages/runtime/test/process.mjs
+++ /dev/null
@@ -1,1180 +0,0 @@
-/* global describe, it, before, beforeEach */
-import assert from 'assert'
-import { createRequire } from 'module'
-import { process } from '../process.js'
-import { setPlatformHelpers } from '../platformHelpers/index.js'
-import singletonVariables, { setDefaultVariables } from '../variables.js'
-
-const require = createRequire(import.meta.url)
-const { styl } = require('@cssxjs/loaders/compilers')
-
-// Configure platform helpers for test environment
-before(() => {
- setPlatformHelpers({
- getDimensions: () => ({ width: 1024, height: 768 }),
- getPlatform: () => 'web',
- isPureReact: () => false,
- initDimensionsUpdater: () => {}
- })
-})
-
-// Helper function to compile stylus to a style object
-// The styl() compiler returns a JSON string, so we need to parse it
-function compileStyl (source) {
- if (!source) return undefined
- const jsonString = styl(source, 'test.styl')
- return JSON.parse(jsonString)
-}
-
-// Helper function to compile stylus and process it through the full pipeline
-function p ({ styleName, fileStyles, globalStyles, localStyles, inlineStyleProps }) {
- return process(
- styleName,
- compileStyl(fileStyles),
- compileStyl(globalStyles),
- compileStyl(localStyles),
- inlineStyleProps || {}
- )
-}
-
-// Reset variables before each test
-beforeEach(() => {
- // Clear singleton variables
- for (const key of Object.keys(singletonVariables)) {
- delete singletonVariables[key]
- }
- // Reset default variables
- setDefaultVariables({})
-})
-
-// ============================================================================
-// LEVEL 1: Simple tests - no var(), no @media, single selector
-// Note: Stylus converts color names to hex codes (red -> #f00, blue -> #00f)
-// ============================================================================
-describe('Level 1: Simple styles - single selector, no variables', () => {
- it('single class with one property', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color red
- `
- }), {
- style: { color: '#f00' } // Stylus converts 'red' to '#f00'
- })
- })
-
- it('single class with multiple properties', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color red
- font-size 16px
- padding 10px
- `
- }), {
- style: {
- color: '#f00',
- fontSize: 16,
- paddingTop: 10,
- paddingRight: 10,
- paddingBottom: 10,
- paddingLeft: 10
- }
- })
- })
-
- it('single class with camelCase CSS properties', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- background-color blue
- border-radius 8px
- font-weight bold
- `
- }), {
- style: {
- backgroundColor: '#00f', // Stylus converts 'blue' to '#00f'
- borderRadius: 8,
- fontWeight: 'bold'
- }
- })
- })
-
- it('non-matching selector is ignored', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color red
- .other
- color blue
- `
- }), {
- style: { color: '#f00' }
- })
- })
-
- it('empty styleName returns only inline styles', () => {
- assert.deepStrictEqual(p({
- styleName: '',
- fileStyles: `
- .root
- color red
- `,
- inlineStyleProps: {
- style: { marginLeft: 10 }
- }
- }), {
- style: { marginLeft: 10 }
- })
- })
-})
-
-// ============================================================================
-// LEVEL 2: Multiple classes without variables
-// ============================================================================
-describe('Level 2: Multiple classes - specificity handling', () => {
- it('two classes matching single-class selectors', () => {
- assert.deepStrictEqual(p({
- styleName: 'root active',
- fileStyles: `
- .root
- color red
- .active
- opacity 0.8
- `
- }), {
- style: {
- color: '#f00',
- opacity: 0.8
- }
- })
- })
-
- it('compound selector has higher specificity', () => {
- assert.deepStrictEqual(p({
- styleName: 'root active',
- fileStyles: `
- .root
- color red
- .active
- color blue
- .root.active
- color green
- `
- }), {
- style: { color: '#008000' } // Stylus converts 'green' to '#008000'
- })
- })
-
- it('three classes with varying specificity', () => {
- assert.deepStrictEqual(p({
- styleName: 'root active card',
- fileStyles: `
- .root
- color red
- .active
- opacity 0.5
- .card
- border-radius 8px
- .root.active
- opacity 0.8
- .root.card.active
- opacity 1
- `
- }), {
- style: {
- color: '#f00',
- borderRadius: 8,
- opacity: 1
- }
- })
- })
-})
-
-// ============================================================================
-// LEVEL 3: Part selectors (::part)
-// ============================================================================
-describe('Level 3: Part selectors', () => {
- it('simple part selector', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color red
- .root::part(input)
- background-color white
- `
- }), {
- style: { color: '#f00' },
- inputStyle: { backgroundColor: '#fff' }
- })
- })
-
- it('multiple part selectors', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color red
- .root::part(header)
- font-size 20px
- .root::part(footer)
- font-size 14px
- `
- }), {
- style: { color: '#f00' },
- headerStyle: { fontSize: 20 },
- footerStyle: { fontSize: 14 }
- })
- })
-
- it('part selector with compound class', () => {
- assert.deepStrictEqual(p({
- styleName: 'root active',
- fileStyles: `
- .root::part(header)
- color red
- .root.active::part(header)
- color blue
- `
- }), {
- headerStyle: { color: '#00f' }
- })
- })
-})
-
-// ============================================================================
-// LEVEL 4: Single var() usage
-// ============================================================================
-describe('Level 4: Single var() usage', () => {
- it('var() with default value for color', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--primary-color, #f00)
- `
- }), {
- style: { color: '#f00' }
- })
- })
-
- it('var() with default numeric value for font-size', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- font-size var(--font-size, 16px)
- `
- }), {
- style: { fontSize: 16 }
- })
- })
-
- it('var() overridden by default variables', () => {
- setDefaultVariables({ '--primary-color': '#00f' })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--primary-color, #f00)
- `
- }), {
- style: { color: '#00f' }
- })
- })
-
- it('var() overridden by singleton variables', () => {
- setDefaultVariables({ '--primary-color': '#00f' })
- singletonVariables['--primary-color'] = '#0f0'
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--primary-color, #f00)
- `
- }), {
- style: { color: '#0f0' }
- })
- })
-
- it('singleton takes precedence over default', () => {
- setDefaultVariables({ '--color': '#00f' })
- singletonVariables['--color'] = '#800080' // purple
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--color, #f00)
- `
- }), {
- style: { color: '#800080' }
- })
- })
-})
-
-// ============================================================================
-// LEVEL 5: Multiple var() usages in same selector
-// ============================================================================
-describe('Level 5: Multiple var() in same selector', () => {
- it('two var() in different properties', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--text-color, #000)
- background-color var(--bg-color, #fff)
- `
- }), {
- style: {
- color: '#000',
- backgroundColor: '#fff'
- }
- })
- })
-
- it('multiple var() with mixed overrides', () => {
- setDefaultVariables({ '--text-color': '#00f' })
- singletonVariables['--bg-color'] = '#ff0'
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--text-color, #000)
- background-color var(--bg-color, #fff)
- border-color var(--border-color, #808080)
- `
- }), {
- style: {
- color: '#00f',
- backgroundColor: '#ff0',
- borderColor: '#808080'
- }
- })
- })
-
- it('three var() with numeric values', () => {
- setDefaultVariables({
- '--padding-top': '20px',
- '--margin-left': '10px'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- padding-top var(--padding-top, 8px)
- margin-left var(--margin-left, 4px)
- font-size var(--font-size, 4px)
- `
- }), {
- style: {
- paddingTop: 20,
- marginLeft: 10,
- fontSize: 4
- }
- })
- })
-})
-
-// ============================================================================
-// LEVEL 6: var() in different selectors and parts
-// ============================================================================
-describe('Level 6: var() across selectors and parts', () => {
- it('var() in different class selectors', () => {
- setDefaultVariables({ '--active-opacity': '0.5' })
- assert.deepStrictEqual(p({
- styleName: 'root active',
- fileStyles: `
- .root
- color var(--color, #f00)
- .active
- opacity var(--active-opacity, 1)
- `
- }), {
- style: {
- color: '#f00',
- opacity: 0.5
- }
- })
- })
-
- it('var() in part selectors', () => {
- setDefaultVariables({ '--header-bg': '#00f' })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--text-color, #000)
- .root::part(header)
- background-color var(--header-bg, #808080)
- .root::part(footer)
- padding-left var(--footer-padding, 10px)
- `
- }), {
- style: { color: '#000' },
- headerStyle: { backgroundColor: '#00f' },
- footerStyle: { paddingLeft: 10 }
- })
- })
-
- it('var() in compound selectors with parts', () => {
- singletonVariables['--active-header-bg'] = '#0f0'
- assert.deepStrictEqual(p({
- styleName: 'root active',
- fileStyles: `
- .root::part(header)
- background-color var(--header-bg, #808080)
- .root.active::part(header)
- background-color var(--active-header-bg, #f00)
- `
- }), {
- headerStyle: { backgroundColor: '#0f0' }
- })
- })
-})
-
-// ============================================================================
-// LEVEL 7: @media queries
-// ============================================================================
-describe('Level 7: @media queries', () => {
- it('simple @media query', () => {
- const result = p({
- styleName: 'root',
- fileStyles: `
- .root
- width 100px
- @media (min-width: 768px)
- .root
- width 200px
- `
- })
- // The style should be present (either 100px or 200px depending on current screen)
- // With our test dimensions of 1024x768, min-width: 768px should match
- assert.ok(result.style)
- assert.strictEqual(result.style.width, 200)
- })
-
- it('@media query not matching', () => {
- const result = p({
- styleName: 'root',
- fileStyles: `
- .root
- width 100px
- @media (min-width: 1200px)
- .root
- width 200px
- `
- })
- // With our test dimensions of 1024x768, min-width: 1200px should NOT match
- assert.strictEqual(result.style.width, 100)
- })
-
- it('@media with var()', () => {
- setDefaultVariables({ '--desktop-width': '500px' })
- const result = p({
- styleName: 'root',
- fileStyles: `
- .root
- width var(--mobile-width, 100px)
- @media (min-width: 768px)
- .root
- width var(--desktop-width, 200px)
- `
- })
- // With test dimensions 1024x768, the media query matches
- assert.strictEqual(result.style.width, 500)
- })
-})
-
-// ============================================================================
-// LEVEL 8: External, global, and local styles hierarchy
-// ============================================================================
-describe('Level 8: Style hierarchy (external > global > local)', () => {
- it('local overrides global overrides external', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color red
- font-size 14px
- `,
- globalStyles: `
- .root
- color blue
- padding-left 10px
- `,
- localStyles: `
- .root
- color green
- `
- }), {
- style: {
- color: '#008000', // green
- fontSize: 14,
- paddingLeft: 10
- }
- })
- })
-
- it('var() in all style levels', () => {
- setDefaultVariables({
- '--file-color': '#f00',
- '--global-padding': '20px',
- '--local-margin': '15px'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--file-color, #000)
- `,
- globalStyles: `
- .root
- padding-left var(--global-padding, 10px)
- `,
- localStyles: `
- .root
- margin-left var(--local-margin, 5px)
- `
- }), {
- style: {
- color: '#f00',
- paddingLeft: 20,
- marginLeft: 15
- }
- })
- })
-
- it('inline styles override all', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color red
- `,
- globalStyles: `
- .root
- color blue
- `,
- localStyles: `
- .root
- color green
- `,
- inlineStyleProps: {
- style: { color: 'purple' }
- }
- }), {
- style: { color: 'purple' }
- })
- })
-})
-
-// ============================================================================
-// LEVEL 9: Complex combinations
-// ============================================================================
-describe('Level 9: Complex combinations', () => {
- it('multiple classes, parts, var(), and hierarchy', () => {
- setDefaultVariables({
- '--primary': '#00f',
- '--header-size': '24px'
- })
- singletonVariables['--active-opacity'] = '0.9'
-
- assert.deepStrictEqual(p({
- styleName: 'root active',
- fileStyles: `
- .root
- color var(--primary, #f00)
- .active
- opacity var(--base-opacity, 0.5)
- .root.active
- opacity var(--active-opacity, 0.8)
- .root::part(header)
- font-size var(--header-size, 16px)
- `,
- globalStyles: `
- .root
- padding-left var(--padding, 10px)
- `,
- localStyles: `
- .root
- margin-left var(--margin, 5px)
- `,
- inlineStyleProps: {
- headerStyle: { fontWeight: 'bold' }
- }
- }), {
- style: {
- color: '#00f',
- opacity: 0.9,
- paddingLeft: 10,
- marginLeft: 5
- },
- headerStyle: {
- fontSize: 24,
- fontWeight: 'bold'
- }
- })
- })
-
- it('var() with rgba color value', () => {
- setDefaultVariables({
- '--string-color': 'rgba(255, 0, 0, 0.5)',
- '--numeric-size': '32px'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--string-color, #000)
- font-size var(--numeric-size, 16px)
- `
- }), {
- style: {
- color: 'rgba(255, 0, 0, 0.5)',
- fontSize: 32
- }
- })
- })
-
- it('deeply nested specificity with vars', () => {
- setDefaultVariables({
- '--level1-color': '#f00',
- '--level2-color': '#00f',
- '--level3-color': '#0f0'
- })
- assert.deepStrictEqual(p({
- styleName: 'root active card',
- fileStyles: `
- .root
- color var(--level1-color, #000)
- .root.active
- color var(--level2-color, #808080)
- .root.active.card
- color var(--level3-color, #fff)
- `
- }), {
- style: { color: '#0f0' }
- })
- })
-
- it('parts with multiple classes and vars', () => {
- setDefaultVariables({
- '--header-bg': '#00f',
- '--active-header-bg': '#0f0',
- '--card-header-bg': '#800080'
- })
- singletonVariables['--full-header-bg'] = '#ffa500' // orange
-
- assert.deepStrictEqual(p({
- styleName: 'root active card',
- fileStyles: `
- .root::part(header)
- background-color var(--header-bg, #808080)
- .root.active::part(header)
- background-color var(--active-header-bg, #f00)
- .root.card::part(header)
- background-color var(--card-header-bg, #00f)
- .root.active.card::part(header)
- background-color var(--full-header-bg, #000)
- `
- }), {
- headerStyle: { backgroundColor: '#ffa500' }
- })
- })
-})
-
-// ============================================================================
-// LEVEL 10: Edge cases and special values
-// ============================================================================
-describe('Level 10: Edge cases', () => {
- it('var() with hyphenated variable names', () => {
- setDefaultVariables({ '--my-very-long-variable-name': '#f00' })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--my-very-long-variable-name, #00f)
- `
- }), {
- style: { color: '#f00' }
- })
- })
-
- it('var() with numeric variable names', () => {
- setDefaultVariables({ '--color-100': '#d3d3d3' })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--color-100, #fff)
- `
- }), {
- style: { color: '#d3d3d3' }
- })
- })
-
- it('empty default in var()', () => {
- singletonVariables['--color'] = '#f00'
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--color)
- `
- }), {
- style: { color: '#f00' }
- })
- })
-
- it('u unit support (1u = 8px)', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- padding-left 2u
- margin-left 1.5u
- `
- }), {
- style: {
- paddingLeft: 16,
- marginLeft: 12
- }
- })
- })
-
- it('multiple inline style props', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color red
- .root::part(header)
- font-size 20px
- `,
- inlineStyleProps: {
- style: { marginLeft: 10 },
- headerStyle: { marginTop: 5 },
- customStyle: { padding: 15 }
- }
- }), {
- style: {
- color: '#f00',
- marginLeft: 10
- },
- headerStyle: {
- fontSize: 20,
- marginTop: 5
- },
- customStyle: {
- padding: 15
- }
- })
- })
-
- it('var() fallback chain - singleton > default > inline default', () => {
- // Test 1: Only inline default
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--test-color, #f00)
- `
- }), {
- style: { color: '#f00' }
- })
-
- // Test 2: Default variable overrides inline default
- setDefaultVariables({ '--test-color': '#00f' })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--test-color, #f00)
- `
- }), {
- style: { color: '#00f' }
- })
-
- // Test 3: Singleton overrides default
- singletonVariables['--test-color'] = '#0f0'
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- color var(--test-color, #f00)
- `
- }), {
- style: { color: '#0f0' }
- })
- })
-})
-
-// ============================================================================
-// LEVEL 11: var() as part of compound values (not the whole value)
-// ============================================================================
-describe('Level 11: var() in compound values', () => {
- it('multiple var() in box-shadow', () => {
- setDefaultVariables({
- '--shadow-x': '2px',
- '--shadow-y': '4px',
- '--shadow-blur': '8px',
- '--shadow-color': 'rgba(0, 0, 0, 0.2)'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- box-shadow var(--shadow-x, 0) var(--shadow-y, 0) var(--shadow-blur, 0) var(--shadow-color, #000)
- `
- }), {
- style: {
- // RN New Architecture supports boxShadow as a string natively
- boxShadow: '2px 4px 8px rgba(0, 0, 0, 0.2)'
- }
- })
- })
-
- it('var() mixed with static values in box-shadow', () => {
- setDefaultVariables({
- '--shadow-color': '#f00'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- box-shadow 2px 4px 8px var(--shadow-color, #000)
- `
- }), {
- style: {
- // RN New Architecture supports boxShadow as a string natively
- boxShadow: '2px 4px 8px #f00'
- }
- })
- })
-
- it('var() in transform with multiple functions', () => {
- setDefaultVariables({
- '--translate-x': '10px',
- '--scale': '1.5'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- transform translateX(var(--translate-x, 0)) scale(var(--scale, 1))
- `
- }), {
- style: {
- // RN applies transforms in reverse order, so scale comes first
- transform: [
- { scale: 1.5 },
- { translateX: 10 }
- ]
- }
- })
- })
-
- it('var() in border longhand properties', () => {
- setDefaultVariables({
- '--border-width': '2px',
- '--border-color': '#00f'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- border-width var(--border-width, 1px)
- border-style solid
- border-color var(--border-color, #000)
- `
- }), {
- style: {
- borderWidth: 2,
- borderStyle: 'solid',
- borderColor: '#00f'
- }
- })
- })
-
- // border shorthand syntax: width style color (all optional, any order for style/color)
- // Common patterns: "1px solid red", "1px solid", "solid red", "1px", etc.
-
- it('border shorthand: width style color (no var)', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- border 2px solid red
- `
- }), {
- style: {
- borderWidth: 2,
- borderStyle: 'solid',
- borderColor: '#f00'
- }
- })
- })
-
- it('border shorthand: width style (no color, no var)', () => {
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- border 2px solid
- `
- }), {
- style: {
- borderWidth: 2,
- borderStyle: 'solid',
- borderColor: 'black' // css-to-react-native defaults to black
- }
- })
- })
-
- it('border shorthand: width style var(color)', () => {
- setDefaultVariables({
- '--border-color': '#0f0'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- border 2px solid var(--border-color, #000)
- `
- }), {
- style: {
- borderWidth: 2,
- borderStyle: 'solid',
- borderColor: '#0f0'
- }
- })
- })
-
- // NOTE: var() in border width position is not currently supported by css-to-react-native
- // Use separate border-width property with var() instead:
- it('border with var(width) using longhand', () => {
- setDefaultVariables({
- '--border-width': '3px',
- '--border-color': '#00f'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- border-width var(--border-width, 1px)
- border-style solid
- border-color var(--border-color, #000)
- `
- }), {
- style: {
- borderWidth: 3,
- borderStyle: 'solid',
- borderColor: '#00f'
- }
- })
- })
-
- it('multiple var() with some overridden by singleton', () => {
- setDefaultVariables({
- '--x': '5px',
- '--y': '10px'
- })
- singletonVariables['--y'] = '20px'
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- box-shadow var(--x, 0) var(--y, 0) 0 #000
- `
- }), {
- style: {
- // RN New Architecture supports boxShadow as a string natively
- boxShadow: '5px 20px 0 #000'
- }
- })
- })
-
- // text-shadow syntax: [color] offset-x offset-y [blur-radius] [color]
- // color can be at start or end, blur-radius is optional
-
- it('var() in text-shadow: offset-x offset-y var(color)', () => {
- setDefaultVariables({
- '--text-shadow-color': 'rgba(0, 0, 0, 0.5)'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- text-shadow 1px 2px var(--text-shadow-color, #000)
- `
- }), {
- style: {
- textShadowOffset: { width: 1, height: 2 },
- textShadowRadius: 0,
- textShadowColor: 'rgba(0, 0, 0, 0.5)'
- }
- })
- })
-
- it('var() in text-shadow: offset-x offset-y blur var(color)', () => {
- setDefaultVariables({
- '--text-shadow-color': 'rgba(0, 0, 0, 0.5)'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- text-shadow 1px 2px 3px var(--text-shadow-color, #000)
- `
- }), {
- style: {
- textShadowOffset: { width: 1, height: 2 },
- textShadowRadius: 3,
- textShadowColor: 'rgba(0, 0, 0, 0.5)'
- }
- })
- })
-
- it('var() in text-shadow: var(color) offset-x offset-y', () => {
- setDefaultVariables({
- '--text-shadow-color': 'rgba(0, 0, 0, 0.5)'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- text-shadow var(--text-shadow-color, #000) 1px 2px
- `
- }), {
- style: {
- textShadowOffset: { width: 1, height: 2 },
- textShadowRadius: 0,
- textShadowColor: 'rgba(0, 0, 0, 0.5)'
- }
- })
- })
-
- it('var() in text-shadow: var(color) offset-x offset-y blur', () => {
- setDefaultVariables({
- '--text-shadow-color': 'rgba(0, 0, 0, 0.5)'
- })
- assert.deepStrictEqual(p({
- styleName: 'root',
- fileStyles: `
- .root
- text-shadow var(--text-shadow-color, #000) 1px 2px 3px
- `
- }), {
- style: {
- textShadowOffset: { width: 1, height: 2 },
- textShadowRadius: 3,
- textShadowColor: 'rgba(0, 0, 0, 0.5)'
- }
- })
- })
-})
-
-// ============================================================================
-// LEVEL 12: Comprehensive integration test
-// ============================================================================
-describe('Level 12: Full integration test', () => {
- it('kitchen sink test', () => {
- setDefaultVariables({
- '--primary-color': '#00f',
- '--secondary-color': '#808080',
- '--spacing-md': '16px',
- '--font-size-lg': '24px'
- })
- singletonVariables['--primary-color'] = '#4b0082' // indigo
- singletonVariables['--active-bg'] = 'rgba(0, 0, 255, 0.1)'
-
- assert.deepStrictEqual(p({
- styleName: 'button primary active',
- fileStyles: `
- .button
- padding-top var(--spacing-md, 12px)
- padding-bottom var(--spacing-md, 12px)
- padding-left var(--spacing-md, 12px)
- padding-right var(--spacing-md, 12px)
- border-radius 8px
- background-color var(--secondary-color, #d3d3d3)
-
- .primary
- background-color var(--primary-color, #00f)
- color white
-
- .active
- opacity 0.9
-
- .button.primary
- font-weight bold
-
- .button.active
- background-color var(--active-bg, transparent)
-
- .button.primary.active
- border-width 2px
-
- .button::part(icon)
- width var(--spacing-md, 16px)
- height var(--spacing-md, 16px)
-
- .button.primary::part(icon)
- opacity 1
-
- .button::part(label)
- font-size var(--font-size-lg, 16px)
- `,
- globalStyles: `
- .button
- cursor pointer
-
- .button::part(label)
- text-transform uppercase
- `,
- localStyles: `
- .button
- min-width 100px
-
- .button.primary
- min-height 40px
- `,
- inlineStyleProps: {
- style: { marginRight: 10 },
- iconStyle: { marginRight: 5 }
- }
- }), {
- style: {
- paddingTop: 16,
- paddingBottom: 16,
- paddingLeft: 16,
- paddingRight: 16,
- borderRadius: 8,
- backgroundColor: 'rgba(0, 0, 255, 0.1)',
- color: '#fff',
- opacity: 0.9,
- fontWeight: 'bold',
- borderWidth: 2,
- cursor: 'pointer',
- minWidth: 100,
- minHeight: 40,
- marginRight: 10
- },
- iconStyle: {
- width: 16,
- height: 16,
- opacity: 1,
- marginRight: 5
- },
- labelStyle: {
- fontSize: 24,
- textTransform: 'uppercase'
- }
- })
- })
-})
diff --git a/packages/runtime/variables.js b/packages/runtime/variables.js
deleted file mode 100644
index 73abc80..0000000
--- a/packages/runtime/variables.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import { observable } from '@nx-js/observer-util'
-
-export let defaultVariables = {}
-
-export default observable({})
-
-export function setDefaultVariables (variables = {}) {
- defaultVariables = { ...variables }
-}
diff --git a/packages/runtime/vendor/react-native-css-media-query-processor/README.md b/packages/runtime/vendor/react-native-css-media-query-processor/README.md
deleted file mode 100644
index 45c6b79..0000000
--- a/packages/runtime/vendor/react-native-css-media-query-processor/README.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# Credits
-
-[kristerkary](https://github.com/kristerkari)
-
-Original code taken from:
-https://github.com/kristerkari/react-native-css-media-query-processor
-
-Original version: 0.21.3
diff --git a/packages/runtime/vendor/react-native-css-media-query-processor/index.js b/packages/runtime/vendor/react-native-css-media-query-processor/index.js
deleted file mode 100644
index 214cb3c..0000000
--- a/packages/runtime/vendor/react-native-css-media-query-processor/index.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import merge from 'deepmerge'
-import memoize from 'micro-memoize'
-import mediaQuery from './mediaquery.js'
-import { getPlatform } from '../../platformHelpers/index.js'
-
-const PREFIX = '@media'
-
-function isMediaQuery (str) {
- return typeof str === 'string' && str.indexOf(PREFIX) === 0
-}
-
-function filterMq (obj) {
- return Object.keys(obj).filter(key => isMediaQuery(key))
-}
-
-function filterNonMq (obj) {
- return Object.keys(obj).reduce((out, key) => {
- if (!isMediaQuery(key) && key !== '__mediaQueries') {
- out[key] = obj[key]
- }
- return out
- }, {})
-}
-
-const mFilterMq = memoize(filterMq)
-const mFilterNonMq = memoize(filterNonMq)
-
-export function process (obj, matchObject) {
- const mqKeys = mFilterMq(obj)
- let res = mFilterNonMq(obj)
-
- mqKeys.forEach(key => {
- if (/^@media\s+(not\s+)?(ios|android|dom|macos|web|windows)/i.test(key)) {
- matchObject.type = getPlatform()
- } else {
- matchObject.type = 'screen'
- }
-
- const isMatch = mediaQuery(obj.__mediaQueries[key], matchObject)
- if (isMatch) {
- res = merge(res, obj[key])
- }
- })
-
- return res
-}
diff --git a/packages/runtime/vendor/react-native-css-media-query-processor/mediaquery.js b/packages/runtime/vendor/react-native-css-media-query-processor/mediaquery.js
deleted file mode 100644
index 32ad3b8..0000000
--- a/packages/runtime/vendor/react-native-css-media-query-processor/mediaquery.js
+++ /dev/null
@@ -1,152 +0,0 @@
-/*
-Copyright (c) 2014, Yahoo! Inc. All rights reserved.
-Copyrights licensed under the New BSD License.
-See the accompanying LICENSE file for terms.
-*/
-
-export default match
-
-// -----------------------------------------------------------------------------
-
-const RE_LENGTH_UNIT = /(em|rem|px|cm|mm|in|pt|pc)?\s*$/
-const RE_RESOLUTION_UNIT = /(dpi|dpcm|dppx)?\s*$/
-
-function match (parsed, values) {
- if (!parsed) {
- return false
- }
- if (parsed.length === 1) {
- return matchQuery(parsed[0], values)
- }
- return parsed.some(mq => matchQuery(mq, values))
-}
-
-function matchQuery (query, values) {
- const inverse = query.inverse
-
- // Either the parsed or specified `type` is "all", or the types must be
- // equal for a match.
- const typeMatch = query.type === 'all' || values.type === query.type
-
- if (query.expressions.length === 0) {
- // Quit early when `type` doesn't match, but take "not" into account.
- if ((typeMatch && inverse) || !(typeMatch || inverse)) {
- return false
- }
- }
-
- const expressionsMatch = query.expressions.every(function (expression) {
- const feature = expression.feature
- const modifier = expression.modifier
- let expValue = expression.value
- let value = values[feature]
-
- // Missing or falsy values don't match.
- if (!value) {
- return false
- }
-
- switch (feature) {
- case 'orientation':
- case 'scan':
- return value.toLowerCase() === expValue.toLowerCase()
-
- case 'width':
- case 'height':
- case 'device-width':
- case 'device-height':
- expValue = toPx(expValue)
- value = toPx(value)
- break
-
- case 'resolution':
- expValue = toDpi(expValue)
- value = toDpi(value)
- break
-
- case 'aspect-ratio':
- case 'device-aspect-ratio':
- case /* Deprecated */ 'device-pixel-ratio':
- expValue = toDecimal(expValue)
- value = toDecimal(value)
- break
-
- case 'grid':
- case 'color':
- case 'color-index':
- case 'monochrome':
- expValue = parseInt(expValue, 10) || 1
- value = parseInt(value, 10) || 0
- break
- }
-
- switch (modifier) {
- case 'min':
- return value >= expValue
- case 'max':
- return value <= expValue
- default:
- return value === expValue
- }
- })
-
- const isMatch = typeMatch && expressionsMatch
-
- if (inverse) {
- return !isMatch
- }
-
- return isMatch
-}
-
-// -- Utilities ----------------------------------------------------------------
-
-function toDecimal (ratio) {
- let decimal = Number(ratio)
- let numbers
-
- if (!decimal) {
- numbers = ratio.match(/^(\d+)\s*\/\s*(\d+)$/)
- decimal = numbers[1] / numbers[2]
- }
-
- return decimal
-}
-
-function toDpi (resolution) {
- const value = parseFloat(resolution)
- const units = String(resolution).match(RE_RESOLUTION_UNIT)[1]
-
- switch (units) {
- case 'dpcm':
- return value / 2.54
- case 'dppx':
- return value * 96
- default:
- return value
- }
-}
-
-function toPx (length) {
- const value = parseFloat(length)
- const units = String(length).match(RE_LENGTH_UNIT)[1]
-
- switch (units) {
- case 'em':
- return value * 16
- case 'rem':
- return value * 16
- case 'cm':
- return (value * 96) / 2.54
- case 'mm':
- return (value * 96) / 2.54 / 10
- case 'in':
- return value * 96
- case 'pt':
- return value * 72
- case 'pc':
- return (value * 72) / 12
- default:
- return value
- }
-}
diff --git a/packages/runtime/vendor/react-native-dynamic-style-processor/README.md b/packages/runtime/vendor/react-native-dynamic-style-processor/README.md
deleted file mode 100644
index cd0de16..0000000
--- a/packages/runtime/vendor/react-native-dynamic-style-processor/README.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# Credits
-
-[kristerkary](https://github.com/kristerkari)
-
-Original code taken from:
-https://github.com/kristerkari/react-native-dynamic-style-processor
-
-Original version: 0.21.0
diff --git a/packages/runtime/vendor/react-native-dynamic-style-processor/index.js b/packages/runtime/vendor/react-native-dynamic-style-processor/index.js
deleted file mode 100644
index 127d17c..0000000
--- a/packages/runtime/vendor/react-native-dynamic-style-processor/index.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import { process as mediaQueriesProcess } from '../react-native-css-media-query-processor/index.js'
-import { transform } from 'css-viewport-units-transform'
-import memoize from 'micro-memoize'
-import { getDimensions } from '../../platformHelpers/index.js'
-
-function omit (obj, omitKey) {
- return Object.keys(obj).reduce((result, key) => {
- if (key !== omitKey) {
- result[key] = obj[key]
- }
- return result
- }, {})
-}
-
-const omitMemoized = memoize(omit)
-
-function viewportUnitsTransform (obj, matchObject) {
- const hasViewportUnits = '__viewportUnits' in obj
-
- if (!hasViewportUnits) {
- return obj
- }
- return transform(omitMemoized(obj, '__viewportUnits'), matchObject)
-}
-
-function mediaQueriesTransform (obj, matchObject) {
- const hasParsedMQs = '__mediaQueries' in obj
-
- if (!hasParsedMQs) {
- return obj
- }
- return mediaQueriesProcess(obj, matchObject)
-}
-
-export function process (obj) {
- const matchObject = getMatchObject()
- return viewportUnitsTransform(
- mediaQueriesTransform(obj, matchObject),
- matchObject
- )
-}
-
-function getMatchObject () {
- const win = getDimensions()
- return {
- width: win.width,
- height: win.height,
- orientation: win.width > win.height ? 'landscape' : 'portrait',
- 'aspect-ratio': win.width / win.height,
- type: 'screen'
- }
-}
diff --git a/plan.md b/plan.md
new file mode 100644
index 0000000..5305660
--- /dev/null
+++ b/plan.md
@@ -0,0 +1,3501 @@
+# Unified CSS-to-RN Engine Plan
+
+This document is the implementation handoff for the CSSX CSS-to-React-Native
+pipeline refactor. It captures the agreed architecture, public APIs, internal
+IR, runtime tracking model, migration path, and test plan. A new agent should
+be able to start from this file and implement the package end to end without
+needing the design discussion history.
+
+## Goal
+
+Unify CSS-to-React-Native style transformation into one maintainable package in
+this monorepo.
+
+The current implementation is split across:
+
+- `cssx` / this monorepo:
+ - Babel transforms
+ - Stylus preprocessing
+ - runtime selector matching
+ - runtime CSS variable substitution
+ - media and viewport unit processing
+ - teamplay-based caching
+- `../css-to-react-native`:
+ - low-level CSS declaration to React Native style transformation
+ - forked support for `var()`, animations, transitions, keyframes
+- `../css-to-react-native-transform`:
+ - full CSS parsing
+ - selector filtering
+ - media query parsing
+ - `:part()` selector support
+ - keyframe extraction
+
+The new package should replace that split with:
+
+- one canonical CSS compiler IR
+- one resolver for static, dynamic, imported, inline, and runtime-generated CSS
+- one runtime dependency tracker
+- one caching model
+- one public API surface re-exported from `cssxjs`
+
+## Non-Goals
+
+These were intentionally out of scope for the first unified-engine
+implementation. The later "Global Theming And Provider Styles Workstream"
+below explicitly reopens provider-scoped variables, `:root`, component tag
+selectors, modern color math, Tailwind utilities, and StartupJS UI migration.
+
+- Runtime Stylus compilation. Runtime `compileCss()` accepts pure CSS only.
+- Full browser selector support. CSSX remains a class-combination selector
+ system.
+- Full browser CSS compatibility, prefixing, or old-browser normalization.
+- Mandatory PostCSS. Client-side compiler size is important.
+- A cssta/styled-components-like component factory API.
+- Animation execution hooks/components. CSSX only emits Reanimated v4-compatible
+ style props.
+- Provider-scoped CSS variables. Variables remain global singleton state for
+ now.
+- CSS custom property declarations inside stylesheets, such as
+ `.root { --x: red }`.
+- `:root` custom property defaults.
+- Interpolation inside Pug `style` blocks.
+- Dynamic `:export` values.
+
+## Global Theming And Provider Styles Workstream
+
+This section captures the next batch of agreed work after the unified engine
+implementation. It moves global theming, component tag overrides, scoped CSS
+variables, and optional Tailwind utilities into CSSX primitives, then migrates
+StartupJS and StartupJS UI onto those primitives.
+
+Work should happen on separate branches in the involved repos:
+
+- `cssx`: `cssx-theme-provider-plan`
+- `startupjs`: `cssx-provider-integration`
+- `startupjs-ui`: `cssx-theme-migration`
+
+Do not treat this as a StartupJS UI-only redesign. CSSX must expose generic
+building blocks that standalone CSSX users can use without StartupJS. StartupJS
+then re-exports CSSX APIs and wires them into its framework provider. StartupJS
+UI becomes a consumer that ships a default theme and components which opt into
+CSSX global customization.
+
+### Goals
+
+- Add provider-level global CSSX sheets.
+- Let provider sheets define scoped `:root` CSS custom properties.
+- Let provider sheets define global utility classes.
+- Add first-class component tag selectors for globally themeable components.
+- Move the `themed()` primitive into CSSX and re-export it from `startupjs`.
+- Make StartupJS UI use CSSX primitives instead of owning the theme engine.
+- Replace StartupJS UI's JS palette/color object system with CSS-first
+ variables and CSS color functions where possible.
+- Add enough modern color math to reproduce the current StartupJS UI theme
+ structure in pure CSS.
+- Add an optional Tailwind preset/layer that can be consumed by CSSX provider
+ styles and `cssx()`.
+- Add a conditional legacy flag for migration testing.
+
+### Provider API
+
+`CssxProvider` should accept a direct `style` prop:
+
+```tsx
+
+
+
+```
+
+`style` accepts the same layer inputs as `cssx()`:
+
+```ts
+type CssxProviderStyle =
+ | string
+ | CompiledCssSheet
+ | TrackedCssxSheet
+ | CssxUtilityLayer
+ | readonly CssxProviderStyle[]
+ | null
+ | undefined
+ | false
+```
+
+Raw CSS strings compile at runtime with the same graceful diagnostics model as
+`useRuntimeCss()`. Compiled sheets and tracked sheets should work during SSR.
+Arrays flatten like React style arrays: ignore falsey values, preserve order,
+and let later layers override earlier layers.
+
+`CssxProvider` should continue to support runtime configuration, either through
+the existing `value` prop or a compatible shape. The provider style API should
+be the ergonomic public path for global theme/style sheets.
+
+Provider cascade order:
+
+1. outer provider `style`
+2. inner provider `style`
+3. imported/file component styles
+4. local `css` / `styl` templates
+5. explicit inline style props
+
+Child provider styles append after parent provider styles, so nested providers
+can override parent themes. StartupJS UI `UiProvider style` should override the
+StartupJS UI default theme.
+
+### StartupJS Provider Integration
+
+Standalone CSSX users use `CssxProvider` directly.
+
+StartupJS framework users normally use `StartupjsProvider`; StartupJS should
+wire its provider `style` prop into CSSX through its plugin/provider hook layer.
+That lets apps write:
+
+```tsx
+
+
+
+```
+
+without manually adding a separate CSSX provider. `startupjs` already re-exports
+`css`, `styl`, and other CSSX APIs; it should also re-export the new provider,
+variable, and theming APIs.
+
+StartupJS UI should not own this framework integration. It should provide an
+inner provider for its own default component theme.
+
+### StartupJS UI Provider Integration
+
+StartupJS UI should ship a default theme sheet as pure `.css` where possible,
+not Stylus unless Stylus features become necessary.
+
+`UiProvider` should wrap its children with:
+
+```tsx
+
+ {children}
+
+```
+
+This makes StartupJS UI self-contained when used outside the full StartupJS
+framework. The app can still pass a stronger override sheet through
+`UiProvider style`.
+
+The total common cascade is:
+
+1. outer app/framework provider style
+2. StartupJS UI default theme
+3. `UiProvider style` overrides
+4. component file/local/inline styles
+
+StartupJS UI should stop seeding global `defaultVariables` for its theme. The
+default UI variables move entirely into the default theme CSS sheet.
+
+### Provider `:root` Variables
+
+Provider `:root` declarations are scoped to that provider subtree. They should
+behave like CSS custom properties in web CSS: nested providers can override
+outer variables without mutating singleton global runtime variables.
+
+Example:
+
+```css
+:root {
+ --color-primary: oklch(62% 0.18 250);
+ --Button-height-m: 32px;
+}
+```
+
+Compiled sheets should preserve root custom properties as structured metadata,
+for example `sheet.rootVariables`, not as legacy top-level style objects. Expose
+helpers such as:
+
+```ts
+getRootVariables(sheet): Record
+```
+
+The exact name can change, but StartupJS UI must not depend on
+`style[':root']` anymore.
+
+Variable precedence, highest to lowest:
+
+1. interpolation/template values used by the current layer
+2. global imperative `variables['--x']`
+3. nearest provider `:root` variable
+4. outer provider `:root` variable
+5. global `defaultVariables`
+6. inline `var(--x, fallback)`
+
+This keeps current global runtime variables powerful and backward compatible,
+while provider variables behave as scoped defaults.
+
+### Variable Store API
+
+Keep `variables` and `defaultVariables` as object-like proxies, but make them
+stricter and add bulk methods directly on the proxies:
+
+```ts
+variables['--x'] = 'red'
+delete variables['--x']
+
+variables.assign({
+ '--x': 'red',
+ '--y': 'blue'
+})
+
+variables.set({
+ '--x': 'red'
+})
+
+variables.clear()
+variables.clear(['--x', '--y'])
+
+defaultVariables.set({
+ '--x': 'red'
+})
+```
+
+`setDefaultVariables(vars)` remains as a compatibility alias for
+`defaultVariables.set(vars)`.
+
+Validation:
+
+- only valid CSS custom property names are allowed, practically
+ `/^--[A-Za-z0-9_-]+$/`
+- invalid writes throw in every environment
+- methods are reserved non-variable properties and should not be enumerable
+- bulk operations validate everything before mutating anything
+- notify once per bulk operation with exactly changed/removed variable names
+
+Provider `:root` variables are CSS strings. Imperative global variables should
+be CSS-first and documented as strings/numbers. Object values with meaningful
+`toString()` can remain tolerated for migration, but StartupJS UI should stop
+depending on object-valued variables.
+
+### Variable Read APIs
+
+Expose provider-aware and global-only variable readers:
+
+```ts
+useCssVariable(name: string, fallback?: unknown): unknown
+useCssVariableRaw(name: string, fallback?: string): string | undefined
+
+getCssVariable(name: string, fallback?: unknown): unknown
+getCssVariableRaw(name: string, fallback?: string): string | undefined
+```
+
+`useCssVariable()`:
+
+- React-only
+- provider-aware
+- subscribed to exactly the variables used, including nested `var()`
+ dependencies
+- returns RN-friendly values by default
+
+`getCssVariable()`:
+
+- global-only
+- not provider-aware
+- not subscribed
+- useful outside render or in non-React code
+
+Internal pure helpers should exist for explicit contexts, for example
+`resolveCssVariable(name, context)`.
+
+RN-friendly value behavior:
+
+- `32px` -> `32`
+- `4u` -> `32`
+- unitless number -> number
+- `%` remains string
+- colors and computed color functions -> RN-compatible color strings
+- complex RN-accepted strings remain strings
+- unsupported values return fallback or `undefined` and report diagnostics in
+ development paths
+
+Raw helpers return the resolved CSS string before RN coercion.
+
+### Inline Style `var()` Resolution
+
+CSSX should resolve `var()` inside inline style props when those props flow
+through CSSX:
+
+```tsx
+
+```
+
+or:
+
+```ts
+cssx('root', sheet, { backgroundColor: 'var(--color-bg-main)' })
+```
+
+This replaces StartupJS UI's brittle JSON-stringify-based
+`useTransformCssVariables()` helper. Plain React Native `style={{ ... }}`
+without CSSX cannot be intercepted.
+
+Inline variable resolution must:
+
+- use the same variable precedence as declarations
+- track exact variable dependencies
+- participate in cache keys/invalidation
+- resolve nested `var()` and fallbacks
+- apply RN-friendly value coercion
+
+### Component Tag Selectors
+
+Add component tag/type selectors for provider/global sheets:
+
+```css
+Button {
+ background-color: red;
+}
+
+Button:part(text),
+Button::part(text) {
+ color: green;
+}
+
+Button.primary {
+ border-color: var(--color-primary);
+}
+
+Button:part(icon).large {
+ width: 24px;
+}
+```
+
+Only tag selectors should be supported for global component defaults. Do not
+support `.Button` as a long-term alias. StartupJS UI's breaking migration guide
+should tell users to change `.Button` global overrides to `Button`.
+
+Both `:part(name)` and `::part(name)` are long-term supported syntax.
+
+Supported selector combinations for the first batch:
+
+- `Tag`
+- `Tag.class`
+- `Tag:part(name)`
+- `Tag::part(name)`
+- `Tag:part(name).class`
+- `Tag:hover`
+- `Tag:active`
+- class selectors and class part selectors, such as `.danger:part(icon)`
+
+Defer descendant selectors such as `Button Text` or `Button .icon`. Parts are
+the intended cross-boundary customization API.
+
+Global utility classes should work without a component tag:
+
+```css
+.danger {
+ border-color: var(--color-error);
+}
+
+.danger:part(text) {
+ color: var(--color-error);
+}
+```
+
+### `themed()`
+
+The CSSX `themed()` primitive should live in CSSX, not StartupJS UI. StartupJS
+re-exports it. StartupJS UI imports it from `startupjs`.
+
+Recommended public APIs:
+
+```ts
+themed(tagName: string, Component: React.ComponentType): React.ComponentType
+useThemeTag(): string | undefined
+```
+
+Implementation should use React 19 context with `use(Context)` where useful so
+it does not depend on Babel threading hidden props through every element.
+
+`cssx()` in React entrypoints may read the current theme tag from context during
+render. Pure `resolveCssx()` remains framework-independent and takes an
+explicit tag/component option.
+
+Boundary behavior:
+
+- `themed('Button', Button)` provides the current tag while rendering the
+ component implementation.
+- internal root and part elements in that component see the `Button` tag.
+- nested themed components push their own tag.
+- non-themed descendants inherit the nearest tag unless a future escape hatch
+ is added.
+
+Parts are explicit. Components must mark exposed internals with `part`:
+
+```tsx
+function Button({ children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export default themed('Button', Button)
+```
+
+Do not infer parts from prop names like `textStyle` or `iconStyle`.
+
+### Modern Color Math
+
+CSSX should implement enough CSS color math to replace the current StartupJS UI
+JS palette/color-object theme structure with CSS variables.
+
+Use `@colordx/core` pinned exactly:
+
+```json
+"@colordx/core": "5.4.3"
+```
+
+The version is intentionally fixed because the package is less widely adopted
+and CSSX will adapt to a specific API.
+
+First batch support:
+
+- `oklch(L C H / alpha?)`
+- `oklab(...)`
+- `rgb()` / `rgba()`
+- `hsl()` / `hsla()`
+- nested `var()` inside color function channels
+- `calc()` inside color channels
+- arithmetic for numeric/percentage channels where needed
+- `color-mix()` implemented by CSSX using colordx primitives
+- interpolation spaces for `color-mix()`:
+ - `oklch`
+ - `oklab`
+ - `srgb`
+
+Defer:
+
+- hue interpolation flags, such as `longer hue`
+- exotic color spaces such as `display-p3`
+- full relative color syntax
+- full CSS unit system inside color math
+
+On React Native, modern color functions must be evaluated to RN-compatible
+strings. On web, pass-through is allowed only if it does not complicate CSSX
+variable resolution; otherwise normalize consistently on all platforms.
+
+Default output for computed modern colors:
+
+```text
+rgba(r, g, b, a)
+```
+
+Plain authored colors such as `red` or `#fff` can remain as authored unless
+they participate in computed color math.
+
+StartupJS UI default theme should become CSS-first, for example:
+
+```css
+:root {
+ --ui-primary-h: 250;
+ --ui-primary-c: 0.18;
+ --ui-primary-l: 62%;
+
+ --color-primary: oklch(var(--ui-primary-l) var(--ui-primary-c) var(--ui-primary-h));
+ --color-primary-strong: color-mix(in oklch, var(--color-primary), black 12%);
+ --color-primary-transparent: color-mix(in srgb, var(--color-primary) 5%, transparent);
+
+ --Button-height-m: 32px;
+ --Button-disabledOpacity: 0.25;
+}
+```
+
+### StartupJS UI Migration
+
+StartupJS UI should stop managing global colors/palettes as its own framework
+layer. It should ship a default CSSX theme and use CSSX primitives:
+
+- `themed()` from `startupjs`
+- `CssxProvider` through `startupjs`
+- `useCssVariable()`
+- `useCssVariableRaw()` only where raw CSS strings are necessary
+- provider `style` overrides
+
+Remove `Colors` compatibility in the breaking StartupJS UI release. Migration
+docs should tell users to use string token names or CSS variable names.
+
+Examples:
+
+```ts
+// old
+color = Colors.primary
+getColor(color)
+
+// new
+color = 'primary'
+useCssVariable(`--color-${color}`)
+```
+
+Component-level vars use full custom property names:
+
+```ts
+const hoverBg = useCssVariable('--Div-hoverBg')
+const height = useCssVariable('--Button-height-m')
+```
+
+Move configurable visual values from Stylus `:export` config objects into CSS
+variables by default. Components can read variables with `useCssVariable()`
+when JS needs the value. Keep true structural JS constants as JS constants when
+they are not meaningful CSS/theme knobs.
+
+Examples:
+
+- good CSS variables:
+ - radii
+ - border widths
+ - disabled opacity
+ - colors
+ - heights
+ - font sizes
+ - icon margins
+- likely JS constants:
+ - supported size names
+ - icon component mappings
+ - structural branching that cannot be a CSS value
+
+### Optional Tailwind Support
+
+Tailwind support should be optional and imported explicitly:
+
+```ts
+import { tailwind } from 'cssxjs/tailwind'
+```
+
+Implementation shape:
+
+- create a separate package/adapter, likely `@cssxjs/tailwind`
+- `cssxjs` may depend on it and expose a separate export entry
+- it is only bundled by clients that import `cssxjs/tailwind`
+- depend on `@mgcrea/react-native-tailwind` pinned exactly, initially:
+
+```json
+"@mgcrea/react-native-tailwind": "0.16.0"
+```
+
+Reuse `@mgcrea/react-native-tailwind` for Tailwind token parsing/config support
+where possible. Do not reimplement the whole Tailwind utility table unless the
+adapter API blocks CSSX integration.
+
+`tailwind()` returns a CSSX layer/preset:
+
+```tsx
+const tw = tailwind({
+ theme: {
+ extend: {
+ colors: {
+ primary: '#1d4ed8'
+ }
+ }
+ }
+})
+
+
+
+
+```
+
+Users can then write:
+
+```tsx
+
+```
+
+No separate `tw()` helper is needed in the first batch. Users can call the
+normal CSSX runtime if they need manual props:
+
+```ts
+cssx('flex-1 bg-gray-100 p-4', tw)
+```
+
+Arbitrary utilities such as `w-[123px]` cannot be pre-generated. The Tailwind
+layer should expose a virtual utility resolver used during `cssx()` resolution:
+
+- finite generated sheet for standard utilities/default vars when useful
+- virtual `resolveUtilityClass(className)` for arbitrary/dynamic classes
+- diagnostics for unsupported utilities
+- bounded cache behavior consistent with CSSX caching rules
+
+Tailwind config:
+
+- build-time/Node can discover `tailwind.config.{js,cjs,mjs,ts}` from project
+ root when appropriate
+- runtime/client cannot use filesystem discovery
+- allow explicit config object
+- users can layer Tailwind with their own StartupJS UI/app overrides through
+ provider style arrays
+
+### Legacy Migration Flag
+
+Add a conditional legacy flag for migration testing:
+
+```js
+// babel-preset-cssxjs / babel-preset-startupjs
+{
+ cssxLegacy: true
+}
+```
+
+Also support an env override:
+
+```sh
+CSSX_LEGACY=0 yarn build
+CSSX_LEGACY=1 yarn build
+```
+
+Default: `true` for the migration release.
+
+When legacy is enabled:
+
+- `matcher` compatibility export works
+- loader emits legacy top-level static style-map entries such as
+ `STYLES.button`
+
+When legacy is disabled:
+
+- `matcher()` throws a clear migration error
+- old top-level static style-map entries are not generated
+- direct `STYLES.button` reads fail loudly
+
+The flag must reach both Babel and Metro/file-loader paths:
+
+- Babel preset passes `cssxLegacy` to inline/styleName plugins
+- Babel plugin can fail fast for `import { matcher } from 'startupjs'` or
+ `cssxjs`
+- Metro transformer passes `cssxLegacy` to `cssToReactNativeLoader`
+- StartupJS Babel preset and Metro config expose/pass the same option
+
+CSSX/StartupJS should keep legacy default-on for one migration release, then
+flip default or remove it in a later major after real apps pass with
+`CSSX_LEGACY=0`.
+
+### Diagnostics
+
+Provider raw CSS diagnostics should be visible and controlled:
+
+- raw provider strings compile through `useRuntimeCss()`
+- expose diagnostics through a hook such as `useCssxDiagnostics()`
+- development warns once per sheet/cache key unless silenced
+- production does not warn by default
+- diagnostics remain available to tooling and AI-generated CSS workflows
+
+### SSR
+
+Provider global styles should work during SSR:
+
+- compiled sheets work synchronously
+- raw strings compile synchronously
+- scoped provider variables resolve deterministically
+- media queries use current CSSX dimensions fallback/config
+- subscriptions use the existing server snapshot path and do not attach
+ browser listeners
+
+### Implementation Phases For This Workstream
+
+1. **Core provider layer model**
+ - extend `CssxProvider` with `style`
+ - normalize provider layer arrays
+ - include provider layers in React `cssx()` resolution
+ - add provider diagnostics plumbing
+ - add SSR tests
+
+2. **Root variable metadata and scoped resolution**
+ - compile `:root` custom properties into sheet metadata
+ - add provider scoped variable stack
+ - update `resolveCssValue()` to accept scoped variable layers
+ - implement variable precedence
+ - add nested provider tests
+
+3. **Variable proxy methods and read APIs**
+ - validate variable names
+ - add `variables.set/assign/clear`
+ - add matching `defaultVariables` methods
+ - keep `setDefaultVariables()` compatibility
+ - add `useCssVariable`, `useCssVariableRaw`, `getCssVariable`,
+ `getCssVariableRaw`
+ - test exact subscriptions and bulk notification behavior
+
+4. **Inline style variable resolution**
+ - resolve inline style strings containing `var()`
+ - track dependencies
+ - convert scalar CSS values to RN-friendly JS values
+ - update cache tests for inline variable changes
+
+5. **Component tag selectors and `themed()`**
+ - extend selector IR with optional component tag
+ - support tag/class/part/pseudo combinations
+ - add `themed()` and theme tag context
+ - make React `cssx()` read theme tag context
+ - keep pure `resolveCssx()` explicit and framework-independent
+ - test parts, nested themed components, inherited tag behavior, and utility
+ classes
+
+6. **Modern color math**
+ - add `@colordx/core@5.4.3`
+ - evaluate OKLCH/OKLab/RGB/HSL and channel `calc()`
+ - implement `color-mix()` for `oklch`, `oklab`, and `srgb`
+ - normalize computed modern colors to `rgba(...)`
+ - add RN and web target tests
+
+7. **Legacy flag**
+ - thread `cssxLegacy` through CSSX Babel preset/plugins/loaders/Metro
+ - thread same option through StartupJS Babel preset/Metro integration
+ - make legacy-off throw for `matcher()`
+ - make legacy-off omit static map entries
+ - test both modes in CSSX and real apps
+
+8. **Optional Tailwind adapter**
+ - add `@cssxjs/tailwind`
+ - expose `cssxjs/tailwind`
+ - adapt `@mgcrea/react-native-tailwind@0.16.0`
+ - add CSSX utility layer interface if needed
+ - support config object and Node config discovery
+ - test standard and arbitrary utilities
+
+9. **StartupJS integration**
+ - re-export new CSSX APIs from `startupjs`
+ - wire `StartupjsProvider style` into `CssxProvider`
+ - expose `cssxLegacy` in StartupJS Babel/Metro config paths
+ - test Dating and other real apps with `CSSX_LEGACY=1` and `0`
+
+10. **StartupJS UI migration**
+ - add default `.css` theme sheet
+ - wrap `UiProvider` in `CssxProvider`
+ - replace current `themed()` implementation with CSSX `themed()`
+ - replace direct `matcher` usage
+ - replace direct `STYLES.class` reads with `cssx()` or CSS variables
+ - move configurable `:export` values to CSS variables where appropriate
+ - remove `Colors` compatibility
+ - update docs and migration guide from `.Button` to `Button`
+
+### Required Tests
+
+CSSX engine tests:
+
+- `:root` extraction
+- provider scoped variable precedence
+- nested provider overrides
+- global variables overriding provider vars
+- `variables.set/assign/clear`
+- invalid variable names throwing
+- `useCssVariable()` exact subscriptions
+- inline style `var()` resolution and cache invalidation
+- tag selectors and class selectors together
+- `:part()` and `::part()`
+- `:hover` and `:active` on tag selectors
+- utility classes from provider sheets
+- runtime raw provider CSS diagnostics
+- SSR provider resolution
+- OKLCH conversion
+- `calc()` in OKLCH channels
+- `color-mix()` in `oklch`, `oklab`, and `srgb`
+- unsupported color diagnostics
+- legacy on/off behavior
+- Tailwind standard utilities
+- Tailwind arbitrary utilities
+
+StartupJS tests:
+
+- `StartupjsProvider style` wraps CSSX provider
+- CSSX APIs re-export from `startupjs`
+- Babel/Metro `cssxLegacy` flag propagates
+
+StartupJS UI tests:
+
+- default theme variables available through `UiProvider`
+- `UiProvider style` overrides default theme
+- component tag overrides apply to roots and explicit parts
+- `.Button` overrides no longer work in new docs/tests
+- migrated components no longer import/use `matcher`
+- migrated components no longer depend on top-level `STYLES.class` entries
+
+Real app migration checks:
+
+- run Dating with `CSSX_LEGACY=1`
+- run Dating with `CSSX_LEGACY=0`
+- repeat on other StartupJS apps before flipping/removing legacy
+
+## Research Summary
+
+### Current CSSX
+
+Important files:
+
+- `packages/loaders/cssToReactNativeLoader.js`
+- `packages/loaders/stylusToCssLoader.js`
+- `packages/loaders/compilers/css.js`
+- `packages/loaders/compilers/styl.js`
+- `packages/babel-plugin-rn-stylename-inline/index.js`
+- `packages/babel-plugin-rn-stylename-to-style/index.js`
+- `packages/runtime/process.js`
+- `packages/runtime/processCached.js`
+- `packages/runtime/matcher.js`
+- `packages/runtime/variables.js`
+- `packages/runtime/dimensions.js`
+
+Current behavior:
+
+- Inline `css``...`` and `styl``...`` templates are compiled by Babel to style
+ objects.
+- External `.cssx.css` / `.cssx.styl` imports are compiled by loaders or Babel.
+- JSX `styleName` is rewritten to runtime calls.
+- Runtime currently handles selector matching, `var()` substitution, media query
+ processing, viewport units, `u` unit strings, and optional teamplay caching.
+- Expression interpolation inside `css``...`` and `styl``...`` currently throws.
+
+### Forked `css-to-react-native`
+
+Useful pieces:
+
+- property transformers
+- `TokenStream`
+- animation and transition transforms
+- keyframe object inlining behavior
+- shorthand behavior and tests
+
+Do not preserve its architecture blindly. In the new engine, `var()` should be
+resolved before property transformation, so the transformer should no longer
+need unresolved `VARIABLE` tokens spread through every parser.
+
+### Forked `css-to-react-native-transform`
+
+Useful pieces:
+
+- current CSS parser usage
+- media query validation
+- selector filtering constraints
+- existing legacy output shape
+- tests for parts, media, keyframes, viewport units
+
+The new package should replace the old nested object output with canonical
+rule/declaration IR.
+
+### cssta
+
+Useful inspiration:
+
+- template expression placeholder extraction
+- preserving dynamic declarations as tuples until runtime
+- splitting compile-time static work from runtime style tuple resolution
+
+Not reused:
+
+- component factory API
+- React context variable model
+- hook-based animation execution
+
+### Parser Size Decision
+
+PostCSS is not the default parser foundation because of client bundle size.
+
+Measured browser bundle baseline with esbuild:
+
+```text
+current stack:
+css/lib/parse + postcss-value-parser + css-mediaquery + helpers
+15.8 KB minified, 6.2 KB gzip
+
+PostCSS stack:
+postcss + postcss-selector-parser + postcss-value-parser + css-mediaquery + helpers
+128.0 KB minified, 36.1 KB gzip
+```
+
+Use the lightweight stack:
+
+- `css/lib/parse` or an equivalent small stylesheet parser
+- `postcss-value-parser` for values
+- `css-mediaquery` or a small compatible evaluator/parser
+- custom narrow selector parser/validator
+
+## Target Package
+
+Create:
+
+```text
+packages/css-to-rn/
+```
+
+Package name:
+
+```text
+@cssxjs/css-to-rn
+```
+
+It is the unified engine package. The public `cssxjs` package re-exports the
+user-facing APIs.
+
+### Package Boundaries
+
+`@cssxjs/css-to-rn` root export:
+
+- framework-independent compiler and resolver
+- no React imports
+- no React Native imports
+- no Reanimated imports
+
+`@cssxjs/css-to-rn/react` and platform subpaths:
+
+- React hooks and tracked wrapper runtime
+- optional peer dependency on `react`
+- optional peer dependency on `react-native`
+- conditional exports for web vs React Native
+
+`cssxjs`:
+
+- public facade used by users
+- re-exports `css`, `styl`, `pug`
+- re-exports `compileCss`, `cssx`, `useRuntimeCss`, `CssxProvider`,
+ `configureCssx`, `variables`, `setDefaultVariables`, `defaultVariables`
+- keeps conditional runtime entrypoints so Expo/RN picks the RN target
+ automatically and web picks the default target
+
+`@cssxjs/runtime`:
+
+- currently internal in practice
+- can be collapsed, removed, or left as a compatibility wrapper after migration
+- should not keep duplicate selector/var/media/cache implementation long term
+
+### TypeScript
+
+Write the new package in TypeScript from the start.
+
+Use Node-strip-friendly TypeScript, following the pattern in
+`../teamplay/tsconfig.json`:
+
+- `type: "module"`
+- `target: "esnext"`
+- `module: "nodenext"`
+- `moduleResolution: "nodenext"`
+- `rewriteRelativeImportExtensions: true`
+- `erasableSyntaxOnly: true`
+- `verbatimModuleSyntax: true`
+- `strict: true`
+- `allowImportingTsExtensions: true` for source tests
+- explicit `.ts` extensions in source imports
+- no enums
+- no parameter properties
+- no namespaces
+- no decorators
+- no top-level await
+
+Scope the TS setup to `packages/css-to-rn` initially. Do not modernize the
+root repo TS config as part of this plan unless needed later.
+
+Use a custom source condition:
+
+```text
+cssx-ts
+```
+
+Package exports should follow the Teamplay source-test pattern:
+
+```json
+{
+ "exports": {
+ ".": {
+ "cssx-ts": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./react": {
+ "react-native": {
+ "cssx-ts": "./src/react-native.ts",
+ "types": "./dist/react-native.d.ts",
+ "default": "./dist/react-native.js"
+ },
+ "cssx-ts": "./src/web.ts",
+ "types": "./dist/web.d.ts",
+ "default": "./dist/web.js"
+ },
+ "./react-native": {
+ "cssx-ts": "./src/react-native.ts",
+ "types": "./dist/react-native.d.ts",
+ "default": "./dist/react-native.js"
+ },
+ "./web": {
+ "cssx-ts": "./src/web.ts",
+ "types": "./dist/web.d.ts",
+ "default": "./dist/web.js"
+ }
+ }
+}
+```
+
+Adjust file names as implementation settles. The important constraints are:
+
+- root export is framework-independent
+- React/RN/web entrypoints are explicit and conditionally resolvable
+- source tests can import `.ts` through `-C cssx-ts`
+- published package emits `.js` and `.d.ts`
+
+Peer dependencies:
+
+```json
+{
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ },
+ "peerDependenciesMeta": {
+ "react": { "optional": true },
+ "react-native": { "optional": true }
+ }
+}
+```
+
+## Public APIs
+
+### Pure Engine APIs
+
+These live at `@cssxjs/css-to-rn`.
+
+```ts
+export function compileCss(
+ css: string,
+ options?: CompileCssOptions
+): CompiledCssSheet
+
+export function compileCssTemplate(
+ cssWithDynamicSlots: string,
+ options?: CompileCssTemplateOptions
+): CompiledCssSheet
+
+export function resolveCssx(
+ input: ResolveCssxInput
+): ResolveCssxResult
+
+export function transformDeclarations(
+ declarations: readonly CssDeclaration[],
+ options?: TransformDeclarationOptions
+): TransformDeclarationResult
+
+export function toLegacyStyleObject(
+ sheet: CompiledCssSheet,
+ options?: LegacyOutputOptions
+): LegacyStyleObject
+```
+
+The exact function names can change, but the package needs these capabilities:
+
+- compile CSS string to canonical IR
+- compile CSS string containing dynamic interpolation slots to canonical IR
+- resolve style props from one or more compiled sheet layers
+- transform resolved declaration values into RN/web style objects
+- output the old object shape temporarily for incremental migration
+
+### Runtime React APIs
+
+These live at `@cssxjs/css-to-rn/react`, `@cssxjs/css-to-rn/web`,
+`@cssxjs/css-to-rn/react-native`, and are re-exported by `cssxjs`.
+
+```ts
+export function cssx(
+ styleName: StyleNameValue,
+ sheet: CompiledCssSheet | TrackedCssSheet | string | Array,
+ inlineStyleProps?: InlineStyleProps
+): ResolvedStyleProps
+
+export function useRuntimeCss(
+ css: string,
+ options?: CompileCssOptions
+): TrackedCssSheet
+
+export function useCssxSheet(
+ sheet: CompiledCssSheet | CompiledCssSheet[],
+ options?: UseCssxSheetOptions
+): TrackedCssSheet | TrackedCssSheet[]
+
+export function useCssxTemplate(
+ sheet: CompiledCssSheet,
+ values: readonly InterpolationValue[],
+ options?: UseCssxTemplateOptions
+): TrackedCssSheet
+
+export function CssxProvider(props: {
+ value?: CssxRuntimeOptions
+ children: React.ReactNode
+}): React.ReactNode
+
+export function configureCssx(options: CssxRuntimeOptions): void
+
+export const variables: Record
+export let defaultVariables: Record
+export function setDefaultVariables(vars: Record): void
+```
+
+Public manual runtime CSS usage:
+
+```tsx
+import { compileCss, cssx, useRuntimeCss } from 'cssxjs'
+
+const sheet = compileCss(generatedCss)
+
+function Button({ disabled, style }) {
+ const trackedSheet = useRuntimeCss(generatedCss)
+
+ return (
+
+ )
+}
+```
+
+Convenience raw string usage is allowed:
+
+```tsx
+
+```
+
+But documented React usage should prefer `useRuntimeCss()` so subscriptions,
+diagnostics, and parsing are controlled.
+
+### `cssx()` Ergonomics
+
+Do not require a `useCssx()` hook per element. The user should be able to write:
+
+```tsx
+const sheet = useRuntimeCss(generatedCss)
+
+return (
+ <>
+
+
+ >
+)
+```
+
+The hook returns a tracked sheet wrapper. `cssx()` is a plain function that
+resolves styles and records dependencies into the tracked wrapper during render.
+The hook owns the actual React subscription lifecycle.
+
+### Compatibility APIs
+
+`css` and `styl` remain both:
+
+- tagged template markers transformed away by Babel
+- spread helpers transformed by Babel when called as functions
+
+The existing user code shape remains:
+
+```tsx
+import { css, styl } from 'cssxjs'
+
+function Button({ color }) {
+ return
+
+ css`
+ .root {
+ color: ${color};
+ }
+ `
+}
+```
+
+## Compiled Sheet IR
+
+The canonical compiler output must be plain JSON-serializable data:
+
+- no functions
+- no Maps
+- no Sets
+- no Symbols
+- no closures
+- no runtime cache state
+
+Runtime cache/tracker state should live in WeakMaps or non-enumerable wrapper
+objects, not inside the serialized IR.
+
+Approximate shape:
+
+```ts
+export interface CompiledCssSheet {
+ version: 1
+ id: string
+ sourceId?: string
+ contentHash: string
+ rules: CssRule[]
+ keyframes: Record
+ exports?: Record
+ metadata: CssxMetadata
+ diagnostics: CssxDiagnostic[]
+ error?: CssxDiagnostic
+}
+
+export interface CssRule {
+ selector: string
+ classes: string[]
+ part: string | null
+ specificity: number
+ order: number
+ media: string | null
+ declarations: CssDeclaration[]
+}
+
+export interface CssDeclaration {
+ property: string
+ value: CssValueAst
+ raw: string
+ order: number
+ dynamicSlots?: number[]
+}
+
+export interface CssKeyframe {
+ selector: 'from' | 'to' | string
+ declarations: CssDeclaration[]
+ order: number
+}
+
+export interface CssxMetadata {
+ hasVars: boolean
+ vars: string[]
+ hasMedia: boolean
+ hasViewportUnits: boolean
+ hasInterpolations: boolean
+ hasDynamicRuntimeDependencies: boolean
+ hasAnimations: boolean
+ hasTransitions: boolean
+}
+```
+
+The exact TypeScript structure can evolve, but these semantic fields are needed.
+
+### IDs And Path Privacy
+
+Compiled sheets need stable hashes:
+
+- build templates/imports:
+ - use relative file path and per-file template/import order as hash input
+ - do not expose the path in emitted runtime objects
+- runtime `compileCss(css)`:
+ - use CSS content as hash input
+
+Recommended build hash shape:
+
+```text
+sourceId = hash(relativeFilePath + ':' + templateIndex)
+contentHash = hash(staticCssContent)
+id = hash(sourceId + ':' + contentHash)
+```
+
+Runtime objects may expose only hashed IDs:
+
+```ts
+{
+ id: 'cssx_abc123',
+ sourceId: 'cssx_src_def456'
+}
+```
+
+Do not leak absolute or relative server paths into code delivered publicly.
+
+Build diagnostics can include actual filenames and code frames because those are
+developer-only build outputs.
+
+Runtime diagnostics for AI-generated CSS should include sanitized line/column
+but no source paths.
+
+## Compiler Behavior
+
+### Modes
+
+`compileCss()` should support separate modes:
+
+```ts
+type CompileMode = 'runtime' | 'build'
+```
+
+Runtime-safe mode is the default for the public API:
+
+- CSS syntax errors return an empty sheet with structured diagnostics
+- dev mode may warn
+- production should not crash
+- unsupported selectors/rules become diagnostics and are ignored
+- invalid declarations become diagnostics and are ignored
+
+Build-strict mode is used by Babel/loaders:
+
+- syntax errors throw
+- invalid static declarations throw
+- unsupported critical constructs throw when they represent developer source bugs
+- errors should include file-aware code frames where possible
+
+For parser syntax errors in runtime mode, return an empty sheet initially. Do not
+attempt partial recovery until there is a good reason and tests.
+
+### Diagnostics
+
+Compiled sheets must expose structured diagnostics suitable for tooling and
+AI feedback:
+
+```ts
+export interface CssxDiagnostic {
+ level: 'warning' | 'error'
+ code: CssxDiagnosticCode
+ message: string
+ line?: number
+ column?: number
+}
+```
+
+Use stable machine-readable codes. Initial codes:
+
+```text
+CSS_SYNTAX_ERROR
+UNSUPPORTED_SELECTOR
+UNSUPPORTED_AT_RULE
+INVALID_DECLARATION
+UNRESOLVED_VARIABLE
+VARIABLE_CYCLE
+VARIABLE_DEPTH_LIMIT
+UNSUPPORTED_INTERPOLATION_POSITION
+INVALID_INTERPOLATION_VALUE
+UNSUPPORTED_CALC
+UNSUPPORTED_BACKGROUND_IMAGE
+UNSUPPORTED_BACKGROUND_SHORTHAND
+```
+
+Deduplicate dev warnings per stylesheet/declaration/error kind/value pattern to
+avoid console spam during repeated renders.
+
+### Source Locations
+
+Runtime IR should not include source maps or full source locations by default.
+
+Runtime diagnostics may include:
+
+- line
+- column
+- sanitized message
+
+Build tools can use parser locations immediately for code frames, but emitted
+runtime objects should stay small and path-free.
+
+## Selector Model
+
+Keep the CSSX selector subset.
+
+Supported:
+
+```css
+.root
+.root.active
+.root:part(label)
+.root.active:part(icon)
+.root:hover
+.root:active
+.root.active:hover
+:export
+```
+
+Unsupported and ignored with dev diagnostics:
+
+```css
+.root .child
+.root > .child
+#id
+[type='x']
+:nth-child(2)
+```
+
+`:hover` and `:active` are aliases for part-style output:
+
+```css
+.root:hover -> hoverStyle
+.root:active -> activeStyle
+```
+
+They are equivalent targets to:
+
+```css
+.root:part(hover)
+.root:part(active)
+```
+
+If both forms target the same logical part, normal cascade decides the result.
+No built-in hover/press state management is included. CSSX only emits
+`hoverStyle` / `activeStyle` props for components that consume them.
+
+Specificity remains CSSX class specificity:
+
+- specificity is class count
+- part/pseudo aliases do not add browser-style specificity
+- within same specificity, later source order wins
+
+## Cascade And Layering
+
+Canonical IR preserves:
+
+- rule order
+- declaration order
+- selector specificity
+- media condition per rule
+
+This is required for browser-like fallback behavior:
+
+```css
+.button {
+ color: red;
+ color: var(--maybe-color);
+}
+```
+
+If `--maybe-color` is unresolved or invalid at runtime, only the second
+declaration is dropped and `color: red` still applies.
+
+Cross-source precedence stays as today:
+
+1. file/imported stylesheet
+2. module-level global inline template
+3. function-level local inline template
+4. inline style props
+
+Model this as ordered sheet layers:
+
+```ts
+resolveCssx({
+ styleName,
+ layers: [fileSheet, globalSheet, localSheet],
+ inlineStyleProps
+})
+```
+
+Within each sheet:
+
+1. match selectors/classes/part
+2. filter inactive media rules
+3. sort/apply by specificity and source order
+4. resolve declarations in order
+5. drop invalid declarations
+
+Across sheets, later layers override earlier layers.
+
+Public `cssx()` should accept a single sheet or an array:
+
+```ts
+cssx('root', sheet, inlineStyleProps)
+cssx('root', [baseSheet, generatedSheet], inlineStyleProps)
+```
+
+## Interpolation
+
+Interpolation is supported only in JS tagged templates:
+
+```tsx
+css`
+ .button {
+ color: ${buttonColor};
+ }
+`
+
+styl`
+ .button
+ color ${buttonColor}
+`
+```
+
+It is not supported in:
+
+- external `.cssx.css` / `.cssx.styl` files
+- module-level global templates
+- Pug `style` blocks
+- selectors
+- property names
+- media queries
+- `:export`
+
+Interpolation is allowed only where CSS `var()` can legally appear in
+declaration values. It can also interpolate a full `var(...)` string.
+
+### Lowering
+
+Babel lowers template expressions to synthetic `var()`-like tokens before CSS
+or Stylus parsing:
+
+```tsx
+css`
+ .root {
+ color: ${color};
+ padding: ${pad} 2u;
+ }
+`
+```
+
+becomes a static source equivalent to:
+
+```css
+.root {
+ color: var(--__cssx_dynamic_0);
+ padding: var(--__cssx_dynamic_1) 2u;
+}
+```
+
+The compiler validates that the synthetic slots appear only inside declaration
+values. If a slot appears in a selector, property name, media query, `:export`,
+or other unsupported position, build mode throws
+`UNSUPPORTED_INTERPOLATION_POSITION`.
+
+For Stylus:
+
+```text
+JS template -> synthetic dynamic var tokens -> Stylus -> CSS -> compileCssTemplate()
+```
+
+This keeps CSS and Stylus interpolation on one path.
+
+### Runtime Values
+
+Dynamic values are passed as an ordered array in template expression order:
+
+```ts
+useCssxTemplate(__sheet, [color, pad])
+```
+
+Accepted interpolation values:
+
+```ts
+string | number | null | undefined | false
+```
+
+Semantics:
+
+- `string`: inserted as raw CSS value text
+- `number`: inserted as raw numeric token
+- `null`, `undefined`, `false`: invalidate only the containing declaration
+- `true`: invalid
+- objects, arrays, functions, symbols, bigint: invalid
+
+Invalid interpolation values drop only the containing declaration at runtime and
+produce a deduped dev diagnostic.
+
+Interpolation cache equality uses `Object.is` over the primitive value array.
+Do not stringify interpolation values.
+
+### Local Templates Only
+
+Interpolations are supported only in function-scoped local templates. This gives
+the runtime a clear render lifecycle:
+
+```tsx
+function Button({ color }) {
+ return
+
+ css`
+ .root { color: ${color}; }
+ `
+}
+```
+
+Module-level templates with expressions remain unsupported because they would
+require global mutable style state or one-time module initialization semantics.
+
+## Value Resolution
+
+Dynamic values must resolve at the CSS declaration-value layer before RN/web
+property transformation.
+
+This is essential for:
+
+```css
+box-shadow: var(--shadow);
+box-shadow: var(--shadow-1), var(--shadow-2);
+box-shadow: var(--x) 2px 8px rgba(0,0,0,var(--alpha));
+padding: var(--button-padding, 8px 16px);
+border: var(--width) solid var(--color);
+transform: translateX(var(--x)) scale(var(--scale));
+```
+
+Resolution pipeline:
+
+1. replace interpolation slots
+2. recursively resolve nested `var()`
+3. resolve/evaluate supported `calc()` and viewport units at the value layer
+4. apply `u` unit semantics
+5. transform final declaration values into RN/web style props
+
+Implementation can combine steps 3 and 4 internally. The important invariant is
+that RN property transformers receive final CSS value strings with no unresolved
+`var()` or dynamic slots.
+
+### CSS Variables
+
+CSS variable priority stays:
+
+1. runtime `variables['--name']`
+2. `defaultVariables['--name']`
+3. inline fallback `var(--name, fallback)`
+
+Nested vars are supported:
+
+```css
+color: var(--button-color, var(--theme-color, red));
+```
+
+Cycles and runaway recursion are invalid:
+
+```css
+var(--a) where --a -> var(--b) and --b -> var(--a)
+```
+
+Implement:
+
+- resolving-name stack for cycle detection
+- explicit recursion depth limit, for example `20`
+- invalid declaration on cycle/depth limit
+- deduped dev warning
+
+Unresolved vars invalidate only the containing declaration.
+
+Do not support stylesheet custom property declarations initially:
+
+```css
+.root {
+ --button-bg: red;
+ background: var(--button-bg);
+}
+```
+
+Do not treat `:root { --x: ... }` as defaults. Ignore with dev warning. Use
+`setDefaultVariables()` for defaults.
+
+### `calc()`
+
+Support limited `calc()` where the final expression can be reduced safely after
+vars/interpolation/viewport units are resolved:
+
+```css
+width: calc(100vw - 16px);
+margin-left: calc(var(--spacing, 8px) * 2);
+```
+
+Do not attempt full browser layout math:
+
+```css
+width: calc(100% - 16px);
+```
+
+Unsupported `calc()`:
+
+- throws in build mode if fully static
+- drops declaration in runtime mode or if dynamic
+- emits `UNSUPPORTED_CALC`
+
+### Viewport Units
+
+Support:
+
+- `vw`
+- `vh`
+- `vmin`
+- `vmax`
+
+Resolve at the declaration-value layer before property transformation. Viewport
+unit users depend on debounced dimension changes.
+
+### `u` Unit
+
+Preserve current CSSX semantics:
+
+```text
+1u = 8px
+```
+
+The new resolver should handle `u` consistently before final RN/web output.
+
+## Media Queries
+
+Store media conditions on rules:
+
+```ts
+{
+ selector: '.button',
+ classes: ['button'],
+ part: null,
+ specificity: 1,
+ order: 4,
+ media: '@media (min-width: 600px)',
+ declarations: [...]
+}
+```
+
+Do not use a separate nested style map in canonical IR.
+
+Rule filtering order:
+
+1. match selector/classes/part
+2. evaluate media condition
+3. resolve active declarations
+
+Inactive media rules must not contribute variable dependencies.
+
+Target optimization:
+
+- media subscribers rerender only when query match result changes
+- viewport unit subscribers rerender when debounced dimension values change
+
+First milestone may use a simpler debounced dimension version for all media and
+viewport dependencies if needed, but the target should be query-match-based
+media invalidation.
+
+Web:
+
+- use `window.matchMedia(query).change` for media query subscriptions when
+ available
+- use debounced `resize` for viewport units
+- SSR/no-window falls back to configured defaults and no-op subscriptions
+
+React Native:
+
+- use `Dimensions` for width/height
+- reevaluate query matches after dimension changes
+
+Dimension listener initialization:
+
+- platform entrypoint installs adapter automatically
+- actual listeners start lazily on first dimension/media subscription
+- listeners stop when last dimension subscriber unsubscribes, if possible
+
+Dimension notification debounce:
+
+- leading notification for immediate rotation/orientation response
+- trailing notification after resize settles
+- default around `100ms`
+- configurable later through provider or singleton config
+
+## Keyframes, Animations, And Transitions
+
+CSSX should emit Reanimated v4-compatible style props only. It should not own
+animation execution, hooks, or animated component wrappers.
+
+Users write:
+
+```tsx
+import Animated from 'react-native-reanimated'
+
+
+```
+
+CSSX emits style props that Reanimated v4 understands.
+
+### Keyframe IR
+
+Store `@keyframes` separately:
+
+```ts
+{
+ keyframes: {
+ fade: [
+ { selector: 'from', declarations: [...] },
+ { selector: 'to', declarations: [...] }
+ ]
+ },
+ rules: [...]
+}
+```
+
+Keyframe declarations use the same dynamic value pipeline:
+
+- `var()` supported
+- interpolation supported when the keyframes are inside a local interpolated
+ template
+- invalid dynamic keyframe declarations are dropped at runtime
+
+Animation declarations resolve names to keyframe objects after dynamic value
+resolution:
+
+```css
+.button {
+ animation: fade var(--duration, 200ms) ease;
+}
+```
+
+Result includes:
+
+```js
+{
+ animationName: { from: {...}, to: {...} },
+ animationDuration: '200ms',
+ animationTimingFunction: 'ease'
+}
+```
+
+Support comma-separated multi-values from the first implementation:
+
+```css
+transition: background 0.2s, transform 0.1s, opacity 0.3s;
+animation: fadeIn 300ms ease, slideIn 500ms ease-out;
+```
+
+## Property Transformation
+
+The property transformer should be designed around final resolved CSS values.
+It can selectively reuse code from `../css-to-react-native`, but should not be
+constrained by the old architecture.
+
+Keep or add support for:
+
+- all existing supported RN style props
+- shorthand expansion
+- border shorthand with dynamic width/color/style after var resolution
+- padding/margin/border radius/width/color/style shorthands
+- transform
+- text-shadow
+- box-shadow
+- animation and transition shorthands/longhands
+- keyframe object inlining
+- `filter`
+- `background-image`
+- limited `background` shorthand
+
+### Box Shadow
+
+React Native now supports web-style `boxShadow` strings. Keep pass-through
+string output after resolving vars/interpolation/calc/viewport units:
+
+```css
+box-shadow: 0 2px 8px rgba(0,0,0,.2), 0 1px 2px #333;
+```
+
+outputs:
+
+```js
+{ boxShadow: '0 2px 8px rgba(0,0,0,.2), 0 1px 2px #333' }
+```
+
+### Filter
+
+React Native supports CSS-like filter strings. Pass through as string after
+value resolution:
+
+```css
+filter: blur(4px) brightness(0.8);
+```
+
+outputs:
+
+```js
+{ filter: 'blur(4px) brightness(0.8)' }
+```
+
+### Background Image
+
+React Native supports the style prop:
+
+```js
+experimental_backgroundImage
+```
+
+for web-like `linear-gradient()` and `radial-gradient()` strings.
+
+CSS:
+
+```css
+background-image: linear-gradient(90deg, red, blue);
+```
+
+React Native output:
+
+```js
+{ experimental_backgroundImage: 'linear-gradient(90deg, red, blue)' }
+```
+
+Web output:
+
+```js
+{ backgroundImage: 'linear-gradient(90deg, red, blue)' }
+```
+
+Use generic kebab-to-camelCase for properties, then special-case
+`backgroundImage` to `experimental_backgroundImage` for React Native target.
+
+Supported background image functions:
+
+- `linear-gradient()`
+- `radial-gradient()`
+
+Unsupported:
+
+- `url(...)`
+- image-set
+- other image functions
+
+Unsupported background images are dropped with `UNSUPPORTED_BACKGROUND_IMAGE`.
+
+Multiple gradients must be preserved as a comma-separated string:
+
+```css
+background-image:
+ linear-gradient(0deg, white, rgba(238, 64, 53, 0.8), rgba(238, 64, 53, 0) 70%),
+ linear-gradient(45deg, white, rgba(243, 119, 54, 0.8), rgba(243, 119, 54, 0) 70%);
+```
+
+### Background Shorthand
+
+Support a limited useful subset:
+
+```css
+background: red;
+background: linear-gradient(90deg, red, blue);
+background: red linear-gradient(90deg, red, blue);
+background: linear-gradient(...), radial-gradient(...);
+```
+
+Output:
+
+- color-only -> `backgroundColor`
+- gradient-only -> `backgroundImage` / `experimental_backgroundImage`
+- color + gradient -> both
+
+Unsupported:
+
+```css
+background: url(foo.png);
+background: no-repeat center/cover red;
+background: fixed border-box red;
+```
+
+Do not implement full browser background shorthand.
+
+## Runtime Store And React Tracking
+
+Replace the dependency on:
+
+- `teamplay`
+- `teamplay/cache`
+- `@nx-js/observer-util`
+
+with a small CSSX-owned store and React integration.
+
+### Variable Store
+
+Preserve current public API:
+
+```ts
+variables['--text'] = '#111'
+delete variables['--text']
+Object.assign(variables, theme)
+setDefaultVariables({ '--text': '#111' })
+```
+
+Implement `variables` as an internal `Proxy` over a plain object:
+
+- detects `set`
+- detects `deleteProperty`
+- records changed variable names
+- batches notifications in a microtask
+- notifies only subscribers interested in changed names
+
+Use microtask batching:
+
+```ts
+Object.assign(variables, {
+ '--bg': 'black',
+ '--text': 'white'
+})
+```
+
+should notify once with `['--bg', '--text']`, not once per assignment.
+
+Variables remain global singleton state initially. Provider-scoped variables are
+out of scope.
+
+### Runtime Options
+
+Defaults must work without any setup.
+
+Optional configuration paths:
+
+```tsx
+
+
+
+```
+
+and:
+
+```ts
+configureCssx({ dimensionsDebounceMs: 100 })
+```
+
+Provider is for React tree options. Singleton config is for early app-wide setup.
+
+### Tracked Sheet Wrapper
+
+Manual runtime CSS should stay ergonomic:
+
+```tsx
+const sheet = useRuntimeCss(generatedCss)
+
+return (
+ <>
+
+
+ >
+)
+```
+
+`useRuntimeCss()` returns a tracked wrapper, not the plain JSON IR. The wrapper:
+
+- contains or references the compiled sheet
+- holds a render-local dependency collector
+- owns the React external-store subscription
+- records dependencies from every `cssx()` call during render
+
+`cssx()` itself:
+
+- does not call hooks
+- can be used inline in JSX spreads
+- resolves style props
+- records exact dependencies into the tracked wrapper if present
+
+### React Subscription Lifecycle
+
+Use `useSyncExternalStore` for external store subscriptions.
+
+Important constraints:
+
+- no global subscription mutation during render
+- render-time dependency collection stays local to the tracked wrapper
+- global subscriber registry is mutated only through hook subscribe/unsubscribe
+ lifecycle
+- aborted/suspended renders must not leak subscriptions
+
+Algorithm target:
+
+1. Hook creates a tracker/wrapper.
+2. Before each render, tracker starts a new dependency collection.
+3. Each inline `cssx()` call resolves styles and records used dependencies:
+ - variable names and their versions
+ - media query IDs/match state
+ - viewport dimension dependency if used
+4. After commit, an effect commits the collected dependency set.
+5. `useSyncExternalStore` subscription listens only for changes intersecting the
+ committed dependency set.
+6. If a dependency changed between render and effect commit, trigger one
+ corrective rerender.
+
+Race safeguard:
+
+- tracker records store version snapshot used during render
+- commit effect compares against current versions
+- if changed, force a rerender so no variable/media change is missed
+
+Memory safety:
+
+- suspended/aborted renders may collect dependencies locally, but never register
+ them globally
+- previous committed subscription remains active until React commits a new one or
+ unmounts
+- tests must cover promise-throwing Suspense renders where effects do not run
+
+### Babel-Compiled Usage
+
+Users still write:
+
+```tsx
+
+```
+
+Babel hides the hook:
+
+```tsx
+function Button() {
+ const __cssxSheet = useCssxSheet(__sheet)
+ return
+}
+```
+
+Babel should inject the hook only when a component's styles can depend on
+runtime state:
+
+- stylesheet uses `var()`
+- stylesheet uses media queries
+- stylesheet uses viewport units
+- local template has interpolations
+- dynamic interpolation could introduce `var(...)`
+
+Static-only styles should not pay a subscription cost.
+
+For any interpolation, always use the tracked runtime path, even if Babel sees a
+literal expression. A string literal can still be `var(--x)`.
+
+Dependency tracking must happen after selector matching and active media
+filtering, so unused selectors do not cause rerenders:
+
+```css
+.root { color: var(--root-color); }
+.label { color: var(--label-color); }
+```
+
+Resolving `styleName="root"` subscribes to `--root-color`, not `--label-color`.
+
+If an interpolation value introduces a variable:
+
+```tsx
+css`
+ .root { color: ${color}; }
+`
+```
+
+and `color === 'var(--button-color)'`, the component subscribes to
+`--button-color`. If later `color === 'red'`, it stops depending on that
+variable after commit.
+
+## Caching
+
+The new engine owns caching directly. Teamplay cache is not needed.
+
+### Static Sheet Result Cache
+
+Static/imported/runtime-generated sheets can be shared by many elements and
+style names. Use a bounded per-sheet result cache.
+
+Target default:
+
+```text
+max 100 resolved entries per sheet
+```
+
+Make the exact size internal initially or configurable later.
+
+Cache key includes only values that affect the resolved element:
+
+- normalized `styleName`
+- sheet ID/hash
+- active layer IDs
+- relevant CSS variable values discovered while resolving matched declarations
+- relevant media query match state
+- dimension values only if viewport units are used
+- inline style props hash
+
+Do not invalidate because an unrelated selector uses an unrelated variable.
+
+### Interpolated Template Cache
+
+Interpolated local templates must keep only one last-result slot:
+
+```ts
+{
+ lastValues,
+ lastResult
+}
+```
+
+If values are the same by `Object.is` array equality, return the same result
+reference. If values change, recompute and replace the previous slot. If values
+later change back to an old value, recompute instead of keeping historical
+variants.
+
+### Raw String Convenience Cache
+
+`cssx('root', generatedCss)` is allowed for convenience. It should internally
+cache only the last raw CSS string and compiled sheet:
+
+```ts
+lastCssString
+lastCompiledSheet
+```
+
+Users who need stronger caching should use:
+
+```ts
+const sheet = useRuntimeCss(generatedCss)
+```
+
+or:
+
+```ts
+const sheet = useMemo(() => compileCss(generatedCss), [generatedCss])
+```
+
+### Inline Style Props Hash
+
+Use value hashing by default for inline style props, matching current CSSX
+ergonomics.
+
+Current behavior uses:
+
+```ts
+simpleNumericHash(JSON.stringify(inlineStyleProps))
+```
+
+Continue this direction:
+
+- use `JSON.stringify`
+- numeric hash is fine
+- do not require users to memoize inline style objects
+- fresh-but-equal inline object literals should hit cache
+
+If `JSON.stringify` throws on cycles, treat that inline input as uncacheable for
+that render and warn in dev.
+
+### Output Shape
+
+Resolved style props should be flattened plain objects, like today:
+
+Input:
+
+```js
+{ style: [{ color: 'red' }, { padding: 8 }] }
+```
+
+Output:
+
+```js
+{ style: { color: 'red', padding: 8 } }
+```
+
+This maximizes stable object identity.
+
+## Part Props
+
+Preserve `part="root"` behavior:
+
+```tsx
+
+```
+
+maps to the normal `style` prop.
+
+Other parts map to:
+
+```text
+title -> titleStyle
+icon -> iconStyle
+hover -> hoverStyle
+active -> activeStyle
+```
+
+The IR can represent:
+
+- normal root styles as `part: null`
+- part styles as `part: 'title'`
+- pseudo aliases as `part: 'hover'` / `part: 'active'`
+
+## Stylus
+
+Stylus remains outside `@cssxjs/css-to-rn`.
+
+Pipeline:
+
+```text
+Stylus source -> CSS string -> compileCss()
+```
+
+Runtime compilation is CSS-only:
+
+```ts
+compileCss(generatedCss)
+```
+
+Do not support:
+
+```ts
+compileStyl(generatedStylus)
+```
+
+This keeps `stylus` out of client bundles.
+
+## Pug
+
+Pug style blocks continue to be transformed into local `css` or `styl`
+templates by the existing Pug/Babel path.
+
+Supported:
+
+```pug
+style(lang='styl')
+ .root
+ color var(--color, red)
+```
+
+Not supported initially:
+
+```pug
+style(lang='styl')
+ .root
+ color ${color}
+```
+
+Pug interpolation syntax is a separate feature and is out of scope.
+
+## Babel And Loader Integration
+
+### Inline Template Plugin
+
+Update `packages/babel-plugin-rn-stylename-inline`:
+
+- stop rejecting all template expressions
+- allow expressions only in function-scoped templates
+- lower expressions to synthetic dynamic var tokens
+- for CSS templates, compile tokenized CSS
+- for Stylus templates, run tokenized Stylus through Stylus first, then compile
+ CSS
+- validate slot positions during compilation
+- hoist static compiled sheet IR after imports
+- inject local runtime hook when needed
+- pass ordered expression array to `useCssxTemplate()`
+
+Conceptual output:
+
+```tsx
+const __sheet = { /* compiled IR */ }
+
+function Button({ color }) {
+ const __CSS_LOCAL__ = useCssxTemplate(__sheet, [color])
+ return
+}
+```
+
+The actual generated code may use different internal variable names, but should
+preserve current user-facing behavior.
+
+Global/module-level templates:
+
+- remain static-only
+- expressions are unsupported
+
+### StyleName Plugin
+
+Update `packages/babel-plugin-rn-stylename-to-style`:
+
+- use new resolver/runtime imports
+- support canonical IR layers
+- preserve file < global < local < inline precedence
+- continue converting `styleName` and `*StyleName`
+- continue handling `part`
+- hide injected hooks from users
+- no longer require `observer()` or teamplay detection for caching
+
+The existing `cache: 'teamplay'` option should become deprecated/no-op or be
+removed in a breaking release path.
+
+### External Imports
+
+External `.cssx.css` and `.cssx.styl` imports should converge on canonical IR.
+
+Migration can use `toLegacyStyleObject()` temporarily, but long term:
+
+```tsx
+import styles from './button.cssx.styl'
+
+
+```
+
+where `styles` is canonical compiled sheet IR.
+
+Build-time compilers must use strict mode:
+
+```ts
+compileCss(css, { mode: 'build' })
+```
+
+### Loaders
+
+Update `packages/loaders/cssToReactNativeLoader.js` to use
+`@cssxjs/css-to-rn` instead of `@startupjs/css-to-react-native-transform`.
+
+Update compiler wrappers in `packages/loaders/compilers/` to emit either:
+
+- canonical IR, once runtime is migrated
+- legacy object shape during the transition
+
+### Umbrella Package
+
+Update `packages/cssxjs`:
+
+- re-export new APIs
+- update runtime conditional exports to point to new platform runtime
+- preserve public import paths where possible
+
+## Legacy Adapter
+
+Include a legacy object-shape adapter for incremental migration:
+
+```ts
+toLegacyStyleObject(sheet)
+```
+
+Output shape:
+
+```js
+{
+ root: { paddingTop: 8 },
+ 'root::part(label)': { color: 'red' },
+ __hash__: 123,
+ __vars: ['--color'],
+ __hasMedia: true
+}
+```
+
+Use this only as a bridge. The canonical rule/declaration IR is the target.
+
+## Tests
+
+Use the same broad test setup pattern as `../teamplay/packages/teamplay`:
+
+- Mocha for source-level engine/isomorphic tests
+- Jest + jsdom for React tests
+- Node `-C cssx-ts` custom condition for direct TS source tests
+- TypeScript type tests/build tests
+
+React integration tests should target React 19 only. Upgrade dev/test deps on
+this branch as needed.
+
+### Test Scripts
+
+Approximate package scripts:
+
+```json
+{
+ "test": "npm run test-engine && npm run test-react && npm run test-types",
+ "test-engine": "NODE_OPTIONS=\"${NODE_OPTIONS:-} -C cssx-ts\" mocha 'test/[!_]*.test.ts'",
+ "test-react": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --experimental-vm-modules -C cssx-ts\" jest --runInBand",
+ "test-types": "tsc -p tsconfig.json --noEmit",
+ "build": "tsc -p tsconfig.build.json"
+}
+```
+
+Exact paths can follow the package layout.
+
+### Pure Engine Tests
+
+Port and expand tests from:
+
+- `../css-to-react-native/src/__tests__`
+- `../css-to-react-native-transform/src/index.spec.js`
+- `packages/runtime/test/process.mjs`
+- `packages/runtime/test/matcher.mjs`
+
+Cover:
+
+- property name normalization
+- raw value transforms
+- unit conversion
+- shorthand expansion
+- border shorthand including dynamic width/color/style after var resolution
+- margin/padding/radius/width/color/style shorthands
+- transform
+- text-shadow
+- box-shadow string pass-through
+- filter string pass-through
+- background-image platform mapping
+- background shorthand limited support
+- unsupported background images
+- animations
+- transitions
+- comma-separated animation/transition values
+- keyframes
+- keyframes with vars
+- keyframes with interpolation slots
+- media query parsing and validation
+- viewport units
+- limited calc
+- `u` unit
+- CSS variables:
+ - runtime value
+ - default value
+ - inline fallback
+ - nested fallback
+ - unresolved
+ - cycles
+ - depth limit
+ - variable inside whole shorthand
+ - variable inside shorthand part
+ - variable inside comma chunk
+ - variable inside complex functions
+- interpolation:
+ - CSS templates
+ - Stylus templates
+ - primitive values
+ - `null` / `undefined` / `false`
+ - invalid `true`
+ - invalid objects/arrays/functions/symbols/bigint
+ - interpolated `var(...)`
+ - unsupported selector/property/media/export positions
+- selectors:
+ - class
+ - multi-class
+ - `:part()`
+ - `::part()`
+ - `:hover`
+ - `:active`
+ - unsupported descendants/IDs/attrs/pseudos
+- cascade:
+ - specificity
+ - source order
+ - declaration fallback when dynamic declaration invalid
+ - file/global/local/inline precedence
+- diagnostics:
+ - stable codes
+ - line/column
+ - empty sheet on runtime syntax error
+ - strict throw in build mode
+ - warning dedupe
+- legacy adapter output
+
+### Cache Tests
+
+Add focused reference-stability tests:
+
+- same static `styleName` returns same result object
+- fresh-but-equal inline object returns same result object due to JSON hash
+- changed inline object invalidates
+- unrelated variable change does not invalidate or rerender
+- used variable change invalidates
+- inactive media variable does not subscribe or invalidate
+- media query match changes invalidate
+- viewport unit dimension changes invalidate
+- static sheet bounded cache evicts predictably
+- interpolated sheet stores only one previous value set
+- interpolation same primitive values returns same references
+- interpolation changed values replace previous cache slot
+- interpolation changed back recomputes rather than using historical cache
+- raw CSS string convenience caches only one compiled string
+
+### React Integration Tests
+
+Use Jest/jsdom and React 19.
+
+Cover:
+
+- `useRuntimeCss()` returns tracked wrapper
+- inline `
` records dependencies
+- multiple `cssx()` calls in one component union dependencies
+- components rerender only for used variables
+- components do not rerender for unused variables
+- interpolation values that introduce `var()` dynamically update subscriptions
+- interpolation values that stop using `var()` remove subscriptions after commit
+- microtask batching of `variables` changes
+- dimension leading/trailing debounce
+- web `matchMedia` query subscriptions
+- viewport unit resize subscriptions
+- SSR/no-window fallback behavior
+- unmount cleanup
+- Suspense-aborted render does not leak subscriptions
+- promise thrown during rerender where effect does not run destructor does not
+ leak new subscriptions
+- stale-check rerenders if variable changes between render and effect commit
+- subscriber counts are observable in tests through internal test-only helpers
+
+### Babel Tests
+
+Update snapshot tests for:
+
+- static inline templates
+- interpolated local CSS templates
+- interpolated local Stylus templates
+- rejection of global template interpolation
+- rejection of unsupported interpolation positions
+- `styleName` transform with injected hook only when needed
+- static template with no hook
+- external imports with canonical IR or legacy bridge
+- `:hover` / `:active` output
+- `part="root"` behavior
+- Pug style blocks still lowering to CSS/Stylus templates
+
+## Migration Milestones
+
+### Milestone 1: Package Scaffold And Test Harness
+
+- Create `packages/css-to-rn`.
+- Add TS package config, build config, source condition, exports.
+- Add Mocha source tests and Jest React test setup mirroring Teamplay.
+- Add initial type declarations through TS source.
+- Add copied/adapted test fixtures from forks and current runtime.
+- Do not change existing production runtime yet.
+
+Exit criteria:
+
+- package tests run against TS source
+- package builds `.js` and `.d.ts`
+- test scaffold includes expected failing tests for new behavior
+
+### Milestone 2: Pure Compiler IR
+
+- Implement lightweight CSS parse path.
+- Implement selector parser/validator.
+- Implement rule/declaration/keyframe IR.
+- Implement metadata and diagnostics.
+- Implement build/runtime modes.
+- Implement path-private hashes.
+- Implement `:export` static-only.
+- Implement unsupported selector diagnostics.
+- Implement legacy adapter enough to compare with current output.
+
+Exit criteria:
+
+- static CSS fixtures compile to expected IR
+- diagnostics work in runtime-safe and build-strict modes
+- legacy adapter matches current static behavior for core cases
+
+### Milestone 3: Value Resolver And Property Transformer
+
+- Implement interpolation slot representation.
+- Implement recursive `var()` resolver with cycles/depth.
+- Implement declaration invalidation.
+- Implement limited `calc()`.
+- Implement viewport and `u` unit handling.
+- Implement or adapt property transforms.
+- Add new properties:
+ - `filter`
+ - `background-image`
+ - limited `background`
+- Implement animations/transitions/keyframes.
+
+Exit criteria:
+
+- forked property tests pass or have intentional documented deltas
+- complex var/shorthand tests pass
+- Reanimated v4 animation style output matches docs
+
+### Milestone 4: Pure Resolver And Caching
+
+- Implement `resolveCssx()` over sheet layers.
+- Implement specificity/source-order cascade.
+- Implement media filtering.
+- Implement dependency reporting from resolution.
+- Implement per-sheet bounded cache.
+- Implement single-entry interpolation cache.
+- Implement inline style JSON hash.
+- Implement raw string single-entry compile cache.
+- Implement flattened output props.
+
+Exit criteria:
+
+- cache reference tests pass
+- dependency-specific invalidation tests pass
+- current matcher/process behavior is covered by new tests
+
+### Milestone 5: React Runtime Integration
+
+- Implement variable store proxy.
+- Implement default variables.
+- Implement microtask batching.
+- Implement platform dimension adapters.
+- Implement web `matchMedia` support.
+- Implement React tracked sheet wrapper.
+- Implement `useRuntimeCss()`, `useCssxSheet()`, `useCssxTemplate()`.
+- Implement `CssxProvider` and `configureCssx()`.
+- Implement Suspense-safe subscription lifecycle.
+
+Exit criteria:
+
+- React tests pass
+- no `observer()` needed
+- no `teamplay` needed
+- no `@nx-js/observer-util` needed
+
+### Milestone 6: Babel And Loader Migration
+
+- Update inline template plugin for interpolation lowering.
+- Update styleName plugin for new resolver/hook path.
+- Update loaders to call `@cssxjs/css-to-rn`.
+- Keep legacy adapter bridge if needed.
+- Update package dependencies.
+- Update `cssxjs` public exports and conditional runtime exports.
+
+Exit criteria:
+
+- existing Babel snapshots updated
+- example app works
+- CSS variables/media no longer need `observer()`
+- static style behavior remains compatible
+
+### Milestone 7: Runtime Package Cleanup
+
+- Remove duplicated logic from `packages/runtime`.
+- Either:
+ - turn it into a compatibility wrapper around the new package, or
+ - remove it from internal generated imports and keep only if publishing
+ compatibility requires it
+- Remove `teamplay` cache integration.
+- Remove `@nx-js/observer-util` dependency.
+- Update docs that currently mention teamplay caching.
+
+Exit criteria:
+
+- public `cssxjs` API works without teamplay
+- docs no longer require `observer()`
+- package dependency graph no longer includes removed runtime deps unless another
+ package still genuinely needs them
+
+### Milestone 8: Docs And Examples
+
+Update docs for:
+
+- interpolation
+- runtime `compileCss()`
+- `cssx()` and `useRuntimeCss()`
+- diagnostics for AI-generated CSS
+- no-observer variable/media rerendering
+- caching behavior
+- `:hover` / `:active` part aliases
+- `filter`
+- `background-image`
+- Reanimated v4 animation expectations
+
+Update examples to demonstrate:
+
+- local interpolation
+- AI-generated CSS runtime use
+- variables without `observer()`
+- media query updates without teamplay
+
+## Implementation Notes
+
+### Avoiding The Old Split
+
+Do not recreate the old three-package architecture inside one package. Use the
+old packages as:
+
+- test sources
+- known-good code snippets
+- behavior references
+
+Build the new architecture around:
+
+- canonical IR
+- value-layer dynamic resolution
+- dependency-aware resolver
+- direct cache ownership
+- React tracked wrapper integration
+
+### Build-Time Versus Runtime Behavior
+
+The same compiler powers both:
+
+- Babel/loaders
+- runtime AI-generated CSS
+
+But options differ:
+
+```ts
+compileCss(css, { mode: 'build' }) // strict, throw
+compileCss(css, { mode: 'runtime' }) // default, graceful diagnostics
+```
+
+### React 19
+
+Only support React 19 going forward for the new runtime integration. Use
+`useSyncExternalStore` for external subscriptions. `use(context)` may be useful
+for reading provider options, but it does not replace external store
+subscription.
+
+### Platform Targets
+
+The engine should understand target platform:
+
+```ts
+platform: 'ios' | 'android' | 'web'
+reactType: 'react-native' | 'web'
+```
+
+This matters for:
+
+- `experimental_backgroundImage` vs `backgroundImage`
+- pure web line-height string handling
+- platform-specific future behavior
+
+### Current Public Behavior To Preserve
+
+- `styleName` accepts string, arrays, and object flags.
+- `styleName` class matching supports multi-class selectors.
+- `part` prop injects part style props into component props.
+- `part="root"` maps to `style`.
+- file/global/local/inline precedence stays the same.
+- static styles continue to be build-time compiled.
+- Stylus global imports/preprocessing stay outside pure CSS engine.
+
+## Open Implementation Choices
+
+These are left to implementation judgment:
+
+- exact internal names for hooks and generated variables
+- exact cache size defaults
+- exact hash function, as long as deterministic and small
+- exact parser abstraction around `css/lib/parse`
+- exact diagnostic message wording
+- whether legacy adapter is used only in tests or also during migration
+
+Do not reopen the high-level decisions in this document unless implementation
+reveals a concrete blocker.
+
+## CSS-First StartupJS UI Refactor Workstream
+
+This workstream supersedes and refines the earlier StartupJS UI migration and
+optional Tailwind notes in the "Global Theming And Provider Styles Workstream".
+The earlier unified CSS engine work remains valid; this section defines the next
+large batch: move StartupJS UI away from Stylus, old palette helpers, separate
+component style files, and startupjs-ui-owned style primitives.
+
+Treat the CSSX, StartupJS, and StartupJS UI PRs as connected draft PRs during
+this work. It is acceptable to keep temporary cross-repo development wiring,
+such as `resolutions`, local file links, or linked packages, while implementing
+and validating the batch. Remove that temporary wiring in a final cleanup pass
+before the PRs become merge-ready. Commit and push regularly as meaningful
+sub-batches land.
+
+### Goals
+
+- Use CSSX standards-oriented CSS as the foundation for StartupJS UI styling.
+- Replace Stylus functions, mixins, `$UI` config, and `u`-based scales with CSS
+ variables, `rem`, `calc()`, `oklch()`, `color-mix()`, `@custom-media`, and
+ component tag/part overrides.
+- Use Tailwind CSS variables as the raw token scale.
+- Use shadcn semantic variables as the primary theme override surface.
+- Let StartupJS UI components consume semantic `--color-*`, `--spacing`,
+ `--radius-*`, `--text-*`, breakpoint, and component-specific variables.
+- Keep Tailwind utility class runtime support optional and separate from the
+ token/theme preset.
+- Move generic style-related JS bridges into CSSX and re-export them from
+ `startupjs`.
+- Remove style-related helpers from `@startupjs-ui/core`.
+- Inline StartupJS UI component styles into component files instead of separate
+ `.cssx.styl` / `.cssx.css` files.
+- Keep `@startupjs-ui/core` only as a small shared non-style type/helper package,
+ currently for `UIRole`.
+
+### Non-Goals
+
+- Do not make bare StartupJS include Tailwind/shadcn themes by default.
+- Do not make StartupJS UI depend on Tailwind utility classes internally.
+- Do not implement the optional Tailwind utility runtime in this batch unless it
+ becomes necessary for validation.
+- Do not preserve old StartupJS UI theme token names as the new API. This is a
+ breaking release; users should migrate to the new tokens.
+- Do not support Tailwind's non-standard `@theme` syntax in the CSSX compiler.
+ Theme assets are plain CSSX-compatible CSS.
+
+### CSSX Theme Assets
+
+CSSX should ship reusable theme asset entrypoints:
+
+```ts
+import tailwindTheme from 'cssxjs/themes/tailwind'
+import shadcnTheme from 'cssxjs/themes/shadcn'
+```
+
+The entrypoints internally export readable `.cssx.css` assets. Do not document
+direct `.cssx.css` imports as public API. Docs can link to the source files as
+the reference for all customizable variables.
+
+Theme source files:
+
+- `tailwind.cssx.css`
+ - manually copied/adapted from the current Tailwind `theme.css`
+ - all Tailwind variables included where CSSX can reasonably represent them
+ - plain `:root` CSS variables, no `@theme`
+ - transform Tailwind-specific `--theme(...)` expressions into plain
+ CSS-compatible variable/fallback expressions or omit only truly irrelevant
+ unsupported pieces
+- `shadcn.cssx.css`
+ - one default shadcn theme with a dark variant
+ - light/default values in `:root`
+ - dark values in `:root.dark`
+ - semantic override tokens such as `--primary` and `--primary-foreground`
+ - Tailwind consumption mappings such as `--color-primary:
+ var(--primary)` and `--color-primary-foreground:
+ var(--primary-foreground)`
+
+Do not add an auto-regeneration script. The Tailwind and shadcn variable names
+are stable enough for manual updates. Add source comments to the files so future
+updates are reviewable.
+
+StartupJS UI should not duplicate the full Tailwind/shadcn files. It should use
+CSSX theme entrypoints and own only its component-specific theme layer.
+
+### Theme Selection Model
+
+`CssxProvider` owns theme selection:
+
+```tsx
+
+
+
+```
+
+`theme` values:
+
+- `auto`: default. Uses OS color scheme and applies `dark` if the active
+ provider styles define a dark theme block.
+- `dark`: applies `:root` plus `:root.dark`.
+- `default`: applies only `:root`.
+- `light`: alias for default unless an explicit `:root.light` block exists.
+- any custom name: applies `:root` plus `:root.`.
+
+Theme variable blocks:
+
+```css
+:root {
+ --background: oklch(1 0 0);
+ --color-background: var(--background);
+}
+
+:root.dark {
+ --background: oklch(0.145 0 0);
+ --color-background: var(--background);
+}
+```
+
+Rules:
+
+- `:root` and `:root.` are variable blocks only.
+- Only CSS custom property declarations are valid inside them.
+- Normal declarations inside root/theme blocks produce diagnostics and are
+ ignored.
+- Provider layers collect `:root` and `:root.` variables into scoped
+ provider variable maps.
+- Component-local sheets should not be used to define global/provider theme
+ variables. Emit a diagnostic if a component-local sheet contains root/theme
+ custom properties unless it is explicitly used as a provider layer.
+- Bare CSSX has no bundled variables, so `theme='auto'` is a no-op until the app
+ provides a style layer with `:root.dark`.
+
+OS color-scheme integration:
+
+- Web: `matchMedia('(prefers-color-scheme: dark)')`.
+- React Native: `Appearance.getColorScheme()` and
+ `Appearance.addChangeListener`.
+- Batch theme-change invalidation through the same runtime store/subscription
+ system used for variables, media, and dimensions.
+- Providers with explicit themes do not need OS color-scheme subscriptions.
+- Providers with `theme='auto'` subscribe and update when OS color scheme
+ changes.
+
+### Theme-Specific Styles
+
+Theme-specific normal styles use built-in CSSX theme media aliases, not root
+theme blocks:
+
+```css
+Button {
+ box-shadow: var(--shadow-sm);
+}
+
+@media (--theme-dark) {
+ Button {
+ box-shadow: none;
+ border-color: var(--color-border);
+ }
+}
+```
+
+Rules:
+
+- `@media (--theme-dark)` matches active theme `dark`.
+- `@media (--theme-light)` and `@media (--theme-default)` match default/light.
+- `@media (--theme-)` matches a custom active theme name.
+- Built-in `--theme-*` aliases are dynamic and independent of user
+ `@custom-media` declarations.
+- User-defined `@custom-media --theme-*` should produce a diagnostic because it
+ collides with CSSX's built-in theme media namespace.
+- Theme media aliases compose with custom media and ordinary media:
+
+```css
+@media (--theme-dark) and (--breakpoint-desktop) {
+ Button { border-width: 1px; }
+}
+```
+
+### Custom Media And Breakpoints
+
+CSSX should support standard `@custom-media`:
+
+```css
+:root {
+ --mobile: 0rem;
+ --tablet: var(--breakpoint-md);
+ --desktop: var(--breakpoint-lg);
+ --wide: var(--breakpoint-xl);
+}
+
+@custom-media --breakpoint-mobile (width < var(--tablet));
+@custom-media --breakpoint-tablet (width >= var(--tablet));
+@custom-media --breakpoint-desktop (width >= var(--desktop));
+@custom-media --breakpoint-wide (width >= var(--wide));
+```
+
+Tailwind raw breakpoint variables should stay available:
+
+```css
+--breakpoint-sm: 40rem;
+--breakpoint-md: 48rem;
+--breakpoint-lg: 64rem;
+--breakpoint-xl: 80rem;
+--breakpoint-2xl: 96rem;
+```
+
+CSSX media evaluation must support Media Queries Level 4 range syntax for the
+common width/height comparisons used by custom media:
+
+- `(width >= 48rem)`
+- `(width > 48rem)`
+- `(width <= 48rem)`
+- `(width < 48rem)`
+- same for `height`
+
+If `css-mediaquery` cannot evaluate range syntax accurately, implement a small
+normalizer/evaluator for these comparisons instead of relying on lossy
+conversion.
+
+`useMedia()` moves to CSSX and is re-exported through `startupjs`.
+
+Behavior:
+
+- reads active `@custom-media` aliases from provider layers
+- includes built-in fallback aliases when none are defined:
+ - `mobile`: width < 48rem
+ - `tablet`: width >= 48rem
+ - `desktop`: width >= 64rem
+ - `wide`: width >= 80rem
+- provider-defined aliases override fallbacks
+- returns a map with normalized names:
+ - `--breakpoint-tablet` -> `media.tablet`
+ - `--compact` -> `media.compact`
+- subscribes only to media/dimension changes that affect the aliases read by the
+ hook
+
+### CSSX Color Bridge
+
+Move generic JS color bridging into CSSX and re-export it from `startupjs`.
+StartupJS UI should use this instead of `colorToRGBA` and token helpers from
+`@startupjs-ui/core`.
+
+Use a single API, not separate color and color-mix functions:
+
+```ts
+const color = useCssColor('primary')
+const foreground = useCssColor('primary-foreground')
+const subtle = useCssColor('primary', 0.15)
+const onWhite = useCssColor('primary', { mix: 0.3, with: 'white' })
+
+const globalColor = getCssColor('primary')
+```
+
+Input resolution:
+
+- `primary` -> `var(--color-primary)`
+- `primary-foreground` -> `var(--color-primary-foreground)`
+- `var(--custom)` -> exact expression
+- raw CSS color -> raw expression
+- `--primary` is ambiguous and should not be supported
+
+Mixing:
+
+- no mix returns the resolved RN-friendly color value
+- numeric mix such as `0.15` means 15%
+- string mix such as `'15%'` is allowed
+- object form supports `{ mix, with }`
+- default `with` is `transparent`
+- implemented through the same CSS value/color resolver as `color-mix()`
+
+Tracking:
+
+- `useCssColor()` is provider-aware and subscribes to all variables used by the
+ color expression and mix target.
+- `getCssColor()` is an imperative escape hatch for global/default variable
+ reads and non-React code. It is not provider-aware in the first batch.
+
+Prefer CSS `var()` and `color-mix()` in component stylesheets/templates. Use
+`useCssColor()` only for JS-only bridges to non-CSSX props or inline style
+composition that cannot be expressed cleanly in CSS.
+
+### Deprecated `u`
+
+Move JS `u()` to CSSX/startupjs as a deprecated helper:
+
+```ts
+u(1) // 8
+```
+
+Rules:
+
+- `1u === 0.5rem === 8px`
+- warn once in development: use `rem`, `var(--spacing)`, or CSS instead
+- keep it for migration of existing JS inline styles
+- StartupJS UI internals should stop using it
+
+CSSX should continue compiling existing CSS `u` units for compatibility, but
+emit deprecated-unit diagnostics in build mode. New StartupJS UI styles should
+use:
+
+- `rem`
+- `calc(var(--spacing) * n)`
+- Tailwind/shadcn/component CSS variables
+
+### Tailwind Utility Runtime
+
+The optional Tailwind utility runtime is a follow-up. This batch should prepare
+the interfaces and token model, but should not depend on utility classes.
+
+When implemented, `tailwind()` should be imported explicitly from a separate
+entrypoint and only bundled by clients that import it.
+
+Utility interoperability requirement:
+
+- utilities should read active `--color-*` variables dynamically
+- if provider styles define `--color-warning`, `bg-warning`, `text-warning`,
+ `border-warning`, etc. can resolve through that variable
+- this works even if `warning` is not in the original Tailwind config
+- cache invalidation must include the variables used by resolved utilities
+- arbitrary classes like `w-[15px]` belong to the optional utility runtime, not
+ the base theme layer
+
+StartupJS UI must not use Tailwind utility classes internally. It uses the same
+tokens through normal CSS.
+
+### StartupJS Provider Integration
+
+Bare StartupJS:
+
+- `StartupjsProvider style` feeds CSSX provider styles.
+- `StartupjsProvider theme` feeds CSSX theme selection.
+- Bare StartupJS does not include Tailwind/shadcn themes automatically.
+- Users can explicitly import CSSX theme entrypoints if they want them.
+
+StartupJS with startupjs-ui installed:
+
+- startupjs-ui plugin injects the internal `UiProvider` into
+ `StartupjsProvider`.
+- App users configure one place:
+
+```tsx
+