std::web::style reference
Typed, compile-time atomic styles. Concepts and the emission model: the styling guide.
import std::web::style::{
style, space, Style, Length, Color, Gradient,
Display, Position, FlexDirection, AlignItems, JustifyContent,
TextAlign, Cursor, Overflow, WhiteSpace, UserSelect, RadialExtent,
};
Constructors and values
fun style(): Style // empty style; chain from here (inside a const)
fun space(step: i32): Length // spacing scale: space(1) = 0.25rem
impl Length {
fun px(value: f64): Length
fun rem(value: f64): Length
fun em(value: f64): Length // relative to the element's own font size
fun pct(value: f64): Length
fun vh(value: f64): Length // viewport units
fun vw(value: f64): Length
fun auto(): Length
fun zero(): Length // bare `0`, not `0px`
fun var(name: str): Length // a CSS custom-property reference ("--w")
fun calc(expression: str): Length // "100% - 2rem" — no calc(..) wrapper
fun raw(expression: str): Length // a COMPLETE value, verbatim: "clamp(..)"
}
impl Color {
fun white(): Color
fun black(): Color
fun transparent(): Color
fun current(): Color // currentColor — the element's own text colour
fun hex(value: str): Color // "#663399"
fun var(name: str): Color // a custom-property reference ("--accent"); the app declares it
fun gray(step: i32): Color // ramps: 50…900
fun blue(step: i32): Color
fun red(step: i32): Color
fun green(step: i32): Color
fun rgba(red: i32, green: i32, blue: i32, alpha: f64): Color // a literal, 0-255 / 0.0-1.0
fun oklch(lightness: f64, chroma: f64, hue: f64): Color // perceptual: 0.0-1.0 / 0.0-0.5 / degrees
fun alpha(self, value: f64): Color // THIS colour at that alpha
}
impl Gradient {
fun linear(degrees: f64): Gradient // 0 up, 90 right, 180 down
fun radial(extent: RadialExtent): Gradient // extent keyword; centred
fun stop(self, color: Color, percent: f64): Gradient
}
alpha renders the relative-colour form, rgb(from <colour> r g b / a),
so a ramp step stays a var(--gray-900) and keeps re-theming — which an
8-digit hex could not do. Channels, alphas and the two-stop gradient
minimum are checked during const evaluation, so a bad value stops the
build naming itself.
oklch is the perceptual literal — hold a hue angle and step the
lightness, and the steps look even across hues, which is what deriving a
palette wants and what rgba cannot promise. Lightness takes the CSS
number form (0.0–1.0, not a percentage), chroma runs 0.0–0.5 (0 is
achromatic; sRGB tops out near 0.37), and the hue angle is degrees in its
canonical 0–360 turn — angles wrap in CSS, so one colour keeps one
spelling and one class. All three ranges are checked during const
evaluation, and .alpha() composes over the result like over any other
colour.
Color::var is Length::var’s counterpart — the typed end of the
dynamic-value channel. It renders var(--name) and declares nothing:
the app owns the custom property’s declaration (its emitted theme block,
or view.style_var writing it at runtime). Both spell the name with its
two leading dashes, and both refuse one without them at const time:
var(button-color) is not a broken reference, it is not a reference —
button-color parses as a keyword, the browser drops the declaration, and
nothing says so until the page is wrong. .alpha() composes over it
through the same relative-colour form, so a variable-backed colour
translucifies exactly like a ramp token.
calc wraps and raw does not: Length::calc(e) is
Length::raw("calc(" + e + ")"). Write calc for arithmetic, raw for a
value that is already whole — clamp(), min(), max(), env(),
fit-content(), or one named expression reused across properties. Both
refuse an empty value at const time.
A Gradient is a background-image value, not a Color: it reaches
a style through background_gradient, which fills a different slot from
background. What a Gradient cannot hold — positioned gradients
(at 20% 40%), multi-layer lists, repeating-*, and data-URI images —
goes to background_image(str), which writes the same slot, so the two
override each other instead of racing in the cascade.
Keyword enums: Display (Flex, Block, …), Position, FlexDirection,
AlignItems, JustifyContent, TextAlign, Cursor, Overflow,
WhiteSpace (Normal, Nowrap, Pre, PreWrap, PreLine), UserSelect (Auto,
Text, All, Off — none, named to stay clear of Option::None, like
Display::Hidden).
All eleven are backed enums: each variant carries the CSS keyword it
stands for, so the enum is that keyword at runtime and .value() hands it
back. That is why the names need not match the keywords — AlignItems::Start
is "flex-start", Display::Hidden is "none" — and why passing one costs
nothing over passing the string.
Display::Hidden.value() // "none"
AlignItems::Start.value() // "flex-start"
Display::parse("inline-block") // Some(Display::InlineBlock)
Display::parse("nope") // None
Style methods
Every method returns a new Style with one more property slot; each slot is
one atomic rule, deduplicated build-wide.
Layout:
| Method | Value |
|---|---|
display | Display (Flex, Grid, Block, Inline, InlineBlock, InlineFlex, InlineGrid, Hidden) |
position | Position |
flex_direction | FlexDirection |
align_items | AlignItems |
justify_content | JustifyContent |
flex | str — the shorthand, "1 1 auto" |
flex_grow | f64 |
flex_shrink | f64 |
grid_template_columns | str — "repeat(3, 1fr)" |
gap, padding, padding_x, padding_y, margin, margin_x, margin_y | Length |
padding_top, padding_right, padding_bottom, padding_left | Length — one edge |
margin_top, margin_right, margin_bottom, margin_left | Length — one edge |
width, height, min_width, max_width, min_height, max_height | Length |
size | Length — width and height, the square case (size(Length::rem(1.0)) for an icon box); writes the same two slots, so mixing with width/height is last-wins |
top, right, bottom, left, inset | Length |
overflow | Overflow |
Appearance:
| Method | Value |
|---|---|
radius | Length |
border | (width: Length, color: Color) — always solid |
border_top, border_right, border_bottom, border_left | (width: Length, color: Color) |
border_none | — fills the border slot, so it removes a border set earlier |
border_color | Color — its own slot, so a hover can recolour without restating the width |
box_shadow | str |
background, color | Color |
background_gradient | Gradient — the background-image slot |
background_image | str — the same slot, for what a Gradient can’t hold |
background_size | str — up to two components, so not a Length |
font_family | str |
font_size | Length |
font_weight | i32 |
line_height | f64 — unitless, and the one to prefer (inherits as a ratio) |
line_height_length | Length — the same slot, when the leading is absolute |
letter_spacing | Length — usually Length::em(..) |
text_align | TextAlign |
text_decoration | str |
white_space | WhiteSpace |
user_select | UserSelect |
cursor | Cursor |
opacity | f64 |
transition | str |
transform | str |
A str-valued method is not a weaker raw: it keeps the property name
checked and completable while the value stays a CSS expression the compiler
has nothing to validate (a font stack, a transform list, a shadow layer).
The name carries the arity — whole box (padding), axis
(padding_x), edge (padding_top) — and there is no multi-value
shorthand method, because padding_y(v).padding_x(h) already computes
padding: v h.
Arities mix, and resolve in authoring order. A property that covers
others forms a family — padding, margin, inset (over top,
right, bottom, left), border (over its parts and edges),
background, flex — and last-wins applies to the whole family: a
longhand written after the shorthand narrows it, a shorthand written
after a longhand replaces the family outright. Per condition, so a
hover or within variant never disturbs the base, and through raw
too, since the family is a fact about the CSS property.
Escape hatches:
fun raw<V: CssValue>(self, property: str, value: V): Style
fun with_length(self, property: str, value: Length): Style
fun with_color(self, property: str, value: Color): Style
CssValue is what a declaration’s value may be: a str (a complete CSS
value, verbatim), a Length, or a Color. A Length or Color that is a
theme token — space(4), Color::blue(300) — carries its own :root
declaration onto the stylesheet, so .raw("padding", space(4)) is never a
dangling var(). with_length and with_color are raw at those two value
types, kept for the spelling the surface has always had.
Pass the value, not its text: space(4).text is the string
var(--space-4) with the declaration left behind, and a str carries no
token.
Two free functions sit beside raw, both ambient inside a css block:
fun var(name: str): str // a custom property READ: var("--brand-ink")
fun piece<V: CssPiece>(value: V): str // one part of a value built by hand
var is how a declaration reads a custom property — --brand-ink is not
an expression, so CSS’s var(--x) is spelled with the name as a string,
and the dashes are checked (a name without them is not a reference at
all). Length::var and Color::var are the same thing at those two
types, for a position that wants the type.
piece renders one part of a value and puts its :root line on the
sheet on the way past — CssPiece is CssValue’s twin over str,
Length, Color and the numbers, because a part sits inside text that
supplies the unit while a whole value must carry its own. A declaration
with SEVERAL arguments joins them with a space through piece
automatically; write it yourself only where the parts are glued:
padding(i"calc({piece(space(4))} + 2px)");.
Conditions
A condition is a value, and a set of them is the same value. on puts a
style under a set; every named combinator below is one condition’s worth of
sugar over it.
fun on<C: IntoConditions>(self, conditions: C, inner: Style): Style
The condition values
Free functions in std::web::style (and in std::web::style::prelude, which is ambient
inside a css block, along with var, piece, the Length/Color
constructors, s(), and the keyword-property types Length, Cursor,
TextAlign and AlignItems — so one file-level
import std::web::style::prelude::{ … }; answers a whole chain built outside a
hole):
fun hover(): Condition // :hover
fun focus(): Condition // :focus
fun active(): Condition // :active
fun disabled(): Condition // :disabled
fun first(): Condition // :first-child
fun last(): Condition // :last-child
fun pseudo(name: str): Condition // any other pseudo-class; no ':' in the name
fun element(name: str): Condition // ::selection — a pseudo-ELEMENT, always last
fun attribute(name: str): Condition // [name] — PRESENCE on the element itself
fun within(condition: Condition): Condition // an ANCESTOR matching `condition`
fun children(): Condition // > * — every direct child
fun divide(): Condition // > :not(:first-child) — every child but the first
fun media(min_width: str): Condition
fun sm(): Condition // 640px
fun md(): Condition // 768px
fun lg(): Condition // 1024px
fun xl(): Condition // 1280px
Two methods on a condition:
fun not(self): Condition // the condition negated, where it is written
fun eq(self, value: str): Condition // an attribute condition's exact value
attribute("data-open") is presence, attribute("data-open").eq("true") is
[data-open="true"]. .eq(..) is deliberately not PartialEq::eq: a
condition value is never compared by user code, and PartialEq is never
derived on Condition so the two readings cannot collide.
+ is the intersection — “both of these at once” — and a set is a value
you can name:
let interactive = const hover() + active().not() + attribute("disabled").not();
let panel = const style().on(interactive, style().opacity(1.0));
The binding is itself const: a const expression reads only
compile-time-known bindings, so a named set has to be one.
The sugar
Each of these is .on(<that one condition>, inner):
fun hover(self, inner: Style): Style
fun focus(self, inner: Style): Style
fun active(self, inner: Style): Style
fun disabled(self, inner: Style): Style
fun first(self, inner: Style): Style // :first-child
fun last(self, inner: Style): Style // :last-child
fun pseudo(self, name: str, inner: Style): Style
fun children(self, inner: Style): Style // @layer vilan{.sX > *}
fun divide(self, inner: Style): Style // @layer vilan{.sX > :not(:first-child)}
fun attribute(self, name: str, value: Option<str>, inner: Style): Style
fun within(self, name: str, value: Option<str>, inner: Style): Style
fun sm(self, inner: Style): Style // breakpoints (min-width):
fun md(self, inner: Style): Style // 640px, 768px, 1024px, 1280px
fun lg(self, inner: Style): Style
fun xl(self, inner: Style): Style
fun media(self, min_width: str, inner: Style): Style
The free constructor and the method of the same name coexist: hover() is the
condition, style().hover(inner) is the sugar that puts a style under it.
Style::attribute and Style::within take the Option<str> A89 gave them —
Some(v) the exact match, None presence — and both are deprecated: the
Option folds into the condition value, so write
.on(attribute(name).eq(value), inner) and
.on(within(attribute(name).eq(value)), inner). They stay for one release.
Stacking
A set has no order. hover() + md() and md() + hover() are the same set,
mint the same class, and emit one rule — the canonicaliser sorts the tokens
into the order a selector is written in (breakpoint, ancestor guard, child
relation, attributes, pseudo-classes, pseudo-element) and hashes that:
style().on(md() + within(attribute("data-theme").eq("dark")) + hover(), style().opacity(0.8))
// @media (min-width: 768px){[data-theme="dark"] .sX:hover{opacity:0.8}}
style().on(hover() + active().not(), style().opacity(0.8))
// .sX:not(:active):hover{opacity:0.8}
style().on(within(attribute("data-theme").eq("dark")) + children(), style().opacity(0.8))
// @layer vilan{[data-theme="dark"] .sX > *{opacity:0.8}}
Nesting on intersects: a.on(X, b.on(Y, s)) gives s the set X ∪ Y.
Media rules emit in ascending min-width order, so .sm(x).lg(y) is
mobile-first — the widest matching breakpoint wins.
Five sets are refused at const time, each because it can never match or is not
a selector CSS admits: a condition and its negation; two breakpoints;
two ancestor guards; two child relations; two pseudo-elements.
Two different values of one attribute (attribute("x").eq("a") + attribute("x").eq("b")) is admitted in this version and recorded — it matches
nothing, and catching it would need the canonicaliser to reason about value
equality.
The SUGAR keeps its own nesting order, and that order is still the only legal
one: md(within(attribute(hover(..)))). Any other order is refused naming
both fixes — the nesting it wanted, and the set that has none.
Negation
.not() is written on the condition it negates:
style().on(attribute("disabled").not() + hover(), style().opacity(0.8))
// .sX:not([disabled]):hover{opacity:0.8} — hovered AND not disabled
style().on(hover().not(), style().opacity(0.8))
// .sX:not(:hover){opacity:0.8}
style().on(within(attribute("data-theme").eq("dark")).not(), style().opacity(0.8))
// :not([data-theme="dark"]) .sX{opacity:0.8}
:not(x) carries x’s own specificity, so nothing about the cascade story
changes: .sX:not([disabled]):hover is (0,3,0) over both of its (0,2,0)
parts, exactly as .sX[disabled]:hover is. A NEGATED ancestor guard is the
one case that moves a rule’s cascade band: the line opens with : rather
than [, so it sorts among the pseudo rules.
Three refusals: a double negation (x.not().not()) is a spelling mistake
rather than a cancellation; a breakpoint cannot be negated (@media not (..) is a grammar of its own and no ruling has reached it); and a
pseudo-element cannot be negated (:not(::selection) is not a selector
CSS admits). .not() on a SET refuses too, naming the per-condition form.
Relations
within(condition) guards the element’s own slots on an ancestor matching
condition — the theme condition
(within(attribute("data-theme").eq("dark")), or any theme id under
kolt-style n-ary theming), an ancestor’s presence flag
(within(attribute("data-collapsed"))), or a pseudo-class the ancestor is in
(within(hover())). Its rule is unlayered and beats the element’s own base
rule exactly when the guard matches. For colours, prefer declaring per-theme
custom properties with a declaration block and
reading them with Color::var(..); within covers the structural
changes a value swap cannot.
A guard holds ONE condition, and it must be one that selects an element: a breakpoint, another guard, a child relation and a pseudo-element are each refused naming where they belong instead.
children() and divide() condition on the element’s children — the
parent-owned spacing idioms (Tailwind’s space-*/divide-*). A rule carrying
a child relation emits inside @layer vilan whatever else conditions it, and
that is the whole cascade story: a child’s own Style always wins against a
rule reaching in from an ancestor, whatever the specificity — they set
defaults the child may refuse, never force. (The cost is symmetric: such a
rule cannot override any unlayered CSS.) Where both touch one property,
divide outranks children on every child but the first, whichever was
written first.
A guard and a child relation are not the same slot, so one set may hold both:
within(attribute("data-theme").eq("dark")) + children() is
[data-theme="dark"] .sX > *. The .children(inner)/.divide(inner) SUGAR
still takes an unconditioned inner style — a pseudo-class under it would bind
to the child’s compound — and a breakpoint wraps either (md(children(..))).
Declaration blocks
Style dresses an element. A declaration block puts a set of
declarations under a selector you choose — a theme’s custom properties
under [data-theme="…"], a :root token table, a reset’s box-sizing —
minting no class, producing no Style and touching no slot key.
fun declarations(): Declarations // opens a declaration chain
fun declare(selector: str, body: Declarations) // puts the block in the stylesheet
fun preflight() // the opt-in base stylesheet (below)
impl Declarations {
fun raw<V: CssValue>(self, property: str, value: V): Declarations
fun color(self, property: str, value: Color): Declarations // `raw` at that value type
fun length(self, property: str, value: Length): Declarations // likewise
}
declare is compile-time-only — it reaches std::web::asset::emit, so it belongs
inside a const expression. Building the chain is ordinary code: a
Declarations value accumulates the token lines it owes and declare puts
them on the sheet with the block.
import std::web::style::{ Color, declare, declarations, space };
fun theme(id: str) {
declare(
i"[data-theme=\"{id}\"]",
declarations()
.color("--color-ink", Color::hex("#fafafa"))
.color("--color-ground", Color::hex("#161616"))
.length("--gap", space(4)),
);
}
let _iron = const theme("iron-dark");
fun main() {}
That emits one line into the build’s stylesheet:
@layer vilan{[data-theme="iron-dark"]{--color-ink:#fafafa;--color-ground:#161616;--gap:var(--space-4)}}
A Color or Length spent in a block — through raw, color or length,
which are one method — carries its own :root token line onto the sheet
exactly as a Style property does, so a ramp or spacing token used here is
never a dangling var(). The line lands when declare does: a chain that is
never declared reaches the stylesheet with nothing.
Ordering
Every block emits inside one cascade layer, @layer vilan, and that is the
whole ordering rule. Unlayered styles beat layered ones whatever their
specificity, so a Style always wins against a declaration block: a
block cannot reach in and out-specify a view’s own rules, however specific
the selector it names, and where its line lands in the stylesheet’s sort
decides nothing. Among blocks, ordinary CSS applies — specificity first,
then the sheet’s own deterministic line order.
The other face of the same rule: a block cannot override an unlayered
declaration either, which includes std’s own token lines (--space-4,
--gray-50) and any hand-written CSS the page loads. Declare your own
custom properties and read them back with Color::var / Length::var —
that composes exactly, and it is what a theme wants anyway.
The base stylesheet: preflight(). std ships no reset by default —
UA defaults are fully in force, so body keeps its 8px margin and
width(px(200)).padding(space(4)) measures 232px. const preflight()
opts in to one, and deleting the line opts back out; there is no build
flag and nothing to switch off, because the only door into the stylesheet
is a const expression:
import std::web::style::preflight;
let _reset = const preflight();
fun main() {}
It is Tailwind’s preflight scope, adapted: box-sizing: border-box
everywhere, the UA’s margins zeroed, its form-control chrome stripped so a
button inherits the page’s font and colour, replaced elements (img,
svg, video, …) laid out as blocks that cannot overflow their
container, list markers and heading sizes removed — plus one addition
that is not in any reset it is adapted from: a, button and select
are display: block. That is the most visible rule in it. An anchor in
running prose becomes a block, so a page that wants inline links styles
them back; this is an opinionated default, not a normalization.
Ordering is the whole design, and it is settled by the layer rather than by
where a line lands. Every reset rule emits inside @layer vilan.preflight,
a sub-layer of the layer declaration blocks use, and the same cascade
sentence applies twice in the same direction: unlayered beats layered, so
every Style wins against every reset rule whatever the specificity; and a
layer’s own rules beat its sub-layers’, so every declare block wins
against every reset rule too. An app tightening one of the reset’s own
rules writes declare("a", …) and needs neither !important nor a longer
selector. The reset is therefore the weakest author-origin thing in the
sheet — which is what a reset should be — and still stronger than every UA
default, because cascade layers order author declarations and the UA
origin loses to all of them.
Refusals
Checked at const time, each naming its fix:
- a selector that is blank, carries a newline — the asset channel is
line-granular, so a newline does not indent the rule, it splits it into
two independently deduplicated and sorted lines — or carries a brace,
which is
declare’s to write; - a selector that is an at-rule:
declareputs declarations under a selector, and a group at-rule (@media,@supports) holds rules, not declarations; - a block with no declarations;
- a property carrying
:or;, the two separators it owns, or a blank value. A;inside a value stays legal, so a data URI rides (url("data:image/svg+xml;base64,…")).
vilan fmt never reorders a declarations() chain. Its links are cascade
text joined in authoring order, where a style() chain’s links each own a
slot and may be sorted freely.
Runtime-legal operations
Construction records rules and therefore lives in const; these record
nothing and work anywhere:
style_a + style_b // merge: per-property, right side wins (impl Add)
style.when(condition: bool, delta: Style): Style // `self + delta` when true, `self` when not
style.class_list(): str // the space-joined class attribute (what `styled` uses)
when is the conditional merge, written as a chain instead of a stack of
ifs:
button_base
.when(is_selected, button_selected)
.when(is_disabled, button_disabled)
It never constructs a style — both sides were built, and emitted their rules,
in their own const expressions — so the construct-in-const rule is untouched
with a runtime flag in the middle. Chain order is precedence, exactly as
with +: when two whens both fire, the later delta wins whatever properties
they share.
The chain reads best when each condition mentions its own flag —
independent state axes, one link each. When one condition has to mention
another link’s flag (!selected && !disabled), the states are not independent,
and a match states that exclusivity structurally where a chain only implies
it. The compound condition is the tell; this is guidance, not a prohibition.