Styling
Real CSS, resolved at build time. Tailwind v4 is the intended way to write it, and the pipeline runs the actual Tailwind — not a reimplementation.
How it works
A stylesheet reaches the compiler by being imported, the same way it would in any web project:
// windows/main/index.tsx
import "./app.css";
Several imports are fine; they cascade in the order the module graph evaluates them,
so a sheet imported later wins ties. An inline style={{ … }} still beats both, as
it does in a browser.
If a stylesheet uses Tailwind, the compiler runs your Tailwind over it during the
build — tailwindcss is your project's dependency, so @import "tailwindcss"
resolves against your node_modules and your version is the one that runs. Nothing
is generated onto disk, so there is no built copy to rebuild or to go stale.
/* windows/main/app.css */
@import "tailwindcss";
dziry's compiler then resolves the result against your tree: selectors match, specificity sorts, inheritance applies, shorthands expand, units convert.
What comes out is a style table of integers and floats. The engine never sees a selector.
A utility that dziry cannot compile makes the build say so, naming the property. That is the honest part: a page that renders is a page whose utilities work.
Static classes
<div className="flex flex-col gap-6 rounded-xl bg-zinc-900 p-6" />;
Nothing special. These resolve to a style id at build time.
Conditional classes
Use cn. Not string concatenation.
const isBig = signal(false);
const isLight = signal(false);
<div className={cn("box rounded-lg px-4 py-2", { active: isBig })} />;
<div className={cn({ light: isLight })} />;
cn does not return a string. By the time a conditional class is a string, the
connection to the signal driving it is gone — and the compiler needs that connection
to resolve the class both ways ahead of time.
So the class is compiled with the flag on and with it off, and flipping it costs a few integer writes into the style table. No string comparison, no selector matching, and nothing per frame.
// Wrong: evaluates a signal object at build time and freezes that way.
<div className={"box " + (isBig ? "big" : "")} />
That compiles cleanly and never updates, which is the failure mode this project treats as worse than a crash.
Inline styles
<div style="color: red; padding: 8px" />;
<div style={{ color: "red", padding: 8, fontWeight: 600 }} />;
A number means pixels, except for genuinely unitless properties — fontWeight,
flexGrow, flexShrink, flex, aspectRatio, gridColumn, gridRow, zIndex,
opacity, lineClamp. The same rule React uses.
Inline styles beat every selector, the same precedence a browser gives them, and both forms cost the runtime nothing.
A non-static value is a build error, not a silent drop — there is no node left to attach it to. Use a conditional class.
Variants
Pseudo-state variants like hover: work, and they work without run-time selector
matching. Each interactive node carries a bitmask of the predicates its styling reads,
plus an offset into a run of precompiled style ids. Hovering costs one u16.
The cascade is resolved per pseudo-state from scratch rather than as a patch over the
base, which is what makes correct per-property hover ∧ focus merging cheap.
The set is :hover, :active, :focus, :focus-visible, :checked, :disabled,
:open and :invalid. Most are the engine's own answers about the pointer and focus;
:invalid is the one that comes from your code — a validate={…} runs, and the field
it rejected wears the bit until the next validation says otherwise.
:::note A predicate is per node; a conditional class is per style row
They look interchangeable until a list. Rows are compiled once and replicated, so every
replica shares one style row: a conditional class on a row's input is the same class on all
of them. A predicate is resolved per node against the controls table, which each replica has
its own row in — so :invalid can be true for row 3 and false for row 4, and
cn("x", { on: sig }) cannot.
:::
Coverage
dziry supports a subset of CSS, and the subset is defined by what Tailwind emits.
Do not trust a number written in prose — measure it:
bun run tailwind-coverage # what fraction works, and what is blocking the rest
bun run css-coverage # supported / unsupported / committed non-goal
bun run conformance # compare emitted values against a browser
tailwind-coverage also ranks the blockers by how many classes each would unblock,
which is how the next thing to implement gets chosen.
Known gaps
These are the ones you will notice first. Check the coverage runners for the current list rather than trusting this one.
line-heightis unsupported, sotext-smandtext-lgset their font size and warn about the line height that comes with them.@media (hover: hover)and@supportsare skipped at-rules.@propertyis not: itsinitial-valueis read, which is what makes Tailwind's--tw-*variables resolve at all.::selectiontakesbackground-colorandcolorand nothing else — a selection is a range inside a box rather than a box, so there is nothing for a padding or a border to apply to, which is also the short list CSS gives the highlight pseudo-elements. dziry's default is a UA-sheet rule onbody::selection, and it is a stated convention: Chromium does not expose its own highlight colour to script, so there is nothing to match.box-shadowsupports the ring subset only: no offset, no blur, a solid spread. That is exactly whatring-*,inset-ring-*andring-offset-*compile to, so every ring utility works andshadow-mdwarns and draws nothing. A style row is a fixed struct and a shadow list is not; seeproperties.ts::parseBoxShadow.mask-imageandmask-compositeare the largest blockers by class count, thencalc()over percentages and viewport units.
Why not Tailwind's preflight
dziry ships its own user-agent stylesheet. Tailwind's reset is written in selectors
dziry does not have — :host, *, ::before, [hidden] — and it is optional by
design. So the entry imports theme.css and utilities.css rather than the umbrella
@import "tailwindcss".
The entry also uses source(none) with explicit @source directives. Otherwise
Tailwind scans the whole project, finds class-shaped strings inside the compiler's own
TypeScript, and emits utilities no page uses — which inflates the sheet and makes any
coverage claim meaningless.