Documentation menu
Package

serez-ui

React-style UI library for Serez Code. Components, a transparent Virtual DOM, and hooks — the same code runs in the terminal (TUI) or in a real native window (GUI).

Install

sz install serez-ui

The model

You write screens as classes that extend Window (the root interface) and reusable pieces that extend Component — both return JSX from render(). State lives inthis fields; mutating it inside an event handler triggers a re-render through the Virtual DOM. JSX is written in .szx files and translated to plain .sz before running.

Tip: the public modifier on methods is optional in .szx — write render() { ... } and the translator adds public for you. Writing it explicitly works too; both styles coexist.
The version floor is real. serez-ui needs Serez-Code ≥ 9.17.0. On an older core the library still loads and then misbehaves without saying so: a Window subclass with no constructor dies at mount() naming an internal field,useEffect never runs cleanups, and components in separate .szx files fail at render time. Check with sz --version.

Quick start

A counter, rendered in a real native window:

import "serez-ui"

class Counter:Window {
    public Counter() {
        super()
        this.count = 0
    }

    public render() {
        return (
            <div>
                <h1>Counter</h1>
                <hr />
                <h2>{this.count}</h2>
                <Button onClick={() => { this.count = this.count + 1 }}>Increment</Button>
                <Button onClick={() => { this.count = 0 }}>Reset</Button>
            </div>
        )
    }
}

let app = new Counter()
app.runGui("Counter", 520, 420)   // native window — quit with Esc or the close button

Coming from React

Most of what you already type works unchanged: JSX, fragments, ternaries, {cond && <X/>}, .filter().map(), this inside a map callback, handlers that capture the index, controlled inputs, this.children, boolean props, {...this.props} and {/* comments */}. The constructor is optional, same as React. These are the differences worth knowing on day one:

Reactserez-ui
className="x"class="x" — className is accepted and renamed for you
style={{color:'red'}}not supported; styling lives in a .szs sheet (you get a warning)
useStatea plain field: this.count = 0, mutated in the handler
useEffect(fn, [x])useEffect(fn, () => [x]) — deps go as a function, see below
key={id}accepted and ignored — reconciliation is by index
useRef / useReducer / useCallback / useMemonot available yet
Context APInot available yet — state goes down as props

useEffect: the three modes

An effect is registered once with addEffect, not on every render. That is the one place the API had to diverge: an array literal is evaluated at registration time and stays frozen at that value, so “run when x changes” would never fire. Pass a function and it is re-evaluated on every pass.

// runs once, on mount
this.addEffect(useEffect(() => { this.cargar() }, []))

// runs on every update
this.addEffect(useEffect(() => { this.tick() }, null))

// runs when this.id changes — deps as a FUNCTION, not an array literal
this.addEffect(useEffect(() => {
    this.buscar(this.id)
    return () => { this.cancelar() }    // cleanup: runs before the next pass and on unmount
}, () => [this.id]))
Registering effects in the constructorworks (core ≥ 9.16). A closure created there captures the finished instance.

Where state lives

This is the one structural habit to change. A Component is re-instantiated every frame, so it has no state of its own — click, re-render, and its fields are back to their initial values. State lives in the Window and comes down as props; components are presentational and report back through callbacks.

// ❌ the counter resets on every re-render
class Contador:Component {
    public Contador() { super(); this.n = 0 }
    public render() { return (<Button onClick={() => { this.n = this.n + 1 }}>{this.n}</Button>) }
}

// ✅ state in the Window, value down, callback up
class App:Window {
    public App() { super(); this.n = 0 }
    public render() { return (<Contador n={this.n} onSumar={() => { this.n = this.n + 1 }} />) }
}
class Contador:Component {
    public render() { return (<Button onClick={this.props.onSumar}>{this.props.n}</Button>) }
}

Persistent component instances are the open design item that unblocks useState in components, the remaining hooks and a Context API — they are one problem, not three.

Building .szx files

JSX lives in .szx files. Run them directly — the runtime translates the JSX to .sz and runs it (and opens the UI) in one step:

sz apps/counter.szx

One root per return — and fragments

A render() must return exactly one root JSX element (same rule as React) — two siblings at the top level of a return(...) are rejected by the translator:

// ❌ two roots — invalid
public render(){
    return(
        <h1>Title</h1>
        <h2>Subtitle</h2>
    )
}

When you don't want an extra wrapper element, use a fragment<> … </> — which groups the siblings without adding a node to the tree (it translates to hFrag([…])):

// ✅ fragment — one root, no extra wrapper
public render(){
    return(
        <>
        <h1>Title</h1>
        <h2>Subtitle</h2>
        </>
    )
}

Wrapping in a real element (<div>…</div>) works too — use it when you want the wrapper to exist for layout or styling; use the fragment when you don't.

A fragment is not free if the component takes a class. <MyComp class="card">forwards the class to the root node the component produces — but a fragment is not a node, so there is nothing to put it on. serez-ui hands it to the fragment's single element child when there is exactly one; with two or more siblings the class is dropped, because applying it to all of them would paint more than you asked for. If the component is meant to be styled from outside, give it a real root element.

Rendering something conditionally

The JavaScript idiom works: flag && <Row/> renders the row when the flag holds and nothing when it does not. Both halves are needed for that — && returns an operand rather than a boolean (see logical operators), and the tree builder discards the falsy result instead of painting it.

{this.props.selected && (
    <Row>
        <Button class="task--edit" onClick={this.props.onEdit}>Editar</Button>
        <Button class="task--delete" onClick={this.props.onDelete}>Borrar</Button>
    </Row>
)}

// Same with a list — an EMPTY list is falsy here, so it renders nothing.
// (This is the one place the language departs from JavaScript on purpose:
//  in JS an empty array is truthy and this idiom is a classic bug.)
{this.props.items && (<List items={this.props.items} />)}

// A ternary against null does the same job and reads better with an else:
{this.props.selected ? (<Row></Row>) : (<Empty />)}

null, booleans and empty arrays are all dropped when the tree is built, so none of them leave a mark — a flag interpolated by mistake shows nothing rather than printing false on screen.

Braces are for interpolation inside JSX. {expr}only means “evaluate this” when it sits inside markup. Writing return {this.props.x} is not JSX — the braces are read as a dict literal and you get Expected ',' between key and value in entry literal, which points nowhere useful. Return the value directly (return this.props.x) or wrap it in markup.

What the translator emits

JSX is sugar. The translator turns every tag into a call to one internal function, __h(tag, props, children): a tag string, a list of [name, value] prop pairs, and a list of children. So <div class="box">hi</div> becomes __h("div", [["class", "box"]], ["hi"]).

Under the hood, the counter's JSX compiles to exactly these calls:

// what <div><h1>…</h1><Button>…</Button></div> compiles to
__h("div", [], [
    __h("h1", [], ["Count: " + this.count]),
    __h("Button", [["onClick", () => { this.count = this.count + 1 }]], ["+1"])
])
You never write this.It is shown so you can read the translator's output when debugging — it is not an API. The names carry a __ prefix precisely so they never collide with your own variables. There was a short-named alias (h) in earlier versions; it was removed in 4.27. Author UIs with JSX in a .szx file — that is the only supported form, and what every example here uses.

Built-in components

Structure uses primitive HTML-like tags the renderer draws directly (div, h1, h2, h3, p, span, hr, ul, li, section, form). Block text (h1/h2/h3/p/span/li/Label) word-wraps to the available width and reflows on resize. For layout, Row places its children side by side (each at its content width, with a gap) — and stacks them vertically when they no longer fit (set wrap={false} to force a single row) — while Col stacks them vertically. For interaction, serez-ui ships 24 built-in components:

ComponentKey propsNotes
ButtononClick, disabledText is the children · Enter/Space activates when focused
Inputvalue, placeholder, type, onChange, onSubmit, disabledOne line · positionable caret, type="password" masks, Enter fires onSubmit
Textareavalue, placeholder, rows, onChange, disabledMulti-line · caret + vertical scroll, Enter inserts a newline
Selectvalue, options, onChange, disabledClick or ←→ cycles options
Dropdownvalue, options, onChange, disabledReal drop list · click/Enter opens, ↑↓ navigate, Enter picks
Checkboxchecked, label, onChange, disabledClick or Space toggles
Switch / Togglechecked, label, onChange, disabledOn/off pill switch (same semantics as Checkbox)
RadioGroupvalue, options, onChange, disabledOne choice · click an option or ↑↓ to move
Slidervalue, min, max, step, onChange, disabledClick the track or ←→ to change
Tabstabs, active, onChange, disabledControlled tab strip — you render the content per active · click or ←→
Collapsible / Accordiontitle, open, onToggle, disabledCollapsible section — a header (chevron) that shows/hides its children · click or Enter/Space
ProgressBarvalue, max, labelNon-interactive (skipped by focus)
Chartdata, type (line/area/bar), height, color, min, max, dotsPlots a numeric series (core vector primitives); sparkline in TUI
LabelCaption text (children); non-interactive
Linkhref, onClick, disabledUnderlined accent · click or Enter activates
Imagesrc, bytes, width, height, alpha, altRaster (PNG/JPG) from a file or bytes in memory (a fetched image); scales to width/height
FileInputonChange, value, label, filterName, exts, save"Choose file…" button → native file dialog; shows the picked name
DropZoneonDrop, label, heightFile drag-drop area; highlights while files hover, onDrop(paths) on drop
Tablecolumns, rowsRead-only grid (aligned cells, header row)
Modalopen, titleWhen open, dims the background and centers a box with the children on top
TooltiptipWraps a child; shows a small box next to the cursor on hover
Toastmessage, kindTransient banner (info/success/warn/error); auto-dismiss from onFrame()
<Input value={this.name} placeholder="your name" onChange={(v) => { this.name = v }} />
<Switch checked={this.dark} label="Dark mode" onChange={(b) => { this.dark = b }} />
<Tabs tabs={["Info", "Config", "Logs"]} active={this.tab} onChange={(i) => { this.tab = i }} />
Full catalog with usage: every component — grouped by type, each with its props (types and defaults), keyboard behavior and working examples — lives in the Component catalog, a dedicated page with its own component index.

Focus & keyboard (GUI)

Every interactive component is focusable and gets a focus index in render order. This navigation is built in — you do not wire it up:

KeyAction
Tab / Shift+TabMove focus to the next / previous component
clickFocus that component (and, in a text field, place the caret under the cursor)
Home EndMove the caret (text fields) · change value (Select / Slider) · switch tab (Tabs)
Backspace / DeleteDelete before / at the caret (auto-repeat when held)
Enter / SpaceActivate the focused Button / Link / Checkbox / Switch / Dropdown · open the dialog (FileInput)
EnterNewline in a Textarea · onSubmit in an Input
Navigate options in an open Dropdown / a RadioGroup
EscClose the window

Focus marks are opt-in (v4.4): by default clicking a widget leaves no visible ring. To mark the focused widget, declare a :focus rule in your .szs — per widget (Input:focus { border-color: #22d3ee }) or global (*:focus { border: 2px solid #f43f5e }). :active-focusis an accepted alias. The mark color comes from the rule's border-color / border / color; under the native renderer the rule is real CSS, so any property works. The caret and text selection are always drawn — they are editing state, not a focus mark.

Text is drawn with real glyphs on a monospace grid, and typing goes through your OS keyboard layout and IME — so accents, ñ and Unicode type straight into an Input or Textarea. A CJK IME composition in progress is drawn underlined at the caret, and when the window loses OS focus the caret stops blinking (a background window costs ~0 CPU).

OS events (drag-drop, gestures)

The GUI surfaces a few window-level OS events as optional Window overrides (all default to no-op; return true to request a redraw). The DropZone and FileInput components cover the common cases, but you can also handle them directly:

public bool onFilesDropped(any paths) {   // files dropped on the window (needs File perm to read)
    this.attached = paths
    return true
}
public bool onFilesHovered(any paths) { ... }   // files dragged over the window (before dropping)
public bool onPinch(any delta)        { ... }   // trackpad pinch/zoom (delta > 0 in, < 0 out)
public bool onTouch(any touches)      { ... }   // touchscreen points: flat [id, phase, x, y, ...]

app.hoveredFiles() returns the paths currently being dragged over the window (or []), so a component can read it from render() to highlight a drop target — which is what DropZone does.

Reusable components

Beyond Window (the root interface), extend Component to build your own reusable pieces. A component receives props (data passed to it) and children (content between its tags) — read them with plain dot access:

class TaskRow:Component {
    public TaskRow() { super() }

    public render() {
        let mark = "[ ] "
        if (this.props.done) { mark = "[x] " }
        return (
            <li>{mark + this.props.text}</li>
        )
    }
}

// use it like any tag — props are attributes, children is the content:
<TaskRow text="buy milk" done={true} />
Note: you write this.props.text with a dot; the translator rewrites it to the dict access serez-code uses under the hood. Content between tags arrives as this.children.

Lists with .map()

Render a list by mapping an array to nodes — no manual loops, no h(...) calls. The callback can return any tag: an HTML element, a fragment <>...</>, or another component:

public render() {
    let tasks = this.props.tasks
    return (
        <ul>
        {
            tasks.map(fn (t) {
                return (
                    <TaskRow text={t} done={false} />
                )
            })
        }
        </ul>
    )
}

Fragments group children without adding a wrapper element — handy when the callback returns bare values:

items.map(fn (x) {
    return (
        <>{x}</>
    )
})

TUI vs GUI

The same component runs in two renderers, but they are not interchangeable for input — each has its own event loop, serez.json permission, and interaction model:

TUI (terminal)GUI (window)
Startapp.runTui()app.runGui(title, w, h)
Permission"Terminal""Gui"
Interactionraw keyboard via onKey(evt)click, typing + built-in focus/keyboard nav
App onKey(evt)✅ dispatched✅ dispatched (4.26+) — alongside the focused widget
// The same component runs itself in either target — the event loop is a
// method on your component (app is your top-level variable):

// Terminal (TUI) — needs "Terminal" permission; onKey drives navigation
app.runTui()                       // quit with q

// Native window (GUI) — needs "Gui" permission; interact via Buttons/Inputs
app.runGui("My App", 560, 460)     // quit with Esc or the close button

For a full worked example of each, see the Build a UI (TUI / GUI) tutorial.

Resizing & width bounds

The GUI reflows on window resize (autosize): full-width controls (Input, Select, Textarea, Dropdown, Slider, ProgressBar, hr) stretch and shrink to the window, while buttons and labels keep their content size. A Row whose children no longer fit stacks them vertically on its own (no config), and content taller than the window scrolls with the mouse wheel (a scrollbar appears on the right). Bound the content width — and the layout centers itself when the max-width is narrower than the window:

app.setMaxWidth(720)   // content never wider than 720px (centered on big screens)
app.setMinWidth(360)   // ...and never narrower than 360px
app.runGui("My App", 900, 600)

Pass 0 (the default) for no limit. For width-driven styling and layout — including stacking a Row when narrow — see CSS media queries below.

Breakpoints in render()

For structure that changes with size — not just styling — read this.viewportWidth() (live px) or this.breakpoint() ("sm" / "md" / "lg") inside render(). The GUI re-runs render() on resize, so you can return a different tree per breakpoint:

public any render() {
    if (this.breakpoint() == "sm") {
        return <Button>Menu</Button>          // phone: a single button
    }
    return (                                   // desktop: a row of links
        <Row>
            <Button>Home</Button>
            <Button>Profile</Button>
            <Button>Settings</Button>
        </Row>
    )
}

Thresholds default to 600 / 960; change them with app.setBreakpoints(smMax, mdMax) before runGui.

CSS with logic (.szs)

Style with a CSS dialect that supports reactive conditions. A selector can carry a condition that is re-checked every frame against your state. This is a quick tour — see the full .szs reference for every property, condition and the responsive variables:

/* counter.szs */
body (count == 0) { background-color: #0f172a; }
body (count != 0) { background-color: #14532d; }

h1     { color: #ffd166; }
Button { background-color: #2563eb; color: #ffffff; }

The count in those conditions is not magic and it is not picked up from your fields automatically: you decide what the sheet can see by overriding styleVars() — one method, one place, explained in full over there. Without it, conditions never match and nothing tells you why.

public any styleVars() { return [["count", this.count]] }

Attach the stylesheet to your component before starting:

app.useStylesheet(parseCss(File.read("apps/counter.szs")))
app.runGui("Counter", 520, 420)

Responsive — media queries

The current width and height (px) are always available as condition variables (no :import needed), so a stylesheet can adapt the UI to the window size — and it reflows live on resize:

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; }                /* smaller heading when small */
h1  (width >= 960)  { font-scale: 4; }

Beyond colors, the sheet understands direction: column (lay a Row out vertically), font-scale: N (integer text-size multiplier for h1/h2/h3/p/span/li) and white-space: nowrap(keep a tag's text on one line — it may clip — instead of word-wrapping).

Secondary Windows (Panels)

Since serez-ui 2.3.0, the GUI renderer runs on top of a retained-mode scene graph, drastically reducing CPU usage. It also adds native support for opening and managing secondary OS windows (referred to as panels).

To use panels, open them using this.openPanel(title, w, h) from within your main application window, and override the renderPanel(id) method in your class to define the UI tree of each panel:

import "serez-ui"

class MyApp:Window {
    public MyApp() {
        super()
        this.message = "Hello from panel"
        this.secondaryWindowId = -1
    }

    public render() {
        return (
            <div>
                <h1>Main Window</h1>
                <Button onClick={() => {
                    if (this.secondaryWindowId == -1) {
                        this.secondaryWindowId = this.openPanel("Controls", 300, 200)
                    }
                }}>Open Secondary Panel</Button>
            </div>
        )
    }

    // Override renderPanel to draw the secondary window's tree
    public renderPanel(int id) {
        if (id == this.secondaryWindowId) {
            return (
                <div>
                    <h2>Panel Controls</h2>
                    <p>{this.message}</p>
                    <Button onClick={() => {
                        this.message = "Button clicked in Panel!"
                    }}>Update Main State</Button>
                    <Button onClick={() => {
                        this.closePanel(this.secondaryWindowId)
                        this.secondaryWindowId = -1
                    }}>Close Panel</Button>
                </div>
            )
        }
        return null
    }
}

let app = new MyApp()
app.runGui("Main App", 600, 400)

Preserved State Identity:click handlers inside a panel's VDOM can directly access and modify the properties of the parent Window class. A click in a secondary panel that updates state automatically re-renders the main window.

MethodReturnsWhat it does
this.openPanel(title, w, h)intOpens a secondary window; returns its id. Call from the main window (e.g. in a click handler or onFrame). Requires runGui running.
this.closePanel(id)Closes the panel with that id.
this.panelCount()intNumber of panels currently open.
renderPanel(id)vdomOverride in your class — returns that panel's UI tree, or null for an empty panel. Called once per frame for each panel.
Full input per panel (v4.4). Every panel carries its own input state — focus index, caret and selection, undo/redo, open Dropdown — fully isolated from the main window and from other panels. Input / Textareaare editable inside a panel, Tab cycles the panel's own widgets, and the keyboard follows whichever window has OS focus (the core accumulates events per window). Calling closePanel(id) from a handler inside that same panel is safe, and closing a panel with the OS window ×removes it automatically while the main loop keeps running. Panels inherit the app's stylesheet and render mode (interpreted or native).
Panels are the serez-ui layer over the core's multi-window Gui API (Gui.openWindow / selectWindow / closeWindow). Reach for that only when you draw raw Gui primitives instead of components.

Native renderer (opt-in)

serez-ui 4.3+ can hand style resolution, layout and painting to the core's primitives engine (Gui.renderTree, requires core ≥ 9.2): every component is lowered to HTML-like primitives and the whole layout + CSS walk runs natively inside the runtime instead of interpreted — measured ~1000× faster on that phase for app-sized trees. All 24 built-in components work under this path (clicks, focus order, overlays and text editing included), and your app code does not change:

let app = new MyApp()
app.useNativeRenderer(true)   // opt-in — call before runGui()
app.runGui("My App", 800, 600)
Opt-in for now. The flag is off by default while the path matures; the classic interpreted renderer stays the default and both render the same UI from the same components and .szs sheet. As of serez-ui 4.4 + core 9.3 the two paths were audited side-by-side (30+ apps, screenshot for screenshot) and are at visual parity: class selectors, selector groups, color / font-scale / opacity inheritance, multi-value padding, width in px/%, overflow: scroll clipping, line-height, white-space: nowrap, :font custom families, position: absolute badges and bare-boolean reactive conditions render the same on both. Interpreted-only gaps left: descendant selectors and :hover (native only), square border corners. Native minors left: transform: scale/rotate, CSS transitions/animations and full specificity (resolution is "last match wins").

API surface

ExportWhat it is
WindowBase class for the root interface
ComponentBase class for reusable components (props + children)
h / hFrag / VNodeHyperscript, fragments + Virtual DOM node
diff / PatchVirtual DOM diffing
useState / useEffect / memoHooks
app.runTui() / app.runGui(title, w, h)Window methods — run the app in the terminal / a native window
app.useStylesheet(sheet)Window method — attach a .szs stylesheet (call before runGui)
app.setMaxWidth(px) / app.setMinWidth(px)Window methods — clamp the GUI content width (0 = no limit; centers under max-width)
app.viewportWidth() / app.breakpoint()Window methods — live viewport width (px) / current breakpoint (sm/md/lg) for a responsive render()
app.setBreakpoints(smMax, mdMax)Window method — set the sm|md and md|lg width thresholds (default 600 / 960)
app.onFrame()Window override — per-frame hook; return true to redraw
app.onFilesDropped / onFilesHovered / onPinch / onTouchWindow overrides — OS file drag-drop and trackpad/touch gestures
app.hoveredFiles()Window method — paths being dragged over the window now ([] if none)
app.openPanel(title, w, h)Window method — Open secondary panel window → ID
app.closePanel(id)Window method — Close secondary panel window
app.panelCount()Window method — Returns total number of active panels
app.renderPanel(id)Window override — Define rendering tree for secondary panel ID
app.useNativeRenderer(b)Window method — render through the core's primitives engine (experimental, core ≥ 9.2; call before runGui)
Renderer / GuiRendererTUI / GUI renderers (used internally by the run methods)
parseCss.szs stylesheet parser

Packaging

Ship a serez-ui app as a self-contained installer (the runtime travels inside, no Serez Code needed on the target) with serez-pack.