Skip to content

Navigation Menu

Sign in
Sign up

Fix theme visual-prop alias validation - #6061

Draft
imdreamrunner wants to merge 25 commits into
feat/theme-tiers from
agentcloud/theme-alias-validator
Draft

Fix theme visual-prop alias validation #6061
imdreamrunner wants to merge 25 commits into
feat/theme-tiers from
agentcloud/theme-alias-validator

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Sep 4, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

Summary

  • resolve finite visual-prop domains through TypeScript’s real module and import bindings
  • reject unsupported closed values in root themes and adaptation rules
  • preserve real extension points, including CustomTextTypes
  • keep target-owned prop domains separate so Text values cannot leak into Heading validation
  • fix and regenerate the probe theme using target-specific component docs

This is a focused follow-up to #5543 and intentionally targets its feat/theme-tiers branch.

Resolution model

The resolver no longer builds a package-wide map keyed by bare alias names. It parses the documented type expression, then asks the TypeScript checker to resolve named references in the lexical scope of the component source that owns that prop. TypeScript therefore owns import aliases, re-exports, package entrypoints, extension substitution, and collisions. Ambiguous, malformed, generic, open, or otherwise non-literal types remain unresolved rather than being guessed.

Test plan

  • full packages/cli Vitest suite: 216 files, 3,206 tests passed
  • 20 focused resolver tests, including same-named aliases in different modules, aliased imports/re-exports, package entrypoints, extension substitution, binding collisions, nested scopes, malformed types, generics, and cycles
  • full pnpm build passed
  • pnpm check:repo passed
  • frozen lockfile validation passed
  • strict CLI TypeScript check passed
  • ESLint passed on changed files

imdreamrunner and others added 23 commits August 25, 2026 20:33
A theme can now say what it looks like at each viewport width. The four
tiers — mobile, tablet, desktop, wide — partition the width axis, so
exactly one matches at any width and no two ever compete. Declaring a
tier turns it on; a theme that declares none emits no tier CSS.
A tier is resolved as a complete theme input rather than as a patch: its
input is merged along its `extends` chain and run through the same
pipeline as the base theme, and only the result is diffed for emission.
That is what keeps a tier's type scale from reverting the theme's font
weights, and a tier's generated axis from beating the theme's explicit
tokens.
`extends` is value inheritance resolved at define time, defaulting to
the theme's own values. Pointer is a separate axis nested inside a tier,
because the 16px body floor is an iOS input-zoom fix — true on a phone
and an iPad alike, never on a desktop window dragged narrow.
... a tier
Review pass over the width-tier feature. Three requirements and five defects.
Requirements
- An undeclared tier is no longer a breakpoint. `{mobile, desktop}` is one
 boundary and two bands, not three bands with the middle one falling back to
 the theme's own values. `wide` alone is refused: with nothing below it to
 bound it, it would match every width.
- Declaring no tier emits no media query anywhere, pinned by its own test
 rather than left to fall out of the others.
- Every tier block is emitted after everything without a media query, in the
 built path as well as the runtime one. The two disagreed: the built path put
 tier CSS before the `[data-astryx-media]` rules and the runtime path after.
Defects
- A pointer refinement was pruned against the base theme, but its competitor
 is the tier it sits in — on a coarse-pointer phone both match. A refinement
 value equal to the base theme's was dropped and the tier went on applying.
 Each layer now diffs against the baseline it actually has to beat.
- A tier re-expanded every axis its theme was declared with, so a tier that
 named one token also reverted whatever those axes had been resolved against.
 Extending `butterTheme` and asking for one spacing token emitted a reverted
 token and four unasked-for rules. Inherited axes are now a completion source
 for the axes a tier states, never an input on their own: a tier resolves on
 top of the theme's resolved values and changes what it names.
- That resolution input was also serialized into every built theme module —
 14,105 bytes, 43% of `neutral`, for a theme with no tiers. The axes alone
 are 766.
- Four places in the build never looked inside a tier: the variant
 augmentation (a tier-only variant was styled but untypeable), the private-var
 check, the unknown-component warning, and the unloaded-font notice.
- A missing `_generateTierCSS` silently dropped every tier from a built
 stylesheet. It now fails the build like the other core imports.
Also: completing a partial scale recomputes the axis, and the shipped duration
table is hand-rounded — no single ratio reproduces it. Two of nine tokens land
5ms out. Pinned by test so it is a known difference rather than a surprise.
Test: 65 tier tests, 12 CLI build tests, 12,326 in the suite. lint:strict,
core typecheck and pnpm build all clean.
...ated axis
An explicit `tokens`/`components` override is applied last at the theme level,
above every generated axis — that is the whole of what pinning a token means.
A tier reaches its theme's values through the resolved seed, which sits at the
BOTTOM, so the moment a tier restated any part of an axis the regenerated axis
landed on top of the pin and reverted it.
A base pinning `--color-accent` red over a scale that generates blue, and a
child that adds nothing but `mobile: {color: {neutralStyle: 'warm'}}`: red
above the breakpoint, blue below it, and the base's heading override gone with
it. Neither the child nor the tier mentioned either one.
The previous commit fixed this for a theme's OWN overrides and reasoned wrongly
about inherited ones — the code comment claimed they "already reach the tier
through seed, at the precedence the root resolution gives them". They reach it
at the bottom, not at their precedence.
Which overrides to re-apply is the whole problem, and it is not the declared
ones: a base's pin is legitimately beaten by an extending theme's generated
axis, and re-applying the declaration would revert that instead — the same bug
in the other direction. It is the ones that WON.
Derived rather than tracked. Carrying the declarations down the `extends` chain
means serializing them into every built theme: measured at 10.5 KB on a 19 KB
module, most of the size regression this branch just removed. The axes are
already serialized, so the question can be asked of the values themselves — a
token is pinned exactly when it is not the value its axis computes. Same answer,
no bytes: `neutral` is 19,376, against 19,372 before this commit.
Also broadens the stale-built-base guard: a base from an older CLI has no
`__axes`, so it can neither complete a partial scale nor separate its pins from
its generated values. It was refused only when the base itself had tiers; now
whenever the extending theme will have them.
Test: 8 new tier tests — the reviewer's child-theme case for tokens and for
components, the tier still winning over an inherited pin, the mirror case that
must NOT resurrect a superseded pin, two levels of chain, a built-shaped base,
and the stale-base refusal. 74 tier tests, 13 CLI build tests, 12,336 in the
suite (13 known parallel-load timeouts, all green serially). lint:strict, core
typecheck and pnpm build clean.
Value comparison recovers ordinary explicit overrides, but cannot distinguish
an explicit value from a generated one when the two happen to be equal. That
is harmless until a tier regenerates the axis to a different value — exactly
what responsive tiers do. A 14px scale plus an explicit 0.875rem base became
1rem in the mobile tier, while a visibly different 2rem pin held.
Carry only the ambiguous paths as `__equalOverrides`: token names and component
leaf paths, never values. Themes without an equal-value pin store nothing. The
mask follows `extends`, drops paths an extending theme's own generated axis
legitimately supersedes, and adds equal explicit paths from the child.
Tier resolution adds the resolved values at those paths to the overrides it
already derives by difference. This preserves explicitness without serializing
the full declarations: neutral remains 19,376 bytes, and a theme with one
ambiguous pin adds only its token name.
Adds direct, inherited, two-level, tier-wins, child-axis-supersedes,
component-path, distinct-pin and built-theme regression cases, plus CLI
serialization tests for both the sparse and absent forms.
Self-audit found two serialization issues after the review blockers were fixed.
First, an inherited equal-pin marker survived when a child explicitly replaced
the same path with a distinct value. Resolution stayed correct, but the marker
was no longer ambiguous and was serialized unnecessarily. Explicit child token
and component paths now remove the inherited marker before equal-valued child
pins are added back. A regression covers the 0.875rem -> 2rem replacement.
Second, built modules serialized the full resolved `__tiers` map even though an
extending theme never reads it: the tier CSS already has those values, and a
child re-resolves from `__tierInput`, `__axes` and `__equalOverrides`. The
redundancy made a one-tier module 6,894 bytes larger than its tierless twin.
Omitting it reduces that exact delta to 124 bytes.
The CLI test now imports the generated module and passes it to defineTheme(),
proving the sparse fields are sufficient rather than merely checking their
names appear in the output.
Core inputs already floor their text at 1rem under a coarse pointer, so raising
typography.scale.base to 16 in the responsive-theme template solved an already
solved problem while lifting every font-size token from 4xs through 5xl.
Keep the pointer axis, but teach a use it uniquely owns: taller element-size
tokens under a finger or stylus. Update the template, full and dense docs,
source JSDoc, changeset and test commentary consistently. Also call out the
intentional consequence of precedence: pinning one generated scale token keeps
that step fixed when a tier moves its generated neighbours, producing an uneven
ladder unless that is deliberate.
Remove DEFAULT_TIER_MAX_WIDTH, tierWidthQuery and ThemeGenerativeAxes from the
public theme barrel. The first two are now module-private; ThemeGenerativeAxes
remains an internal cross-module type for __axes. Keep the author-facing tier
vocabulary and input types public.
Round-three review found that the public wording still described the tiers as a
full partition where exactly one always matches. Sparse declarations do not
cover every width: declared bands are disjoint, the lowest declared bounded
tier owns every width through its upper bound, and root values apply above the
highest bounded tier unless wide is declared.
Correct that model consistently in the template, full and dense docs, source
JSDoc, changeset and PR summary. Also make TierBreakpoints module-private and
remove it from the public theme barrel; it only types internal query
construction.
...tations
# Conflicts:
#	packages/cli/api/theme/build/build.mjs
#	packages/core/src/theme/defineTheme.ts
...tations
# Conflicts:
#	docs/architecture/theme-authoring-contract.md
Resolve finite literal aliases when discovering component visual-prop domains, reject unsupported closed values at the root and in adaptations, and preserve real extensibility points including CustomTextTypes.
Test Plan:
- Full packages/cli Vitest suite: 215 files, 3186 tests
- packages/cli strict TypeScript check
- ESLint on changed files

vercel Bot commented Sep 4, 2026
edited
Loading

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Sep 5, 2026 6:56am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Sep 4, 2026
Prefer target-specific component docs when generating visual-prop selectors so aggregate docs cannot leak values across targets.
Test Plan:
- pnpm build
- probe-theme.test.mjs
- generate-probe-theme.mjs --check
- theme-probe build
Replace package-wide alias matching with TypeScript compiler resolution in each documented prop owner. Only finite string and number literal domains are accepted; ambiguous, malformed, generic, or open types remain unresolved.
Test Plan:
- Full packages/cli Vitest suite: 216 files, 3206 tests
- pnpm build
- pnpm check:repo
- frozen lockfile install check
- packages/cli strict TypeScript check
- ESLint on changed files
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@cixzhang cixzhang Awaiting requested review from cixzhang cixzhang is a code owner
@josephfarina josephfarina Awaiting requested review from josephfarina josephfarina is a code owner

Assignees

No one assigned

Labels

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

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

1 participant

AltStyle によって変換されたページ (->オリジナル) /