Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3fd5db7
codemods: scaffold @stylexjs/codemods package and docs skeleton
ahmedsadid Jul 18, 2026
ab45fb8
codemods: IR types, rewriter wrapper, seam enforcement tests
ahmedsadid Jul 18, 2026
0c52660
codemods: compile/lint/semantic-diff gates + fixture harness, each ga…
ahmedsadid Jul 18, 2026
d485a6c
codemods: IR-completeness harness stub (measured coverage, 0/5 at M0)
ahmedsadid Jul 18, 2026
b4c877f
codemods: core engine — buildIR (L4) + emit (L7), tested against hand…
ahmedsadid Jul 20, 2026
eb47bf7
codemods: Emotion adapter — detect (L2), read (L3), rewrite (L8), imp…
ahmedsadid Jul 20, 2026
dcb624c
codemods: semantic-diff gate — normalize comma-whitespace in values
ahmedsadid Jul 20, 2026
a8851f9
codemods: wire transform into fixture harness; un-skip checks 1 & 4; …
ahmedsadid Jul 20, 2026
9af4a55
codemods: real IR-completeness round-trip — stylexToIR reader + measu…
ahmedsadid Jul 20, 2026
ed567b5
codemods: referee (L5) — the two-order agreement check for conditions
ahmedsadid Jul 20, 2026
a467ce9
codemods: the flip + hover-guard in emit (L4/L7)
ahmedsadid Jul 20, 2026
400fb90
codemods: Emotion adapter parses conditions; transform runs the referee
ahmedsadid Jul 20, 2026
cb92622
codemods: M2 fixtures — hover/focus/media/pseudo-element convert; ref…
ahmedsadid Jul 20, 2026
74bf550
codemods: IR-completeness reader handles conditions — coverage 2/5 ->…
ahmedsadid Jul 20, 2026
44ee2db
codemods: normalize (L6) — physical to logical property mapping
ahmedsadid Jul 21, 2026
359f29a
codemods: postprocess (L10) — run StyleX's own eslint autofixes on th…
ahmedsadid Jul 21, 2026
59ad8eb
codemods: referee guards the sibling-media reorder hazard
ahmedsadid Jul 21, 2026
a6bed74
codemods: wire normalize (before referee) and postprocess (after prin…
ahmedsadid Jul 21, 2026
cef65c2
codemods: M3a fixtures — logical-properties converts; skip-sibling-me…
ahmedsadid Jul 21, 2026
aadb19e
codemods: semantic-diff gate canonicalizes box shorthands to physical…
ahmedsadid Jul 21, 2026
2fc5ef5
codemods: M3b fixtures — multi-value margin & padding shorthands convert
ahmedsadid Jul 21, 2026
eb7c9db
codemods: engine support for keyframes + identifier references
ahmedsadid Jul 21, 2026
8f2cc36
codemods: Emotion adapter converts object-form keyframes
ahmedsadid Jul 21, 2026
6d8a41a
codemods: semantic-diff compares @keyframes frame contents; allowlist…
ahmedsadid Jul 21, 2026
6cb0fa1
codemods: ADR-0001 — defer theme tokens to M6 (a trusted transformation)
ahmedsadid Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ module.exports = {
'**/*.d.ts',
'**/pages.gen.ts',
'**/devtools-extension/extension/**',
// codemod fixtures are deliberately non-conforming (broken-on-purpose
// inputs that the correctness gates must fail on)
'**/codemods/__fixtures__/**',
],
overrides: [
{
Expand Down
1 change: 1 addition & 0 deletions .flowconfig
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ module.name_mapper='^@stylexjs/babel-plugin$' -> '<PROJECT_ROOT>/packages/@style
module.name_mapper='^@stylexjs/stylex$' -> '<PROJECT_ROOT>/packages/@stylexjs/stylex/src/stylex.js'
module.name_mapper='^@stylexjs/shared$' -> '<PROJECT_ROOT>/packages/@stylexjs/shared/src/index.js'
module.name_mapper='^style-value-parser$' -> '<PROJECT_ROOT>/packages/style-value-parser/src/index.js'
module.name_mapper='^@stylexjs/eslint-plugin$' -> '<PROJECT_ROOT>/packages/@stylexjs/eslint-plugin/src/index.js'
; type-stubs
module.system.node.resolve_dirname=flow_modules
module.system.node.resolve_dirname=node_modules
Expand Down
17 changes: 17 additions & 0 deletions packages/@stylexjs/codemods/.babelrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module.exports = {
assumptions: {
iterableIsArray: true,
},
presets: [
[
'@babel/preset-env',
{
exclude: ['@babel/plugin-transform-typeof-symbol'],
targets: 'defaults',
},
],
'@babel/preset-flow',
'@babel/preset-react',
],
plugins: [['babel-plugin-syntax-hermes-parser', { flow: 'detect' }]],
};
100 changes: 100 additions & 0 deletions packages/@stylexjs/codemods/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Architecture

The codemod is a source-to-source compiler: styling code in, equivalent
StyleX out. It is built so that supporting a new source library never means
rebuilding the compiler — only writing a new front-end for it.

> **One engine, swappable readers.** A small library-specific **adapter**
> turns one styling library into a neutral style model (the IR). A single
> library-agnostic **core** turns that model into correct, lint-clean
> StyleX. To add Emotion, MUI, or CSS Modules you write an adapter — the
> engine never changes.

## The seam

| | Adapter (per source) | Core engine (shared) |
| --- | --- | --- |
| Owns | detect, read, rewrite call sites, import cleanup | IR build, conflict referee, value normalization, emit, registry, postprocess, gates |
| Knows about | one styling library's syntax | only the IR and StyleX |
| Changes when | a new source is added | StyleX itself gains a feature |

**Load-bearing invariant:** `src/core/` imports *nothing* from
`src/adapters/` and never touches a parser node — it only sees
declarations, the IR, and emitted StyleX fragments. Enforced by
`__tests__/seam-test.js` from commit one. The seam is crossed exactly
twice:

1. **Adapter → core:** a flat list of `declarations`
(`{ context, property, value }`) — "here is what styles exist".
2. **Core → adapter:** the **binding map** — "style site #1 becomes
`styles.button`" — which the adapter places back into that library's
idiom.

A second invariant keeps the AST toolkit swappable: only
`src/core/rewriter.js` may import jscodeshift/recast (also enforced by the
seam test).

## The IR (`src/core/ir.js`)

The IR is shaped like **StyleX's output** (property-grouped, conditions
nested inside a property), not like any source's input. A style either maps
into the IR (convertible) or it does not (flagged — never guessed); the
edge of the IR is the edge of what is automatable.

Its only two open axes are the ones StyleX itself grows along, both modeled
as variant types so growth stays additive:

- `Condition` — `pseudo-class` / `pseudo-element` / `at-rule` (later:
ancestor selectors, `@supports`, `@container`).
- `Value` — `static` and `first-that-works` today; a `dynamic`
(function-form) variant lands in v1.1.

Completeness is **measured, not asserted**: the IR-completeness harness
(`__tests__/ir-completeness-test.js`) round-trips valid StyleX style
objects through a test-only reader and reports a coverage percentage. An
IR gap is safe — in the pipeline the construct is flagged, never
emitted incorrectly.

## The gates (`src/core/gates/`)

Independent correctness checks, used as test assertions and as a runtime
safety net. Each is proven to FAIL on a deliberately-broken input
(`__fixtures__/broken/`) — a gate that never fails is worthless.

- **Compile** (`compile.js`) — output must compile through the real
`@stylexjs/babel-plugin`; also yields the plugin's rule metadata.
- **Lint** (`lint.js`) — output must pass every `@stylexjs/eslint-plugin`
rule at *error* with zero messages (zero autofixes needed).
- **Semantic diff** (`semanticDiff.js`) — net CSS before (from
`@emotion/serialize`, the source library's own ground truth) and after
(from babel-plugin metadata) must match across every condition
combination, minus an explicit allowlist of sanctioned diffs
(hover-guard, physical→logical). Its parsers throw on anything they
cannot provably represent, so an unparsable input can never diff clean
by accident.
- **Render gate** *(future, v2.0)* — Playwright computed-style diff in a
real browser, keeping the semantic-diff gate honest.

## Testing layers

1. **Gate self-tests** — each gate green on the trivial pair AND red on a
broken pair (`__tests__/gates-test.js`).
2. **Fixture tests** — the executable spec. Every
`__fixtures__/emotion/<name>/{input,expected}.js` pair must: match the
transform byte-exactly (after Prettier), compile, lint clean, and pass
the semantic diff (`__tests__/emotion-fixtures-test.js`).
3. **Engine unit tests** — hand-written IR fed to the engine (from M1),
proving the engine is source-neutral.
4. **Seam tests** — the architectural invariants, as assertions.
5. **IR-completeness** — the measured coverage number.

## Layer map

| Layer | Module |
| --- | --- |
| Parse/print wrapper | `src/core/rewriter.js` (jscodeshift, isolated) |
| IR types | `src/core/ir.js` |
| Gates | `src/core/gates/{compile,lint,semanticDiff}.js` |
| Emotion adapter | `src/adapters/emotion/` (M1+) |
| Engine (build IR, referee, normalize, emit) | `src/core/` (M1–M4) |
| CLI & dry-run report | `src/cli/` (M6) |
59 changes: 59 additions & 0 deletions packages/@stylexjs/codemods/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# @stylexjs/codemods

Codemods for migrating styling libraries to [StyleX](https://stylexjs.com) —
Emotion first, built as **one library-agnostic engine with swappable
per-library adapters**.

> **Status: pre-release, milestone M3 of the v1.0 (MVP) ladder.** The
> correctness harness — fixture tests plus three gates (compile, lint,
> semantic-diff) — is in place and proven; transforms land milestone by
> milestone. Converting today: static styles, self-targeting conditions
> (pseudo-classes/elements, media queries), physical→logical properties,
> multi-value shorthands, and object-form keyframes. Nothing here is published
> yet, and the package name/location is pending maintainer confirmation.

## Principles

- **Bail loudly.** Only provably-safe styles are converted. Anything else is
flagged with a `// TODO(stylex-migration): …` comment, and a file is
refused outright when a partial conversion could change rendering.
Wrong-but-plausible output is the one unacceptable result.
- **Gated output.** Every conversion must compile through
`@stylexjs/babel-plugin`, pass `@stylexjs/eslint-plugin` at *error* with
zero autofixes needed, and have semantically identical net CSS (checked
against Emotion's own serializer, minus an explicit allowlist:
hover-guard, physical→logical properties).

## Converts / flags / refuses (planned v1.0 scope)

| Bucket | Patterns |
| --- | --- |
| Converts | static `css={{…}}` / `css({})`; self-targeting pseudo-classes/elements; media queries and nesting; physical→logical properties; multi-value margin/padding shorthands; object-form `keyframes`; fallback arrays |
| Flags *(planned)* | template literals; dynamic styles; `styled(Component)`; out-of-element selectors; `<Global>`; `shouldForwardProp`; `!important` |
| Refuses | any file where partial conversion could change rendering; conflicting cascades (referee disagreement); ≥2 sibling media queries on one property; theme tokens (until M6 — see [ADR-0001](./docs/decisions/0001-tokens-are-a-trusted-transformation-deferred-to-config.md)) |

Theme tokens → `defineVars` is deferred to the configuration milestone (M6):
it is a *trusted* transformation (a token's value is external to the file, so
it cannot be verified by the semantic-diff gate) and requires the
user-authored `resolveValue` config.

## Compatibility

| | Version |
| --- | --- |
| Emits StyleX | 0.19.x |
| Reads Emotion | 10–11 (object syntax) |

See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the design.

## Development

```sh
cd packages/@stylexjs/codemods
yarn jest # run from the package cwd, not the repo root
yarn build
```

Fixture pairs live in `__fixtures__/emotion/<name>/{input,expected}.js`; set
`UPDATE_STYLEX_CODEMOD_FIXTURES=1` to regenerate expected files when a
change is intentional.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// DELIBERATELY BROKEN: `stylex.create` must be called with a statically
// analyzable object literal — a function call argument makes the real
// @stylexjs/babel-plugin throw. The compile gate must FAIL on this file.
import * as stylex from '@stylexjs/stylex';

function getStyles() {
return { badge: { color: 'red' } };
}

const styles = stylex.create(getStyles());

export default styles;
12 changes: 12 additions & 0 deletions packages/@stylexjs/codemods/__fixtures__/broken/lint-invalid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// DELIBERATELY BROKEN: compiles, but violates @stylexjs/eslint-plugin rules
// ('colr' is not a valid property; styles are never used). The lint gate
// must FAIL on this file.
import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
badge: { colr: 'red' },
});

export default function Badge() {
return styles != null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from 'react';
import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
alert: {
color: 'maroon',
fontWeight: 700,
},
});

export default function Alert() {
return <p {...stylex.props(styles.alert)}>Alert</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @jsxImportSource @emotion/react */
import * as React from 'react';
import { css } from '@emotion/react';

export default function Alert() {
return <p css={css({ color: 'maroon', fontWeight: 700 })}>Alert</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';

import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
field: {
color: {
default: 'black',
':focus': 'blue',
},

fontSize: {
default: null,
'@media (min-width: 600px)': 18,
},
},
});

export default function Field() {
return <input {...stylex.props(styles.field)} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** @jsxImportSource @emotion/react */
import * as React from 'react';

export default function Field() {
return (
<input
css={{
color: 'black',
':focus': { color: 'blue' },
'@media (min-width: 600px)': { fontSize: 18 },
}}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as React from 'react';

import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
link: {
color: {
default: 'blue',

'@media (hover: hover)': {
default: null,
':hover': 'navy',
},
},
},
});

export default function Link() {
return (
<a {...stylex.props(styles.link)} href="#top">
Link
</a>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @jsxImportSource @emotion/react */
import * as React from 'react';

export default function Link() {
return (
<a css={{ color: 'blue', ':hover': { color: 'navy' } }} href="#top">
Link
</a>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react';
import * as stylex from '@stylexjs/stylex';

const spin = stylex.keyframes({
from: {
transform: 'rotate(0deg)',
},

to: {
transform: 'rotate(360deg)',
},
});

const styles = stylex.create({
spinner: {
animationDuration: '1s',
animationName: spin,
},
});

export default function Spinner() {
return <div {...stylex.props(styles.spinner)}>Loading</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** @jsxImportSource @emotion/react */
import * as React from 'react';
import { keyframes } from '@emotion/react';

const spin = keyframes({
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' },
});

export default function Spinner() {
return (
<div css={{ animationName: spin, animationDuration: '1s' }}>Loading</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as React from 'react';

import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
sidebar: {
insetInlineStart: 0,
marginInlineStart: 16,
paddingInlineEnd: 8,
marginTop: 4,
},
});

export default function Sidebar() {
return <aside {...stylex.props(styles.sidebar)}>Sidebar</aside>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @jsxImportSource @emotion/react */
import * as React from 'react';

export default function Sidebar() {
return (
<aside css={{ marginLeft: 16, paddingRight: 8, left: 0, marginTop: 4 }}>
Sidebar
</aside>
);
}
Loading
Loading