{"version":"v1.0","client_state":{"DocsNavigation.default":{"fields":{"query":""},"persist":"memory","send_to_server":true},"ColorScheme.default":{"fields":{"mode":"system"},"persist":"local","send_to_server":true}},"endpoints":{"action":"/_bretzel/action","sse":"/_bretzel/sse","refetch":"/_bretzel/refetch"},"csrf":"f3096b96136bc92e76211b66698230e1","page_id":"103dd1c2e9f24e60b4bad9725e17d364","address":""}

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.
ParameterWhat it settles
semanticThe 11 colour roles — `primary`, `error`, `surface`… Retint `primary` and the whole app follows.
semantic_darkThe same roles in dark mode. Omitted, a role keeps its light colour — only the surfaces have a dark value shipped.
paletteThe named, fixed shades (`green`, `sky`…), served under the `ui-` prefix so as not to collide with Tailwind.
palette_darkThe same shades in dark mode.
componentsThe theme of one precise component — its slots, variants, sizes, modifiers. Merged with the shipped one.
fontsThe 3 families (sans, serif, mono) → `--font-<slot>`.
spacingThe step the whole spacing scale derives from: `h-10` is ten notches, like `p-4` or `gap-2`. Omitted, Tailwind's (`0.25rem`) holds.
textThe 13 text steps → `--text-<step>`. The middle one is called `base`, not `md` — `md` is a `size=` name.
shapeThe 3 radius families (box, field, selector) → `--radius-<family>`.
strokeThe stroke width. ONE value: the two other steps derive from it in `calc()` (`-strong` ×2, `-accent` ×4).
cssA CSS file of the app's, injected as is — that is where a `@font-face`, a `@keyframes`, a `@supports` lives.
scrollbarThe scrollbar's width and colours.
iconsThe default icon set, its style and its size.
baseThe 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:
FamilyRolesIn dark mode
Brand and statusprimary, secondary, success, error, warning, infocolour UNCHANGED — only the foreground is recomputed
Surfacesbackground, surface, interface, text, mutedcolour replaced by its dark value
In other words: a brand colour does NOT change because one switches to dark. It is the surfaces that flip, and that is enough. Every role's `foreground`, for its part, is ALWAYS recomputed by contrast — which is why the text stays readable in both modes without anyone writing anything.
# 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:
KeyRole
slotsthe component's pieces (`root` + named slots)
variantsalternative styles — one active value at a time
sizessizes — one active value at a time
modifiersbool 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.
ScopeHow
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 →