Summary#
A theme block declares your app's design tokens — the named values (colors, spacing steps, radii, …)
your UI is built from. Grouping them in a theme means a value like your brand color is defined once and reused
everywhere, so restyling the app is a change in one place.
osy kit --tokens prints the tokens you already have — the starter theme the Osyrin.Ui (the UI kit) ships, by group and with
each value — which is where to look before inventing a name.
theme Default {
Colors {
Primary = "#0077B6";
OnPrimary = "#FFFFFF";
Border = "#E3E6EA";
}
Radius {
Md = 10;
Lg = 16;
}
}Tokens are organized into groups. Each leaf assignment (Primary = "#0077B6") is one token; its path
through the groups (Colors → Primary) is what makes its name unique, so two groups can both have a Md without
colliding.
The top-level groups are a closed set — the token kinds the editor understands, so Bg = ‹caret› can offer
your colors, FontSize = ‹caret› your sizes, and so on:
Colors·Space·Radius·Font·FontSize·FontWeight·Shadow·Motion·ZIndex·Breakpoints·Series·Length·Density·Touch
The font kinds are separate flat groups (Font for families, FontSize, FontWeight) rather than one mixed
Type { … }, so each token's kind is unambiguous. An unrecognized group name is a compile error with a
did-you-mean — a theme group that isn't one of these is a typo, not a silent no-op.
Signature#
theme { Colors { … } Radius { … } } — named design tokens, and references between themDescription#
Where a theme goes — and why there is nothing to wire#
Declaring it is the whole step. Put the theme block in any file the manifest's model glob already covers —
model/theme.osy by convention — and every control and page in the app is styled by it. There is no registration
call, no Theme = on the app, nothing to import: a theme is part of the model, like an entity.
The name is only a name. theme Default and theme Anything behave identically; light and dark are a property
of each TOKEN (Modes.Of(light: …, dark: …) — see [[ui-theming#modes]]), not of the block.
(Said here because its absence is what a reader goes looking for. Every other framework has a provider, a plugin or
a config key, so "I have written the tokens — now how do I attach them?" is the next question, and a page that never
answers it reads as incomplete. One run spent a call grepping this page for app.osy, Theme =, UseTheme and
attach, and found nothing, because there is nothing.)
The groups a theme may declare#
A theme's top-level groups are a closed set — anything else is a compile error with a did-you-mean. Each group feeds a family of style props, which is why the grouping exists rather than one flat list of names.
| Group | Holds | Feeds |
|---|---|---|
Colors | colours and palettes | bg:, color:, border: |
Space | the few spacings that are a decision, not a scale step | p:, m:, gap: |
Radius | corner radii — owns the Sm/Md/Lg triple | rounded: |
Font | font stacks | font: |
FontSize | type sizes, named for the ROLE the text plays | fontSize: |
FontWeight | type weights | fontWeight: |
Shadow | elevation, named for the elevation not the control | shadow: |
Motion | whole transitions, not bare durations | transition: |
ZIndex | layering policy | z: |
Breakpoints | your own width names — Layout.AtLeast(Tablet) reads these | responsive props |
Length | widths, heights, control and row heights | w:, h:, minW:, maxW:, basis: |
Series | chart series colours | plotting controls |
Length is the sizing group. Size, Density and Touch are accepted as named sub-scales of the same kind, so an
existing theme keeps compiling — but the kit spends Size on the control-size enum (size: Size.Lg), and one
spelling standing for two different things is the reason Length is what every kit example uses.
Giving a token a plain value — a color or a number#
A token most often holds a literal value — a color string or a number:
theme Default {
Colors { Primary = "#0077B6"; }
Radius { Md = 10; }
FontSize { Body = "13px"; Heading = "22px"; }
}Each literal token becomes a reusable style value your components draw from. Changing the literal changes every place that uses the token.
Can one token reuse another's value?#
A token can reference another token by name instead of repeating a value:
theme Default {
Colors {
Border = "#E3E6EA";
Grid = Colors.Border; // Grid resolves to whatever Border is
}
}Grid = Border keeps Grid pointing at Border as a living reference, not a copy — if you later change
Border, Grid follows automatically, with no need to update it or rebuild. A reference names a token by its
plain leaf name; a token in the same group is preferred, so the Border above binds to the Border in
Colors.
Referencing a token that doesn't exist is a compile error — a typo like Grid = Bordr is caught, not
silently ignored (the same way a mistyped color step like Primary.Hund is).
One seed color, a whole ramp — Palette.From#
A color token can be a whole palette instead of a single value — Primary = Palette.From("#0077B6") generates an
even ramp of shades from one seed, and Primary.Hover / Primary[600] reach its steps. See color palettes for the
full story.
theme Brand {
Colors {
Primary = Palette.From("#0077B6");
Line = Primary[200]; // a light step from the ramp
}
}Why is my token name a collision? — one leaf, one value#
A reference names a token by its group and name (Rounded = Radius.Card) — a bare leaf does not say which
vocabulary it means, since a theme token, an enum member and a style keyword are all spelled alike in that
position. The leaf still has to denote exactly one value across your whole app, because the token map is keyed
by it: declaring the same name under two different groups is a compile error:
theme Admin {
Space { Md = "12px"; }
Radius { Md = "8px"; } // error: duplicate token 'Md' declared in groups 'Space' and 'Radius'
}Name tokens by their role and the question doesn't arise — Radius { Control; Card; Pill; } reads better at
the call site (Rounded = Card) than a second Sm/Md/Lg scale would, and it can only mean one thing.
Re-declaring a name under the same group is not a collision — that is how you override a token a UI kit shipped, and both resolve to the same value slot.
Your value wins. A token you declare shadows a same-named one from the kit, exactly as your own component shadows a kit component of the same name. So a theme of your own needs to restate only what you are changing:
⚑ That holds across GROUPS too, and it is why declaring FontWeight { Normal = …; } is not an error even though
the kit ships a Motion.Normal: leaf names are one flat namespace, so your declaration takes the leaf and the
kit's becomes unreachable in your app. A use site that names the kit's group is told so —
"'Normal' is a token in the FontWeight group, not Motion" — rather than the whole theme being refused.
theme Brand {
Colors { Primary = "#0077B6"; } // shadows the kit's Primary
}
// every other kit token — Surface, Border, Danger — still appliesShadowing is per token, not per theme: the kit's other tokens keep applying, so you never have to copy a kit's whole palette to change one colour of it.
Does a length token need px? — units#
A token that holds a length carries its own unit, as a string:
theme Admin {
Radius { Card = "12px"; } // ✓ border-radius: 12px
Space { Md = "12px"; } // ✓ gap: 12px
}A bare number is emitted unitless, which is correct for ZIndex, FontWeight and Breakpoints — and wrong
for anything the browser needs a unit for. Style props are the other way round: there a bare number takes the
prop's unit (Px = 4 → 1rem), because the prop already knows what it is.
Dark mode — per-mode values#
A token can hold different values per mode — most commonly light and dark — by giving it a mode map:
theme Default {
Colors {
Surface = Modes.Of(light: "#FFFFFF", dark: "#111111");
OnSurface = Modes.Of(light: "#111111", dark: "#F5F5F5");
}
}The Light value is the default. Dark applies automatically when the visitor's device prefers a dark
color scheme — the correct colors are there on the very first paint, with no flash. You can also force a mode
explicitly (for a theme toggle) by setting data-theme="dark" on the page, which wins over the device preference.
A visitor's explicit choice is remembered across visits and applied on the first paint of their next visit —
still with no flash — so a returning user always lands in the mode they picked.
A mode value is a normal token value — a literal (as above) or a reference to another token — so everything from
the sections above applies inside a mode map too. Modes are open-ended: Light and Dark are the
common pair, but you can define others and select them with data-theme.
Making the app dark-only (or light-only) — Mode#
The device preference is the right default for an app that offers both looks. An app that has one look says
so, with a Mode setting at the top of its theme:
theme Midnight {
Mode = Dark;
Colors {
Surface = Modes.Of(light: "#FFFFFF", dark: "#111111");
Accent = "#FF2D95";
}
}Now every token resolves its Dark value by default, on any device, on the first paint. Mode = Light; does the
same in the other direction — worth writing when your app is deliberately light, because without it a visitor whose
phone prefers dark gets the dark column of every mode map you wrote.
It pins the default, not the choice: data-theme still wins, so a toggle keeps working in an app that
declares a Mode. Leave Mode out to follow the device, which is what every theme does by default.
This matters most for tokens you did not write. The UI kit's controls paint
with the kit's own Surface/OnSurface/Border tokens — and those carry mode maps. So an app whose own palette is
dark, but which never says Mode = Dark;, gets kit buttons and cards in their light colors on a light-mode
browser: a white button on a near-black page, from source that reads perfectly.
Switching mode — a toggle#
Two verbs switch the mode at runtime and remember the choice:
Theme.Toggle()— flip between light and dark.Theme.Set(mode)— apply a named mode, e.g.Theme.Set("dark")(use this when your theme has more than the light/dark pair).
Both persist the choice, so it survives navigation and the visitor's next visit (applied on the first paint, no flash). Call them from an action:
component ModeButton() {
action Flip() { Theme.Toggle(); }
render { Pressable(onClick: Flip) { Text("🌓"); } }
}You don't have to write your own — the UI kit ships a ready one. Drop ThemeToggle()
into any page (pass label to change its face, or declare your own same-named ThemeToggle to fully restyle it):
[Page("/")]
[AllowAnonymous]
component Home() {
render {
Row { Text("My app"); ThemeToggle(); }
}
}The platform ships the switching mechanism and the kit control, but injects no toggle of its own — where the button lives is your layout's decision, not the platform's.
Styling a component — variants#
A component styles itself with a variants recipe. base is what it always looks like; each dimension names one
of the component's parameters, and adds the styling that applies for its value.
theme Kit {
Colors { Surface = "#FFFFFF"; OnSurface = "#111111"; Primary = "#0077B6"; OnPrimary = "#FFFFFF"; Danger = "#C1121F"; }
Radius { Md = "10px"; }
}
enum Tone { Neutral, Primary, Danger }
enum Size { Sm, Md, Lg }
component Button(Tone tone, Size size) {
variants {
base { Bg = Colors.Surface; Color = Colors.OnSurface; Rounded = Radius.Md; Px = 4; Py = 2; }
tone { Primary { Bg = Colors.Primary; Color = Colors.OnPrimary; } Danger { Bg = Colors.Danger; } }
size { Sm { Px = 3; Py = 1; } Lg { Px = 5; Py = 3; } }
}
render { Text("Save"); }
}An enum parameter lists a block per member, as above. A bool parameter has only one thing to say, so it says it directly — these are the styles that apply when it is true:
component Rail(bool collapsed, bool drawerOpen) {
variants {
base { W = "264px"; }
collapsed { W = "64px"; } // when `collapsed` is true
drawerOpen { TranslateX = "0"; } // when `drawerOpen` is true
}
render { Text("Rail"); }
}There is no false block, because not applying the styles is exactly what false means. Writing the enum shape on a
bool (collapsed { True { … } }) is a compile error that shows you the spelling above.
Reach for a bool before minting an enum to carry one. A two-member enum RailMode { Open, Collapsed } says no more
than bool collapsed does, and it costs you at the call site: the component ends up taking the enum and a bool for
the same fact, because a variant's enum value is not something the render body can read back as a condition.
The property names are a closed vocabulary — a typo is a compile error with a suggestion, not a declaration that silently does nothing:
| Group | Props |
|---|---|
| Paint | Bg Color Border Rounded Shadow Opacity |
| Spacing | P Px Py Pt Pr Pb Pl · M Mx My Mt Mr Mb Ml · Gap |
| Size | W H MinW MinH MaxW MaxH Grow |
| Type | FontSize FontWeight |
| Placement | Position Display Overflow Inset Top Right Bottom Left Z |
| Motion | Transition TranslateX TranslateY |
Values are tokens, numbers, or keywords. A bare name is a token reference (Bg = Surface) —
which is what lets one theme restyle everything. A number takes the prop's unit: spacing props use the spacing
scale (Px = 4 → 1rem), size props are pixels (W = 280 → 280px).
Position, Display and Overflow take a keyword from a fixed set, because there is nothing a theme could
usefully say about position: fixed. A keyword is written qualified, with the prop's own name as the group —
Position = Position.Fixed; — because a bare Fixed could equally be a token you declared, and the two are spelled
alike. (A token reference stays bare, as above: the prop already decides that vocabulary.)
| Prop | Accepts |
|---|---|
Position | Position.Static Position.Relative Position.Absolute Position.Fixed Position.Sticky |
Display | Display.None Display.Block Display.Flex Display.Grid Display.InlineFlex Display.Contents |
Overflow | Overflow.Visible Overflow.Hidden Overflow.Auto Overflow.Scroll |
Responsive — one design, every width#
Declare your breakpoints as tokens, then override any style prop at any of them:
theme Admin {
Breakpoints { Compact = 640; Cozy = 960; }
}
enum RailState { Closed, Open }
component Sidebar(RailState rail) {
variants {
base {
Position = Position.Fixed; TranslateX = "-100%"; // a phone: an off-screen drawer
Cozy { Position = Position.Static; TranslateX = "0"; } // ≥ 960px: a docked rail
}
rail { Open { TranslateX = "0"; } }
}
render { Text("Sidebar"); }
}Styling is mobile-first. base is unconditional — it is what the narrowest screen gets — and each breakpoint
adds styling on top of it as the screen grows. Write it this way round and a phone never pays to undo a desktop
layout it was never going to use.
A breakpoint block may contain any style props, and may nest a pseudo-state (Cozy { Hover { … } }). Naming a
breakpoint you didn't declare is a compile error, with a suggestion.
Which width? For a component, the browser window. A component decides placement — is this a sidebar or a drawer — and placement is a property of the page, not of the box the component happens to sit in. A foreign control is the opposite: it reflows its own interior against the size of its own mount element, so the same grid becomes cards whether it is narrow because the phone is narrow or because you put it in a narrow panel. Your breakpoint tokens are visible to controls as CSS variables, so both use the same numbers.
There is no drawer or modal — building chrome from tokens#
The platform ships no drawer, no modal, no sticky header. It ships the vocabulary, so you build the one you want — and the theme still owns the policy: which layer things stack on, how fast they move.
theme Admin {
ZIndex { Overlay = 20; }
Motion { Slide = "transform 0.18s ease"; }
}
enum RailState { Closed, Open }
component Sidebar(RailState rail) {
variants {
base { Position = Position.Fixed; Inset = 0; Right = 0; W = 280; Overflow = Overflow.Auto;
Z = ZIndex.Overlay; Transition = Motion.Slide; TranslateX = "-100%"; }
rail { Open { TranslateX = "0"; } }
}
render { Text("Sidebar"); }
}Z = Overlay and Transition = Slide are ordinary token references, so layering and timing stay consistent
across every piece of chrome in the app — the same reason colors do.
See also#
- color palettes — turn one seed color into a full ramp with
Palette.Fromand its named/numbered steps. - layout primitives — the layout primitives (
gap/align/justify) you arrange components with. - component — declaring a component and its
renderblock.