Core · guide
Stylesheet diagnostics
What happens to CSS Vixen cannot read — the at-rules, selectors and @apply names it drops, the build step's two refusal channels, where each refusal is now reported, and why a rule that does nothing used to be indistinguishable from a rule that was never written.
Edit this page on GitHubDocuments
What it is
Vixen's cascade recovers from a stylesheet it cannot read rather than refusing it. An at-rule it does
not implement is dropped and the rest of the sheet still applies; a selector it cannot compile
matches nothing and its neighbours still match; an @apply naming a utility that does not exist
contributes no declarations and the rest of the block stands. That is CSS's own error-recovery model
and it is the right one for a UI that must not disappear over a typo.
The cost of it is that a rule which does nothing looks exactly like a rule nobody wrote.
Three log events close that gap. Every refusal the cascade makes is now reported as it is made:
| Id | Level | What it means |
|---|---|---|
7004 | Warning | The stylesheet loader, the selector compiler or the layout bridge dropped something, and the text it names is the whole of what can be named. |
7005 | Warning | An @apply could not be expanded — a name that is not a utility, or one carrying a variant. |
7006 | Warning | The same refusal as 7004, where what was refused is a fragment of a larger rule. The message names the fragment and the rule to go and change. |
They arrive through ILogger, so they land in Vixen.Core.Diagnostics' always-on RingBufferSink:
the editor's Console panel, a game's log overlay, a rolling log file, and a crash dump.
What it is for
Finding the rule that is not applying. The messages themselves are not new — StyleSheetLoader
has answered an unknown at-rule with "Vixen does not understand this rule" since it was written,
and SelectorCompiler has named every selector it refused. What was new is a reader. Both lists sat
behind public properties that nothing outside the engine's own tests had ever looked at, so the
answer to "why is this rule doing nothing" existed, in memory, and no one could see it.
@apply is the expensive member of that class. It is expanded at install time by UiDocument,
which means a name it cannot resolve costs you declarations rather than a rule — and a block silently
missing three of its eight declarations is much harder to spot than a block missing entirely.
Every mistyped at-rule is the cheap member. @suports, @meida: all of them take the same path,
and all of them used to vanish.
⚠ This list used to include "a
@containerquery Vixen has not implemented", and that was wrong twice over. ExCSS knows@containerand hands it back as a parsed rule rather than an unknown one, so it never took this path and never produced a warning — it was dropped in silence.@containeris implemented now; an unreadable condition inside one (@container (prefers-color-scheme: dark)) is refused at load with a diagnostic that does reach here.
Using it
Give the document a logger. UiDocument takes one, and a document handed none reports into
NullLogger — the previous silence, with an extra step.
/* Every line below is dropped, and every one of them now says so. */@suports (display: grid) { .card { display: grid } } /* 7004 — not an at-rule Vixen knows */.card >>> .body { color: red } /* 7006 — not a combinator Vixen supports */.card::before { content: '>' } /* 7006 — names `.card::before`, not `::before` */.card { @apply p-4 hover:bg-accent notautility; } /* 7005 twice: the variant, and the name */In the editor there is nothing to wire: Vixen.Editor.App builds its shell with a logger over the
same ring the Console panel reads, filed under the category Vixen.Ui.Styling so that "the styling
is wrong" is one filter rather than a search through the editor's own chatter.
A refusal is reported once. The loader's and the compiler's lists accumulate for the life of the
document, and the drain keeps a watermark per producer, so installing a fourth sheet does not replay
the first sheet's problems. A reload — a resize that flips a breakpoint, a saved .vcss — rebuilds
both producers and the refusals that survive it are reported again, which is what makes a hot reload
that fixes one rule and breaks another legible.
A dropped declaration inside a rule Vixen did understand is a third list, and it is drained
too. LayoutStyleBuilder.Diagnostics answers a different question — what parsed as CSS and then
meant nothing to the layout, grid-template-columns: 4furlongs being the canonical case — and is
produced inside the per-element pass rather than at load, so it is drained at the end of Update
rather than after a load. It reports on 7004, because it is the same event with a different
source.
⚠ A refusal names the fragment; 7006 also names the rule. The cascade stops on the smallest
thing it could not use — ::before, a combinator, one declaration — and reports that, which is
right and is not enough on its own: a sheet with two ::before rules used to produce two warnings
that were character-for-character identical, and neither said which rule to open. There are no line
numbers to fall back on, because the CSS parser does not carry source positions through to the nodes
the compiler walks, so the enclosing selector is the only locator there is. Where it is known you get
7006 and the rule is in the message; where the fragment already is the whole rule you get 7004
and it is named once.
The layout bridge's refusals are always 7004. It is handed a ComputedStyle — interned property
and value ids, with the rule, origin, layer and specificity that produced them already resolved and
discarded — so there is no rule left to name. What it gives you instead is a text that is a locator
of its own: grid-template-columns: 4furlongs is the declaration as you wrote it, and greppable
across a project's sheets in a way a bare ::before is not.
The one event here that is not a refusal
⚠ 7007 reports a rule that applied and answered late, which is the opposite failure and needs
saying separately. A container-type makes an element answerable about its own measured box, so
the cascade depends on the layout and the layout depends on the cascade. That closes cleanly for a
container whose inline size comes from its parent — width: auto on a normal-flow block is sized by
the containing block and not by its contents — and it does not close for one whose inline size is
decided by what is inside it: a flex item on its content basis, a floated or absolutely positioned
box, a width: max-content. There the query widens the content, the wider content widens the
container, and the container's next verdict is different.
UiDocument bounds that with SettlePasses rather than spinning, and UiDocument.Settled has
reported the result since the wiring landed — as a boolean about the whole document. 7007 names the
container instead, by its container-name where it has one, with the box it measured on the last
pass:
.seesaw { container-type: inline-size; container-name: seesaw; } /* a flex item with no width */.body { width: 10px; }@container seesaw (max-width: 100px) { .body { width: 900px; } }
The query container 'seesaw' never settled: it measured 900×100 on the last of 3 layout passes and its box was still moving. Its own @container verdicts are one pass stale, because a container sized by its contents can change the contents that size it. Give it a definite inline size.
The cure is a definite inline size on the container, or a width: auto in normal flow. ⚠ The
engine does not yet impose that for you: CSS's container-type carries contain: inline-size,
which makes a container's own inline size independent of its contents by fiat, and that coercion is
owed under doc 43 § A16. Until it lands the frame is drawn one pass stale and this is the report of
it.
Examples
A sheet with one bad rule in it still works, and says what it lost. The rule below installs
cleanly, .card gets its colour, and one warning appears:
@nonsense pretend { color: red }.card { color: red }
The stylesheet loader refused '@nonsense pretend { color: red }': Vixen does not understand this rule. It was dropped; the rest of the stylesheet still applies, so the visible effect is a rule that does nothing.
An @apply with one bad name keeps the good ones. This block gets its padding; only the second
name is refused.
.card { @apply p-4 notautilityatall; }
An @apply could not be expanded: 'notautilityatall' is not a utility Vixen knows. The declarations it stood for are missing from the rule it was written in.
A variant is refused rather than approximated, and this is the one refusal that is a design
decision rather than a gap. @apply hover:bg-accent would have to invent a rule whose selector
differs from the block it sits in, which is not what apply this here means. Write the hover rule.
/* ⛔ refused, on 7005 */.card { @apply hover:bg-accent; }/* ✅ what to write instead */.card:hover { @apply bg-accent; }Nothing is logged for a sheet the engine understood, including @layer and @media — which
matters, because a channel that spoke on every load would be a channel nobody reads.
The build step's own refusals
The cascade's refusals reach the log at run time. The generator's refusals happen at build time and
have their own two channels on UtilityGenerator, reported into
obj/…/<Assembly>.unrecognised.txt and summarised on the build line.
⚠ The split exists because one false was standing for two situations, and the useless one drowns
the useful one by three orders of magnitude. UtilityFamilies.TryResolve returns false both for
"there is no such family" and for "that family has no such value", and everything it refused went
into one list — which for Vixen.Editor.Ui is seven thousand entries, because the scanner is
over-inclusive on purpose and reads every English word in every comment. So bg-clip-text, a real
Tailwind class against a root Vixen registers, sat among them unmarked. Indistinguishable from a typo
is the failure mode.
Unrecognised | The candidate named no family at all. Prose, overwhelmingly — and a misspelt family, like flexx. |
Unresolved | The candidate named a registered family and still emitted nothing. Each is a UtilityRefusal carrying the family that was consulted, the value or variant it had nothing for, and a UtilityRefusalKind saying which. |
var generator = new UtilityGenerator(tokens);generator.Generate(["bg-clip-text", "however"]);// "however" is prose; "bg-clip-text" is a class whose root exists.UtilityRefusal refusal = generator.Unresolved[0];// refusal.Family == "bg", refusal.Detail == "clip-text", refusal.Kind == UtilityRefusalKind.ValueA UtilityRefusalKind.Variant refusal is the same defect one field over: the utility itself
resolved and one of its variants did not, so wednesday:p-4 emits nothing while p-4 is perfectly
fine. Nothing that has survived TryResolve is prose, so it belongs in this channel and not the
other.
⚠ A refusal is not a to-do list entry, because it names the family that was consulted rather than
the one that should have been. UtilityFamilies.SplitName takes the longest registered prefix, so
rounded-ss-lg is refused by rounded when what it wants is a rounded-ss nobody has registered.
There is no shorter prefix to retry either — docs/plan/43-web-styling-parity.md § F8 has the
measurement, and ShadowedFamilyTests re-takes it on every build.
See also
- Cascade layers — where a rule sits in the ladder, and why a rule that applies can still lose.
- Utility styles — the build step, the palette, and
@apply's place in it. docs/manual/log-events.md— the register these ids are allocated in, and the rules that keep a number in a bug report meaning something.Core/Vixen.Ui/Containers.cs— the container-scope walk7007is reported from, and why the containers it names are measured rather than predicted.Core/Vixen.Ui/StyleDiagnostics.cs— the drain itself, and why its watermark is keyed on the producer rather than on a count.