Adoption DX: ship a canonical foundation setup + one-screen smoke test + migration audit checklist (cascade-layer safety)
- Dominant language
- TypeScript
- Stars
- 13k
- Forks
- 1.1k
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 690
Description
## Summary
Adopting Astryx into an existing app is currently a **build-your-own-setup** exercise: the canonical layer order and required imports live in doc comments inside `reset.css` and `tailwind-theme.css`, but there is no single copy-pasteable "foundation" that a consumer installs *before plugging in any components*, and no cheap way to verify the foundation is correct before building screens on top of it.
The failure mode this creates is severe and silent. In one real migration, a pre-existing universal reset (`*{padding:0}`) was bound into a consumer layer positioned **after** `astryx-base`. Because cascade-layer position beats specificity outright, that zero-specificity reset won against every Astryx primitive's padding rules — `padding: 0` on Button/Badge/Input/Card app-wide, no errors, no warnings. It shipped to ~10 pages before anyone caught it. Astryx itself was blameless (its CSS was correctly layered in `astryx-base`, and it already ships its own `:where()` reset) — the break was entirely in how the app composed layers around it.
This is the same class of trap already reported from a different angle in #3374 item 1 (unlayered Tailwind preflight defeating `astryx-theme`). Both are cascade-layer-position footguns at the integration boundary. This issue proposes three concrete, low-cost guardrails so the *next* adopter can't fall into either.
## The core insight
The whole family of bugs reduces to one rule most developers don't hold in their head:
> **With `@layer`, layer position decides the winner before specificity is ever evaluated.**
A `*{padding:0}` reset "always loses" in an unlayered world (lowest specificity) and "always wins" the moment it sits in a later layer than the component library. Same CSS, opposite outcome — flipped purely by the surrounding layer order. Adopters carry the unlayered mental model ("resets are safe, my className overrides win because I wrote it last") into a layered world where it's false.
## Proposal
Three complementary guardrails, smallest first:
### 1. A single canonical "foundation" setup, shipped as one thing
Today the correct order + imports are scattered across two files' doc comments. Provide **one** authoritative, copy-pasteable foundation for the two common cases (with-Tailwind and without-Tailwind), e.g. `@astryxdesign/core/foundation.css` (or a documented snippet block that is the single source of truth):
```css
/* Canonical layer order — declared once, before any import */
@layer reset, theme, base, astryx-base, astryx-theme, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base); /* NOTE: layered, not unlayered — see #3374 */
@import "@astryxdesign/core/reset.css"; /* @layer reset */
@import "@astryxdesign/core/astryx.css"; /* @layer astryx-base */
@import "@astryxdesign/theme-neutral/theme.css"; /* @layer astryx-theme */
@import "@astryxdesign/core/tailwind-theme.css";
@import "tailwindcss/utilities.css" layer(utilities);
```
The load-bearing invariants, stated explicitly next to it:
- **Any app reset belongs in the lowest layer (`reset`), never in a layer above `astryx-base`.** Astryx ships its own `:where()` reset — prefer removing the app's legacy reset entirely.
- **Preflight must be layered** (unlayered preflight beats `astryx-theme` — #3374 #1).
- **A consumer's own `className`/utility wins only via layer position (`utilities`), not source order.** It passes through to the DOM regardless, but whether it *overrides* Astryx is a layer question.
### 2. One component smoke test (catches the whole class in one screen)
A tiny, canonical "foundation check" — a single page/story rendering one Button + one Input + one Card + one Table — plus an assertion that a primitive computes **non-zero padding**. The roxabi regression reproduced identically on every page; a single non-zero-padding assertion on one Button would have caught it before any real screen was built. Ship it as:
- a doc "verify your setup" page/story, and/or
- a snippet in the adoption guide that asserts `getComputedStyle(button).paddingInline !== '0px'`.
The key property: it must run **before** feature work, so a broken foundation fails immediately rather than after N screens are built on sand.
### 3. A migration audit checklist (docs)
A short, prescriptive checklist for "adopting/migrating an existing app onto Astryx", owning the cross-cutting concerns that no per-component doc covers:
- [ ] Declare the canonical `@layer` order (§1) once, before any `@import`.
- [ ] Audit every pre-existing global/reset stylesheet — classify each into a layer **deliberately**; never let an `@import` inherit a layer implicitly. (The original bug: a reset `@import` with no `layer()` keyword silently inherited a consumer layer above `astryx-base`.)
- [ ] Remove or demote the app's legacy reset — Astryx ships its own.
- [ ] Layer Tailwind preflight (#3374 #1).
- [ ] Set `moduleResolution: bundler`/`node16+` (#3374 #2).
- [ ] Theme via `defineTheme` / the accent-family API (see note below), not hand-written individual color tokens.
- [ ] Run the foundation smoke test (§2) and eyeball the component catalog in both themes **before** building screens.
## Notes on two things I checked while writing this (current `0.1.4`)
- **`--color-on-accent` is now auto-derived** by `expandColorScale` (`light-dark(P[100], P[20])`, a contrast-computed foreground baked from the accent hue). A consumer using `defineTheme({ accent })` gets a correct on-accent automatically — the original failure came from hand-writing only `--color-accent` and never wiring `--color-on-accent`. Adopting the accent-family API resolves this; hand-poking individual tokens does not. Worth calling out explicitly in the theming/migration docs: **prefer the accent-family API over individual token overrides.**
- **No bordered/outline Button (or Badge) variant still exists** in `0.1.4` (`primary | secondary | ghost | destructive`; base `borderWidth: 0`). `secondary` has a neutral *fill* affordance, but there is no rest-state *border* variant, so a consumer `outline → ghost` shim renders borderless. If a bordered affordance is intended to be supported, that's a separate primitive gap (not addressed by the accent-family work). Flagging here only so the migration guide can set the right expectation; happy to split into its own issue if useful.
## Why this is worth doing
The theming model is genuinely good once the foundation is right — the entire cost is at the integration boundary, and it's almost all cascade-layer position. #3374 documents the traps as an experience report; this issue asks for the *prescriptive, verifiable* counterpart: one setup to copy, one test to run, one checklist to follow — so a silent app-wide regression can't ship from a one-line layer-ordering mistake that no code review catches.
Related: #3374 (adoption DX umbrella), #3371 (custom-variant typing), #3373 (swizzle StyleX build).
Contributor guide
Assessment
This issue has not been assessed yet.