Getting started

Your first app

Build a click counter end to end: scaffold it, read the three files, add a theme, and watch hot reload swap markup and script while keeping the running state.

01

Scaffold and run

The counter template writes three files into a new directory and opens a live window.

shell
lumenc new counter my-counter
lumenc run my-counter
02

Know the layout

A Lumen app is a single directory. Only main.lmn is required; everything else is optional and resolves next to it.

# project layout
my-counter/
├── main.lmn    # markup tree (required)
├── main.css    # styling (optional)
├── main.rhai   # script logic (optional)
└── lumen.toml  # per-app config (optional)

If main.css sits next to the entry file, it's applied automatically. Config lives in lumen.toml — window title and size, asset roots, and more — and unknown keys are rejected so typos surface as errors.

03

Read the markup

Every .lmn file has exactly one <root>. Unknown tags fail fast at parse time with a byte offset, so a typo can't slip through.

main.lmn
<root>
  <column padding="24" gap="12">
    <label id="counter-label" text="Lumen — clicks: 0" />
    <button id="bump">Click me</button>
  </column>
</root>

<column> stacks children top to bottom; padding and gap take logical pixels. An id is the handle your script binds to.

04

Wire the script

An optional main.rhai holds view logic. on_start() runs once after the tree spawns — register handlers and seed signals there.

main.rhai
fn on_start() {
    let clicks = signal("clicks", 0);
    derive("counter-label", [clicks], |n| "Lumen — clicks: " + n);
    on("click", "bump", "handle_click");
}

fn handle_click(_id) {
    let c = signal("clicks", 0);
    c.set(c.get() + 1);
}

signal(name, default) returns a handle into the reactive store. derive(name, deps, fn) registers a computed value that re-runs whenever a dependency changes and writes the result back — and the label, whose id matches, re-renders each time.

05

Add a theme

Create main.css. Declare tokens on :root, reference them with var(--name), and remember: inline markup attributes always win over CSS.

main.css
:root {
  --bg: #0d0d14;
  --fg: #e6e6e6;
  --accent: #7aa2f7;
}

root   { bg: var(--bg); text-color: var(--fg); }
label  { font-size: 18; text-align: center; }
button {
  bg: var(--accent);
  text-color: #0d0d14;
  radius: 8;
  hover-bg: #9bb8ff;
  press-bg: #5f86d0;
}

Save it. The running app re-parses the stylesheet and re-applies styling — no restart, no flicker.

06

Feel the hot reload

With the app still running, edit any of the three files and save. lumenc run watches the directory and reloads only what changed — and each path preserves the state that matters.

# what survives a save
FileOn save
main.lmnRe-parse and re-spawn, restoring stateful components by id — the counter value, input cursor, toggle state, and scroll position survive.
main.cssRe-apply styling in place. Focus and hover state are kept; a class flip that changes nothing is a no-op.
main.rhaiSwap the script while keeping the live scope, so signals and handler registrations don't blink back to their defaults.

Change the button text from Click me to Bump it and save — the label flips instantly while the running count stays put. That state-preserving swap is the core of the Lumen workflow.

What is a signal, exactly? A named entry in a host-local store, held as a string on the wire. .get() reads it, .set(v) writes it and marks it dirty, and derive(...) recomputes downstream values. The same primitive drives text content, checkbox state, and slider values — bind-text, bind-checked, and bind-value close the loop in both directions.
Browse the reference → Study full examples