Introduction
Velox is a modern UI framework for Rust that turns single-file .vx components into fast, native desktop applications — rendered by Skia, driven by reactive signals, and shipped as a single native binary.
Write a component once in a familiar, HTML-like template syntax with an inline <script setup> block, and Velox compiles it into Rust. During development you get a dev-server loop that classifies each edit by SFC block — style, template, or script — and rebuilds and relaunches the native window, with an HMR control channel on 127.0.0.1:31313; for release you get a self-contained executable with no runtime, no browser, and no JavaScript engine.
<template>
<div class="app">
<h1>Count: {{ count }}</h1>
<button @click="increment">Add one</button>
</div>
</template>
<script setup>
use velox_core::ergonomics::Ref;
pub struct State {
count: Ref<i32>,
}
impl State {
pub fn new() -> Self {
Self { count: velox_core::r#ref!(0) }
}
pub fn count(&self) -> i32 {
self.count.get()
}
pub fn increment(&self) {
self.count.set(self.count.get() + 1);
}
}
</script>
Why Velox
- Single-file components — templates, styles, and logic live together in one
.vxfile. The Velox compiler parses them and generates idiomatic Rust you can read, debug, and ship. - Reactive by default — fine-grained signals and effects power state management. Change a signal, and your view recomputes; no manual wiring. Velox’s render loop is immediate-mode, and
:keytoday is a plain string attribute with no identity or reconciliation semantics — see Reconciler for the honest picture, including the:keyreorder caveat. - Skia rendering — the same graphics engine behind Chrome and Flutter draws every frame, with a flexbox layout engine and a fixed viewport contract that keeps your UI responsive at any window size, DPI, or scale factor.
- A real dev loop — save a
.vxfile and the dev server classifies the edit by SFC block (style, template, or script), rebuilds, and relaunches the native window; an HMR control channel on127.0.0.1:31313coordinates the running app. Module-level hot reload is not implemented yet — today every save is a full rebuild and restart.
Who it’s for
Velox is for Rust developers who want to build desktop UIs without reaching for a browser shell, and for teams coming from Vue or React who want the single-file component workflow with native performance. If you can write HTML and Rust, you can build a Velox app.
Next steps
Head to Getting Started to install the CLI and build your first component, then follow the Dashboard tutorial for a guided tour.
Velox is built and maintained by FAHIM AHMED · fahimaloy@tutamail.com · github.com/fahimaloy/velox
Getting Started
Velox is a Rust UI framework with Vue-like single-file components: you write .vx files — a <template>, a <script setup> block and a <style scoped> block in one file — and they compile to Rust that renders natively with Skia. This guide takes you from a clean machine to a running app in a few minutes.
Prerequisites
- Rust 1.91 or later. Install with rustup and check with
rustc --version. - A desktop platform. Linux, macOS, and Windows are all supported.
| Platform | Notes |
|---|---|
| Linux | File watching is backed by inotify, a finite kernel resource. If your watch budget is exhausted the dev server tells you and keeps running — see Troubleshooting. |
| macOS | No extra setup. |
| Windows | The native renderer build is not target-gated; GPU-backed rendering is Unix-only. |
Install veloxc
veloxc is both the compiler and the CLI: it turns .vx files into Rust at build time and drives the dev server.
From crates.io
cargo install veloxc
Verify the install:
veloxc version
This prints the version, the edition, and the platform.
Note: the CLI binary and all its subcommand help now read
veloxc, consistent with the installed package name.
From source
git clone https://github.com/fahimaloy/velox
cd velox
cargo install --path veloxc
Create a project
veloxc init my-app
cd my-app
veloxc init scaffolds a complete, working project — not a stub. The generated app is a todo list with a composer, filters, a light/dark theme toggle, and two dialogs, so everything worth learning from is on screen from the first run.
my-app/
├── Cargo.toml
├── build.rs
├── README.md
├── assets/
│ ├── velox-logo.svg
│ └── velox-logo.png
└── src/
├── main.rs
├── App.vx
└── components/
├── Todos.vx
├── TodoInput.vx
├── TodoItem.vx
├── Confirm.vx
└── Modal.vx
| File | Role |
|---|---|
Cargo.toml | Dependencies on the Velox crates, serde_json, and veloxc as a build dependency |
build.rs | Compiles src/App.vx — and every component it imports — into Rust in OUT_DIR |
src/main.rs | Entry point: builds state, hands the render and event closures to the window |
src/App.vx | Root component: page chrome, theme toggle, dialogs |
src/components/*.vx | The list, the input, a single row, and two reusable dialogs |
assets/ | Static assets; <img src="…"> paths resolve against the project root |
Note:
veloxc initwrites path dependencies when a workspace is found; otherwise it uses publishedversiondependencies (SCAFFOLD_VERSION) for crates.io installs, so the generated manifest is always resolvable. See CLI Reference for details.
Note:
veloxc initwrites path dependencies when it can find a Velox workspace — theVELOX_PATHenvironment variable, a workspace above the current directory, or the checkout the CLI itself was built in. Otherwise it writes git dependencies pinned to the commit the CLI was built at, so a scaffold always compiles against tested code.
Tip: Package names must start with a letter or
_. Dots and spaces in the name are normalized to dashes:veloxc init my.appcreatesmy-app.
Run the dev server
veloxc dev
The first run compiles the project (a full cargo build — it takes a minute), prints a banner, and opens the app window:
⚡ Velox dev server v0.1.2
➤ Project: my-app
➤ Watching: /home/you/my-app/src
➤ Watching: /home/you/my-app/assets
➤ HMR: port 31313 (auto-reload on save)
➤ Build: debug
r: reload c: clear q: quit
Every line is a statement about the run you are in. The HMR line says unavailable: <reason> if the port could not be bound, rather than advertising a reload that cannot happen. Watching lists one directory per line — the dev server watches src/ and assets/, not the project root, so target/ writes never burn the file watcher’s budget.
While the server runs, type one of:
| Key | Action |
|---|---|
r | Rebuild and restart the app by hand |
c | Clear the terminal and redraw the banner |
q | Stop the dev server (and the app) |
Now edit src/App.vx and save. The loop does the rest: the save is debounced, classified by which block of the file changed (<style>, <template>, or script), and answered with a rebuild and an app restart. A compile error does not stop the server — the diagnostics print, the watcher stays alive, and the next save retries. If the app itself exits, the server tells you and keeps watching: press r to restart, or save a file to rebuild.
Build for release
veloxc build --release
This runs cargo build --release for the current project. Start the built app with:
veloxc run --release
veloxc run builds first when --release is passed, then opens the window.
veloxc build also compiles a single .vx file to Rust source — useful when you want to read what the compiler generates from your component:
veloxc build src/App.vx --out-dir gen
This writes gen/app.rs, the compiled component as a Rust module. With no --out-dir, the output lands in target/velox-gen.
Note: The generated file is Rust source, not an executable — the CLI says so itself when you run this form.
Troubleshooting
Files are no longer detected on save (Linux)
inotify is a finite kernel resource. When fs.inotify.max_user_watches is exhausted, the dev server reports the error and keeps running, but saves may no longer be detected. Raise the limit:
sudo sysctl -w fs.inotify.max_user_watches=524288
sudo sysctl -w fs.inotify.max_user_instances=1024
To persist it, write those two lines to a file in /etc/sysctl.d/ and run sudo sysctl --system. The dev server also honors r to rebuild by hand while watching is degraded.
Next steps
| Tutorial | You build |
|---|---|
| Veloxc Init Default | The default scaffold: components, theme, dialogs, v-for |
| Dashboard | Analytics dashboard: grid, charts, sidebar, real-time updates |
| Markdown Editor | Split-pane editor with live preview, toolbar, resize handle |
| Task Board | Kanban board: drag-to-reorder, persistence, modal forms |
| Settings Panel | System settings UI: forms, tabs, accordion, toasts |
For the full command surface — lint, add component, and every flag — see the CLI Reference.
Veloxc Init Default Example
Default veloxc init scaffold (todo app). Demonstrates components, theme toggle, dialogs, v-for, v-model, signals, and scoped CSS.
Build and run:
cd examples/veloxc-init-default
cargo build --release
cargo run --release
For more, see the CLI reference and the root README examples table.
Dashboard Example
Analytics dashboard with sidebar navigation, stat cards, a bar chart, responsive grid layout, and dark-mode styling.
Build and run:
cd examples/dashboard
cargo build --release
cargo run --release
For more, see the CLI reference and the root README examples table.
Markdown Editor Example
Split-pane Markdown editor with a live preview, toolbar, resizable panes (flex), and file-open simulation.
Build and run:
cd examples/markdown-editor
cargo build --release
cargo run --release
For more, see the CLI reference and the root README examples table.
Task Board Example
Kanban board with multi-column layout, drag-to-reorder cards (click-to-move), persistent JSON storage, and toast notifications.
Build and run:
cd examples/task-board
cargo build --release
cargo run --release
For more, see the CLI reference and the root README examples table.
Settings Panel Example
System settings UI inspired by macOS Settings: sidebar tabs, forms, toggles, select inputs, accordion sections, and validation.
Build and run:
cd examples/settings-panel
cargo build --release
cargo run --release
For more, see the CLI reference and the root README examples table.
Template Syntax
A Velox component lives in a single .vx file — a Single-File Component (SFC) with a <template>, a <script setup>, and a <style> block. This page is the complete reference for the template language: structure, attributes, directives, expressions, and slots. Every construct here is compiled to Rust by the velox-sfc parser and codegen before your app ever runs.
Anatomy of a .vx file
<template>
<div class="app">
<h1 class="title">{{ title }}</h1>
<p class="count">{{ count }}</p>
<button class="btn" @click="increment">+1</button>
</div>
</template>
<script setup>
use velox_core::ergonomics::Ref;
pub struct State {
counter: Ref<i32>,
}
impl State {
pub fn new() -> Self {
Self { counter: velox_core::r#ref!(0) }
}
pub fn title(&self) -> String {
String::from("Velox Counter")
}
pub fn count(&self) -> i32 {
self.counter.get()
}
pub fn increment(&self) {
self.counter.set(self.counter.get() + 1);
}
}
</script>
<style scoped>
.app { width: 100%; min-height: 100vh; }
.btn { padding: 6px 12px; }
</style>
The file holds up to three block types:
| Block | Cardinality | Purpose |
|---|---|---|
<template> | 0 or 1 | The markup. One root element, directives, bindings, interpolations. |
<script setup> | 0 or 1 | Component state and logic as Rust: a State struct plus its impl. Imports live here. |
<script> | 0 or 1 | Plain Rust script block. Imports here do not register components. |
<style> | 0 or 1 | CSS for the component. Declare scoped to confine rules to this component. |
A <template> block without any <script> or <script setup> block is reported as incomplete: the component would lack state and event handling.
Note: the template must have exactly one root element. A file whose
<template>holds two roots fails to compile withtemplate has multiple root elements— wrap the markup in a single container element.
The State struct
<script setup> is Rust, with two conventions the compiler relies on:
- A
pub struct Stateholds the component’s reactive fields. pub fnmethods onStateare what the template calls: interpolations read them,@eventhandlers dispatch to them, andv-modelgenerates a setter that writes through them.
Reactive state is a Ref<T> (a wrapper around Rc<Signal<T>>), created with r#ref!(initial_value). Reads go through .get(), writes through .set(value).
<script setup>
use velox_core::ergonomics::Ref;
pub struct State {
counter: Ref<i32>, // reactive scalar
label: Ref<String>, // v-model target
}
</script>
Elements and attributes
The template language is HTML-like: nested elements, self-closing tags (<input/>), attributes, text, and {{ interpolation }} splits. Nesting is capped at 256 unclosed elements — deeper markup fails with template nesting depth 256 exceeded rather than exhausting memory.
Every attribute falls into one of five kinds, chosen by its prefix:
| Kind | Syntax | Resolves to | Example |
|---|---|---|---|
| Static | name="value" | Literal attribute value | class="btn" |
| Bind | :name="expr" | Expression evaluated against State | :disabled="!is_valid" |
| Event | @event="handler" | Method name on State | @click="increment" |
| Directive | v-name="expr" | Structural directive | v-if="count > 0" |
| Slot shorthand | #name="expr" | Named slot fill (see Slots) | #footer="frag" |
Note: attribute values must be quoted —
"double"or'single'. There is no unquoted-value form; a bare token after=is skipped, not guessed.
Bindings
A :name="expr" bind evaluates expr on every render and writes the result into the element’s attributes. Binds work on any attribute — including :class, :style, and :key:
<li v-for="todo in todos" :key="todo.id" :class="todo.completed ? 'done' : ''">
{{ todo.text }}
</li>
Inside a v-for body, binds may reference the loop item and its index directly (:key="todo.id", :key="i").
Interpolation
{{ expr }} inserts the value of expr as text at that point in the tree:
<p>Clicked {{ count }} times</p>
The expression is re-evaluated on every render, so state changes appear on the next frame. An interpolation that is never closed is a hard error — the compiler reports unclosed '{{' — expected a matching '}}' with a caret pointing at the opener.
Directives
Directives are the v--prefixed attributes that give an element structure. The full set is small and deliberate — five directives, one alias, one slot spelling:
| Directive | Value form | Behavior |
|---|---|---|
v-if | expr | Render the element only when expr is truthy; otherwise emit an empty text node. |
v-else-if | expr | Continuation of a v-if chain. Alias: v-elseif. |
v-else | (none) | Final arm of a v-if chain. |
v-show | expr | Always renders the element; toggles an inline display: none style when expr is falsy. |
v-for | item in items or (item, index) in items | Render the element once per entry in the collection. |
v-model | field | Two-way bind on an <input>: desugars to :value="field" plus an @input handler. |
v-slot:name | expr | Fill a child component’s named slot. Shorthand: #name. |
Note: an element cannot carry both
v-forandv-else-if; that combination is rejected by the compiler.
v-if / v-else-if / v-else
<p v-if="count > 10">Many</p>
<p v-else-if="count > 0">Some</p>
<p v-else>None</p>
The chain is positional: a v-else-if or v-else attaches to the nearest preceding v-if sibling. Whitespace text nodes between chain elements are valid. v-if removes the element from the tree entirely when its condition is false — use v-show when the element must stay laid out and merely hide.
v-show
<div v-show="is_open" class="panel">…</div>
v-show always renders the element and toggles CSS display: none based on the expression, preserving the element in the tree. If the element already carries a style attribute, the display value is merged into it.
v-for
<li v-for="item in items" :key="item.id">{{ item.text }}</li>
<li v-for="(item, index) in items" :key="index">{{ index }}: {{ item.text }}</li>
- The value splits on
in: the left side names the item variable, optionally destructured as(item, index). - Without destructuring, the index is still available as
indexwhen written; the internal default is__idx. - Add a
:keyon loop elements — either:key="item.id"or:key="{{ item.id }}"; both spellings are normalized to the same binding. - A
v-modelrooted at the loop item or index is read-only by design: the value bind renders, and the missing write is reported as a warning, because a write to a loop item cannot be routed through theState-rooted dispatcher.
v-model
<input class="label-input" v-model="label"/>
v-model="field" desugars into a :value="field" bind plus an @input handler that calls the generated setter __vmodel_set_field — for v-model="form.name", the setter is __vmodel_set_form_name and writes self.form.name. The target field needs a VModel implementation on its type; Ref<T> implements it.
Expressions
Directive values, binds, and interpolations share one expression language. It is a small, typed expression grammar compiled to Rust:
| Category | Syntax |
|---|---|
| Literals | true, false, numbers (42, 3.14), strings ("done") |
| Identifiers | count, is_visible, form.name (field access chains) |
| Unary | !expr, -expr |
| Comparison | ==, !=, >, <, >=, <= |
| Logical | &&, || |
| Arithmetic | +, -, *, /, % |
| Ternary | cond ? then : else |
| Method calls | obj.method(args) |
| Field access | expr.field |
| Index access | expr[index] |
| Grouping | (expr) |
<p v-if="count > 0 && !is_hidden">visible with items</p>
<p>{{ items[0].text }}</p>
<p>{{ items.length > 3 ? "many" : "few" }}</p>
Comparisons need numeric operands and logical operators need boolean operands; identifiers used in a boolean context are wrapped in truthy checks during compilation.
Components
A tag starting with an uppercase ASCII letter is a component reference. Import the component in <script setup> and use its PascalCase name as the tag:
<template>
<div class="app">
<TodoItem v-for="todo in todos" :key="todo.id" :todo="todo" @remove="remove"/>
</div>
</template>
<script setup>
import TodoItem from './components/TodoItem.vx';
</script>
- Both import forms are supported:
import X from './path.vx'andimport { A, B } from './path.vx'. - Imports are a Velox DSL, not Rust — the compiler rewrites them into module declarations with
#[path]attributes. - Attributes on a component tag bind props into the child (
:todo="todo"); an@eventattaches a handler the child can emit back to. - A PascalCase tag that is not an import produces an
unknown componentwarning naming the tag with a line/column and the fix: import it in<script setup>or fix the tag name. An unregistered component renders as an inert element rather than the intended component.
Slots
A <slot> element in a component template is an outlet for content passed from the parent:
<!-- Card.vx -->
<template>
<div class="card">
<slot name="header"></slot>
<slot></slot>
<slot name="footer">Fallback content</slot>
</div>
</template>
The parent fills slots with a nested <template v-slot:name> or its #name shorthand:
<Card #header="h">
<h1>{{ h }}</h1>
</Card>
- A
<slot>with nonameattribute is the default slot. - Slot names are folded to kebab-case on both sides of the component boundary:
name="footerBar",name="footer_bar", andv-slot:footerBarall resolve to the same slot. A<slot name>on the child and av-slot:on the parent can never disagree on spelling. - The fallback is the feature: a slot’s own children render when the caller passed nothing under that name. A mismatch is a naming mistake, not a broken component.
- The
#name="slotProps"value is parsed but not bound — slot props are not yet wired from the child back into the caller’s fragment, so the shorthand’s value cannot be resolved and is reported as such rather than silently rendering empty.
Comments
| Context | Syntax | Behavior |
|---|---|---|
<style> block | /* comment */ | Recognized and stripped before scoping. |
<script setup> | // line, /* block */ | Ordinary Rust comments. |
<template> | <!-- comment --> | Not supported — see below. |
Warning: the template parser does not recognize
<!-- -->comments. It tolerates them without crashing, but treats them as malformed markup that is silently dropped — and everything after the comment inside the enclosing element is dropped with it. Do not use HTML comments in templates; put a CSS comment in the<style>block or a Rust comment in<script setup>instead.
Syntax summary
| Construct | Syntax | Notes |
|---|---|---|
| SFC blocks | <template>, <script setup>, <script>, <style scoped> | One root element required in <template> |
| Static attribute | name="value" | Quoted values only |
| Bind | :name="expr" | Re-evaluated every render |
| Event | @event="handler" | Modifiers: .stop .prevent .once .capture .self |
| Conditional | v-if, v-else-if, v-elseif, v-else | Positional chain |
| Visibility | v-show="expr" | Toggles inline display: none |
| Loop | v-for="item in items" / (item, index) in items | Pair with :key |
| Two-way bind | v-model="field" | Desugars to :value + @input |
| Slot outlet | <slot name="name"> | Unnamed = default slot; children are fallback |
| Slot fill | <template v-slot:name> or #name | Kebab-case slot names |
| Component | PascalCase tag + import X from './X.vx' | Unknown tags warn |
| Interpolation | {{ expr }} | Unclosed {{ is a hard error |
| Nesting limit | 256 unclosed elements | Deeper markup fails with an actionable error |
See also
- Events —
@eventbindings, payloads, and dispatch. - Styling — the
<style>block, cascade, and supported properties. - Dev Workflow & HMR — editing
.vxfiles live.
Events
Velox components respond to user input through declarative event bindings in the template, backed by a typed event system in the renderer.
Attaching handlers
Attach a handler to any element with the @<event> syntax, pointing at a method on your State:
<template>
<div>
<button @click="increment">Clicked {{ count }} times</button>
</div>
</template>
<script setup>
use velox_core::ergonomics::Ref;
pub struct State {
count: Ref<i32>,
}
impl State {
pub fn new() -> Self {
Self { count: velox_core::r#ref!(0) }
}
pub fn count(&self) -> i32 {
self.count.get()
}
pub fn increment(&self) {
self.count.set(self.count.get() + 1);
}
}
</script>
State lives as a Ref<T> field on State, and the template reaches it through an accessor method — the same contract every shipped component uses. See Template Syntax for the full block rules.
Supported events
| Event | Fires when |
|---|---|
click | Left-button press on the element (hit-tested against layout geometry) |
dblclick | Two clicks on the same element within 400 ms |
input | A text field’s value changes |
change | A text field commits its value (e.g. on submit) |
focus / blur | A text field gains or loses focus |
keydown / keyup / keypress | A key is pressed, released, or pressed-and-released |
mousedown / mouseup / mousemove | Mouse buttons and movement |
mouseenter / mouseleave | The cursor enters or leaves the element |
hover | The cursor moves over a hoverable element |
submit | A form is submitted |
Event payloads
You can attach an explicit payload to an event using on:<event>-payload, and handlers will receive it. Alternatively, use inline Rust closures in the script to receive the payload (or the event name if no payload was provided).
Example SFC snippet — the first button carries an explicit payload string, the second uses an inline closure that receives the payload (or the event name):
<template>
<div>
<button @click="inc" on:click-payload="amount:5">Add 5</button>
<button @click="|p| state.handle_payload(p)">Handle Payload</button>
</div>
</template>
<script setup>
pub struct State;
impl State {
pub fn handle_payload(&self, payload: &str) {
println!("payload={} ", payload);
}
}
</script>
Note: no HTML comments above — the template parser does not recognize
<!-- -->. It drops the comment and everything after it inside the enclosing element. Put explanatory comments in<style>(/* */) or<script setup>(//) instead; see Comments.
Add on:click-payload when you want to pass extra data (IDs, quantities) from the template to the handler. Use inline closures in <script setup> when you want to handle the raw payload string directly.
How dispatch works
- The SFC codegen produces a helper
make_on_event(state)that returns a closure with signatureFnMut(&str, Option<&str>)— event name, optional payload. Every template handler becomes an arm of that closure; handlers declared with a payload parameter receive it, others are called zero-arg. Handlers owned by a persistent child component state route tostate.{owner}.{method}. - At runtime, the renderer hit-tests clicks against the laid-out tree (
collect_click_targets/hit_test_clickinvelox-renderer/src/events.rs). When a hit matches, the handler is invoked throughmake_on_event. - Payload forwarding: an explicit
on:<event>-payloadattribute is forwarded as the payload string when present (velox-renderer/src/lib.rs:2128-2131). Otherwise, pointer events receive a JSON object with the mouse coordinates —{"x":<x>,"y":<y>}.
Event modifiers
Velox recognizes the Vue-style modifiers .stop, .prevent, .once, .capture, and .self, parsed from the handler name (e.g. @click.stop="submit"). Modifiers are emitted inline by the codegen alongside the handler call.
Keyboard input
@keydown handlers receive a stable, author-facing key name as the payload — a string, not a winit enum — so templates never leak a dependency type. The vocabulary follows the DOM KeyboardEvent.key convention: "Enter" (not "Return"), "Space", "Escape", with upper-case bare letters for shifted and unshifted keys alike ("a" and "A" share a name). Anything outside the table becomes "Unidentified".
While a text field is focused, editing keys (backspace, delete, arrows, Home/End) edit the value and dispatch input; Enter dispatches change; Escape ends the editing session, dropping focus and clearing the selection.
Styling
Velox styles elements with CSS. A component declares styles in its <style> block, elements can carry inline style attributes, and a built-in user-agent stylesheet provides sensible HTML defaults. All three layers run through one cascade on every frame — there is no separate build step for CSS.
Where styles live
| Layer | Declared in | Precedence |
|---|---|---|
| User-agent | Built in (velox-style ua.css) | Lowest |
| Author | <style> blocks in .vx files | Middle |
| Inline | style attributes on elements | Highest |
Inline styles win over stylesheet rules, which win over the UA sheet — the same ordering browsers apply.
<template>
<div class="card" style="margin-top: 12px">…</div>
</template>
<style scoped>
.card {
width: 100%;
padding: 16px;
background-color: #1a1a2e;
}
</style>
Scoped and global styles
Declare the block <style scoped> to confine every rule to that component’s own elements:
- The component gets a unique
data-v-<hash>scope id derived from its name. - Each selector is rewritten with the matching attribute selector —
.headerbecomes.header[data-v-abc1ef23]. - The scope id is exposed as
SCOPE_IDso rendered VNodes are tagged with the same attribute the scoped selectors rely on.
An unscoped <style> block passes through unchanged and applies globally. Inside a scoped block, @keyframes inner selectors (from, to, percent offsets) stay unscoped while the outer rules are scoped.
The cascade
apply_with_cascade composes the layers UA < author < inline and runs the result over the whole tree once per frame:
- Matching is by compound selector against the element and, for multi-part selectors, its ancestors.
:hovermatches when the pointer is over the element (the renderer supplies the hover predicate).::placeholderdeclarations are written to a separate attribute (style:placeholder) so they can never repaint the element’s own value.
Selectors
| Selector | Example | Matches |
|---|---|---|
| Tag | button | Elements with that tag |
| Universal | * | Any element |
| Class | .btn | Elements whose class attribute contains the token |
| Compound | h1.title | Tag and class on the same element |
| Attribute | [disabled] / [type="text"] | Elements carrying the attribute (optionally with an exact value) |
| Descendant | .header h1 | An h1 with any .header ancestor |
| Child | div > .card | A .card exactly one level below a div |
| Hover | button:hover | The element while the pointer is over it |
| Pseudo-element | input::placeholder | The placeholder of an <input> that is currently showing it |
Note:
::placeholderis the only pseudo-element Velox supports, and it matches only an<input>— not atextarea— that carries a non-emptyplaceholderattribute and an empty value. A filled field shows the value, not the placeholder, so a::placeholderrule never wins there.
Supported properties
The table below lists every property the style engine parses and which rendering reader honors it. The live path has three readers — the flexbox/table layout pass, the box painter, and the text painter — and a property is honest only if at least one of them looks it up. Properties marked parsed only survive the cascade but are then dropped; veloxc lint reports them.
Layout and box model
| Property | Values | Rendered by |
|---|---|---|
display | block, inline, inline-block, flex, grid, none (hidden alias) | Layout + box painter |
position | static, relative, absolute, fixed, sticky | Layout + box painter |
z-index | Integer | Stacking context |
opacity | 0.0–1.0 (clamped) | Layout + box painter |
width / height | Length | Layout |
min-width / min-height | Length | Layout |
max-width / max-height | Length | Layout |
margin | Length shorthand (1–4 values) + margin-top/-right/-bottom/-left | Layout |
padding | Length shorthand (1–4 values) + padding-top/-right/-bottom/-left | Layout + box painter |
top / right / bottom / left | Length | Layout |
box-sizing | content-box, border-box | Layout (UA default is border-box) |
Flexbox
| Property | Values | Rendered by |
|---|---|---|
flex-direction | row, row-reverse, column, column-reverse | Layout |
flex-wrap | nowrap, wrap, wrap-reverse | Layout |
justify-content | flex-start (start), flex-end (end), center, space-between, space-around, space-evenly | Layout |
align-items | flex-start (start), flex-end (end), center, baseline, stretch | Layout |
align-self | auto + the align-items values | Layout |
flex-grow / flex-shrink | Number | Layout |
flex-basis | Length | Layout |
gap (grid-gap) | Length (sets both axes) | Layout |
row-gap / column-gap | Length | Layout |
Backgrounds and borders
| Property | Values | Rendered by |
|---|---|---|
background-color | Color | Box painter |
background | Color-only shorthand — the first color token is used | Box painter |
border | <width> <style> <color> shorthand | Box painter |
border-width | Length shorthand (1–4 values) | Box painter |
border-radius | Length shorthand (1–4 values) | Box painter |
border-style | Border style values | Parsed only |
border-color | Color values | Parsed only |
background-image | Value string | Parsed only |
Typography
| Property | Values | Rendered by |
|---|---|---|
color | Color | Text painter (inherits) |
font-size | Length | Text painter (inherits) |
font-family | Family string (e.g. system-ui) | Text painter (inherits) |
font-weight | normal, bold, bolder, lighter, or 100–900 | Text painter (inherits) |
line-height | Number (multiplier), px length | Text painter (inherits) |
text-align | left, center, right, justify | Text painter (inherits) |
vertical-align | baseline, top, bottom, middle | Layout (inherits) |
text-decoration | none, underline, overline, line-through | Text painter (inherits) |
font-style | normal, italic, oblique | Parsed only |
letter-spacing | Length | Parsed only |
Overflow and misc
| Property | Values | Rendered by |
|---|---|---|
overflow | visible, hidden, scroll, auto | Layout + box painter |
overflow-x | visible, hidden, scroll, auto | Parsed only — use the overflow shorthand |
overflow-y | visible, hidden, scroll, auto | Parsed only — use the overflow shorthand |
white-space | normal, nowrap, pre, pre-wrap, pre-line | Text painter (inherits) |
text-overflow | clip, ellipsis | Text painter |
visibility | visible, hidden, collapse | Parsed only — visibility: hidden hides nothing; use display: none or v-show |
transform | Transform functions (translateX, scaleX, …) | Parsed only — forces a stacking context, no visual effect |
box-shadow | 2px 2px 4px rgba(0,0,0,0.5) form | Parsed only |
transition | Comma-separated <property> <duration> <timing> | Parsed only — no reader plays a transition |
Tip:
veloxc lintreports exactly the parsed only properties above, by file and rule index. Unknown declarations are filtered out silently by the cascade, so lint deliberately does not flag them — vendor prefixes and custom properties stay quiet.
Tip: to hide an element use
display: none(orv-showin templates), notvisibility: hidden. To round corners useborder-radius, and give depth withborder— notbox-shadow.
Units
All length values share one parser:
| Unit | Example | Resolves against |
|---|---|---|
px | 16px | Physical pixels |
% | 100% | Parent size |
rem | 1.5rem | Root font size |
em | 1.25em | Parent font size |
vw | 50vw | Viewport width |
vh | 100vh | Viewport height |
dvw / dvh | 100dvh | Viewport width/height (treated as vw/vh) |
auto | auto | Automatic (layout-dependent) |
| (none) | 0, 16 | Unitless zero, plain numbers as px |
A responsive page fills the viewport with a root of width: 100%; min-height: 100vh — the canonical pattern in every scaffold and example, and one the layout engine reflows visibly on every window resize.
Colors
| Form | Examples |
|---|---|
| Named | black, white, red, green, blue, yellow, cyan, magenta, silver, gray/grey, maroon, olive, lime, aqua, teal, navy, fuchsia, purple, orange, pink, transparent |
| Hex | #abc, #aabbcc, #aabbccdd (3, 6, or 8 digits) |
| Functional | rgb(255, 0, 0), rgba(255, 0, 0, 0.5) |
Inheritance
Inherited properties flow from parent to child per browser CSS behavior:
color, font-size, font-family, font-weight, font-style, line-height, letter-spacing, text-align, vertical-align, white-space, visibility, cursor, text-decoration
Everything else is non-inherited: each element reads its own declaration, so a descendant of a clipped box is not clipped unless it says so. ::placeholder declarations never inherit — not even from their own element.
Fonts
Font matching resolves against system fonts and custom font files. font-weight accepts the CSS keywords and the full 100–900 numeric scale (Thin 100 through Black 900, default Normal 400); font-style accepts normal, italic, and oblique; line-height accepts normal (browser default 1.2×), a number multiplier, or a px length.
See also
- Template Syntax — the
<style scoped>attribute and directive reference. - Renderer — how the cascaded tree is laid out and painted.
- Dev Workflow & HMR — editing styles live.
Renderer
velox-renderer is the crate that paints. It takes the cascaded VNode tree your <template> and <style> blocks produce, lays it out, draws it with Skia, and presents the pixels to a native window through softbuffer — all inside a winit event loop. Everything heavy is feature-gated: with no features enabled the crate compiles to a fast stub, and you opt in to the real backend when you are ready.
The rendering pipeline
Every frame runs the same pipeline — first frame, resize, or repaint alike:
| Stage | Where | What happens |
|---|---|---|
| 1. View function | your make_view closure | Builds the VNode tree + stylesheet from the current logical size |
| 2. Hover tagging | with_hover_ids | Tags every element with a data-hover-id |
| 3. Cascade | style_vnode_with_hover | Applies UA < author < inline (see Styling) |
| 4. Input attributes | inject_input_caret_attrs | Text inputs get caret/focus attributes — unfocused defaults on frame one |
| 5. Layout | velox_dom::layout::compute_layout | Flexbox/table layout against logical w×h |
| 6. Scroll + targets | apply_scroll_offsets, recompute_targets | Applies scroll offsets; recomputes click/hover/input hit targets |
| 7. Paint | skia_render::skia_impl::render_frame | Skia draws the tree into the raster surface |
| 8. Present | SoftbufferPresenter::present | Copies the finished pixels to the window |
The first frame renders before the event loop starts, so the window has content even on platforms where an early request_redraw() does not produce a RedrawRequested (certain Wayland/X11 compositors).
Entry points
#![allow(unused)]
fn main() {
// Paint a window whose contents are produced by a view closure.
run_window_vnode_skia(
title: &str,
make_view: F, // FnMut(u32, u32) -> (VNode, Stylesheet)
on_event: G, // FnMut(&str, Option<&str>)
get_title: H, // FnMut() -> String
) -> Result<(), String>
// HMR variant — same contract plus a receiver for HmrMessage values.
run_window_vnode_skia_with_hmr(
title: &str,
make_view: F,
on_event: G,
get_title: H,
hmr_rx: Arc<Mutex<Receiver<HmrMessage>>>,
) -> Result<(), String>
}
Both live behind #[cfg(feature = "skia-native")]. The event loop parks on ControlFlow::Wait — a UI application should not burn a core polling, and Wait is what keeps idle CPU at zero.
The make_view contract
make_view is FnMut(w: u32, h: u32) -> (VNode, Stylesheet), where (w, h) are logical viewport dimensions — Viewport::from_i32(physical, scale).logical_size(). The renderer calls it once on startup and again on every RedrawRequested, resize, and scale-factor change with the current logical w,h.
Templates must not ignore the dimensions (|_w, _h| defeats reflow). The canonical responsive pattern is a viewport-filling root — width: 100%; min-height: 100vh on .app — which reflows visibly on every window resize:
<style scoped>
.app { width: 100%; min-height: 100vh; }
</style>
Root normalization is the safety net: velox_dom::layout::root_is_viewport_filling ensures the first VNode always fills the logical viewport even without explicit sizing. A root sized 100%, 100vw, 100dvw, 100vh, 100dvh, or min-height: 100%/100vh/100dvh counts as viewport-filling.
Viewport handling
The Skia surface, the softbuffer presenter, and the window loop share one Viewport type — the single source of truth for physical size, logical size, and scale:
#![allow(unused)]
fn main() {
pub struct Viewport {
pub physical: PhysicalSize, // window inner size, clamped to >= 1
pub logical: LogicalSize, // (physical / scale).round().max(1)
pub scale: f32, // device pixel ratio; invalid values fall back to 1.0
}
}
| Rule | Detail |
|---|---|
| Physical clamp | Width/height clamped to a minimum of 1 — a zero-sized window never reaches Skia |
| Logical derivation | (physical / scale).round().max(1) per axis |
| Invalid scale | Non-finite or <= 0.0 scale falls back to 1.0 |
| Single rounding | physical = (logical × scale).round() — one canonical forward mapping, so fractional scales never produce 0.25px subpixel edges |
| Pixel-grid snap | snap_logical_to_physical_grid(value, scale) aligns coordinates to device pixels after canvas.scale(scale) |
| Default | 800 × 600 at scale 1.0 |
At scale 1.5, an 800 × 600 physical window reports a 533 × 400 logical viewport. Resize hooks fire only after a coalesced physical resize has committed and the logical size actually changed — a resize drag produces many Resized events, and only the last one per frame materializes. Scale-factor changes recompute logical dimensions from physical and deliberately do not reallocate the pixel buffer.
Backends and feature flags
The crate builds nothing by default — default = []:
| Feature | Pulls in | What you get |
|---|---|---|
| (none) | velox-core, velox-dom, velox-style only | A stub renderer; compiles fast, no window |
skia | (nothing — an API-surface switch) | The Skia API surface compiles; no native backend yet |
skia-native | skia + skia-safe 0.91.1 (gl, egl), winit 0.28, softbuffer 0.3, raw-window-handle 0.5, egl 0.2, glow 0.12, svg | The real backend: windowed rendering with Skia + softbuffer |
svg | resvg 0.48 | SVG rasterisation for <img src="logo.svg"> |
Note:
svgis split out ofskia-nativeso a build that never paints SVG can skip resvg entirely;skia-nativeimplies it to preserve current behaviour. resvg is built withdefault-features = falseand onlyraster-imageskept — the dropped font features (fontdb, rustybuzz, ttf-parser) exist to typeset SVG<text>, by far the largest build cost, spent on the one thing a logo never has. An SVG that embeds a PNG still resolves.
Note: the platform crates build anywhere —
skia-safe/egl/glowneed no build script or C toolchain, andsoftbuffersupports Windows natively — soskia-nativecompiles on every target. GPU acceleration stays unix-gated; see From paint to pixels.
Every scaffolded project enables the real backend: veloxc init writes velox-renderer = { version = "0.1.2", features = ["skia-native"] } into the generated Cargo.toml.
From paint to pixels: the presenter
The rendering target is a CPU raster surface — sk::surfaces::raster_n32_premul, created once per window by SkiaSurface::new_raster. There is no GPU context, and that is deliberate: the pixel tests in velox-renderer/tests/ compare rendered bytes, and determinism is what makes them possible. A GPU path would forfeit determinism and has no resource-cache limit set — enabling one today would be a resource-exhaustion risk, not an optimization. The accepted cost: CPU rasterization is slower for large paints, and raster_n32_premul has no subpixel text antialiasing, so Velox text will not match a browser pixel-for-pixel.
SoftbufferPresenter::present moves the finished frame to the window:
read_pixelscopies the whole surface into velox’s own staging buffer, requested in the surface’s nativeN32order. On little-endian targetsN32isBGRA_8888— exactly softbuffer’s word order — so no swizzle pass exists.buffer_mutmaps softbuffer’s buffer. The OS hands out a fresh mapping per frame; it is not velox’s to cache.- One bulk
copy_from_slicemoves the staging bytes in — no per-pixel loop. buffer.present()blits. On a broken compositor connection (EPIPE) the presenter degrades to a no-op: rendering still runs offscreen and the error surfaces once per session on stderr instead of crashing the event loop.
If the compositor is unavailable at all (CI, containers, SSH), headless mode kicks in: the window and event loop are still created, presentation is skipped, and rendering runs offscreen. Enable with VELOX_HEADLESS=1 or let it auto-detect via presenter::is_compositor_available().
Performance notes
The buffers are already reused — do not “fix” them:
- The raster surface is allocated once per window at window setup, in both the normal loop (
velox-renderer/src/lib.rs:1907) and the HMR loop (velox-renderer/src/lib.rs:2569). The only post-setup reallocation is theelsepath ofresize. - The staging buffer (
rgba: Vec<u8>,velox-renderer/src/presenter.rs:294) is allocated once, insideSoftbufferPresenter::new(velox-renderer/src/presenter.rs:308), and only reallocated inresizewhen the dimensions actually changed.
What remains on the per-frame path is bandwidth, not allocation: three full-surface passes plus a clear — canvas.clear, read_pixels, and copy_from_slice (a ~1.92 MB memcpy at 800×600). Shrinking that is a damage-limited-repaint problem; a “reuse the buffer” change cannot address it.
Note:
render_vnode_to_rgbaallocates a fresh surface and buffer on every call — it is the benchmark path and has zero production callers. The frame-cost bench header says so itself: the bench overstates total frame cost. Treatlayout_usandcascade_usfrom the bench as evidence; the derivedpaint_usdescribes a path the product never runs.
Known per-frame allocations still on the live path, all in velox-renderer/src/skia_render.rs:
| Site | Cost |
|---|---|
ImageCache::load + its per-frame recreation | A disk read and a full PNG/JPEG decode per frame, per image |
AdvanceKey construction | A String allocation on every text-advance lookup — hit or miss |
RenderPaints::new, default_family(), child_family.to_string() | Five heap-backed paints + small strings per frame / per element node |
The font cache, by contrast, is already handled: it lives in a thread-local and is taken-and-returned per frame, so the fontconfig scan behind load_default_typeface runs once per thread rather than once per frame.
HMR channel
The dev server and a running app talk over a small TCP protocol, implemented in velox-renderer/src/hmr.rs:
velox_renderer::DEFAULT_HMR_PORTis31313(velox-renderer/src/hmr.rs:23). The dev server listens on127.0.0.1:31313; apps connect to it.hmr_config() -> Option<u16>returnsSome(port)when the process runs under the dev server (VELOX_HMR=1), reading the port fromVELOX_HMR_PORT(defaulting to31313); otherwiseNone.run_hmr_client(port, tx)spawns a background thread that connects to the dev server, forwardsHmrMessagevalues through a channel, retries every 500 ms when the connection is lost, and onFullReloadexits the process with code 0 — the dev server sees the child exit and restarts it with a fresh build.
Messages are newline-delimited JSON:
| Message | Meaning |
|---|---|
FullReload | Rebuild and re-mount. The app exits with code 0; the dev server restarts it. |
HotReload { module_path } | Re-execute a specific module. Future use — currently treated as a FullReload. |
KeepWindow | Keep-alive no-op; lets the dev server probe the connection without restarting. |
The dev-side half of the story — watch, classify, rebuild, relaunch — is on Dev Workflow & HMR.
See also
- Template Syntax — the VNode tree this renderer paints.
- Styling — the cascade that runs in stage 3.
- Dev Workflow & HMR — what triggers a rebuild while you edit.
Dev Workflow & HMR
veloxc dev is the inner loop of working with Velox: it watches your files, rebuilds on save, relaunches the app, and keeps a hot-reload channel open so the running app can be told to restart itself. This page is the complete reference — the watch loop, how a save is classified, what updates live versus what restarts, the keyboard shortcuts, and what to do when the OS watch budget runs out.
The dev loop
veloxc dev # watch the current directory
veloxc dev -w path/to/app # watch a specific project root
veloxc dev --release # rebuild and relaunch in release mode
There is no positional directory argument — the watch root is passed with --watch/-w and defaults to ..
When the server starts it names every directory it watches. A scaffolded project gets two roots: src/ and assets/ — replacing an image an <img src> points at is exactly as much a rebuild as editing a .vx file, and watching src/ alone made that edit silently do nothing. A project without assets/ still starts; a root that does not exist is skipped rather than reported.
Watching is backed by the OS — inotify on Linux — not by polling, so a save is picked up in milliseconds. target/, .git, .vscode, .idea, and every dot-directory are excluded at any depth: cargo build writes thousands of files into target/, and watching it is what exhausts the kernel’s watch budget.
Every event flows through the same pipeline:
inotify event ──▶ exclude (target/, dot-dirs) ──▶ classify (which SFC block?)
──▶ 50 ms debounce window (burst coalescing) ──▶ rebuild ──▶ relaunch
Save → classify → rebuild
The watcher does not just report that something changed — it classifies which SFC block the change landed in, exactly as Vite does. Both the previous and current source are parsed with the velox-sfc parser and their block contents diffed, so an unterminated </templ typed mid-keystroke is detected as broken rather than mistaken for a style-only edit.
| Kind | When | Response |
|---|---|---|
StyleOnly | Only <style> content differs | Routed to a stylesheet swap (see below) |
TemplateOnly | Only <template> content differs | Full cargo build + relaunch |
Script | Script logic changed, more than one block, the file does not parse as an SFC, or it is not an SFC at all | Full cargo build + relaunch |
Every ambiguity resolves to the conservative Script kind. The bias is deliberate and one-directional: mis-classifying an edit as cheaper would swap a stylesheet while the template that renders it never recompiled — stale code that looks live. Over-reporting costs a rebuild the user would have got anyway.
A single editor save produces several raw events (create, modify, close_write), so events land in a 50 ms debounce window. The window is a deadline, not a sleep: the dev loop blocks on its command channel for exactly the remaining time, so a keystroke or a finished build still wakes it early. Several edits in one window are folded with the rule Script > TemplateOnly > StyleOnly — the merge can never make a build cheaper than any single edit in it.
While a build is running, further saves are recorded rather than acted on, and become one follow-up build when the current compile ends — N saves inside one compile cost one rebuild, not N competing cargo build processes.
The build itself runs on a worker thread, so the loop keeps servicing keystrokes, app exits, and further saves while the compiler works. q works during a cold compile too.
What updates live vs what restarts
The honest answer today:
| Change | What happens |
|---|---|
<style> block edit | Classified StyleOnly, routed to a stylesheet swap — but all kinds rebuild today |
<template> edit | Full rebuild + relaunch |
<script> edit | Full rebuild + relaunch |
Asset in assets/ | Full rebuild + relaunch |
| New file, deleted file, unparseable file | Full rebuild + relaunch |
Note: every classified change rebuilds today — the style-swap path needs an
HmrMessage::StyleUpdatemessage that does not exist yet, and flipping the switch before it exists would silently break CSS edits (nothing would rebuild and nothing would replace them). The classification is complete and tested now, so the live-CSS win lands as a one-line change when the message arrives.
What the dev server never does: stop on a compile error. A failed build prints its diagnostics and the watcher stays alive — the next save retries.
The HMR channel
On startup the dev server binds a TCP listener on 127.0.0.1:31313 (velox_renderer::DEFAULT_HMR_PORT) before the banner is drawn, so every line the user reads is a statement about the run they are in. The app is spawned with VELOX_HMR=1 and VELOX_HMR_PORT=31313 — but only when the listener is really listening; with no listener the app gets VELOX_HMR=0 so it never connects to a stranger holding the port.
On a rebuild the dev server sends a FullReload message (newline-delimited JSON) over that channel. The app’s HMR client receives it and exits with code 0; the dev server then reaps the old process, compiles, and relaunches with a fresh build:
save ──▶ classify ──▶ debounce ──▶ [velox] Sent FullReload to app
──▶ app exits (code 0) ──▶ ⏳ Compiling... ──▶ ✓ Compiled in 0.8s ──▶ App started (HMR enabled)
The port is fixed — there is no --hmr-port flag yet — so a stray listener squatting on 31313 wedges hot reload for this project until it is found and killed. The banner tells you when that has happened; see below.
Banner and output
⚡ Velox dev server v0.1.2
➤ Project: counter
➤ Watching: /home/you/counter/src
➤ Watching: /home/you/counter/assets
➤ HMR: port 31313 (auto-reload on save)
➤ Build: debug
r: reload c: clear q: quit
One ➤ Watching: line is printed per root. The HMR line never advertises auto-reload it cannot deliver: when the bind fails it reads port 31313 (unavailable: port 31313 in use by another process), and the started-app line correspondingly says App started (HMR unavailable: …).
Other lines you will see:
| Line | When |
|---|---|
⏳ Compiling... | On start and before every rebuild |
✓ Compiled in 0.8s | A successful build |
App started (HMR enabled) | The app process was spawned |
↻ src/App.vx changed (Script) — rebuilding | A debounced save triggered a rebuild (the kind is the classification) |
↻ Manual reload requested | You pressed r |
App exited. Press 'r' to restart, or save a file to rebuild. | The app exited on its own |
✗ App crashed. Fix the error and save to rebuild. | Pressing c while the app is down |
✗ Build failed panel | A compile error — up to 20 error[...]/error:/cannot find/expected lines, with Edit the file and save to rebuild. underneath |
👋 Dev server stopped. | You pressed q, or stdin closed |
The build-failure panel is deliberately not fatal and not the whole log: it filters the compiler’s stderr to the diagnostic lines (falling back to the last 15 lines when no error line matches), so the fix is visible without scrolling.
Keyboard shortcuts
| Key | Action |
|---|---|
r (or reload) | Rebuild and restart the app by hand |
c (or clear) | Clear the screen and redraw the banner |
q (or quit, exit) | Stop the dev server and kill the running app |
The app is killed before the goodbye is printed, and closing stdin (e.g. running the server behind a pipe that ends) stops the server too.
inotify troubleshooting
On Linux, watching is backed by inotify — a finite kernel resource. If the limit is exhausted, watcher.watch() fails with No space left on device, the dev server reports the error and keeps running, but files may no longer be detected. The failure is never fatal: a blind watcher is still a running dev server you can rebuild from by hand.
sudo sysctl -w fs.inotify.max_user_watches=524288
sudo sysctl -w fs.inotify.max_user_instances=1024
To persist the limit across reboots, write those two lines to a file in /etc/sysctl.d/ (for example /etc/sysctl.d/90-velox.conf) and run sudo sysctl --system.
Tip: if you suspect a dead watcher, press
r— a manual rebuild works whether or not watching is alive. Then check thattarget/is outside the watched tree: the dev server excludes it itself, but a second watcher you started (another tool, anotherveloxc dev) may not.
Tip: run
veloxc devfrom the project root. The watch roots are resolved relative to it (src/,assets/), and the banner names exactly what is being watched — if a directory you expect is missing from the banner, it does not exist on disk.
See also
- Renderer — the HMR channel protocol and what
FullReloaddoes on the app side. - CLI Reference — every command and flag, including
veloxc dev. - Template Syntax — the blocks the classifier inspects.
CLI Reference
veloxc is the command-line tool that scaffolds, builds, lints, and runs Velox applications — install it with cargo install veloxc. Every command on this page is verified against the CLI source; veloxc --help lists them, and veloxc <subcommand> --help is the authoritative flag reference.
Command summary
| Command | Purpose |
|---|---|
veloxc init <name> | Create a new Velox project with scaffolding |
veloxc build [input] | Build the current project, or compile a .vx component to Rust |
veloxc run | Build and run the current project |
veloxc dev | Start the dev server with file watching and hot reload |
veloxc lint [target] | Check .vx files for issues |
veloxc add component <name> | Generate a new component .vx file |
veloxc version | Show version and system info |
veloxc init <name>
Creates a complete, working project — not a stub. The scaffold is a todo list app with components, assets, and a build script, so everything is on screen from the first veloxc dev.
| Flag | Short | Default | Meaning |
|---|---|---|---|
--template <name> | -t | default | Project template. The flag is accepted; today every template value produces the same scaffold. |
--local <path> | (none) | (none) | Use the Velox workspace at <path> for path dependencies instead of git. |
The scaffold writes:
<name>/
├── Cargo.toml # velox-core/dom/style, velox-renderer (skia-native), veloxc build-dep
├── build.rs # compiles src/App.vx + imports via veloxc::build_cmd
├── README.md
├── assets/
│ ├── velox-logo.svg
│ └── velox-logo.png
└── src/
├── main.rs
├── App.vx
└── components/
├── Todos.vx TodoInput.vx TodoItem.vx
└── Confirm.vx Modal.vx
Dependency resolution, in order: --local <path> wins; otherwise the CLI walks up from the current directory looking for a Velox workspace (a velox-core/Cargo.toml), or uses the checkout the CLI itself was built in — either way the scaffold gets relative path dependencies. When no workspace is found it falls back to git dependencies pinned to the commit the CLI was built at, so a scaffold always compiles against tested code.
veloxc init counter
✅ Created Velox project: counter
📦 To get started:
cd counter
veloxc dev
veloxc build
veloxc run
✅ Created Velox project at: counter
📖 Next steps:
cd counter
veloxc dev
The scaffold writes
veloxccommands in its next-steps banner (line 21 of itsREADME.md). The CLI and the scaffold are consistent. The installed-CLI case (cargo install veloxc) is covered by theversion = "..."dependency branch:generate_cargo_tomldetectsrev == "unknown"(theVELOX_GIT_REV_FALLBACKsentinel) and emits plain version pins instead of an unresolvablerev = "unknown", which cargo rejects (revspec 'unknown' not found).
veloxc build [input]
Two modes, chosen by whether a positional input is given:
| Mode | Invocation | Behavior |
|---|---|---|
| Project build | veloxc build | cargo build for the current project |
| Component compile | veloxc build src/App.vx | Compile the .vx file and all imported components to Rust source |
| Flag | Short | Meaning |
|---|---|---|
--out-dir <dir> | -o | Output directory when compiling a .vx file (default target/velox-gen) |
--release | (none) | Release mode when building the current project |
The component compile is recursive: every component imported from <script setup> is parsed and compiled, one .rs file per component in the output directory. The root component is wrapped in a pub mod block (so include! in main.rs works), imported components are declared with #[path] attributes, and child-component styles are merged into the root stylesheet — scoped ones stay scoped. When run from a build script, cargo:rerun-if-changed directives are emitted for every .vx file read, so Cargo re-runs the build script when a component changes.
veloxc build src/App.vx -o target/velox-gen
[velox] Generated: target/velox-gen/app.rs
ℹ️ The generated .rs file is source code, not an executable.
veloxc run
Builds first when --release is passed, then runs the project. The app inherits your terminal.
| Flag | Meaning |
|---|---|
--release | Build in release mode before running |
veloxc run # debug build (existing binary is reused by cargo)
veloxc run --release # builds in release mode first, then runs
veloxc dev
Starts the dev server: file watching, rebuild on save, and the HMR channel that tells the running app to restart itself.
| Flag | Short | Default | Meaning |
|---|---|---|---|
--watch <dir> | -w | . | Watch directory (project root; builds with cargo run) |
--release | (none) | (none) | Release mode build |
There is no positional directory argument — the watch root is passed with --watch/-w. The server watches src/ and assets/ (roots that do not exist are skipped), binds 127.0.0.1:31313 for hot reload, and serves keyboard commands on stdin.
veloxc dev
The full workflow — what updates live, what restarts, the banner, the shortcuts, and inotify troubleshooting — is on Dev Workflow & HMR.
veloxc lint [target] [--fix]
Checks .vx files for issues. Three kinds of findings:
| Finding | Example | Severity |
|---|---|---|
| Parse errors | Unclosed {{, multiple root elements | Error — fails the command |
| Script-idiom warnings | Cell/RefCell usage where Ref is expected | Warning — advisory only |
| Parsed-but-unrendered CSS | visibility: hidden, box-shadow, transition | Warning — with the reason |
The target defaults to src and may be a single .vx file or a directory (walked recursively, .vx files only). CSS warnings are reported by file and rule index; unknown declarations are not flagged — the cascade filters those out silently, so vendor prefixes and custom properties stay quiet.
--fix applies the two auto-fixes — trailing whitespace on every line, and exactly one final newline. Parse errors are not auto-fixable: non-parseable files are reported as errors and left untouched.
veloxc lint src/App.vx
veloxc lint # every .vx under src/
veloxc lint --fix # …and fix whitespace issues
📊 Lint results: 8 files, 0 errors
veloxc add component <name>
Generates a new, self-contained component .vx file in src/components/. The name is normalized: my-counter, side_bar, and My Component all produce a PascalCase struct (SideBar), a matching PascalCase filename (SideBar.vx), and a kebab-case CSS class (side-bar). The name must start with a letter, and the file is never overwritten.
| Flag | Short | Default | Meaning |
|---|---|---|---|
--slots <NAME,NAME> | -s | header | Named slots to declare, comma-separated. The implicit default slot is always included. |
Slot names are folded to the form the SFC parser registers — footerBar and main_content are scaffolded as footer-bar and main-content — and default cannot be requested explicitly.
veloxc add component side-bar --slots header,footer
✅ Created component: src/components/SideBar.vx
Slots: default, header, footer
Fill one from a parent: <SideBar><template v-slot:header>…</template></SideBar>
(shorthand: <SideBar><template #header>…</template></SideBar>)
Import it from a parent component, e.g.:
import SideBar from './components/SideBar.vx'
veloxc version
veloxc version
velox 0.1.2
Edition: 2021
Platform: Linux
Note:
Edition: 2021is a fixed string from the CLI itself, not read from your project’s manifest.
Exit codes
| Code | Meaning |
|---|---|
0 | Success — also the code the app exits with when it receives an HMR FullReload |
1 | Any command failure: cargo build/run failure, SFC parse errors, lint findings (Lint failed with N errors), missing project, invalid names |
2 | Invalid usage (unknown flag, missing subcommand) — standard clap behavior |
A failing veloxc lint exits non-zero, so it drops straight into CI: veloxc lint src/ as a step fails the pipeline on the first parse error.
Environment variables
| Variable | Set by | Read by | Meaning |
|---|---|---|---|
VELOX_PATH | veloxc init --local | veloxc init | Workspace to link as path dependencies |
VELOX_HMR | veloxc dev (spawn) | the running app | 1 enables the HMR client; 0 disables it |
VELOX_HMR_PORT | veloxc dev (spawn) | the running app | Port of the dev server’s HMR listener (default 31313) |
VELOX_HEADLESS | you | the renderer | 1 forces offscreen rendering (no presentation) |
See also
- Dev Workflow & HMR —
veloxc devend to end. - Renderer — the HMR channel and rendering backends the CLI wires up.
- Template Syntax — what
veloxc lintchecks and whatveloxc buildcompiles.
Architecture
Velox is a Rust workspace composed of focused crates that together form a reactive UI framework. Each crate owns one responsibility, and they fit together in a clear top-down data flow.
The crate graph
| Crate | Responsibility |
|---|---|
velox-core | Reactive primitives: signals, effects, lifecycle hooks, and watch — the state layer. |
velox-sfc | Parses .vx/.vue Single File Components into a template AST and generates Rust code (render() functions and stubs). |
velox-dom | Lightweight virtual DOM (VNode, Props), a minimal diff algorithm, and a flexbox layout engine. |
velox-style | CSS handling (tag/.class selectors) that computes inline styles for VNodes. |
velox-renderer | Feature-gated rendering backends behind a stable Renderer trait (backend_name(), mount()), plus the event registry for on:<event> handlers. |
veloxc | The CLI that compiles .vx files into Rust modules and scaffolds new projects. |
velox/
├── velox-core/ # Reactive state management
├── velox-sfc/ # Single File Component compiler
├── velox-dom/ # Virtual DOM + layout engine
├── velox-style/ # CSS styling system
├── velox-renderer/ # Rendering backends (Skia)
├── veloxc/ # CLI tools
└── examples/ # Example applications
Data flow
Data flows top-down, from state to pixels:
- State — a change in a
velox-coresignal triggers view recomputation via effects. - View — the generated
render()fromvelox-sfc(or a manual view builder) produces aVNodetree. - Style —
velox-styleannotates theVNodes with inline styles derived from the stylesheet. - Layout & diff —
velox-domcomputes the flexbox layout against the viewport and, where a diffing consumer exists, patches between old and new trees. - Render —
velox-renderermounts theVNodes and paints every frame; events bubble back up through the event registry.
Platform layering
The native window stack is layered deliberately:
- winit owns the OS window, the event loop, and input events (resize, scale-factor changes, keyboard, mouse).
- Skia rasterizes every frame into a premultiplied RGBA raster surface (
raster_n32_premul). - softbuffer presents that surface to the screen, with Wayland and X11 compatibility on Linux.
This split keeps rendering deterministic and portable. See Rendering Pipeline for how a frame is painted and what it costs.
Backends
Rendering backends are feature-gated:
skia— an API-only stub surface for tests and headless checks.skia-native— pulls inskia-safefor the real renderer (a heavier native build). Windowed rendering currently uses a raster surface presented via softbuffer; GPU-backed surfaces are experimental on Linux and fall back to raster unless EGL window surfaces are implemented.
This modular design isolates responsibilities and makes it easy to evolve components independently.
Rendering Pipeline
How Velox turns state into pixels, and why the pipeline looks the way it does. This page records the rationale and the edge mechanisms behind the live render loop in velox-renderer.
For the stage-by-stage walkthrough of an actual frame, the presenter contract, buffer reuse, per-frame allocations, and the font cache, see Renderer. That page is the canonical reference; this one only covers what it does not.
The frame, end to end
Every frame follows the same path:
- View — the renderer calls
make_view(w, h)with the current logical viewport dimensions (see the Viewport Contract). - Style & layout — the
VNodetree is styled and laid out against the viewport (compute_layout). - Paint — Skia draws the laid-out tree into a raster surface.
- Present — the surface’s pixels are copied into a staging buffer and handed to
softbuffer, which presents them to the OS window.
The rendering target is a CPU raster surface — deliberately
The surface is sk::surfaces::raster_n32_premul, created by SkiaSurface::new_raster (velox-renderer/src/skia_surface.rs:65). There is no GPU context on the live path.
This is a considered choice, not a limitation to work around:
- Determinism. The pixel tests in
velox-renderer/tests/compare rendered bytes. A CPU raster path is deterministic; a GPU path forfeits that. Any future GPU backend must be a separate feature-gated backend that runs the pixel tests in a tolerance-based mode. - Resource safety. A GPU context requires setting Skia’s
GrDirectContextresource-cache limit explicitly — Skia defaults it to 256 MB and can occupy roughly twice that when full. Enabling a GPU context without that limit is a resource-exhaustion risk, not an optimization.
The accepted cost: CPU rasterization is slower for large paints, and raster_n32_premul has no subpixel text antialiasing, so Velox text will never match a browser pixel-for-pixel.
The surface is allocated once per window — in the normal loop at velox-renderer/src/lib.rs:1907 and the HMR loop at velox-renderer/src/lib.rs:2569. The only post-setup reallocation is the else path of resize (velox-renderer/src/skia_surface.rs:175).
Two invalidation mechanisms, not one
Resize and scale-factor changes are handled by two separate mechanisms, both correct:
- Physical size —
ResizeState(velox-renderer/src/lib.rs:91-95) coalesces the burst ofResizedevents a drag produces and keeps the committed logical baseline. Only the last event of a frame materializes, andrenderer.resizethen reallocates the surface (velox-renderer/src/skia_surface.rs:175). - Scale factor — handled separately by the shared
window_scale_factor_changedhelper (velox-renderer/src/lib.rs:1452-1480), wired into both loops atvelox-renderer/src/lib.rs:2071andvelox-renderer/src/lib.rs:2808. It rescales the logical cursor position byold / newso the pointer does not jump, queues the new physical size, and callsset_scale_factor(velox-renderer/src/skia_surface.rs:141→velox-renderer/src/viewport.rs:136). It deliberately does not reallocate the pixel buffer: logical dimensions are recomputed from physical, and the actual recreate waits for the nextRedrawRequested, so a scale change costs one layout, not one layout plus a surface rebuild.
Reading the frame-cost benchmark
velox-renderer/tests/frame_cost_bench.rs measures layout_us and cascade_us directly; those numbers are valid, and its header (velox-renderer/tests/frame_cost_bench.rs:1-24) explains the tree it mirrors (nested flex containers, the case that makes layout expensive).
Its frame_us — and the derived paint_us residual — builds a fresh raster surface per call via render_vnode_to_rgba, a path the real loop does not run and that has zero production callers. The bench therefore overstates total frame cost. Treat layout_us and cascade_us as evidence; treat paint_us as a label on a number you should not quote.
See also
- Renderer — the frame pipeline table, presenter, and buffer lifecycle.
- Viewport Contract — logical vs physical size and scale.
Viewport Contract
The contract between your app and the renderer: make_view(w, h).
The contract
make_view has the signature:
#![allow(unused)]
fn main() {
FnMut(w: u32, h: u32) -> (VNode, Stylesheet)
}
where (w, h) are logical viewport dimensions in CSS pixels:
logical = Viewport::from_i32(physical_w, physical_h, scale_factor).logical_size()
// physical = (logical * scale).round(); the single rounding point is Viewport
The renderer calls make_view with the current logical w, h:
- once on startup (the initial frame, before the event loop),
- on every
RedrawRequestedafter aWindowEvent::Resized/ScaleFactorChanged.
The authoritative contract is the rustdoc on run_window_vnode_skia and run_window_vnode_skia_with_hmr in velox-renderer/src/lib.rs.
Why it matters (the Flutter invariant)
Layout is viewport-driven: compute_layout(&vnode, w as i32, h as i32). If make_view ignores w, h (writing |_w, _h|), the app is non-responsive — resize has no visible effect and elements hide when they should be visible.
A Velox app follows the Flutter invariant: it survives any window size, including 0×0 → restore, rapid drag-resize, and fractional DPI, with no hidden-when-visible element.
Template obligation
All shipped templates (veloxc/templates/project/, examples/counter/, examples/todo/, examples/showcase/, and projects created via veloxc init) follow two rules:
1. Acknowledge (w, h) — don’t discard them
#![allow(unused)]
fn main() {
// ❌ wrong
move |_w: u32, _h: u32| -> (VNode, Stylesheet) { ... }
// ✅ correct
move |w: u32, h: u32| -> (VNode, Stylesheet) {
let _viewport = (w, h);
// ... render_with_state, Stylesheet::parse ...
}
}
All templates bind w, h and keep let _viewport = (w, h); so clippy and future audits visibly track the contract (and it’s a hook for future viewport-aware rendering).
2. Use a viewport-filling root
So compute_layout actually reflows, the canonical pattern on the root .app is:
.app {
width: 100%;
min-height: 100vh;
/* display/flex per template varies */
}
width: 100% makes the root span the viewport width; min-height: 100vh pins it to at least the viewport height (not height: 100%, which over-constrains flex children). velox-dom already treats min-height: 100vh and width: 100% × height: 100% as viewport-filling and maps them to the avail_w/h of compute_layout (see layout.rs viewport-height handling).
Every shipped App.vx carries this — in veloxc/templates/project/src/App.vx, examples/counter/src/App.vx, examples/todo/src/App.vx, examples/showcase/src/App.vx, and the strings generated by veloxc/src/commands/init.rs (generate_app_vx / init_app).
Verification
- Repro tests —
velox-cli/tests/template_w_h_repro.rs(string checks that no|_wdiscard remains and the root haswidth: 100%/min-height: 100vh) andvelox-dom/tests/template_w_h_layout_repro.rs(a simulated screenshot: maximized vs 400×300 —compute_layoutmust yield a largerrect.wfor the larger viewport and must not hide a 50%-width child). - Manual drag-resize — run any example (
cargo run -p velox-example-todo), drag-resize the window arbitrarily; the root visibly fills with no clipping or hidden child. - DPI — fractional scale (
wlr-randr --scale 1.25) still reflows, because logicalw, hare alwaysViewport-derived with a single rounding point.
Reconciler
How Velox handles list reordering, what :key does today, and the deliberate decision behind it.
What :key does today
:key compiles, its expression is normalized and analyzed, and it lands as a plain runtime key string attribute on VNode::Element.props.attrs["key"]. It does not reorder, does not diff, and does not preserve per-node identity or state.
Concretely: velox-sfc/src/template_codegen.rs:93 (resolve_key_expr) normalizes the value; the v-for emit sites re-insert it as a runtime attribute (velox-sfc/src/template_codegen.rs:2241, :2251, :2854, :2864); a non-v-for :key reaches the renderer through emit_bind_attr (:4554, the attr_name == "key" branch). VNode::key() at velox-dom/src/diff.rs:56 reads it back out.
Why there is no production reconciler
The render loop is immediate-mode: it paints the &VNode handed to it each frame (run_window_vnode_skia at velox-renderer/src/lib.rs:1811, run_window_vnode_skia_with_hmr at :2421). There is no retained tree, no patch applier, and no previous tree to diff against. Reconciliation is a technique for mutating a live tree in place — and Velox does not keep one.
Two keyed reconcilers have existed:
- A retired one (
reconcile_keyed_children) was deleted: it waspubwith zero production callers (only test call sites), and on a key match it kept the stale previous content — a keyed child whose text or attributes changed kept the old version. - A live, correct one —
pub fn diffatvelox-dom/src/diff.rs:70(keyed path indiff_children_keyedat:121,Patch::MoveChildat:52). It is complete, duplicate-key safe, and emits reorder as a move rather than an insert/remove pair. It is retained, tested, and is what a future retained-rendering step would wire in. Today only tests call it.
Where reorder correctness comes from
Reorder correctness comes from VNode child order, not from :key: compute_layout (velox-dom/src/layout.rs:3599) lays children out in VNode order, so a reordered list already lays out reordered. It is pinned by tests — velox-dom/tests/key_reorder_layout.rs asserts that a reordered tree produces reordered geometry and that the key value itself is inert to layout.
The tradeoff
Template authors writing :key get no state preservation across a reorder, and this is a real, currently-shipping cost. All cross-frame state is keyed by the structural path, never by key:
- Input state is
InputTarget.path: Vec<usize>(velox-renderer/src/events.rs:68), restored bypreserve_input_state(velox-renderer/src/events.rs:799), which matches onp.path == t.pathalone. - Scroll offsets live in a
HashMap<Vec<usize>, f32>(velox-renderer/src/lib.rs:1774).
So when a keyed list reorders, focus, caret, selection and scroll position travel with the position, not with the item: an <input> inside a reordered v-for loses its focus and caret to whichever item now occupies its index.
The decision
:key is formally declared a non-goal for identity and reconciliation. It is a plain, well-formed, stable-per-render string attribute, and that is its entire contract.
Real :key semantics would require one of three things, each of which was weighed and rejected:
- A state slot on
VNode.VNodeis pure data — no per-node state, no instance handle, no lifecycle hook. Anode_idfield would duplicate the"key"attribute that already exists. - A component-instance registry. A second instance state and a second lifetime to get wrong, plus a
VNode-shaped change at the boundary. - Re-keying cross-frame state by
keyinstead of bypath. The cheapest route, and still wrong here: switching would silently move every focus, caret and scroll offset in the application the first time a template author used:keyinconsistently.
What would revisit it
Any one of:
- Layout invalidation lands and introduces content-addressed node identity — the only identity mechanism the ruling permits, and the only one that could key cross-frame state without a
VNodefield. - A user-visible bug for focus/caret/scroll loss across a
v-forreorder is filed. - A component-instance registry is introduced, making per-node state exist at all.
Revisiting means re-opening this decision explicitly — not silently changing diff. Wiring diff::diff into the render loop is additionally blocked until an order-surviving identity exists, a consumer for Patch::MoveChild is defined, layout caching is settled, and duplicate-key semantics are specified at the call site.