Skip to content

No author-controlled cascade layer: preflights are pinned to @layer cw-base while utilities ship unlayered #21

Description

@glennmichael123

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:140Preflight { 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:

  1. A top-level layers: ['cw-base', 'cw-utilities', 'app'] config key that names the utility layer and lets authors order their own.
  2. A per-preflight layer field: { layer: 'app', getCSS: () => … }.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions