There is a rule in CSS Cascade Level 5 that inverts the instinct most of us have built over a decade of writing stylesheets: for any given property, an un-layered author declaration beats every layered one โ regardless of selector specificity, and regardless of which file loaded last. A bare button { } in someone's reset defeats .btn inside @layer components. We learned this the expensive way, by shipping a stylesheet that was correct, polite, and comprehensively overruled on every Tailwind v3 page it landed on.
The rule
The cascade sorts declarations by origin and importance first, then by cascade layer, then by specificity, then by source order. "Layer beats specificity" is the part people remember. The part that bites is where un-layered styles sit in that ordering: for normal (non-!important) author declarations, un-layered styles form the highest-priority band of the author origin. Every layer you declare sits below them, in the order you declared it.
So this is a fight you lose, and specificity never enters into it. Here is the smallest thing that reproduces it. The first block is a reduction of the parts of Tailwind v3's preflight that matter; the second is a framework doing the polite, recommended thing:
/* host page โ un-layered, loaded FIRST */
*, ::before, ::after { border-width: 0; border-style: solid; }
button { background-color: transparent; background-image: none;
padding: 0; font-weight: inherit; color: inherit; }
ul { list-style: none; margin: 0; padding: 0; }
h2 { font-size: inherit; font-weight: inherit; }
/* framework โ fully layered, loaded SECOND */
@layer reset, base, layout, components, utilities;
@layer components {
.btn { padding: 8px 24px; font-weight: 600;
background-color: rgb(40,50,60); border: 1px solid rgb(70,80,90); }
.prose ul { list-style: disc; padding-left: 24px; }
.prose h2 { font-size: 18px; font-weight: 700; }
}
Computed styles for <button class="btn"> and the prose elements, measured in Chrome:
border-top-width: 0px โ the universal selector wins.padding-left: 0px,font-weight: 400,background-color: rgba(0, 0, 0, 0) โ a barebuttonselector, specificity (0,0,1), beats.btnat (0,1,0)..prose ul:list-style-typenone,padding-left0px..prose h2:font-weight400,font-size16px โ the heading renders pixel-identical to body text.
Every single declaration in the layered block lost. The framework stylesheet is still there, still parsed, still matching โ it just never wins a property the host also sets.
Why this is specifically a framework author's problem
Emitting everything inside @layer is normally the right call, and it is the behaviour we document and recommend. Farvist declares its order once, in scss/base/_layers.scss:
@layer reset, base, layout, components, utilities;
That buys two things. Utilities beat components without !important, purely by layer order. And any CSS the consumer writes outside a layer beats all of Farvist โ no specificity war, no !important arms race. "Write .btn { border-radius: 0 } in your own sheet and you win" is a genuinely nice property to be able to promise.
It becomes a liability the moment your stylesheet is an add-on rather than the page's primary framework. dist/farvist-ai.css is exactly that: the AI-interface kit โ chat thread, prompt composer, rendered-markdown prose, tool-call cards, diffs, snippets, the โK palette โ as a drop-in stylesheet for sites already running Tailwind, Bootstrap or their own CSS, 6.4 KB gzip minified. It deliberately ships no reset, no grid, no utilities. The host page keeps its own. Being fully layered is the whole social contract of that file.
The contract holds beautifully against Bootstrap, whose component CSS is un-layered class selectors: Bootstrap's .btn restyles the kit's send button, which is what a host expects. It also holds against Tailwind v4, whose preflight lives inside @layer base โ layered, so it sorts below the kit's components layer and loses. The contract collapses against Tailwind v3.
Tailwind v3's preflight is un-layered
Preflight in v3 is emitted outside any layer. Against a layered add-on, that makes it undefeatable for every property it touches, and it touches a lot of them. Verified against real [email protected] output, the ones that mattered to us:
- Borders are zeroed globally through a universal-selector rule (and recoloured to
#e5e7eb). Every glass edge in the kit is a 1px border โ.message-bubble,.prompt,.tool-call,.toast,.command,.diff,pre.snippet,.avatar. All of them vanished: the glass surfaces kept their fills and shadows and lost every edge. - Buttons get
background-color: transparentandbackground-image: none, lose their padding, and havefont-weightandcolorset toinherit. The gradient send button lost its padding, its weight and its gradient, and rendered as inherited body text on nothing. - Lists lose their markers and indentation; headings get
font-sizeandfont-weightset toinherit.
That last one is worth separating from the others, because it is not cosmetic. The kit's .prose scope exists to render model output โ markdown that a language model just produced. A bulleted list with no bullets and no indentation is a run of sentences; an <h2> at body weight and body size is not a heading. The document structure the model expressed is still in the DOM, but it is gone from the rendered page. That is information loss, and it is the reason we treated this as a correctness bug rather than a styling nit.
Loading your stylesheet later does not help
The instinct on seeing a cascade conflict is to load your file after theirs. In the repro above the layered sheet is second, and it still loses everything. Source order is the last tiebreaker the cascade consults; layer position is settled several steps earlier. Nothing you do with <link> ordering, bundler output order, or injection timing changes a layered-versus-un-layered comparison. Raising specificity does not help either โ .prose .message .btn inside a layer still loses to button outside one.
There are exactly two ways out: leave the layer, or use !important. And !important is stranger than you would guess โ for important declarations, layer order reverses, and un-layered important sits at the bottom. We measured it: an !important declaration inside @layer components beat an !important declaration outside all layers, even though the un-layered rule came first in the file and had identical specificity. Correct per spec, and a fine piece of trivia, but shipping !important from a framework is exactly the rudeness layers were invented to avoid. So: leave the layer.
The fix: re-assert exactly what preflight took
dist/farvist-ai-compat.css is an un-layered stylesheet, loaded after the Tailwind build, that re-states the specific properties preflight zeroes โ and nothing else. Real lines from the shipped file, wrapped here for readability:
.btn{padding:0.5rem 1.5rem;font-size:1rem;font-weight:600;line-height:1.6;
color:var(--fv-btn-color);background-color:var(--fv-btn-bg);
border:1px solid var(--fv-btn-border)}
.btn-gradient-primary{color:var(--fv-btn-color);background-image:var(--fv-gradient-primary)}
.message-bubble{border:1px solid var(--fv-glass-border)}
.tool-call{border:1px solid var(--fv-glass-border);border-left:3px solid var(--fv-border-color)}
.prose ul{margin-bottom:0.75rem;padding-left:1.5rem;list-style:disc}
.prose h2{font-size:1.125rem;margin-top:1rem;margin-bottom:0.5rem;font-weight:700}
Every selector carries exactly one class and nothing heavier: 26 of the 46 rules are a bare class at (0,1,0), and the rest add a type selector where the kit's own scoping needs one โ .prose ul at (0,1,1), .prose li + li at (0,1,2). That single class is the whole mechanism, because specificity compares class count before element count. Once you are back in the un-layered band, ordinary rules apply again, and they produce a clean three-way ranking:
- Preflight's universal and element selectors, (0,0,0) and (0,0,1) โ no class at all, so lowest.
- The compat sheet's rules, one class each โ beats preflight on specificity, however many element selectors preflight stacks up.
- The host's own class rules, also one class but loaded after โ beats the compat sheet on source order.
So a Tailwind v3 host gets its kit back without losing the ability to restyle it. Adding those class rules to the repro restores the button exactly: border-top-width 1px, padding-left 24px, font-weight 600, background-color rgb(40, 50, 60); the list gets disc and its indent back.
Note what this file gives up, deliberately. Being un-layered, it no longer defers to the host for the properties it names. We accepted that trade for a bounded set of properties on a bounded set of class names, and shipped it as a separate opt-in file so that hosts which do not need it never pay for it. Tailwind v4 and Bootstrap pages should not load it.
Generate the compat sheet; do not hand-write it
A hand-maintained compatibility sheet is a guaranteed future bug: someone changes a border colour token or a button's padding in the Sass, the main build updates, and the compat file quietly starts re-asserting last quarter's values over the top of the current ones. It would fail in the worst possible way โ silently, and only on the hosts that need it most.
So dist/farvist-ai-compat.css is generated. scripts/gen-ai-compat.mjs parses the compiled, autoprefixed dist/farvist-ai.css into a selector โ declarations map, then walks a spec that lists, per selector, which properties preflight is known to damage:
const CONTROL = ['padding', 'font-size', 'font-weight', 'line-height',
'color', 'background-color', 'border'];
const SPEC = [
['.btn', CONTROL],
['.btn-gradient-primary', ['color', 'background-image']],
['.message-bubble', ['border']],
['.tool-call', ['border', 'border-left']],
['.prose ul', ['margin-bottom', 'padding-left', 'list-style']],
// โฆ50 entries in total
];
Every emitted value is read out of the build. There is no second source of truth, so drift is not merely discouraged, it is unrepresentable. The 50 spec entries collapse to 46 unique selectors in the output โ several selectors appear more than once in the spec, grouped by the reason they need rescuing.
The interesting design decision is in the error handling, and it is a two-tier rule. Property names are treated as candidates: if the kit never declares font-weight on a given selector, preflight cannot take it away, so a missing property is silently skipped. But a missing selector, or a selector for which not one listed property is found, means the build has moved out from under the spec โ and that throws:
if (missing.length) {
throw new Error(`gen-ai-compat: spec entries not found in dist/farvist-ai.css โ the build changed; update the SPEC:\n ${missing.join('\n ')}`);
}
Rename .tool-call, drop .suggestion, move a border onto a different element, and the build stops with the list of stale entries. The failure mode we care about is a compat file that looks fine and protects nothing; this converts that into a loud build error. The result is 3.7 KB raw, 0.95 KB gzip, and it has its own budget in scripts/check-size.mjs (2 KB gzip) alongside the three main builds, so it cannot balloon unnoticed.
The general rule
None of this is about Farvist, or about Tailwind. If you ship CSS that lands on pages you do not control โ a framework, a design system consumed by other teams, an embeddable widget โ here is the procedure:
- Default to layered. It is still right. It gives your consumers a clean override story and keeps you out of specificity escalation.
- Inventory the un-layered resets in your ecosystem. Not the frameworks โ the resets. Tailwind v3's preflight, hand-rolled
* { margin: 0; padding: 0 }boilerplate, vendored copies of normalize, globals injected by CSS-in-JS runtimes. Any of these that is un-layered outranks all of your CSS, permanently. - Compute the intersection. The properties those resets set, intersected with the properties your components declare. That intersection โ not your whole stylesheet โ is your exposure. Ours came to 46 selectors, mostly borders and button chrome.
- Re-assert only the intersection, un-layered, at the lowest specificity that wins. One class is enough to beat any element or universal selector, however many of them there are. Do not reach for
!important, and do not add a second class โ every point of specificity you add is a point your consumer has to beat later. - Generate it from your compiled output. If the values can be typed by hand, they will eventually be wrong. Make a missing selector a build failure.
- Ship it as an opt-in file. Hosts that do not need the escape hatch should not inherit its impoliteness.
What this does not fix
The compat sheet restores an enumerated list of properties on an enumerated list of selectors. If a host has some other un-layered global rule โ a project-wide font-family override, an opinionated * { transition: none } โ that rule still wins for its properties, and nothing in the compat file addresses it. This is a targeted patch for one known reset, not a general shield.
It is also not a substitute for the other things an add-on stylesheet has to get right. The kit still assumes box-sizing: border-box (Tailwind and Bootstrap both set it globally; a bare page does not), and it is rem-sized against a browser-default 16px root, so a host using the html { font-size: 62.5% } pattern renders the whole kit at roughly 62% scale. Cascade layers have nothing to say about either.
And we would not recommend this pattern for a primary framework. If your stylesheet is the page's CSS, un-layered re-assertions just make you harder to override for no benefit. The technique earns its keep in exactly one situation: you are a guest on someone else's page, and the host's reset is destroying information rather than restyling it.
The takeaway
We shipped a fully layered stylesheet believing that "layered" meant "well-behaved". It does โ but well-behaved is not the same as robust. The moment your CSS shares a page with an un-layered reset, your carefully ordered layers are all sitting below it, and no selector you can write inside them will change that. Test against the real resets your users actually run, because the failure is silent and your own test page will never show it.
Everything described here is in the repo: the generator, the generated compat stylesheet, and the build notes in the docs. Farvist is MIT-licensed and free.