Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

MethodValue
displayDisplay (Flex, Grid, Block, Inline, InlineBlock, InlineFlex, InlineGrid, Hidden)
positionPosition
flex_directionFlexDirection
align_itemsAlignItems
justify_contentJustifyContent
flexstr — the shorthand, "1 1 auto"
flex_growf64
flex_shrinkf64
grid_template_columnsstr — "repeat(3, 1fr)"
gap, padding, padding_x, padding_y, margin, margin_x, margin_yLength
padding_top, padding_right, padding_bottom, padding_leftLength — one edge
margin_top, margin_right, margin_bottom, margin_leftLength — one edge
width, height, min_width, max_width, min_height, max_heightLength
sizeLength — 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, insetLength
overflowOverflow

Appearance:

MethodValue
radiusLength
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_colorColor — its own slot, so a hover can recolour without restating the width
box_shadowstr
background, colorColor
background_gradientGradient — the background-image slot
background_imagestr — the same slot, for what a Gradient can’t hold
background_sizestr — up to two components, so not a Length
font_familystr
font_sizeLength
font_weighti32
line_heightf64 — unitless, and the one to prefer (inherits as a ratio)
line_height_lengthLength — the same slot, when the leading is absolute
letter_spacingLength — usually Length::em(..)
text_alignTextAlign
text_decorationstr
white_spaceWhiteSpace
user_selectUserSelect
cursorCursor
opacityf64
transitionstr
transformstr

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: declare puts 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.

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.