Theme
The theme carries the visual identity: the colours, the shapes, the stroke, the typography. Under the bonnet it is Tailwind v4, compiled by a Rust binary — no Node.js in production.The 14 levers
Everything is set from a single object. One passes only what one changes; the rest inherits the shipped theme.| Parameter | What it settles |
|---|---|
| semantic | The 11 colour roles — `primary`, `error`, `surface`… Retint `primary` and the whole app follows. |
| semantic_dark | The same roles in dark mode. Omitted, a role keeps its light colour — only the surfaces have a dark value shipped. |
| palette | The named, fixed shades (`green`, `sky`…), served under the `ui-` prefix so as not to collide with Tailwind. |
| palette_dark | The same shades in dark mode. |
| components | The theme of one precise component — its slots, variants, sizes, modifiers. Merged with the shipped one. |
| fonts | The 3 families (sans, serif, mono) → `--font-<slot>`. |
| spacing | The step the whole spacing scale derives from: `h-10` is ten notches, like `p-4` or `gap-2`. Omitted, Tailwind's (`0.25rem`) holds. |
| text | The 13 text steps → `--text-<step>`. The middle one is called `base`, not `md` — `md` is a `size=` name. |
| shape | The 3 radius families (box, field, selector) → `--radius-<family>`. |
| stroke | The stroke width. ONE value: the two other steps derive from it in `calc()` (`-strong` ×2, `-accent` ×4). |
| css | A CSS file of the app's, injected as is — that is where a `@font-face`, a `@keyframes`, a `@supports` lives. |
| scrollbar | The scrollbar's width and colours. |
| icons | The default icon set, its style and its size. |
| base | The theme one inherits from. `base=None` starts from scratch — and you become responsible for the 11 roles. |
app = Bretzel(theme=Theme(semantic={"primary": "#27754a"}))
Tailwind under the bonnet
Every component compiles into Tailwind v4 classes. You can always add your own through `classes=` — it is the universal escape hatch, on any component. Your classes are placed last, so they win on source order.ui.button("Pay", classes="rounded-full px-8 shadow-lg")
ui.card(classes="border-2 border-dashed")
# raw Tailwind: p-4, flex gap-2, hover:bg-…, etc.
The trap that only breaks in production
An ASSEMBLED class does not survive production. `f"bg-{colour}-500"` works in dev — the browser compiler sees everything — and disappears in production, where the binary scans literal text. The HTML is identical on both sides, so it only shows IN production. Write the whole class, or go through the theme.
The semantic colours (roles)
11 ROLE slots. Their point: they remap with the theme — retint `primary` and the whole app follows. They are passed as `color=`.primarysecondarysuccesserrorwarninginfobackgroundsurfaceinterfacetextmuted
ui.button("OK", color="primary")
ui.alert("Failed", color="error")
Automatic foreground: every role has a `<color>-foreground` derived by contrast — the text stays readable on the background, in light as in dark.The Bretzel palette colours (shades)
31 NAMED, FIXED hues (not roles — concrete colours). Two ways to use them:graymauveslatesageolivesandtomatoredrubycrimsonpinkplumpurplevioletirisindigobluecyanskytealjadegreengrassyellowamberorangegoldbronzebrownblackwhite
# 1) through color= — the framework rewrites with the ui- prefix
ui.badge("New", color="green")
# → bg-ui-green text-ui-green-foreground
# 2) through classes= directly (the explicit form)
ui.badge("New", classes="bg-ui-green text-ui-green-foreground")
The `ui-` prefix avoids clashing with Tailwind's stock utilities (`bg-green-500`). And of course all the usual Tailwind stays available through `classes=`.Light and dark
The 11 roles do not behave the same when the lights go off, and the split is clean — measured on the shipped theme, not described from memory:| Family | Roles | In dark mode |
|---|---|---|
| Brand and status | primary, secondary, success, error, warning, info | colour UNCHANGED — only the foreground is recomputed |
| Surfaces | background, surface, interface, text, muted | colour replaced by its dark value |
# The current mode READS like an ambient state.
from bretzel import ColorScheme
ui.text(f"mode = {ColorScheme().mode}") # light | dark | system
# `semantic_dark=` is ONLY needed if the light mode's
# value does not work in dark — a dark green on a dark
# background, for instance.
Theme(
semantic={"primary": "#14532d"}, # readable in light
semantic_dark={"primary": "#4ade80"}, # …not in dark
)
The mode is applied before the first pixel — no white flash when loading a page in dark mode.The shapes and the stroke
Radii are set per FAMILY, not per component: `box`, `field`, `selector`. `box` for what contains, `field` for a control one aims at, `selector` for a small mark or a nested control.Theme(
shape={"box": "1rem", "field": "0.5rem"},
stroke="1.5px", # -strong ×2, -accent ×4 derive from it
)
An unknown family RAISES at startup, with the message naming the three valid ones — an invented token would be read by no class, and the radius would not move, in silence.Typography
`fonts=` declares the family for the 3 slots (sans, serif, mono); `css=` carries the `@font-face` that makes it available. They are the two halves of one question: WHAT to load, and HOW.Theme(
fonts={"sans": "Inter, ui-sans-serif, system-ui, sans-serif"},
css=Path("app/identity.css"), # @font-face, keyframes
)
No CDN, and that is a choice
Bretzel downloads no font and writes no `<link>` to a third-party CDN. The font is served from `Bretzel(static_dir=…)`, like the rest of the assets — it is the only form that makes the rendering depend on nobody else, and leaks no visitor's IP address.
The structure of a component theme
A component's theme is ONE SINGLE DICT holding everything: the `root` and the other `slots`, plus the `variants` / `sizes` / `modifiers` axes.# bretzel/components/feedback/badge/theme.py
BADGE_THEME = {
"slots": { # root + named slots
"root": "inline-flex items-center gap-1 …",
"dot": "shrink-0 rounded-full bg-current",
},
"variants": { # the colour STEPS
"soft": "bg-(--bz-bg) text-(--bz-text)",
"solid": "bg-(--bz-solid) text-(--bz-on-solid)",
},
"sizes": { # a string OR a multi-slot dict
"sm": "px-2 py-0.5 text-xs",
},
"modifiers": { # a bool reactive_prop → a class
"loading": "cursor-progress",
},
}
`compose_class("root")` assembles in order: the `root` slot, then the active `variant`, then the `size`, then the truthy `modifiers`. Every key of the dict has a precise role:| Key | Role |
|---|---|
| slots | the component's pieces (`root` + named slots) |
| variants | alternative styles — one active value at a time |
| sizes | sizes — one active value at a time |
| modifiers | bool flags — several can be on at once |
Customising — global or local
Two scopes, and they do not compose the same way: the central theme REDEFINES the entry it names, an instance override is ADDED to the theme's.| Scope | How |
|---|---|
| Global (the whole app) | Bretzel(theme=Theme(components={…}, semantic={…}, shape={…})) |
| Local (one instance) | ui.button(classes="…") or ui.button(slots={"root": "…"}) |
from bretzel.theme import Theme
# GLOBAL — applies to every Button in the app.
# An entry supplied here REDEFINES that entry of the
# shipped theme (the whole string); the rest (slots,
# variants, sizes) is preserved by merging.
app = Bretzel(theme=Theme(
semantic={"primary": "#27754a"},
components={"button": {"sizes": {"md": "h-11 px-5 text-sm gap-2"}}},
))
# LOCAL — this Button only: the string is ADDED to the slot.
ui.button("Just this one", slots={"root": "rounded-2xl"})
Theming a component that comes from elsewhere
A third-party library can publish its components so they become themable like the framework's — otherwise all that would be left is `classes=` at the call site, repeated everywhere, with no cascade and no dark-mode coherence.# the third-party package's pyproject.toml
[project.entry-points."bretzel.scan_roots"]
my-components = "my_components" # sweep my files
[project.entry-points."bretzel.components"]
my-components = "my_components" # load my code
# And in the app that installs it:
Theme(components={"gauge": {"slots": {"root": "…"}}})
Two declarations because they are two different contracts: “sweep my files” is not “load my code”. A key colliding with a framework component's is REFUSED at startup — otherwise the app would think it was styling one and would style the other.The exact contract per component (slots, variants, sizes available):ui.* catalogue →