.szs — CSS with logic
serez-ui styles its GUI with .szs: a small CSS dialect with the same selectors and properties you already know, but where conditions are reactive — re-evaluated every frame against your component state and the live window size. It is parsed in pure Serez-Code (no CSS engine), so it stays tiny and predictable.
.szs is not a core built-in — it ships with serez-ui (parsed by parseCss) and styles its GUI renderer. It owns the design (everything visual — color, spacing, alignment, sizing, visibility), while your .szx (JSX) owns the logic and structure — like CSS vs HTML/JS. The editor extension highlights .szs files.Attach a stylesheet
Load the sheet from a file and hand it to your Window before runGui. Stylesheets live in .szs files (not inline strings): Serez-Code interpolates {} inside string literals, so reading from a file keeps the CSS braces literal. Reading a file needs the File permission.
app.useStylesheet(parseCss(File.read("app.szs"))) // before runGui
app.runGui("App", 640, 480)Selectors & properties
A rule is selector { property: value; }. The selector is a tag name (body, h1, Button, Row, …), a class (.card, matched against the node's class prop), a comma group (h2, h3 { … }), * (every tag), or a pseudo-state: :hover, :focus, :active and :disabled. A pseudo-state rule can set any property (background, color, border, transform, …), not just a focus ring — Button:hover { background-color: … } works. Focus is opt-in: Input:focus / *:focus (alias :active-focus) turns on the focus mark; without such a rule, focused widgets show no ring. When several rules set the same property, the last matching one wins. Descendant chains (section span) apply under the native renderer.
| Property | Applies to | Values | Effect |
|---|---|---|---|
| Typography | |||
color | text tags + controls + containers | hex / name | text color — set on a container it is inherited by loose text and child text without a rule of its own |
font-size | text tags (h1–h6, p, span, li, Label) + every widget (Button, Input, Select, Tabs, …) & containers | px (e.g. 20px or 20) | real pixel font size — any value, not just multiples of 8; a widget grows its box from the label. Inherited down the subtree |
font-scale | text tags & containers | integer | legacy size multiplier (font-scale: 2 = font-size: 16px); font-size wins if both are set |
font-family | text tags + controls (body = app default) | family name / :font alias | proportional text in that font |
font-weight / font-style | text tags | bold / italic / normal | bold / italic (b/strong & i/em imply these by default) |
line-height | text tags | integer px | vertical distance between lines |
letter-spacing | text tags | integer px | extra space between characters |
text-align | text tags / Label (classes too: p.centered) | left / center / right | horizontal text alignment |
text-decoration | text tags | underline / line-through | underline or strike-through |
text-transform | text tags | uppercase / lowercase / capitalize | change letter case |
white-space | text tags | nowrap | keep on one line (no wrap; may clip) |
| Box & background | |||
background-color / background | body, containers and controls (Button, Input, Select, Checkbox, Dropdown, Textarea, Switch, Tabs, Slider, RadioGroup, ProgressBar, Modal, Toast, Chart, Tooltip, Image) | hex / name | fill — on accent-driven widgets it colors the accent (Slider fill/knob, Checkbox box, Switch on-track, RadioGroup dot, Tabs underline), not the whole strip |
background-image | containers | url(path) | image stretched to the box, behind the content, clipped to border-radius |
padding | containers & controls | 1–4 values (CSS sides) | inner spacing — padding: 6 20 = vertical / horizontal |
margin + margin-top/right/bottom/left | any tag / containers | 1–4 values / integer px | outer spacing (shorthand or per-side; the loose sides override the shorthand) |
border / border-color / border-width | containers + controls | 2px solid #333 / hex / px | box outline — follows border-radius (rounded) |
border-radius | containers, controls, Image (by tag / class / id / state) | integer px (or 50%/100% = pill) | antialiased rounded corners — for fills, borders and images |
box-shadow | containers + controls | offX offY blur [spread] color | soft drop shadow behind the box |
opacity | containers | 0–1 | translucent background and text, multiplied down the subtree |
| Sizing & layout | |||
width / height | containers | px or % | explicit box size (50% of the available column) |
min-width / max-width | containers (and body = content column) | px | clamp the box (or the whole content column via body) |
display | any tag / containers | none / flex / grid | hide the element (reactive), lay children out in a row, or as a grid |
flex-direction | containers | row / column | direction children stack in. A container with no display declared stacks in a column — that is the default. Declaring display: flex without a direction makes it a row, as in CSS |
grid-template-columns / grid-template-rows | display: grid containers | 1fr 1fr, 100px 1fr, repeat(3, 1fr) | column / row tracks — fixed px or fr fractions of the leftover |
gap / column-gap / row-gap | Row, grid & containers | integer px | space between children (grid uses gap for both axes) |
justify-content / align-items | Row | flex-start/center/flex-end/space-between/space-around/space-evenly · flex-start/center/flex-end | distribute / cross-align Row children |
direction | Row | column | lay a Row out vertically |
overflow | containers with height | scroll / hidden | clip children to the fixed-height box; the page flows right below it |
| Positioning & transform | |||
position | children of a container | absolute (+ left/right/top) · relative (+ top/left offset) | absolute = out-of-flow badge anchored to the parent box; relative = shift the element visually without moving the flow |
z-index | any element | integer | lift the element (and its subtree) above overlapping siblings |
transform | any element | translate(x,y) / translateX/Y · rotate(deg) · scale(s[,s]) / scaleX/Y | visual translate / rotate / scale of the element + subtree (origin = top-left; does not affect the flow) |
| Interactive | |||
cursor | widgets | pointer / text / move / crosshair / not-allowed / … | mouse cursor while hovering |
Colors are hex (#rgb, #rrggbb, and the color-picker forms #rgba / #rrggbbaa — the alpha channel is ignored by the interpreted renderer; under the native renderer it makes the background truly translucent, like rgba()), rgb(r, g, b) / rgba(r, g, b, a), or a basic name (white black red green blue yellow gray). Sizes are pixels — the px unit is optional (20px and 20 are the same).
/* app.szs */
body { background-color: #0f172a; }
h1 { color: #ffd166; font-scale: 3; }
p { color: #94a3b8; }
Button { background-color: #2563eb; color: #ffffff; }
.card { background-color: #1e293b; border: 1px solid #334155; border-radius: 8; padding: 12; }
Input:focus { border-color: #22d3ee; } /* opt-in focus mark */property: value, so unknown ones are simply ignored — more get wired to the renderer as serez-ui grows (see Looking ahead).Fonts — :font and font-family
Declare font files in a :font block (the renderer loads them lazily) and pick families per tag with font-family. System-installed fonts work by name without any block; a downloaded .ttf (e.g. a Google Font) goes through :font. Set it on body and the whole app inherits it.
:font {
Titular: fonts/Roboto.ttf; /* alias: path to a .ttf/.otf */
}
body { font-family: "Segoe UI"; } /* app-wide default (system font) */
h1 { font-family: "Titular"; } /* the :font alias */Custom families render proportionally (real glyph advances, antialiased) and text wrapping measures with the actual font. Input / Textarea content stays monospace so caret positioning holds.
Exposing your state — styleVars()
A reactive condition is only as good as the variables it can read, and a stylesheet cannot reach into your program on its own. styleVars() is the one door: a method you overrideto hand the sheet the values its conditions are allowed to see. Everything on this page that says “your state” means whatever this method returned on the current frame.
It lives on Window — and on any Component — and returns an array of [name, value] pairs. The default implementation returns an empty array, which is why a fresh app's conditions never fire: nothing has been exposed yet.
class Counter extends Window {
public Counter() {
this.count = 0
this.alert = false
}
// The names on the left are the ones a .szs condition can use.
public any styleVars() {
return [
["count", this.count],
["alert", this.alert]
]
}
public any render() { /* … */ }
}And now those names are live in the sheet:
body (count == 0) { background-color: #0f172a; }
body (count != 0) { background-color: #14532d; }
.warn (alert) { background-color: #7f1d1d; }There is nothing to subscribe to and nothing to invalidate: styleVars() is called again on every frame, so the moment this.count changes, the next frame restyles. That is the whole reactivity model.
:import does not wire anything. A sheet may open with an :import { count: count; } block, and it is tempting to read it as the declaration that connects the variable. It is not — the parser skips the block entirely and the values come from styleVars() alone. Treat it as a comment that documents what the sheet expects. Writing it without overriding styleVars() gives you a condition that never matches, with no error to tell you why.What happens on each frame
The root window is not the only source. Every Component in the tree can override styleVars() too; the renderer stamps those pairs onto the node the component produced and then walks the tree collecting them, so a rule can react to state that lives deep inside a child. Root pairs and component pairs end up in the same flat context, together with the built-in width / height:
Component, derive the values from props. A component instance is rebuilt every frame, so a field it set on itself is gone by the time the next frame reads it — the condition would quietly go stale. Read this.props["…"] instead and keep the state in the window above, which is the same rule that governs component state in general.class Badge extends Component {
public any styleVars() {
// from props — NOT from a field this component set on itself
return [["unread", this.props["unread"]]]
}
public any render() { /* … */ }
}Reactive conditions — the logic
Any selector can carry a condition in parentheses, re-checked every frame: selector (var OP literal) { … }. Operators: == != < > <= >= — or a bare variable (.warn (alert) { … }) which applies while the value is truthy (false, 0, "" and nulldon't pass). If the variable is not available, the rule simply does not apply.
body (count == 0) { background-color: #0f172a; }
body (count != 0) { background-color: #14532d; }
.warn (alert) { background-color: #7f1d1d; } /* bare boolean */The variables come from two places: your state, which you expose with styleVars(), and the built-in width / height (the live window size, in px, always available).
Combining conditions — and / or / not
Conditions compose with and, or and not, spelled as words like in a CSS media query. The symbolic && / || / ! are accepted as aliases, so both styles work.
body (width > 600 and flag == true) { background-color: #c12; }
.item (selected or hovered) { border-color: #3b82f6; }
.row (not hidden) { display: flex; }
/* same three, with the symbolic aliases */
body (width > 600 && flag == true) { background-color: #c12; }
.item (selected || hovered) { border-color: #3b82f6; }
.row (!hidden) { display: flex; }Precedence is the usual one: not binds tighter than and, and and tighter than or — so a or b and c reads as a or (b and c). There are no grouping parentheses: the sheet scanner closes the condition at the first ), so if you need an explicit grouping, precompute it as a single variable in styleVars().
The connectives only count as whole words and quoted values are respected, so a variable named android or notify, or a value like "a or b", are never split.
useNativeRenderer(true)the sheet is handed to the core's own CSS engine, so composite conditions need a core new enough to understand them — on an older core those rules simply do not apply. The default interpreted renderer works on any supported core.Grouping many rules — @when / @else
Wrap several rules in a @when (condition) block to share one condition across many selectors (tags, classes, ids) — instead of repeating it rule by rule. The condition is the same reactive logic as above (a styleVars() variable, or width / height, with and / or / not) — not a fixed media query.
@when (width < 300 and darkMode) {
body { color: #fff }
.card { padding: 8 }
#main { gap: 4 }
}@else is the complement of the preceding @when, and @else (condition) chains an else-if. The branches are mutually exclusive — evaluated top to bottom, the first match wins — so you never have to negate a range by hand:
@when (width < 200) { body { color: #100 } }
@else (width < 400) { body { color: #200 } }
@else { body { color: #300 } }Blocks nest (a @when inside a @when — the conditions are AND-ed), and a rule inside a block may still carry its own (condition), combined with the block's. @else negates the whole preceding condition, so a composite like (a or b) complements correctly. Unknown at-rules (@media, …) are ignored.
@when only groups the condition — it does not add specificity. The last matching rule wins for a given property, so if a block lower in the sheet also sets it, that later value takes over regardless of the @when nesting. Put the block that should win last.Exposing state — styleVars()
Override styleVars() to publish the state your conditions read. (An optional :import { … } block at the top of the sheet documents which variables you use, but the values are taken from styleVars() / width / height directly.)
public any styleVars() { return [["count", this.count]] }Responsive — media queries
width and height (px) are always available — no styleVars() needed — so .szs doubles as a responsive system, and it reflows live as the window resizes:
body (width < 600) { background-color: #1e1b4b; } /* phone-ish */
body (width >= 600) { background-color: #0f172a; }
Row (width < 600) { direction: column; } /* stack the row when narrow */
h1 (width < 600) { font-scale: 2; }
h1 (width >= 960) { font-scale: 4; }Pair this with code-level breakpoints (app.breakpoint()) and automatic Row wrapping — see the serez-ui responsive section.
A complete example
/* counter.szs */
body (count == 0) { background-color: #0f172a; }
body (count != 0) { background-color: #14532d; }
h1 (width < 600) { font-scale: 2; }
h1 (width >= 600) { font-scale: 3; }
h1 { color: #ffd166; }
Button { background-color: #2563eb; color: #ffffff; }// counter.szx
class Counter:Window {
public Counter() { super(); this.count = 0 }
public render() {
return (
<div>
<h1>Count: {this.count}</h1>
<Button onClick={() => { this.count = this.count + 1 }}>+1</Button>
</div>
)
}
// expose state to the .szs conditions
public any styleVars() { return [["count", this.count]] }
}
let app = new Counter()
app.useStylesheet(parseCss(File.read("counter.szs")))
app.runGui("Counter", 520, 360)Notes & limits
- Conditions combine with
and/or/not, but there are no grouping parentheses — precompute an explicit grouping as one variable instyleVars(). @when (condition) { … }groups many rules under one shared condition;@else/@else (condition)chain mutually-exclusive branches. They group the condition only — the cascade is still last-match-wins.- Properties are limited to the table above; other declarations parse but are ignored.
- Last matching rule wins for a given property (no specificity ranking).
- Pseudo-states (
:hover/:focus/:active/:disabled) work in both renderers and accept any property. Descendant selectors (section span) only apply under the native renderer. transformis visual-only (does not affect layout); the origin is the element's top-left, androtate/scaleapply per node (notransform-originyet).- Read the sheet from a
.szsfile, not an inline string. - Colors: hex (
#rgb/#rrggbb/#rgba/#rrggbbaa),rgb()/rgba(), or a basic name. Alpha tints the background only under the native renderer; the interpreted one reads the RGB and ignores it.
Looking ahead
The table above now covers most day-to-day CSS: full typography (including real font-size in px and letter-spacing), the box model, flex Row and grid, positioning, transform, and interactive pseudo-states. Still on the list: grid-template-areas, background-size: cover/contain, a configurable transform-origin, and niche pieces (box-sizing, outline, filter, transition/animation). The parser accepts arbitrary property: value declarations, so unknown ones are ignored and new ones get wired without a syntax change — this page tracks the surface as it expands.