Problem
The starter imports Tailwind utilities unlayered:
/* styles/css/index.css */
@import "tailwindcss/utilities.css";
CSS modules are also unlayered. When a CSS module rule and a Tailwind utility set the same property on the same element, both have specificity (0,0,1,0) and the cascade winner is decided by source order. That order is not stable between dev and prod:
- Dev: each module loads as a separate sheet, appended after Tailwind. CSS module wins.
- Prod: Vite bundles everything into one stylesheet; Tailwind utilities can land last. Tailwind utility wins.
Result: a component that renders correctly in dev silently flips behavior in prod (or vice versa). Failures are typically positioning/sizing — text blocks shifting, elements rendering at wrong dimensions — which only surface in QA after a deploy.
Concrete instance in the starter
components/image.module.css declares .image { display: block; } and applies it directly to the rendered <img>. Any caller mixing a Tailwind display utility on the same element (e.g. <Image className=\"hidden dt:block\" />) ends up with two rules at specificity (0,0,1,0) competing on display, with the dev/prod source-order flip described above.
The same applies to the object-fit variants in that file, and to any future module that sets a property Tailwind also covers (positioning, sizing, padding, margin, gap, color, transform, etc.).
Workarounds available today
Two patterns can mitigate this without a full layer rollout:
- Drop the rule's specificity to zero with
:where(). :where(.image) { display: block; } collapses the selector to (0,0,0), so any caller utility wins by default. Localized fix per offending module.
- Don't split one property between Tailwind and a CSS module on the same element. Convention only — pick one owner per property. If
s.shell sets left, drop left-1/2/dt:left-auto from the className.
Both work, but they're per-component opt-ins. New code that doesn't follow them silently inherits the dev/prod flip.
Proposed fix: layer everything except CSS modules
CSS @layer is the right tool. Once Tailwind, the project's globals, and third-party CSS are all in layers, unlayered CSS modules win unconditionally — no cascade-order dependence, no :where() workaround, no per-component convention.
Target cascade order (low → high priority):
@layer reset, third-party, utilities, components;
/* unlayered: CSS modules — highest */
Implementation outline:
-
styles/css/index.css — add an explicit @layer declaration so layer priority doesn't depend on first-appearance order, then move imports into layers:
@layer reset, third-party, utilities, components;
@import "tailwindcss/utilities.css" layer(utilities);
@import "./reset.css" layer(reset);
@import "./tailwind.css" layer(utilities); /* custom @utility output */
@import "./global.css" layer(components);
/* root.css, fonts.css, media.css can stay unlayered — :root vars / @font-face / @custom-media don't enter the cascade */
-
Audit third-party CSS sources. Many libraries ship CSS via JS side-effect imports, not @import, so they currently sit unlayered. Each needs to be re-imported explicitly via @import \"<path>\" layer(third-party); in index.css. Sources to enumerate per-project (varies with which libraries are installed):
@responsive-image/react (e.g. .ri-img, .ri-responsive { width: 100% })
- Smooth-scroll / overlay-scroll libraries if used (e.g. Overlay Scrollbars)
- Headless UI libraries that ship default styles (Base UI, Radix, etc. — varies)
- Theatre.js if used
The easiest way to enumerate: build the project, inspect the emitted CSS bundle, and grep for selectors that don't come from Tailwind, the project's own modules, or the styles/css files.
-
Drop any :where() workarounds added solely for cascade reasons. The wrapper is no longer needed once layers carry the priority. :where() used for selector-list flattening (e.g. matching one of several roots without bumping specificity) stays.
-
Visual QA pass on every page in projects spun from the starter, after the change. Rules that were silently winning or losing on source-order will now obey layer order, and that will surface latent visual differences. Plan the rollout for a calm sprint, not during active feature work.
Risks
- Missed third-party source. If a library's CSS import is missed in step 2, it stays unlayered and outranks Tailwind — same direction of failure as today, but for one specific selector instead of the cascade as a whole. Mitigated by enumerating side-effect CSS imports systematically (inspect the prod build's CSS output).
- Lightning CSS
@layer handling. The starter's browserslist (iOS >= 16, Safari >= 16) supports @layer natively, so Lightning CSS shouldn't flatten it — verify with a prod build and inspect the emitted CSS to confirm layers survived.
- Behavior change on previously-fragile call sites. Pages that happened to render correctly because Tailwind was winning without a layer will keep working (Tailwind still wins via the
utilities layer over third-party). Pages that relied on a CSS module beating Tailwind via source order will need the CSS module to remain unlayered — which it will, since CSS modules are not touched by this change.
When to do this
Defer until either:
- The convention has accumulated past ~3–4
:where() workarounds across the codebase, or
- A planned QA window is available (mid-sprint, not feature-crunch).
Until then, the "don't split a property" rule + selective :where() is sufficient. Document the convention in the starter's CLAUDE.md / AGENTS.md so projects spun from the starter inherit the rule too.
Acceptance criteria
@layer reset, third-party, utilities, components; declared in styles/css/index.css.
- All side-effect-imported third-party CSS re-imported into the
third-party layer.
- Any
:where() workaround added solely to neutralize cascade order is removed.
- Visual QA pass complete on a project that uses the starter, with no regressions.
- Convention documented in the starter's agent guides.
Problem
The starter imports Tailwind utilities unlayered:
CSS modules are also unlayered. When a CSS module rule and a Tailwind utility set the same property on the same element, both have specificity
(0,0,1,0)and the cascade winner is decided by source order. That order is not stable between dev and prod:Result: a component that renders correctly in dev silently flips behavior in prod (or vice versa). Failures are typically positioning/sizing — text blocks shifting, elements rendering at wrong dimensions — which only surface in QA after a deploy.
Concrete instance in the starter
components/image.module.cssdeclares.image { display: block; }and applies it directly to the rendered<img>. Any caller mixing a Tailwind display utility on the same element (e.g.<Image className=\"hidden dt:block\" />) ends up with two rules at specificity(0,0,1,0)competing ondisplay, with the dev/prod source-order flip described above.The same applies to the
object-fitvariants in that file, and to any future module that sets a property Tailwind also covers (positioning, sizing, padding, margin, gap, color, transform, etc.).Workarounds available today
Two patterns can mitigate this without a full layer rollout:
:where().:where(.image) { display: block; }collapses the selector to(0,0,0), so any caller utility wins by default. Localized fix per offending module.s.shellsetsleft, dropleft-1/2/dt:left-autofrom the className.Both work, but they're per-component opt-ins. New code that doesn't follow them silently inherits the dev/prod flip.
Proposed fix: layer everything except CSS modules
CSS
@layeris the right tool. Once Tailwind, the project's globals, and third-party CSS are all in layers, unlayered CSS modules win unconditionally — no cascade-order dependence, no:where()workaround, no per-component convention.Target cascade order (low → high priority):
Implementation outline:
styles/css/index.css— add an explicit@layerdeclaration so layer priority doesn't depend on first-appearance order, then move imports into layers:Audit third-party CSS sources. Many libraries ship CSS via JS side-effect imports, not
@import, so they currently sit unlayered. Each needs to be re-imported explicitly via@import \"<path>\" layer(third-party);inindex.css. Sources to enumerate per-project (varies with which libraries are installed):@responsive-image/react(e.g..ri-img,.ri-responsive { width: 100% })The easiest way to enumerate: build the project, inspect the emitted CSS bundle, and grep for selectors that don't come from Tailwind, the project's own modules, or the
styles/cssfiles.Drop any
:where()workarounds added solely for cascade reasons. The wrapper is no longer needed once layers carry the priority.:where()used for selector-list flattening (e.g. matching one of several roots without bumping specificity) stays.Visual QA pass on every page in projects spun from the starter, after the change. Rules that were silently winning or losing on source-order will now obey layer order, and that will surface latent visual differences. Plan the rollout for a calm sprint, not during active feature work.
Risks
@layerhandling. The starter's browserslist (iOS >= 16,Safari >= 16) supports@layernatively, so Lightning CSS shouldn't flatten it — verify with a prod build and inspect the emitted CSS to confirm layers survived.utilitieslayer overthird-party). Pages that relied on a CSS module beating Tailwind via source order will need the CSS module to remain unlayered — which it will, since CSS modules are not touched by this change.When to do this
Defer until either:
:where()workarounds across the codebase, orUntil then, the "don't split a property" rule + selective
:where()is sufficient. Document the convention in the starter's CLAUDE.md / AGENTS.md so projects spun from the starter inherit the rule too.Acceptance criteria
@layer reset, third-party, utilities, components;declared instyles/css/index.css.third-partylayer.:where()workaround added solely to neutralize cascade order is removed.