Every preflight is wrapped in @layer cw-base, while the generated utilities are emitted unlayered. Because any unlayered rule beats any layered rule regardless of specificity, author CSS can never outrank a utility — and there is no config surface to change that.
Repro
import { CSSGenerator, defaultConfig } from '@cwcss/crosswind'
const g = new CSSGenerator({
...defaultConfig,
preflights: [{ getCSS: () => `.card { background: red; }` }],
})
g.generate('bg-blue-500')
const css = g.toCSS()
console.log([...new Set([...css.matchAll(/@layer\s+([a-z-]+)/g)].map(m => m[1])))
// -> ["cw-base"] ... the only layer name that ever appears
console.log(css.includes('.bg-blue-500'))
// -> true, and it is NOT inside any layer
console.log('layers' in defaultConfig)
// -> false
So .card { background: red } (layered) loses to .bg-blue-500 (unlayered) even though the author wrote it deliberately and it has equal specificity.
Where it's hardcoded
dist/chunk-jbn8cyfb.js:453 (CSSGenerator.toCSS) builds @layer cw-base { … } around every preflight, then pushes rulesToCSS(...) for the utilities and each media-query bucket with no layer wrapper at all.
The type surface confirms there is no escape hatch: dist/types.d.ts:140 — Preflight { getCSS: () => string } has no layer field, and CrosswindConfig (types.d.ts:28-50) has no layers key.
Returning @layer x { … } from getCSS does not work either — it just nests as cw-base.x, which still ranks below the unlayered utilities.
Why it matters
In a real app the config file becomes the only place complex rules can live (scoped <style> has its own tradeoffs, and utilities can't express everything). Ours has ~500 lines of preflight CSS. Every rule in it that needs to beat a utility has to win on specificity instead — so the file accumulates defensive selectors like:
.dark .bg-blue-50 { background-color: #172554; }
That's 45 lines of hand-written overrides whose only job is to out-specify a generated utility. With a layer order they'd be one ordinary block.
Worth noting the ordering comment in our own config reasons about source order, which is the wrong mechanism — layered-vs-unlayered decides it before source order is ever consulted. That's an easy trap to fall into, which is part of the argument for making the model explicit.
Ask
Any one of these would solve it:
- A top-level
layers: ['cw-base', 'cw-utilities', 'app'] config key that names the utility layer and lets authors order their own.
- A per-preflight
layer field: { layer: 'app', getCSS: () => … }.
- At minimum, emit utilities into a named layer (
@layer cw-utilities) so authors can write @layer app and control the order themselves.
Option 3 alone is nearly free and unblocks the common case.
Environment
crosswind 0.2.15, consumed through @stacksjs/stx 0.2.153, Bun 1.3.1.
Every preflight is wrapped in
@layer cw-base, while the generated utilities are emitted unlayered. Because any unlayered rule beats any layered rule regardless of specificity, author CSS can never outrank a utility — and there is no config surface to change that.Repro
So
.card { background: red }(layered) loses to.bg-blue-500(unlayered) even though the author wrote it deliberately and it has equal specificity.Where it's hardcoded
dist/chunk-jbn8cyfb.js:453(CSSGenerator.toCSS) builds@layer cw-base { … }around every preflight, then pushesrulesToCSS(...)for the utilities and each media-query bucket with no layer wrapper at all.The type surface confirms there is no escape hatch:
dist/types.d.ts:140—Preflight { getCSS: () => string }has nolayerfield, andCrosswindConfig(types.d.ts:28-50) has nolayerskey.Returning
@layer x { … }fromgetCSSdoes not work either — it just nests ascw-base.x, which still ranks below the unlayered utilities.Why it matters
In a real app the config file becomes the only place complex rules can live (scoped
<style>has its own tradeoffs, and utilities can't express everything). Ours has ~500 lines of preflight CSS. Every rule in it that needs to beat a utility has to win on specificity instead — so the file accumulates defensive selectors like:That's 45 lines of hand-written overrides whose only job is to out-specify a generated utility. With a layer order they'd be one ordinary block.
Worth noting the ordering comment in our own config reasons about source order, which is the wrong mechanism — layered-vs-unlayered decides it before source order is ever consulted. That's an easy trap to fall into, which is part of the argument for making the model explicit.
Ask
Any one of these would solve it:
layers: ['cw-base', 'cw-utilities', 'app']config key that names the utility layer and lets authors order their own.layerfield:{ layer: 'app', getCSS: () => … }.@layer cw-utilities) so authors can write@layer appand control the order themselves.Option 3 alone is nearly free and unblocks the common case.
Environment
crosswind 0.2.15, consumed through @stacksjs/stx 0.2.153, Bun 1.3.1.