Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 .vx file. 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 :key today is a plain string attribute with no identity or reconciliation semantics — see Reconciler for the honest picture, including the :key reorder 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 .vx file and the dev server classifies the edit by SFC block (style, template, or script), rebuilds, and relaunches the native window; an HMR control channel on 127.0.0.1:31313 coordinates 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.
PlatformNotes
LinuxFile 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.
macOSNo extra setup.
WindowsThe 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
FileRole
Cargo.tomlDependencies on the Velox crates, serde_json, and veloxc as a build dependency
build.rsCompiles src/App.vx — and every component it imports — into Rust in OUT_DIR
src/main.rsEntry point: builds state, hands the render and event closures to the window
src/App.vxRoot component: page chrome, theme toggle, dialogs
src/components/*.vxThe list, the input, a single row, and two reusable dialogs
assets/Static assets; <img src="…"> paths resolve against the project root

Note: veloxc init writes path dependencies when a workspace is found; otherwise it uses published version dependencies (SCAFFOLD_VERSION) for crates.io installs, so the generated manifest is always resolvable. See CLI Reference for details.

Note: veloxc init writes path dependencies when it can find a Velox workspace — the VELOX_PATH environment 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.app creates my-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:

KeyAction
rRebuild and restart the app by hand
cClear the terminal and redraw the banner
qStop 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

TutorialYou build
Veloxc Init DefaultThe default scaffold: components, theme, dialogs, v-for
DashboardAnalytics dashboard: grid, charts, sidebar, real-time updates
Markdown EditorSplit-pane editor with live preview, toolbar, resize handle
Task BoardKanban board: drag-to-reorder, persistence, modal forms
Settings PanelSystem 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:

BlockCardinalityPurpose
<template>0 or 1The markup. One root element, directives, bindings, interpolations.
<script setup>0 or 1Component state and logic as Rust: a State struct plus its impl. Imports live here.
<script>0 or 1Plain Rust script block. Imports here do not register components.
<style>0 or 1CSS 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 with template 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 State holds the component’s reactive fields.
  • pub fn methods on State are what the template calls: interpolations read them, @event handlers dispatch to them, and v-model generates 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:

KindSyntaxResolves toExample
Staticname="value"Literal attribute valueclass="btn"
Bind:name="expr"Expression evaluated against State:disabled="!is_valid"
Event@event="handler"Method name on State@click="increment"
Directivev-name="expr"Structural directivev-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:

DirectiveValue formBehavior
v-ifexprRender the element only when expr is truthy; otherwise emit an empty text node.
v-else-ifexprContinuation of a v-if chain. Alias: v-elseif.
v-else(none)Final arm of a v-if chain.
v-showexprAlways renders the element; toggles an inline display: none style when expr is falsy.
v-foritem in items or (item, index) in itemsRender the element once per entry in the collection.
v-modelfieldTwo-way bind on an <input>: desugars to :value="field" plus an @input handler.
v-slot:nameexprFill a child component’s named slot. Shorthand: #name.

Note: an element cannot carry both v-for and v-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 index when written; the internal default is __idx.
  • Add a :key on loop elements — either :key="item.id" or :key="{{ item.id }}"; both spellings are normalized to the same binding.
  • A v-model rooted 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 the State-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:

CategorySyntax
Literalstrue, false, numbers (42, 3.14), strings ("done")
Identifierscount, is_visible, form.name (field access chains)
Unary!expr, -expr
Comparison==, !=, >, <, >=, <=
Logical&&, ||
Arithmetic+, -, *, /, %
Ternarycond ? then : else
Method callsobj.method(args)
Field accessexpr.field
Index accessexpr[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' and import { 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 @event attaches a handler the child can emit back to.
  • A PascalCase tag that is not an import produces an unknown component warning 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 no name attribute is the default slot.
  • Slot names are folded to kebab-case on both sides of the component boundary: name="footerBar", name="footer_bar", and v-slot:footerBar all resolve to the same slot. A <slot name> on the child and a v-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

ContextSyntaxBehavior
<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

ConstructSyntaxNotes
SFC blocks<template>, <script setup>, <script>, <style scoped>One root element required in <template>
Static attributename="value"Quoted values only
Bind:name="expr"Re-evaluated every render
Event@event="handler"Modifiers: .stop .prevent .once .capture .self
Conditionalv-if, v-else-if, v-elseif, v-elsePositional chain
Visibilityv-show="expr"Toggles inline display: none
Loopv-for="item in items" / (item, index) in itemsPair with :key
Two-way bindv-model="field"Desugars to :value + @input
Slot outlet<slot name="name">Unnamed = default slot; children are fallback
Slot fill<template v-slot:name> or #nameKebab-case slot names
ComponentPascalCase tag + import X from './X.vx'Unknown tags warn
Interpolation{{ expr }}Unclosed {{ is a hard error
Nesting limit256 unclosed elementsDeeper markup fails with an actionable error

See also

  • Events — @event bindings, payloads, and dispatch.
  • Styling — the <style> block, cascade, and supported properties.
  • Dev Workflow & HMR — editing .vx files 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

EventFires when
clickLeft-button press on the element (hit-tested against layout geometry)
dblclickTwo clicks on the same element within 400 ms
inputA text field’s value changes
changeA text field commits its value (e.g. on submit)
focus / blurA text field gains or loses focus
keydown / keyup / keypressA key is pressed, released, or pressed-and-released
mousedown / mouseup / mousemoveMouse buttons and movement
mouseenter / mouseleaveThe cursor enters or leaves the element
hoverThe cursor moves over a hoverable element
submitA 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 signature FnMut(&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 to state.{owner}.{method}.
  • At runtime, the renderer hit-tests clicks against the laid-out tree (collect_click_targets / hit_test_click in velox-renderer/src/events.rs). When a hit matches, the handler is invoked through make_on_event.
  • Payload forwarding: an explicit on:<event>-payload attribute 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

LayerDeclared inPrecedence
User-agentBuilt in (velox-style ua.css)Lowest
Author<style> blocks in .vx filesMiddle
Inlinestyle attributes on elementsHighest

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 — .header becomes .header[data-v-abc1ef23].
  • The scope id is exposed as SCOPE_ID so 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.
  • :hover matches when the pointer is over the element (the renderer supplies the hover predicate).
  • ::placeholder declarations are written to a separate attribute (style:placeholder) so they can never repaint the element’s own value.

Selectors

SelectorExampleMatches
TagbuttonElements with that tag
Universal*Any element
Class.btnElements whose class attribute contains the token
Compoundh1.titleTag and class on the same element
Attribute[disabled] / [type="text"]Elements carrying the attribute (optionally with an exact value)
Descendant.header h1An h1 with any .header ancestor
Childdiv > .cardA .card exactly one level below a div
Hoverbutton:hoverThe element while the pointer is over it
Pseudo-elementinput::placeholderThe placeholder of an <input> that is currently showing it

Note: ::placeholder is the only pseudo-element Velox supports, and it matches only an <input> — not a textarea — that carries a non-empty placeholder attribute and an empty value. A filled field shows the value, not the placeholder, so a ::placeholder rule 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

PropertyValuesRendered by
displayblock, inline, inline-block, flex, grid, none (hidden alias)Layout + box painter
positionstatic, relative, absolute, fixed, stickyLayout + box painter
z-indexIntegerStacking context
opacity0.0–1.0 (clamped)Layout + box painter
width / heightLengthLayout
min-width / min-heightLengthLayout
max-width / max-heightLengthLayout
marginLength shorthand (1–4 values) + margin-top/-right/-bottom/-leftLayout
paddingLength shorthand (1–4 values) + padding-top/-right/-bottom/-leftLayout + box painter
top / right / bottom / leftLengthLayout
box-sizingcontent-box, border-boxLayout (UA default is border-box)

Flexbox

PropertyValuesRendered by
flex-directionrow, row-reverse, column, column-reverseLayout
flex-wrapnowrap, wrap, wrap-reverseLayout
justify-contentflex-start (start), flex-end (end), center, space-between, space-around, space-evenlyLayout
align-itemsflex-start (start), flex-end (end), center, baseline, stretchLayout
align-selfauto + the align-items valuesLayout
flex-grow / flex-shrinkNumberLayout
flex-basisLengthLayout
gap (grid-gap)Length (sets both axes)Layout
row-gap / column-gapLengthLayout

Backgrounds and borders

PropertyValuesRendered by
background-colorColorBox painter
backgroundColor-only shorthand — the first color token is usedBox painter
border<width> <style> <color> shorthandBox painter
border-widthLength shorthand (1–4 values)Box painter
border-radiusLength shorthand (1–4 values)Box painter
border-styleBorder style valuesParsed only
border-colorColor valuesParsed only
background-imageValue stringParsed only

Typography

PropertyValuesRendered by
colorColorText painter (inherits)
font-sizeLengthText painter (inherits)
font-familyFamily string (e.g. system-ui)Text painter (inherits)
font-weightnormal, bold, bolder, lighter, or 100–900Text painter (inherits)
line-heightNumber (multiplier), px lengthText painter (inherits)
text-alignleft, center, right, justifyText painter (inherits)
vertical-alignbaseline, top, bottom, middleLayout (inherits)
text-decorationnone, underline, overline, line-throughText painter (inherits)
font-stylenormal, italic, obliqueParsed only
letter-spacingLengthParsed only

Overflow and misc

PropertyValuesRendered by
overflowvisible, hidden, scroll, autoLayout + box painter
overflow-xvisible, hidden, scroll, autoParsed only — use the overflow shorthand
overflow-yvisible, hidden, scroll, autoParsed only — use the overflow shorthand
white-spacenormal, nowrap, pre, pre-wrap, pre-lineText painter (inherits)
text-overflowclip, ellipsisText painter
visibilityvisible, hidden, collapseParsed only — visibility: hidden hides nothing; use display: none or v-show
transformTransform functions (translateX, scaleX, …)Parsed only — forces a stacking context, no visual effect
box-shadow2px 2px 4px rgba(0,0,0,0.5) formParsed only
transitionComma-separated <property> <duration> <timing>Parsed only — no reader plays a transition

Tip: veloxc lint reports 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 (or v-show in templates), not visibility: hidden. To round corners use border-radius, and give depth with border — not box-shadow.


Units

All length values share one parser:

UnitExampleResolves against
px16pxPhysical pixels
%100%Parent size
rem1.5remRoot font size
em1.25emParent font size
vw50vwViewport width
vh100vhViewport height
dvw / dvh100dvhViewport width/height (treated as vw/vh)
autoautoAutomatic (layout-dependent)
(none)0, 16Unitless 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

FormExamples
Namedblack, 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)
Functionalrgb(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

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:

StageWhereWhat happens
1. View functionyour make_view closureBuilds the VNode tree + stylesheet from the current logical size
2. Hover taggingwith_hover_idsTags every element with a data-hover-id
3. Cascadestyle_vnode_with_hoverApplies UA < author < inline (see Styling)
4. Input attributesinject_input_caret_attrsText inputs get caret/focus attributes — unfocused defaults on frame one
5. Layoutvelox_dom::layout::compute_layoutFlexbox/table layout against logical w×h
6. Scroll + targetsapply_scroll_offsets, recompute_targetsApplies scroll offsets; recomputes click/hover/input hit targets
7. Paintskia_render::skia_impl::render_frameSkia draws the tree into the raster surface
8. PresentSoftbufferPresenter::presentCopies 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
}
}
RuleDetail
Physical clampWidth/height clamped to a minimum of 1 — a zero-sized window never reaches Skia
Logical derivation(physical / scale).round().max(1) per axis
Invalid scaleNon-finite or <= 0.0 scale falls back to 1.0
Single roundingphysical = (logical × scale).round() — one canonical forward mapping, so fractional scales never produce 0.25px subpixel edges
Pixel-grid snapsnap_logical_to_physical_grid(value, scale) aligns coordinates to device pixels after canvas.scale(scale)
Default800 × 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 = []:

FeaturePulls inWhat you get
(none)velox-core, velox-dom, velox-style onlyA stub renderer; compiles fast, no window
skia(nothing — an API-surface switch)The Skia API surface compiles; no native backend yet
skia-nativeskia + skia-safe 0.91.1 (gl, egl), winit 0.28, softbuffer 0.3, raw-window-handle 0.5, egl 0.2, glow 0.12, svgThe real backend: windowed rendering with Skia + softbuffer
svgresvg 0.48SVG rasterisation for <img src="logo.svg">

Note: svg is split out of skia-native so a build that never paints SVG can skip resvg entirely; skia-native implies it to preserve current behaviour. resvg is built with default-features = false and only raster-images kept — 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/glow need no build script or C toolchain, and softbuffer supports Windows natively — so skia-native compiles 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:

  1. read_pixels copies the whole surface into velox’s own staging buffer, requested in the surface’s native N32 order. On little-endian targets N32 is BGRA_8888 — exactly softbuffer’s word order — so no swizzle pass exists.
  2. buffer_mut maps softbuffer’s buffer. The OS hands out a fresh mapping per frame; it is not velox’s to cache.
  3. One bulk copy_from_slice moves the staging bytes in — no per-pixel loop.
  4. 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 the else path of resize.
  • The staging buffer (rgba: Vec<u8>, velox-renderer/src/presenter.rs:294) is allocated once, inside SoftbufferPresenter::new (velox-renderer/src/presenter.rs:308), and only reallocated in resize when 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_rgba allocates 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. Treat layout_us and cascade_us from the bench as evidence; the derived paint_us describes a path the product never runs.

Known per-frame allocations still on the live path, all in velox-renderer/src/skia_render.rs:

SiteCost
ImageCache::load + its per-frame recreationA disk read and a full PNG/JPEG decode per frame, per image
AdvanceKey constructionA 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_PORT is 31313 (velox-renderer/src/hmr.rs:23). The dev server listens on 127.0.0.1:31313; apps connect to it.
  • hmr_config() -> Option<u16> returns Some(port) when the process runs under the dev server (VELOX_HMR=1), reading the port from VELOX_HMR_PORT (defaulting to 31313); otherwise None.
  • run_hmr_client(port, tx) spawns a background thread that connects to the dev server, forwards HmrMessage values through a channel, retries every 500 ms when the connection is lost, and on FullReload exits the process with code 0 — the dev server sees the child exit and restarts it with a fresh build.

Messages are newline-delimited JSON:

MessageMeaning
FullReloadRebuild 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.
KeepWindowKeep-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

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.

KindWhenResponse
StyleOnlyOnly <style> content differsRouted to a stylesheet swap (see below)
TemplateOnlyOnly <template> content differsFull cargo build + relaunch
ScriptScript logic changed, more than one block, the file does not parse as an SFC, or it is not an SFC at allFull 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:

ChangeWhat happens
<style> block editClassified StyleOnly, routed to a stylesheet swap — but all kinds rebuild today
<template> editFull rebuild + relaunch
<script> editFull rebuild + relaunch
Asset in assets/Full rebuild + relaunch
New file, deleted file, unparseable fileFull rebuild + relaunch

Note: every classified change rebuilds today — the style-swap path needs an HmrMessage::StyleUpdate message 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.



  ⚡ 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:

LineWhen
⏳ Compiling...On start and before every rebuild
✓ Compiled in 0.8sA successful build
App started (HMR enabled)The app process was spawned
↻ src/App.vx changed (Script) — rebuildingA debounced save triggered a rebuild (the kind is the classification)
↻ Manual reload requestedYou 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 panelA 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

KeyAction
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 that target/ is outside the watched tree: the dev server excludes it itself, but a second watcher you started (another tool, another veloxc dev) may not.

Tip: run veloxc dev from 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 FullReload does 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

CommandPurpose
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 runBuild and run the current project
veloxc devStart 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 versionShow 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.

FlagShortDefaultMeaning
--template <name>-tdefaultProject 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 veloxc commands in its next-steps banner (line 21 of its README.md). The CLI and the scaffold are consistent. The installed-CLI case (cargo install veloxc) is covered by the version = "..." dependency branch: generate_cargo_toml detects rev == "unknown" (the VELOX_GIT_REV_FALLBACK sentinel) and emits plain version pins instead of an unresolvable rev = "unknown", which cargo rejects (revspec 'unknown' not found).


veloxc build [input]

Two modes, chosen by whether a positional input is given:

ModeInvocationBehavior
Project buildveloxc buildcargo build for the current project
Component compileveloxc build src/App.vxCompile the .vx file and all imported components to Rust source
FlagShortMeaning
--out-dir <dir>-oOutput 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.

FlagMeaning
--releaseBuild 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.

FlagShortDefaultMeaning
--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:

FindingExampleSeverity
Parse errorsUnclosed {{, multiple root elementsError — fails the command
Script-idiom warningsCell/RefCell usage where Ref is expectedWarning — advisory only
Parsed-but-unrendered CSSvisibility: hidden, box-shadow, transitionWarning — 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.

FlagShortDefaultMeaning
--slots <NAME,NAME>-sheaderNamed 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: 2021 is a fixed string from the CLI itself, not read from your project’s manifest.


Exit codes

CodeMeaning
0Success — also the code the app exits with when it receives an HMR FullReload
1Any command failure: cargo build/run failure, SFC parse errors, lint findings (Lint failed with N errors), missing project, invalid names
2Invalid 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

VariableSet byRead byMeaning
VELOX_PATHveloxc init --localveloxc initWorkspace to link as path dependencies
VELOX_HMRveloxc dev (spawn)the running app1 enables the HMR client; 0 disables it
VELOX_HMR_PORTveloxc dev (spawn)the running appPort of the dev server’s HMR listener (default 31313)
VELOX_HEADLESSyouthe renderer1 forces offscreen rendering (no presentation)

See also

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

CrateResponsibility
velox-coreReactive primitives: signals, effects, lifecycle hooks, and watch — the state layer.
velox-sfcParses .vx/.vue Single File Components into a template AST and generates Rust code (render() functions and stubs).
velox-domLightweight virtual DOM (VNode, Props), a minimal diff algorithm, and a flexbox layout engine.
velox-styleCSS handling (tag/.class selectors) that computes inline styles for VNodes.
velox-rendererFeature-gated rendering backends behind a stable Renderer trait (backend_name(), mount()), plus the event registry for on:<event> handlers.
veloxcThe 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:

  1. State — a change in a velox-core signal triggers view recomputation via effects.
  2. View — the generated render() from velox-sfc (or a manual view builder) produces a VNode tree.
  3. Style — velox-style annotates the VNodes with inline styles derived from the stylesheet.
  4. Layout & diff — velox-dom computes the flexbox layout against the viewport and, where a diffing consumer exists, patches between old and new trees.
  5. Render — velox-renderer mounts the VNodes 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 in skia-safe for 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:

  1. View — the renderer calls make_view(w, h) with the current logical viewport dimensions (see the Viewport Contract).
  2. Style & layout — the VNode tree is styled and laid out against the viewport (compute_layout).
  3. Paint — Skia draws the laid-out tree into a raster surface.
  4. 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 GrDirectContext resource-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 of Resized events a drag produces and keeps the committed logical baseline. Only the last event of a frame materializes, and renderer.resize then reallocates the surface (velox-renderer/src/skia_surface.rs:175).
  • Scale factor — handled separately by the shared window_scale_factor_changed helper (velox-renderer/src/lib.rs:1452-1480), wired into both loops at velox-renderer/src/lib.rs:2071 and velox-renderer/src/lib.rs:2808. It rescales the logical cursor position by old / new so the pointer does not jump, queues the new physical size, and calls set_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 next RedrawRequested, 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 RedrawRequested after a WindowEvent::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 |_w discard remains and the root has width: 100% / min-height: 100vh) and velox-dom/tests/template_w_h_layout_repro.rs (a simulated screenshot: maximized vs 400×300 — compute_layout must yield a larger rect.w for 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 logical w, h are always Viewport-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 was pub with 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 diff at velox-dom/src/diff.rs:70 (keyed path in diff_children_keyed at :121, Patch::MoveChild at :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 by preserve_input_state (velox-renderer/src/events.rs:799), which matches on p.path == t.path alone.
  • 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:

  1. A state slot on VNode. VNode is pure data — no per-node state, no instance handle, no lifecycle hook. A node_id field would duplicate the "key" attribute that already exists.
  2. A component-instance registry. A second instance state and a second lifetime to get wrong, plus a VNode-shaped change at the boundary.
  3. Re-keying cross-frame state by key instead of by path. 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 :key inconsistently.

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 VNode field.
  • A user-visible bug for focus/caret/scroll loss across a v-for reorder 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.