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

Theming and design languages

A Theme is a bag of resolved color tokens generated from hues:

let theme = Theme::from_accent(160.0, Mode::Dark); // teal accent

from_accent produces two 12-step OKLCH ramps (tinted neutrals + accent), status palettes (danger/warning/success), text roles, borders, surfaces, and shadows. Gamut mapping reduces chroma, never clips, so every hue is safe.

Widgets defer their colors with themed closures, because view() has no theme parameter:

div().themed(|t: &Theme, s| s.bg(t.surface).border(1.0, t.border))

Flip Mode::Light/Mode::Dark (or return a different theme from App::theme) and everything follows.

Web-grade by default: derive

When you don’t want to think in ramps at all, derive the whole palette from three inputs — Linear’s model, on fenestra’s OKLCH scales:

let theme = Theme::derive(
    BaseField { hue: 80.0, chroma: 2.5 }, // warm paper field
    40.0,                                  // terracotta accent hue
    Contrast::High,                        // crisp ink-on-paper
    Mode::Light,
);

base is the neutral field (its hue and how far it departs from gray), accent_hue the brand hue, and contrast (Low / Standard / High) scales every step’s lightness distance from the page background. from_accent and duotone are special cases — derive at Standard contrast reproduces them exactly — and every contrast level still clears the APCA floors, so derivation never ships an illegible theme. Recipes carry it too: {"mode":"light","derive":{"base_hue":80,"base_chroma":2.5,"accent_hue":40,"contrast":"high"}}.

A matching corner-radius family comes from one knob. Theme::radius is a RadiusScale the whole kit reads — buttons, inputs, selects, cards, menus, modals, tooltips, even concentric menu items resolve their corners from it — so theme.with_radius(scale) re-rounds (or un-rounds) everything at once. RadiusScale::from_base(8.0) yields {sm, md, lg, xl} at fenestra’s ratios (0.6 / 1.0 / 1.4 / 2.0 ×); the default base (10) reproduces R_SMR_XL exactly (the stock look is unchanged), RadiusScale::sharp() (1–4px) gives crisp near-square tech chrome, and RadiusScale::soft() a rounder, friendlier feel. Pills and avatars (R_FULL) stay round regardless.

The 12-step scale

Each ramp follows the Radix model, so styling is arithmetic rather than art. The neutral steps carry names:

steprolesteprole
1 bgapp background7 border_strongstrong border
2 surfacesubtle surface9 text_subtlelow-emphasis text
3 elementelement fill11 text_mutedsecondary text
4 element_hoverhovered fill12 textprimary text
5 element_activepressed fill
6 borderborder

Interaction is “+1 step”: a control rests on element, hovers to element_hover, presses to element_active. Solid accents do the same — accentaccent_hoveraccent_active (one OKLCH-lightness notch darker), and each status color carries solid / solid_hover / solid_active. Pressed colors are mode-invariant, so a button feels the same in light and dark.

Every ramp also has a translucent alpha twin (neutral_alpha, accent_alpha) — the same step rendered as the lowest-alpha color that composites over bg back to the solid value. Use a twin where a tint must read correctly over an arbitrary surface, not just the page background.

Provably legible: APCA

Because fenestra resolves every color at construction, it can prove a theme is readable — something no CSS framework can do:

let theme = Theme::from_accent(262.0, Mode::Dark);
assert!(theme.validate_contrast().is_ok()); // every text pair clears its floor

validate_contrast scores each text/background role pair with APCA (apca::lc, the APCA-W3 0.98G-4g algorithm) against a role-tiered lightness-contrast floor — primary text Lc 75 (the stock themes reach 90+), secondary text 55, control labels 60, colored component text 40 — and returns the pairs that fall short. The built-in themes and every shipped Look are asserted to pass in headless tests, and your own themes can be too. (APCA scores text legibility, so borders and other non-text contrast aren’t checked.)

The role floors are fixed per tier, but two helpers go finer. APCA is the draft WCAG-3 contrast method, and apca::required_lc(size_px, weight) exposes its readability criterion as a function: small or thin text needs more Lc, large or heavy text less. Feed it to theme.contrast_ok(text, bg, size_px, weight) to prove a specific label legible at its real rendered size — not just against a tier average. And theme.text_on(bg) returns the more-legible ramp extreme — a theme-tinted neutral that reads strongly on any custom or status surface (the on_accent rule generalized to colors the theme never generated; on a hard mid-tone, where no color reads well, it lands at secondary-text grade):

let theme = Theme::light();
// A 13px caption needs more contrast than 16px body text:
assert!(theme.contrast_ok(theme.text, theme.surface, 13.0, 400.0));
// Legible text for an arbitrary brand surface, picked from the ramp ends:
let label = theme.text_on(brand_color);

The role floors (75/60/55/40) are unchanged regression sentinels; required_lc now anchors them to the same APCA scale (required_lc(16, 400) is the primary-text floor, 75).

Elevation

Shadows are layered (a tight contact shadow under a soft ambient one) and tinted with the surface hue at low chroma rather than flat black, so an editorial green field casts a green-black shadow. ShadowToken runs Xs/Sm/Md/Lg/Xl; the Xl token is a three-layer overlay shadow for modals. In dark mode, elevation lightens surfaces (elevated_surface(level)) rather than relying on shadows. Solid controls can carry a 1px inset top highlight (.highlight_top(color)) — the subtle top sheen that reads as “raised.”

Rather than re-typing radius + fill + border + shadow at each call site, a Surface material bundles them per elevation roleCard, Raised, Popover, Menu, Modal, Thumb, Tooltip — resolved against the theme. el.surface(Surface::Menu) gives a floating panel its whole look in one call; floating roles carry radius and shadow depth at or above the resting card by construction, so every floating thing matches. The kit’s cards, menus, modals, toasts, tooltips, and slider thumbs all derive from this one table.

For an edge that sits outside the box, .ring(width, color) draws a crisp band hugging the corner radius (the “ring, not border” look) — rendered as a zero-blur spread shadow, so unlike .border(..) (an edge stroke) it never covers content and recolors with zero layout cost. Reach for it for selection and focus rings and sub-pixel hairlines; it stacks and composes with shadows.

How a resting card separates is a knob too: Theme::elevation is Shadowed (the stock subtle card shadow) or Flattheme.with_elevation(Elevation::Flat) drops the shadow on resting Card/Raised surfaces in favor of their border and surface tone-step. That’s sharper, and the honest choice in dark mode where shadows barely register; floating roles (menus, popovers, modals, tooltips) always keep their shadow. The console Look ships Flat.

Borders resolve per edge, too: alongside the uniform .border(w, color), .border_top/right/bottom/left(w, color) draw straight hairline edges — a header’s bottom rule, a left accent rail, a ruled table row — with no manual 1px divider child.

Typography

Letter spacing follows Inter’s dynamic-metrics tracking curve at the actual font size (positive at caption sizes, tightening as text grows), so display sizes are tracked correctly without hand-tuning. Tabular figures are one call — .tabular() — for tables, timers, and any numbers that align in columns or update in place:

text(format!("{revenue:>10}")).tabular()

Interaction, motion, and sizing tokens

Interaction is tokenized too, so the whole kit moves and reacts in one language (the Interactivity chapter shows the builders). The state layer (STATE_LAYER) is the veil a control lays over itself — hover 8%, focus and press 12%, drag 16%. Motion lives in MotionDuration (100–300 ms) and the Material easing curves EASE_STANDARD / EASE_DECELERATE / EASE_ACCELERATE; PRESS_SCALE (0.97) is the pressed-control dip. The focus ring is FOCUS_RING — a 3px halo at 0.5 alpha flush outside a ring-colored border, recolored to the danger hue when a control is .invalid(true).

Control sizes share a height grid so a row of mixed controls lines up:

sizeheightfontsizeheightfont
Xs24pxXsMd36pxSm
Sm32pxSmLg40pxBase

ControlSize::metrics() resolves the full bundle — height, padding, gap, font, icon edge — the kit’s buttons, inputs, and selects build from.

One Density knob packs that grid tighter or looser: ControlSize::metrics_at(Density::Compact) shrinks height/padding/gap/icon for dense pro-tool UIs, Spacious loosens them, and Comfortable (the default) is the table above — byte-identical to before density existed. Density scales spacing, not type: the label font stays tied to the ControlSize, so text never shrinks below its legible size.

The ControlSize-driven widgets take it directly — button("Save").density(Density::Compact), and likewise icon_button, text_input, and select — so a form restyles by passing one value per control (fenestra has no style inheritance, so density is explicit per widget, not ambient). density_showcase renders all three side by side.

Optical adjustments

Some shapes have to measure “wrong” to look right, because the eye weighs the area near a boundary. fenestra_core::optical carries the corrections: CIRCLE_OVERSHOOT (~1.1284 — a circle must be ~12.84% larger than a square to read as the same size; overshoot(size) applies it) and centroid(vertices), the visual-mass center used to nudge an asymmetric shape — the play triangle shifted right off its bounding-box center so it sits centered in its circle.

path() icons take these as builders: .optical_overshoot() scales a round or pointed glyph up so it matches square-edged neighbors, and .optical_center() seats an asymmetric glyph on its centroid (the play-button nudge). Both are opt-in per icon — fenestra has no style inheritance and auto-detecting which icons need correcting would silently shift every existing one — so an uncorrected path renders byte-identically.

Beyond the SaaS look

Theme::duotone(neutral_hue, neutral_chroma, accent_hue, mode) builds atmospheric fields — deep green, warm paper — instead of near-gray neutrals. Custom faces register under font roles:

let mut fonts = Fonts::embedded();
fonts.register(FamilyRole::Display, playfair_bytes.to_vec());
text("Evolution").family(FamilyRole::Display).size_px(148.0);

The repository’s poster example reproduces an editorial study-guide cover this way — golden-tested like everything else. The point: design languages are code, and beauty is testable.

For the most expressive surfaces, fenestra_core::effects generates textures: mesh(w, h, &[MeshPoint]) is a multi-point gradient field (the Stripe “liquid light” look, blended in OKLab from theme-token colors), and grain(w, h, seed, intensity) is deterministic film grain to break up banding. Both return RGBA8 for image_rgba8 — generated, not shaded, so they golden-lock too.

Theme files

Themes serialize as recipes — the few numbers a theme generates from, not hundreds of resolved colors:

{"mode": "dark", "duotone": {"neutral_hue": 152.0, "chroma": 6.0, "accent_hue": 72.0}}

ThemeSpec::from_json(s)?.theme() resolves through the same builders (Theme::dark, from_accent, duotone); spec.to_json() writes one. Unknown fields are errors, so a typo’d recipe fails loudly instead of silently falling back. Recipes stay tiny, hand-editable, and stable across fenestra versions.