Skip to content

feat(cli): doctor detects CSS theming escapes and swizzled components - #5260

Merged
cixzhang merged 3 commits into
docs/agent-theme-guide-linkfrom
feat/doctor-theme-drift
Aug 27, 2026
Merged

cixzhang merged 3 commits into
docs/agent-theme-guide-linkfrom
feat/doctor-theme-drift

Conversation

@josephfarina

Copy link
Copy Markdown
Contributor

Stacked on #5226. Review that one first; this diff is only the two new checks.

Summary

Two read-only, text-only astryx doctor checks for the cases where an app provably steps outside its theme. No modules are imported or evaluated and the scan is bounded, so both are cheap enough for the default run (~250ms added) and safe in CI.

CSS theming escapes covers three cases:

  • A write to a private --_* var. astryx theme build already rejects these in a theme; they are no more valid in raw CSS, so this is a fail to match.
  • A system token redefined in :root/html/:host. A theme applies inside @scope ([data-astryx-theme=...]), so a global definition sits outside it and overrides every theme at once.
  • The deprecated bare prop classes (.astryx-button.primary) in place of the reflected data attributes. The fix names the exact replacement selector.

Swizzled components fails on exactly one combination: ejected source that imports StyleX while no StyleX compiler is configured. That is not a build error — the component renders completely unstyled, with no warning — which makes it the most expensive thing here to find by hand. Otherwise it is informational, noting that swizzled copies stop tracking upstream fixes and no longer respond to theme component overrides.

Three details that are load-bearing

Each of these produced a false positive before it was handled, found while testing the approach against this repo:

  • Generated theme CSS is skipped. The pipeline emits private vars and bare prop classes itself. Judging the tool's own output as consumer error accounted for 6 of the first 7 findings against apps/docsite.
  • Comments are blanked, not stripped. Removing them collapses lines, which shifts every reported line number — one finding pointed at line 104 when the code was at 174. For a diagnostic an agent edits from, a wrong line is worse than no line.
  • Bare-class detection matches only enumerated prop/state values. Otherwise .astryx-card.my-highlight — a consumer's own class — trips it.

Against this repo the pair reports exactly one finding, and it is a true positive: a hand-written .astryx-pagination-dot.active in apps/docsite/src/app/globals.css:174.

Test plan

  • 46 tests pass, roughly half of them negative controls: clean consumer CSS, a consumer class chained onto an Astryx class, a token name inside a comment, generated output, node_modules/dist, a project with nothing swizzled, and an unrelated directory named astryx
  • Line-number accuracy pinned by a test with a multi-line banner
  • check:cli-structure, readme:check, check:changesets, golden suite
  • Typecheck: 6 strict errors, all pre-existing in assets/templates/blocks/
  • Verified against apps/docsite — 1 finding, manually confirmed genuine

Made with Cursor

Two read-only, text-only checks for the cases where an app provably steps
outside its theme.

CSS escapes covers three: a write to a private --_* var, which theme build
already rejects and which is no more valid in raw CSS; a system token
redefined in :root/html/:host, which sits outside the theme's @scope and so
beats every theme at once; and the deprecated bare prop classes
(.astryx-button.primary) in place of the reflected data attributes.

Swizzled components fails on exactly one combination — ejected source that
imports StyleX with no StyleX compiler configured. That is not a build
error. The component renders completely unstyled, with no warning, which
makes it the most expensive thing here to discover by hand.

Three details are load-bearing, each of which produced a false positive
before it was handled. Generated theme CSS is skipped, because the pipeline
emits private vars and bare classes itself and judging its own output as
consumer error dominated the early findings. Comments are blanked while
preserving newlines, because stripping them collapses lines and shifts
every reported line number. And bare-class detection matches only the
enumerated prop and state values, so a consumer's own class chained onto an
Astryx class is left alone.

Half the tests are negative controls. A detector that only proves it finds
things says nothing about whether it will bury the reader in noise, and
every false positive here is one an agent would act on.

Co-authored-by: Cursor <cursoragent@cursor.com>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 20, 2026
@vercel

vercel Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 24, 2026 8:15pm

Request Review

@cixzhang cixzhang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the negative controls and the line-number care are the parts most detectors skip.

Two to fix. The bare-class fix always names data-variant, but a component reflects the prop key: docsite's own .astryx-pagination-dot.active is [data-active="active"], and [data-variant="active"] matches nothing — so following doctor drops the rule (theme-drift.mjs:334).

STYLEX_COMPILERS is missing @stylexjs/webpack-plugin and @stylexjs/rollup-plugin, the two our own styling doc tells people to install (styling.doc.mjs:353). A Webpack user who swizzles gets [fail], exit 1 in CI, and advice to add a compiler they already have.

Same shape both times, and main now has the answer: foundation/discovery/theming-targets.mjs carries each target's className, props and states. Merge main and derive from it, loaded only when there's a hit to name so the 700ms stays off the clean path.

Any reason to keep the lists local?

[Reviewed by Robohands]

Comment thread packages/cli/api/doctor/theme-drift.mjs Outdated
message:
`${bareClasses.length} selector(s) use deprecated bare prop classes, e.g. ${first.detail} at ` +
`${at(first, ctx.cwd)}.`,
fix: `Target the reflected data attribute instead: .${base}[data-variant="${value}"].`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Components reflect the prop key — [data-variant="active"] matches nothing for .active.

}

/** StyleX compiler plugins. Swizzled StyleX source is inert without one. */
const STYLEX_COMPILERS = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @stylexjs/webpack-plugin and @stylexjs/rollup-plugin — both in our own styling doc.

josephfarina and others added 2 commits August 24, 2026 13:01
Review found two bugs, both from hardcoding a list.

The bare-class fix always named data-variant, but a component reflects the
prop *key*: docsite's own .astryx-pagination-dot.active is
[data-active="active"], because Pagination renders
themeProps('pagination-dot', {active: 'active'}). Following the advice
replaced a working rule with a selector that matches nothing — the exact
class of confidently-wrong, mechanically-applicable fix this check exists
to avoid.

STYLEX_COMPILERS omitted @stylexjs/webpack-plugin and
@stylexjs/rollup-plugin, the two `astryx docs styling` tells people to
install. A Webpack user who swizzled got [fail], exit 1 in CI, and advice
to add a compiler they already had.

Both lists are gone. collectThemingTargets and the component docs now
supply the states, the prop keys and their enumerated values, so the
replacement selector is derived rather than guessed and a token that is
neither a state nor a documented prop value is recognised as the
consumer's own class and dropped. That also removes the false positive the
curated list only avoided by omission.

The enumeration costs ~300ms, so it is loaded lazily and keyed by core
directory: a project with no bare classes to name resolves in 3ms.

Co-authored-by: Cursor <cursoragent@cursor.com>
@josephfarina

Copy link
Copy Markdown
Contributor Author

Both correct, and both were the same mistake — I hardcoded a list rather than reading the one that exists. No reason to keep them local; merged main and derived from collectThemingTargets plus the component docs.

The data-variant one is the worse of the two, because it is precisely the failure this check is meant to catch: advice that is confidently wrong and mechanically applicable. Following it replaced a working rule with a selector matching nothing. Now resolved from the target's states and props, with the enumerated values parsed from the documented prop type union:

  • .astryx-pagination-dot.active → .astryx-pagination-dot[data-active="active"] (state, reflects its own name)
  • .astryx-button.primary → .astryx-button[data-variant="primary"] (value of variant)
  • .astryx-button.sm → .astryx-button[data-size="sm"] (same target, different prop — so it has to be resolved, not guessed)

A useful side effect: a token that is neither a state nor a documented prop value is now recognised as the consumer's own class and dropped. .astryx-card.promo was only passing before because the curated list happened not to contain promo — it is now correct by construction.

STYLEX_COMPILERS now carries @stylexjs/webpack-plugin, @stylexjs/rollup-plugin and @stylexjs/postcss-plugin alongside the Babel and community/SWC entries, with a SYNC: comment pointing at the bundler table in styling.doc.mjs so the two cannot drift again.

On the 700ms: the enumeration measures ~300ms here and is loaded lazily, keyed by core directory. A project with no bare class to name resolves in 3ms — the clean path never pays it. When core cannot be resolved at all the findings are dropped rather than reported without a usable fix, since guessing the attribute is what caused this.

48 tests, including one per resolution path and one pinning the drop-when-docs-unreachable behaviour.

@cixzhang cixzhang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — deriving both from theming-targets is the right fix. Verified: docsite now gets [data-active="active"], and a webpack or rollup swizzle project exits 0.

[Reviewed by Robohands]

@cixzhang
cixzhang merged commit 2e5f87a into docs/agent-theme-guide-link Aug 27, 2026
7 checks passed
@github-actions
github-actions Bot deleted the feat/doctor-theme-drift branch August 27, 2026 09:11
josephfarina added a commit that referenced this pull request Sep 3, 2026
…#5260)

* feat(cli): doctor detects CSS theming escapes and swizzled components

Two read-only, text-only checks for the cases where an app provably steps
outside its theme.

CSS escapes covers three: a write to a private --_* var, which theme build
already rejects and which is no more valid in raw CSS; a system token
redefined in :root/html/:host, which sits outside the theme's @scope and so
beats every theme at once; and the deprecated bare prop classes
(.astryx-button.primary) in place of the reflected data attributes.

Swizzled components fails on exactly one combination — ejected source that
imports StyleX with no StyleX compiler configured. That is not a build
error. The component renders completely unstyled, with no warning, which
makes it the most expensive thing here to discover by hand.

Three details are load-bearing, each of which produced a false positive
before it was handled. Generated theme CSS is skipped, because the pipeline
emits private vars and bare classes itself and judging its own output as
consumer error dominated the early findings. Comments are blanked while
preserving newlines, because stripping them collapses lines and shifts
every reported line number. And bare-class detection matches only the
enumerated prop and state values, so a consumer's own class chained onto an
Astryx class is left alone.

Half the tests are negative controls. A detector that only proves it finds
things says nothing about whether it will bury the reader in noise, and
every false positive here is one an agent would act on.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): derive bare-class advice and the compiler list from real data

Review found two bugs, both from hardcoding a list.

The bare-class fix always named data-variant, but a component reflects the
prop *key*: docsite's own .astryx-pagination-dot.active is
[data-active="active"], because Pagination renders
themeProps('pagination-dot', {active: 'active'}). Following the advice
replaced a working rule with a selector that matches nothing — the exact
class of confidently-wrong, mechanically-applicable fix this check exists
to avoid.

STYLEX_COMPILERS omitted @stylexjs/webpack-plugin and
@stylexjs/rollup-plugin, the two `astryx docs styling` tells people to
install. A Webpack user who swizzled got [fail], exit 1 in CI, and advice
to add a compiler they already had.

Both lists are gone. collectThemingTargets and the component docs now
supply the states, the prop keys and their enumerated values, so the
replacement selector is derived rather than guessed and a token that is
neither a state nor a documented prop value is recognised as the
consumer's own class and dropped. That also removes the false positive the
curated list only avoided by omission.

The enumeration costs ~300ms, so it is loaded lazily and keyed by core
directory: a project with no bare classes to name resolves in 3ms.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

This branch was successfully deployed

1 active deployment
Preview — 3a7c050b Deployed Aug 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants