Styling & theming¶
CSS in Fitz LiveViews has three layers, and you'll usually use all three:
- Design tokens —
--flv-*CSS variables that define the palette, surfaces, borders, radii. Set them once in the host<head>; everything reads them. - Scoped styles — a
<style scoped>block per component, class-mangled so it can't leak or collide. - Global styles — a
<style global>block for page-level rules.
No build step, no CSS-in-JS, no className gymnastics — plain CSS in your
.fitzv, plus a token layer you can re-theme by overriding one variable.
1. Design tokens — --flv-*¶
Every companion UI component reads its colours, surfaces and borders from --flv-*
CSS variables with a literal fallback, e.g. `background: var(--flv-surface,
fff)`. So a component renders fine with no theme loaded — and including the theme¶
lets the host re-skin the whole kit by redefining a variable.
fitz_liveviews ships the default token set as ui_theme():
That drops a <style> block defining the tokens:
:root {
--flv-color-primary: #ce412b;
--flv-color-success: #2e7d32;
--flv-color-danger: #c62828;
--flv-color-warning: #ed6c02;
--flv-color-info: #1565c0;
--flv-color-muted: #8b8b93;
--flv-surface: #ffffff; /* card / input background */
--flv-surface-2: #f5f5f7; /* subtle fill */
--flv-border: #d9d9e0;
--flv-text: #1a1a1a;
--flv-text-muted: #55555c;
--flv-radius-md: 8px;
--flv-shadow-card: 0 6px 20px rgba(0, 0, 0, .12);
}
Re-theme by redefining any of them after ui_theme():
The whole companion UI turns purple with rounder corners — no component change. The tokens carry no strong aesthetic on purpose (a neutral, Radix-style base), so they're a clean canvas to brand.
2. Scoped styles — <style scoped>¶
A component styles itself with a <style scoped> block. Its class selectors are
mangled with a per-component hash so they can't leak out or collide with
another component's .card or .title:
component badge {
state { label: Str = "", variant: Str = "muted" }
<template>
<span class="pill" data-variant="{variant}">{label}</span>
</template>
<style scoped>
.pill {
border-radius: 999px; padding: .2rem .6rem;
background: var(--flv-surface-2, #f5f5f7);
color: var(--flv-text, #1a1a1a);
}
.pill[data-variant="primary"] { background: var(--flv-color-primary, #ce412b); color: #fff; }
</style>
}
Under the hood .pill becomes .pill-badge-c-<hash> (an FNV hash of the
component + CSS), and the same suffix is added to the class in the rendered
HTML. You write plain .pill; the scoping is automatic and invisible.
Read the tokens (var(--flv-*)) rather than hard-coding colours, so a host
re-theme reaches your component too.
3. Global styles — <style global>¶
For page-level rules that should be shared (a reset, body typography, a
utility class), use <style global> — emitted verbatim, not scoped:
<style global>
body { font-family: system-ui, sans-serif; margin: 0; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; }
</style>
Reach for global sparingly — scoped is the default so components stay
self-contained.
4. Dark mode¶
Theming is per-browser and lives in localStorage — it flips a
data-theme attribute on <html>, never travels over the WebSocket. The default
ui_theme() already ships the dark token overrides:
:root[data-theme="dark"] {
--flv-surface: #1e1e22;
--flv-surface-2: #2a2a30;
--flv-border: #3a3a42;
--flv-text: #e8e8ea;
--flv-text-muted: #a0a0a8;
--flv-shadow-card: 0 6px 20px rgba(0, 0, 0, .5);
}
So any component reading var(--flv-*) is dark-mode-ready for free. For your own
dark tweaks, mirror the selector:
.badge-ok { background: #e8f5e9; color: #2e7d32; }
:root[data-theme="dark"] .badge-ok { background: #14351f; color: #7ee2a8; }
Wire the toggle with the theme_scripts helpers (boot + cycle) and the
ThemeToggle component:
from fitz_liveviews.ui.theme_scripts import theme_boot_script, theme_cycle_script
from fitz_liveviews.ui.ThemeToggle import theme_toggle, theme_toggle_render
theme_boot_script("my-theme")— in<head>, setsdata-themefromlocalStoragebefore first paint (no flash of the wrong theme).theme_cycle_script(...)— near</body>, defineswindow.flvCycleTheme(light → dark → auto) and paints the toggle button's label.<html data-theme="auto">— the starting attribute.
The Admin ABM (examples/admin.md) wires all of this.
5. Scoped styles and hydration (v0.41.5)¶
A component that hydrates (SSR → client) can carry its own
<style scoped> on its root — you don't have to move the CSS to the host
<head>. The SSR emitter server-paints the scoped <style> inline, the client
build injects it into <head> on boot, and the adopt walk skips the leftover
server-painted <style> so the DOM lines up:
component App hydrate {
state { label: Str = "shipping" }
event on_label() { label = payload["value"] }
<template>
<div class="card"><span class="lbl">{label}</span>
<input class="inp" @input="on_label" value="{label}" />
</div>
</template>
<style scoped>
.lbl { font-weight: 700; color: var(--flv-color-primary, #ce412b); }
.inp { padding: .4rem .6rem; }
</style>
}
Before v0.41.5 this styling had to live in the host page; now it co-locates with
the component. (Only the SSR/LiveView WebSocket path still forbids <style> in
the diffed root — that's a protocol constraint, not a hydration one.)
Rules of thumb¶
- Colours, surfaces, radii → tokens. Read
var(--flv-*, fallback); never hard-code a hex you'd want to re-theme. - Component look →
<style scoped>. Self-contained, collision-free. - Page reset / shared utilities →
<style global>. Sparingly. - Dark mode →
data-theme+ token overrides. Free for token-driven CSS; mirror the:root[data-theme="dark"]selector for custom rules.