Styling & theming¶
Per-widget style= overrides (Flutter-style) and re-theming the whole pegment design system at once.
At a glance:
# Any surface widget takes a Flutter-style `style={...}` of CSS overrides:
Button("Save", style={"background": "#16a34a", "borderRadius": "999px"})
Styling & the design system¶
Every widget renders with a stable pg-<type> class — pg-text,
pg-column, pg-textinput, and so on. This is the selector the Pegment design
system (Workpeg's stylesheet) targets, so a Peg picks up a consistent look without
any styling code. The pg- prefix scopes the stylesheet to Workpeg widgets — it
won't leak onto anything else on the host page.
Pegment is Workpeg's full design system (mirrored to cdn.workpeg.com): a token
set (--color-*, --text-*, with light and dark palettes), a reset,
typography, and components. It styles components (buttons, inputs, cards,
text, gaps) — it does not lay out the page. The root (#workpeg-root) is just
a full-height flex column, so the Peg owns its own width, padding and centring.
Text("Title") # → <span class="pg-text">
Button("Save") # → <button class="btn btn--primary pg-button">
A few widgets map onto Pegment's native components: Button renders
Pegment's .btn (btn--primary / --secondary / --ghost / --error),
Badge its .badge, and GravatarImage its .pg-av. Everything else is the
pg-<type> widget layer. (The inert pg-button marker is still added so custom
CSS/tests can target it.)
Variants are modifier classes. Pass variant="<name>" and the widget gains a
pg-<type>-<variant> class alongside the base, which the stylesheet can style
differently (for Button, the variant maps to Pegment's .btn--<variant>):
Button("Cancel", on_click=self.cancel, variant="secondary") # .btn .btn--secondary
Text("Heading", variant="heading") # .pg-text .pg-text-heading
Container(child, variant="card") # .pg-container .pg-container-card
Per-widget style={...}. For one-off overrides, pass a style dict of CSS
properties — Flutter's ElevatedButton.styleFrom(...). It's applied as inline
styles, which win over the pegment class, and every value may be dynamic
(state, ternaries, f-strings) so a widget can restyle reactively:
Button(
"Red" if self.flag else "Green",
on_click=self.toggle,
style={"background": "#e23b3b" if self.flag else "#0a7a4f"},
)
Text("Big", style={"fontSize": "22px", "fontWeight": "800"}) # camelCase ok
Container(child, style={"background": "#111", "borderRadius": "14px"})
Interaction states — :hover, :active, :focus. A pseudo-class can't be
an inline style, so the same style dict takes them as nested dicts: a
:state key maps to a dict of overrides that apply only while the widget is in
that state. The dict is the whole API — no extra state, no on_mouse_enter
handlers. Put the animation on the base style with transition, and the
state block sets just what changes:
# A ti-selector icon that spins 180° on hover:
Icon("selector", size=30,
style={"transition": "transform .25s ease",
":hover": {"transform": "rotate(180deg)"}})
# A button that lifts on hover and presses in on click:
Button("Save", on_click=self.save, style={
"transition": "transform .12s ease, box-shadow .12s ease",
":hover": {"transform": "translateY(-2px)", "boxShadow": "0 6px 16px rgba(0,0,0,.18)"},
":active": {"transform": "translateY(0)", "boxShadow": "0 2px 6px rgba(0,0,0,.22)"},
})
The state allowlist is fixed — :hover, :active, :focus,
:focus-visible, :disabled, :current, :selected, :link-hover,
:rest — and a block is plain CSS (states can't nest, and there are no
descendant or arbitrary selectors), so a style dict can never grow into a
free-form stylesheet.
:rest — a resting value a state must override. Plain style keys compile
to inline styles, and no class rule — not even :hover — can beat an
inline value. So the moment a state needs to change a resting value, that
resting value must move into :rest: it lands in the stylesheet as the
element's baseline (.cls { … }), which .cls:hover { … } out-specifies.
Leave it as a plain key and the hover will silently never fire:
# WRONG — inline color pins the icon; :hover can never change it
Container(Icon("star"), style={
"color": "var(--color-icon-secondary)",
":hover": {"color": "var(--color-text-primary)"}})
# RIGHT — the resting color lives in :rest, so :hover wins on hover
Container(Icon("star"), style={
":rest": {"color": "var(--color-icon-secondary)"},
":hover": {"color": "var(--color-text-primary)"}})
:link-hover — revealed by the enclosing link. Its block applies while an
enclosing <a> is hovered (the rule compiles to a:hover .cls), which is
how a nav row reveals its trailing controls: park the hidden state in :rest,
the revealed state in :link-hover, and hovering anywhere on the row shows
them — no handlers, no state:
Container(Row(Icon("pin"), Icon("star")), style={
"transition": "max-width .18s ease, opacity .18s ease",
":rest": {"max-width": "0", "opacity": "0"}, # collapsed
":link-hover": {"max-width": "120px", "opacity": "1"}, # row hovered
})
:current is the URL-aware one: it applies to a Link whose href matches the
current page (the router marks that link aria-current="page"). Use it for a
selected/active nav highlight — because it's driven by the URL and re-applied on
every render, it survives client-side navigation and a full page refresh,
unlike a :hover/click-only state:
Link("Kanban", href=f"/b/{route.business_id}/kanban", style={
"borderRadius": "8px",
":hover": {"background": "var(--color-surface-sunken)"},
":current": {"background": "var(--color-surface-sunken)"}, # the active page
})
:current also matches a calendar's today, which carries
aria-current="date": in a
CalendarDatePicker's
day_style, ":current" styles the day that is today. One key per state,
whatever the attribute that says so, and the rule has the specificity
:current always had.
:disabled — a control that cannot be used. The pseudo-class, and a
control that carries aria-disabled="true" instead: a calendar's disabled
day stays a focusable button so a screen reader can reach it and say why
it cannot be picked, and ":disabled" in day_style styles it. Nothing
that had a :disabled block before changes: the only other widget that
sets aria-disabled is a dropdown's option row, which takes no style=.
:selected — a control in its selected state. Like :current, it is
not a CSS pseudo-class but the attribute the runtime sets: it applies while
the widget carries aria-pressed="true", aria-checked="true" or
aria-selected="true", so one key covers a pressed toggle and a checked
one alike, and the state that is drawn is the state that is announced. It
is written for ToggleButton, whose own
selected look it beats; Chip and Pill mark their selection with classes
of their own and do not match it. Like the stylesheet's own selected state,
it never applies while the control is disabled, so a disabled toggle keeps
its disabled look and your own :disabled block is what restyles it.
ToggleButton(Text("Pin"), selected=self.pinned, on_change=self.set_pinned,
style={":selected": {"background": "#16a34a",
"color": "#ffffff"}})
A toggle's colours are also design tokens (--toggle-btn-bg-selected,
--toggle-btn-label-selected, …), and setting a token is the gentler
override: every state that reads it keeps working. See
Styling a toggle.
Each distinct block is compiled once into a single SDK-owned <style> and
the element tagged with the generated class (pg-st<n>); identical blocks —
repeated widgets, re-renders — share one class, so the sheet can't grow
without bound. Values are re-checked with the same safety rules below, so
nothing unsafe reaches a rule either. (Plain, non-:state values stay inline
as before.) A custom property (--my-token) works inside a block as it does
outside one, its name kept as written. Because a block is rule text in one
shared sheet, a value must close what it opens: an unclosed quote or
bracket ('"red', "rgb(0, 0, 0") or a line break would have swallowed every
state rule after it, so a literal one is a compile error and one that
arrives from state is dropped. A quoted value that closes —
'"Inter", sans-serif' — is fine.
Only widgets that paint a surface are stylable — Container, Text,
Button, Link, Image, the form controls (TextInput, TextArea, Select,
Checkbox, Slider, RadioGroup), the toggles (ToggleButton,
ToggleGroup), Dot, CalendarDatePicker, CalendarDateRangePicker,
YearPicker, TimePicker, DateSegments, ButtonGroup, CalendarCard,
Table, and ListView. Pure layout and
structural primitives have no box of their own, so style= on them is a compile
error (SizedBox() does not support style=); this includes the multi-child
containers (Row, Column, Stack, Wrap, GridView) and the flex/constraint
wrappers (Expanded, Spacer, Padding, Center, ConstrainedBox, …). To
style a layout, wrap it in a Container. The allowlist is opt-in, so a new
widget is non-stylable until it's explicitly added.
Keys are CSS property names — kebab-case, with camelCase accepted and converted.
It is CSS-only and safe: values are checked at compile time — a literal,
and a module constant or an f-string with nothing to fill in, which the
compiler knows just as well — and dynamic values again at runtime, and
rejected if they contain a resource-loading
function (url(...), image-set(...), cross-fade(...)), expression(...),
javascript:, angle brackets, or anything that could break out of the
declaration; each is set via el.style.setProperty, which cannot inject extra
rules or scripts. So this is a controlled override of pegment, not a CSS/JS
injection hole.
Interaction events beyond on_click. Surface widgets (the same
style=-able set above) also take a curated set of interaction handlers — each a
self.<method> reference or a lambda, exactly like on_click:
| Keyword | Fires on | Handler gets |
|---|---|---|
on_double_click |
double-click | — |
on_mouse_enter / on_mouse_leave |
pointer enters / leaves | — |
on_focus / on_blur |
focus enters / leaves (bubbling, so it catches a composite widget's inner field) | — |
on_key |
a key is pressed | the key string, e.g. "a", "Enter" |
Container(Text("Hover me"),
on_mouse_enter=self.hover, on_mouse_leave=self.unhover)
TextInput(value=self.q, on_change=self.set_q, on_key=self.shortcut) # key → method(key)
They're bound centrally (one place, all widgets), so they reject on a non-surface
widget just like style= (wrap a Row/Column in a Container).
Tooltips. A hover bubble is a single floating element the runtime appends to
<body>, so it appears immediately (unlike the native title's ~1s delay)
and is never clipped by a scrolling sidebar. It dismisses on any click or
scroll. No state or re-render needed. Two ways to add one:
title=prop — the quick one-off on any surface widget (top placement, default theme), which may be dynamic:
Tooltip(child, …)widget — full control. Chooseplacement("top"default ·"bottom"·"left"·"right") and theme it with acolor(else it inherits the pegment tokens); a matching arrow points at the child.placementis a preference — the bubble auto-flips to the opposite side and clamps to stay on screen when it would overflow (so a"top"tooltip on a top-edge button shows below instead, never clipped). It's transparent — adds no box, so the hover area and layout are exactly the child's:
Tooltip(Button("‹", on_click=self.toggle),
text=f"{'⌘' if platform()=='mac' else 'Ctrl'}+\\ toggles the sidebar",
placement="bottom")
Tooltip(icon, text="Delete", placement="right", color="#e23b3b")
Resizing. Drag-to-resize is a runtime gesture (mouse-driven, no state or
re-render) on two surfaces. A grip on the edge shows a col-resize cursor and
tints on hover; an optional key persists the dragged size across re-renders
(without one a resize lasts until that subtree next re-renders — fine for a
Shell sidebar, which rarely repaints).
Resizable(child, …)widget — make one child horizontally resizable by dragging a grip on its edge, like a panel border. The child needs a definite starting width (e.g.Container(width=…));min/max(px) clamp the drag;sideis the grip edge ("right"default or"left"). It's transparent — adds no box, so layout is exactly the child's:
Table(resizable=True)— Excel-style column resizing. A grip on each header cell's right edge drags that column's width; columns keep natural content sizing until the first drag, which locks in pixel widths.min_col/max_col(px) clamp every column;keypersists the widths:
Table(
TableRow(Text("Name"), Text("Lang"), Text("Score")),
TableRow(Text("Ada"), Text("Python"), Text("99")),
border=1, resizable=True, min_col=70, key="scores",
)
A global shortcut with on_shortcut. Register a Cmd/Ctrl + key combo
(⌘ on macOS, Ctrl elsewhere — handled for you) in on_mount; it's tied to
the page and removed on navigation, so on a Shell it lives as long as the shell
does:
from workpeg.ui import on_shortcut, platform
class AppShell(Shell):
open = State(True)
def on_mount(self): on_shortcut("\\", self.toggle) # ⌘\ / Ctrl\
def toggle(self): self.open = not self.open
Pass modifier=False for a plain key (no Cmd/Ctrl) — e.g. f to toggle
fullscreen. Plain-key shortcuts are ignored while you're typing in a form field,
so they never hijack a keystroke: on_shortcut("f", self.go_fs, modifier=False).
platform() returns the OS family — "mac", "windows", "linux" or
"other" — read like color_scheme(), e.g. to show the right modifier:
Text(f"{'⌘' if platform() == 'mac' else 'Ctrl'}+\\ to toggle").
Real fullscreen with fullscreen. The fullscreen sentinel drives the
Fullscreen API (like pressing F on a video). Call the toggles from an event
handler (they need a user gesture); read fullscreen.active() in build() to
react — the page re-renders whenever fullscreen changes, including the user
pressing Esc:
from workpeg.ui import fullscreen, on_shortcut
class Chart(App):
def on_mount(self): on_shortcut("f", self.go_fs, modifier=False)
def go_fs(self): fullscreen.toggle() # also enter() / exit()
def build(self):
return Container(
...,
# an exit button shown only in fullscreen
Button("Exit", on_click=self.go_fs, style={
"position": "fixed", "top": "14px", "left": "14px",
"display": "block" if fullscreen.active() else "none"}),
)
It fullscreens the page (the persistent mount root, so it survives re-renders — fullscreening a widget that gets rebuilt would drop straight back out).
The stylesheet is bundled — and it is the only one. Every build copies
pegment.css next to the app and links it locally in <head>, before the app
scripts, so the design system is in place when the Peg mounts and the Peg is
self-contained (no runtime dependency on the network):
- No raw CSS or JS injection. A build never emits a raw
<style>/<script>or an author-written stylesheet: the bundled stylesheet is the SDK-ownedpegment.css, fixed and not configurable. Per-widgetstyle={...}is the only custom-CSS surface, and it's a controlled one — validated property/value pairs, set viael.style.setProperty(plain values) or written into a single SDK-owned<style>for:stateblocks. That state sheet is generated, not author CSS: it holds only.pg-st<n><state> { … }rules built from the same validated pairs (fixed-allowlist states, no selectors), so it can't introduce arbitrary rules or scripts (see above). The only external resource a build will link is an allowlisted Google Font (see Web fonts below) — never an arbitrary stylesheet. (pegment.cssis developed in the SDK and also published tocdn.workpeg.comvia CI; a build uses its bundled local copy, not the online one.) - Theme via tokens. Pegment's design tokens are CSS custom properties on
:root(--color-brand-primary,--radius, …), so a theme overrides values, not rules — seeThemebelow. - Inline styles still win. The compiler's own prop-driven inline styles
(
Container(color=…),Image(width=…)) take precedence over the stylesheet, so explicit widget props always render as written. - Naming is a contract. The
pg-<type>/pg-<type>-<variant>convention is what Pegment is written against; it's stable across builds.
variant=is supported onButton,Text, andContainertoday (pegment shipssecondary,heading/caption/muted, andcard); it extends to the other widgets on the same keyword.
Calendar tokens¶
A calendar's look is a set of design tokens it reads but never declares:
each is written var(--calendar-…, <the design's value>), so it has a
default in every theme and a value set on any ancestor reaches every day
inside. Set them on one calendar with its style=, or on a Container
around several. The card tokens (--calendar-card-*, --calendar-radius,
--calendar-pad) draw a CalendarCard too, with
the same rule. A date or time field's calendar and wheel open in a
popover outside the page's tree, which takes the tokens set around the
field, or on it, as it opens (Picking from a calendar or a
wheel):
from workpeg.ui import App, CalendarDatePicker
class Compact(App):
def build(self):
return CalendarDatePicker(style={
"--calendar-day-size": "32px",
"--calendar-day-bg-selected": "#16a34a",
})
| Token | Default | What it sets |
|---|---|---|
--calendar-width |
seven days and the padding: 300px at the defaults |
the card's width |
--calendar-pad |
16px |
the card's padding |
--calendar-radius |
24px |
the card's corners |
--calendar-gap |
12px |
between the header, the weekday row and the weeks |
--calendar-card-bg |
var(--color-surface-default) |
the card's fill |
--calendar-card-border |
1px solid var(--color-surface-default-subtle) |
the card's border |
--calendar-card-shadow |
the design's two-layer shadow | the card's shadow |
--calendar-day-size |
36px |
a day's circle, and the height of a week; the card widens with it |
--calendar-day-radius |
999px |
a day's corners |
--calendar-row-gap |
2px |
between weeks |
--calendar-day-fg |
var(--color-text-primary) |
a day's number |
--calendar-day-bg-hover |
var(--color-surface-raised) |
a hovered day |
--calendar-day-fg-outside |
var(--color-text-disabled) |
a neighbouring month's day |
--calendar-day-fg-disabled |
var(--color-text-disabled) |
a day that cannot be picked |
--calendar-day-bg-today, --calendar-day-fg-today |
the brand blue's 15% tint, the brand blue | today |
--calendar-day-bg-selected, --calendar-day-fg-selected |
the brand blue, white | the day picked |
--calendar-dot-size, --calendar-dot-offset |
3px, 5px |
a marker dot, and how far above a day's bottom it sits |
--calendar-year-w, --calendar-year-h |
80px, 32px |
a year in the year view |
--calendar-year-max-h |
220px |
the year view's height before it scrolls |
--calendar-fade |
32px |
how far the year view fades at an end with more to scroll to |
--calendar-range-bg |
the brand blue's 15% tint | the band under a range's days |
--calendar-range-preview-bg |
the brand blue's lighter tint | the preview of the range a press would make |
--time-picker-h |
220px |
a time wheel's height |
--time-picker-cell, --time-picker-gap |
36px, 8px |
a wheel's cell, and the space between two; the ring is a cell and 8px |
--time-picker-col |
36px |
the narrowest a wheel's column is (a long AM/PM word widens its own); the card is as wide as its columns |
--time-picker-radius, --time-picker-border-w |
16px, 1px |
the wheel's card |
--time-picker-fade |
44px |
how far a column fades at an end with more to scroll to |
--date-input-h |
40px |
a standalone date or time field's height; fused into an Input (every DateInput, TimeInput and DateTimeInput) the Input's 40px or 44px box is its own |
--date-input-radius, --date-input-pad |
12px, 14px |
a standalone field's corners, and its padding at either side |
A CalendarCard sets one more, which the
stylesheet never reads: --calendar-row-max, 300px inside the card and
unset outside it, how wide the rows the
calendar library builds may grow, one month's width.
Set it on the card's own style= to change it. The library's
preset tiles read one of their own,
--cal-preset-trailing, the colour of a tile's trailing words: the
placeholder grey until it is set, and brand blue on a pressed tile whatever
it is set to.
For a touch screen, raise --calendar-day-size ("44px" meets WCAG's
enhanced 44-pixel target size); the card's default width follows
it, so the days stay inside the card and under their weekday headers. A
--calendar-width you set yourself is kept as it is.
For one state of every day, day_style takes a style= dict with state
keys (":hover", ":selected", ":current", ":disabled"), and
year_style the same for every year of the year view, where ":current"
is this year; a token is the gentler override, since every state that
reads it keeps working. A year reads the day tokens for its colours, so
retinting the picked day retints the picked year too. In Windows High
Contrast the picked day or year is the system's Highlight colour, today
is outlined, a day that cannot be picked is GrayText, and focus is a
CanvasText outline; a range's band is drawn as Highlight lines. In a
range picker every day of the
range carries aria-selected, so ":selected" in its day_style
matches all of them, the days between the ends included; to retint only
the band, set --calendar-range-bg. A time wheel's picked row is drawn
with the same tokens, the band's tint behind it and the picked colour as
its text and ring, so a calendar and its wheel retint together; its cells
share the days' hover, focus and disabled looks. Like the rest of pegment.css, the
calendar's styles are bundled into each peg: a peg already deployed draws them only after it
is rebuilt.
Web fonts: fonts = [...]¶
The one sanctioned exception to "no external resources" is a Google Font.
Declare a fonts list — Google Fonts stylesheet URLs — once, as a class attribute
on your Router (or a single App); the build injects them as <link> tags in
the head (with the usual preconnects), then point --font-base at the family
with a Theme or a style=:
from workpeg.ui import App, Router, Route, Theme
class MyApp(Router):
fonts = ["https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap"]
theme = Theme(font="'Orbitron', sans-serif") # use it everywhere
routes = [Route("/", Home)]
# …or scope it to one widget:
Text("01:23", style={"fontFamily": "'Orbitron', monospace"})
It is strictly allowlisted: each URL must be https:// on
fonts.googleapis.com or fonts.gstatic.com, spelled exactly that way —
anything else (an arbitrary stylesheet, http://, a look-alike host, a tracker,
a backslash, a port, a space, a user@) is a compile error. The
allowlist is re-checked and every URL HTML-escaped at the actual injection point
in the build, so a Peg can pull a Google Font but can never smuggle a foreign
resource into its head. fonts is head-only — it never reaches the generated JS.
Nothing a Peg loads by default leaves its own origin
The base UI font, Geist, is self-hosted — served from your own
dist/fonts/ alongside pegment.css, not fetched from Google. Declaring
fonts is the one thing in a normal build that reaches a third party.
(Two opt-in widgets also do: GravatarImage fetches from gravatar.com,
and the AI chat surface fetches source favicons from Google.)
Declaring one changes that, and it is worth knowing where: Google is told the address of every page that loads the font. On a Peg that address is your public hostname, which is not a secret — but it is a request your app would not otherwise make, which is why the base font is self-hosted and this is opt-in.
Theme: re-theme the design system¶
To customise the look from Python — without writing or injecting any CSS —
override Pegment's design tokens with a Theme. Declare it once as a
class attribute on your Router (or a single App); each keyword sets the
matching Pegment custom property on :root, so every widget re-themes at
once:
from workpeg.ui import App, Router, Route, Theme
class MyApp(Router):
theme = Theme(primary="#0a7a4f", radius="12px", font="Georgia, serif")
routes = [Route("/", Home)]
| Token keyword | Pegment property | Controls |
|---|---|---|
primary / primary_fg |
--color-brand-primary / --color-brand-on-primary |
Accent colour and its foreground |
fg / muted |
--color-text-primary / --color-text-secondary |
Body and secondary text |
page |
--color-bg-canvas |
Page (canvas) background |
bg / surface / sidebar |
--color-surface-default |
Raised-surface backgrounds |
border |
--color-border-subtle |
Borders / dividers |
radius / gap |
--radius / --space-sm |
Corner rounding / default spacing |
font |
--font-base |
Font family |
transition |
--transition |
Transition timing for interactive states |
Unknown keys are rejected at compile time. Pegment also ships transitions on
the interactive widgets timed by --transition, so hover and focus states
animate smoothly out of the box — retune them with Theme(transition="300ms ease").
Token values run the same safety check as a style= value — a
resource-loading function (url(...), image-set(...), cross-fade(...)),
expression(...), or a declaration-breakout character is a compile error.
(The tokens feed background: var(--token) rules, so this keeps a theme from
smuggling in an external fetch.) The structured colour props —
Container(color=…) and Tooltip(color=…) — are validated the same way.
Values are CSS strings ("#0a7a4f", "12px", "Georgia, serif"). It compiles
to one Workpeg.applyTheme({…}) at bootstrap, which calls
document.documentElement.style.setProperty(...) for each token — no
stylesheet or <style> is emitted, only the design system's own variables are
set. This is the sanctioned override path: pick variants for predefined
looks, set a Theme to re-tone everything, and use a widget's structured
style props (Container(color=…)) to fine-tune one widget.
Light & dark: set_color_scheme¶
Pegment ships full light and dark token palettes, so dark mode needs no
hand-picked colours. set_color_scheme("dark" | "light" | "system") switches
between them by toggling the .dark / .light class on <html>; "system"
removes both, so the OS prefers-color-scheme decides. The class lives on
<html>, so the choice survives navigation:
from workpeg.ui import App, Row, Button, set_color_scheme
class Page(App):
def build(self):
return Row(
Button("Light", on_click=self.light, variant="secondary"),
Button("Dark", on_click=self.dark, variant="secondary"),
Button("System", on_click=self.system, variant="secondary"),
)
def light(self): set_color_scheme("light")
def dark(self): set_color_scheme("dark")
def system(self): set_color_scheme("system")
The mode is a literal ("dark" / "light" / "system"), so use one small
method per option. A fresh page load starts in "system" until you set it.
Bespoke palettes with set_theme. For a custom look beyond light/dark — a
brand colour, a different radius — call set_theme(Theme(...)) from a handler.
It overrides the named Pegment tokens on :root app-wide (a clean swap — the
previous override is cleared first, so pass a complete Theme; Theme() clears
back to Pegment's defaults):
from workpeg.ui import App, Button, Theme, set_theme
class Page(App):
def brand(self): set_theme(Theme(primary="#0a7a4f", primary_fg="#fff"))
def default(self): set_theme(Theme()) # back to Pegment's brand
def build(self):
return Button("Use brand colour", on_click=self.brand)
Read the OS preference with color_scheme(). It returns "light" or
"dark" (the CSS prefers-color-scheme) at render time — handy to display the
current preference or branch on it. (For just following the OS, prefer
set_color_scheme("system"), which does it in CSS with no re-render.)
on_color_scheme_change(self.method) (registered in on_mount) still fires a
self.<method> when the OS preference flips live — useful if you mirror the
scheme into your own State. The watcher is tied to the page and removed on
navigation. Compiles to Workpeg.onColorScheme.
The dropdown panel in hand-written markup¶
Most of pegment.css styles what the compiler emits. The dropdown panel is
the exception: .dropdown and its parts are ported from the design system
for markup outside a peg's page — a business's own website, or markup a peg
function returns — and no widget emits them. A peg's own page is Python only, so
this is not a way to add markup to one.
<div class="dropdown" role="menu" aria-label="File actions">
<span class="dropdown__section-label">Account</span>
<div class="dropdown__item dropdown__item--selected" role="menuitem" tabindex="0">
<i class="ti ti-user"></i>
<span class="dropdown__label">Profile</span>
<span class="dropdown__check"><i class="ti ti-check"></i></span>
</div>
<div class="dropdown__divider"></div>
<div class="dropdown__item dropdown__item--danger" role="menuitem" tabindex="0">
<i class="ti ti-trash"></i><span class="dropdown__label">Delete</span>
</div>
</div>
| Class | What it is |
|---|---|
dropdown__item |
a row; add --selected, --active, --danger, --disabled |
dropdown__label |
the row's text; truncates with an ellipsis |
dropdown__meta |
right-aligned auxiliary text (a shortcut, a count) |
dropdown__check / dropdown__arrow |
trailing tick, or a submenu chevron |
dropdown__item--rich + dropdown__content + dropdown__desc |
a two-line row |
dropdown__section-label / dropdown__divider |
a heading, a rule |
dropdown__search / dropdown__search-input |
the filter row |
dropdown__dot |
a leading status dot |
--selected is a picked value and --active is the thing you are looking
at — they paint the same and mean different things, which is why both names
exist. Neither a section label nor a divider takes pointer events, so the
gap between two groups cannot swallow a click aimed at the row beneath it.
Three panels look like this and only two share these names. The Menu
widget's panel takes the same chrome from the same rule, so the two cannot
drift. The picker's panel (Select) is fused into the field system and
keeps that vocabulary — its metrics were matched separately.
Scrollbars¶
Pegment restyles every scroll region's scrollbar — a scrollable Column,
a ListView, the app chrome's sidebar and menus — to one subtle look: thin,
a muted rounded thumb on a transparent track, darkening on hover (and
scrollbar-width: thin on Firefox). There's nothing to opt into and no knob;
it exists so a mid-page scroll area doesn't fall back to the heavy native bar.
See also: Widgets · Core concepts