Guide

The whole model, in the order you meet it.

Laying it out
01

Structure is layout

<row gap="8" align="center">
  <image src="icon.svg" w="20" h="20">
  <text grow>A row lines things up</text>
  <button>Go</button>
</row>

<row> lines up, <col> stacks, <grid cols="3"> tiles, <stack> layers, <scroll> clips. A box measures itself from its content and its padding unless you give it w or h. Something with grow takes its share of what is left over, its share of the free space, not its content plus a share, which is what makes two of them exactly equal.

Responsive without a breakpoint

There is no media query and nowhere to put one. The author says what a thing needs; the layout decides what fits.

<!-- as many columns as fit -->
<grid col-min="220">…</grid>

<!-- a row, until it cannot be -->
<row wrap="200">…</row>

A row whose children cannot each have 200 pixels stops being a row: it folds onto as many lines as it takes, and at one child per line it is a column. One calculation in layout.c, so it folds at the same pixel on all three systems, which a stylesheet per platform could never promise.

Making it look right
02

Attributes are the cosmetics

Written where the thing is, because there is no selector to write them somewhere else. Any of them can be set from a script by the same name, and read back as what it is:

card.w        // 120, a number, not "120"
card.w + 10   // 130
card.bold     // true, a boolean
card.bg       // "#222222", a string

Named styles, without a cascade

<style name="card" bg="#222" radius="10" border="1" border-color="#333" pad="16">

<col use="card"> … </col>

A bundle of attributes under a name. It does not cascade, it does not match, and it cannot reach anything it was not written on.

Movement

<picture src="sword.svg" animate="rotate 2s alternate" from="-5" to="5">
<col use="card" animate="scale 150ms ease-out" from="0.86" to="1">

One numeric attribute between two values. A one-shot animation runs again every time its node is shown, which is what makes a dialog pop open rather than pop open once. The clock runs at sixty a second only while something is moving, and honours the system's reduced-motion setting without being asked. For anything Spark has no opinion about, listen for frame.

ease-in, ease-out and ease-in-out say how the movement is spread over its time. Without one it is linear, which is a machine moving a thing at a constant rate — nothing in the world does that, and it is what makes an animation look mechanical. ease-out is the one to reach for: most motion in an interface is something arriving.

Movement without an animation

<button transition="bg 120ms ease-out" bg="#2a2a2a" bg-hover="#333">
<fill id="bar" transition="at 220ms ease-out" at="0 0 0 6">

A property that is set moves to its new value instead of jumping. That is most of the motion in an interface — a colour under the pointer, a bar that grows, a panel that opens — and without it every one of them has to be eased by hand in script, sixty times a second. Two colours mix as colours, not as the numbers inside their text. A node has one movement at a time and the newer intent wins.

What a field will hold

<input type="number">                  <!-- digits, one point, a sign -->
<input type="integer" maxlength="4">
<input allow="0123456789abcdef">

The character is taken back as it is typed, on all three systems, and a driver that writes a value the field would not accept is told so rather than ignored. A box that takes anything and complains at the end lets somebody get it wrong and find out after the build.

What is judged is the whole string, not the character being added: - on its own and 1. on its own are both on the way to a number and neither is one. Space around it means nothing — a value pasted out of a browser has a space on the end as often as not, and a paste that appears to do nothing is worse than a space nobody can see.

A picture that fills its box

<image src="shot.png" fit="cover">

The default keeps the whole picture and leaves bars beside it; cover fills the box and loses the edges; fill stretches. A thumbnail band is a shape a picture almost never has, which is the case worth naming.

<image src="shot.png" fit="cover" zoom="1.1" transition="zoom 200ms ease-out">

zoom keeps less of a covering picture. The box does not move — one that grew its box would grow over whatever is beside it — so a picture that leans in under the pointer is this attribute and a transition on it.

State and pieces
03

State with a name

<input bind="name">
<text>Hello {{name}}</text>
<button disabled="{{name == ''}}">Continue</button>

Typing sets the signal, and the two things that named it are written, one label, one enabled state, on the widgets that were already there. No virtual tree, no render pass, no diff.

The expressions are deliberately tiny: a name, a quoted literal, ==, !=, !. Anything harder belongs in your own language, which is already on the other end of a signal: spark.signal("name", "Ada"), app.signal(...) from Python, or spark live myapp signal name Ada against a window that is already open.

Components

<component name="user-card" props="name,email">
  <col pad="12" gap="4" bg="#f4f4f5" radius="8">
    <text bold>{{name}}</text>
    <text>{{email}}</text>
  </col>
</component>

<user-card name="Ada" email="ada@example.com"></user-card>
<user-card name="{{who}}" email="{{mail}}"></user-card>

A prop is the caller's text, not the caller's value, so the first card is two words and the second is two live bindings. Naming the props in props="" is what tells {{name}} inside the body apart from a signal of the same name.

Templates: markup by name

<template name="kv">
  <row gap="8"><text grow>$k</text><text>$v</text></row>
</template>

<kv k="Version" v="1.0.0">

Text in, text out, while parsing. Nothing is left at runtime, and a template cannot see anything but what its call hands it, which is what stops it becoming a scope.

Making it do things
04

Events

button.addEventListener("click", save);
document.body.addEventListener("key", function () {
  if (document.body.key === "Escape") close();
});
card.addEventListener("drop", function () {
  open(String(document.body.files).split("\n")[0]);
});

Spark has no event object, a listener is a function of no arguments everywhere, so what came with an event is read off <window> like any other property. Key names are decided once for all three systems, so nobody has to know whether this one calls it Return or Enter.

A sound, when something finishes

spark.sound("sounds/done.wav", 0.5);

NSSound on macOS, PlaySound on Win32, and whatever the desktop brought along on Linux. Spark carries no audio library and no decoder, it hands over a path.

More than one window

spark.open("settings.html");   // a second window, a second document
spark.tell("saved");           // a line to every other window

A window is a document: its own tree, its own script context, its own clock. They share a process and nothing else, so the only thing one can do to another is tell it something.

Past the vocabulary
05

Building what is not in the box

Six layers, in the order to reach for them:

a look<style> + use=""
markup<template>, <component>
a drawing<surface> with <fill> and <picture>
behavioura function over ids
one system's own API<native class=""> inside <platform os="">
a whole new widgeta plugin, see below
<surface h="60" self="stretch">
  <fill id="knob" at="20 10 40 40" radius="20" bg="#4a90e2">
</surface>

A drawn shape is a real target: give that <fill> a click listener and it is hit-tested, hovered and given a hand cursor like any widget. A knob, a colour wheel, a chart with tooltips, a confetti burst, all of it without adding anything to Spark.

A widget Spark does not have

Write a plugin. It is a shared library that exports one function, gets a struct of function pointers, and never links Spark itself:

int spark_plugin_init(const SparkPluginHost *host) {
    host->register_widget("gauge", &ops);   // make, place, update, measure, destroy
    host->register_command("sqlite.query", query);
    return SPARK_PLUGIN_ABI;
}
# spark.toml, next to the document
[plugins]
load = ["spark-sqlite"]

The tag then parses like any other, and the command is reachable from every language Spark speaks, with no binding to write on either side. A plugin may exist on one system only; a tag with nothing behind it on this one lays out as an empty box rather than stopping the app.

What the app is allowed to do

[permissions]
filesystem = ["~/Documents/**"]
network    = ["api.example.com"]
shell      = false

spark inspect app.html reads it back without running anything. Paths are resolved before they are matched, so .. cannot climb out of a grant and a symlink cannot point through one.

A document with no spark.toml is unrestricted while it is a file on your disk, and may do nothing once it is packed. Same silence, opposite meaning: a file you are editing is yours, a packed app was handed to somebody who did not write it. spark pack copies the manifest into the binary and warns when there is none to copy.

Addressable
06

Any language, one layer

# Python, shell, Go, Rust, the same fourteen commands
spark app.html --serve

The window opens and speaks one line at a time over stdin and stdout. clients/spark.py is 208 lines and clients/spark.sh is 65; the format is in PROTOCOL.md. Nothing has to stay in sync as Spark grows, a new property, service or plugin command is a new string, not a new binding.

Looking at a window that is already open

$ spark app.html --agent &
$ spark apps
$ spark live app actions
$ spark live app set email ada@example.com
$ spark live app press create

The same protocol as --serve, on a local socket beside a running window. tree gives everything, actions gives what can be pressed and filled in. Useful for a test; useful for anything driving your app that is not a person.

Saying what a driver may touch

<window agent="read">
  <input  id="customer" agent="read write">
  <button id="send"     agent="invoke">Send invoice</button>
  <button id="delete"   agent="deny">Delete account</button>
</window>

read, write, invoke, all, deny. A control that says nothing inherits from the nearest ancestor that does, so a document is shut at the top and opened one control at a time. One that says nothing anywhere is open, the same bargain spark.toml makes, and for the same reason: a model that breaks every app that already exists is one nobody turns on.

invoke carries read with it, because a control in actions is listed by name and nobody means "press this blind". write does not: a field an agent may fill but never read back is a real thing to want.

This never applies to --serve. That hands the window to a program which is the app, its own logic in Python or a shell instead of JavaScript, and an app is not a stranger to itself.

What it is, exactly. A statement of intent that Spark enforces at its own door, which is what makes a well-behaved agent behave. It is not a wall against a hostile one: something allowed to write a field and press a permitted button can still reach a state you would not have chosen. For what the app may do to the machine, see spark.toml, a different question, deliberately a different file.

A session is a text file

$ spark record app.html session.txt   # use the window
$ spark replay app.html session.txt   # watch it again

press	save
set	who	value	Ada
set	shout	checked	true

Because the whole surface is nine string operations, the protocol that drives an app is already a recording of it. Nothing is captured or serialised; the log is the session.

That holds for a person, not only a program. Every click and every value typed goes through one function, so what lands in the file is the command that would have caused it: names and values, never coordinates. A recorder that writes down where the pointer was stops working the moment somebody moves a button. This one keeps working, and the test proves it by replaying a recording into a document whose layout was changed underneath it.

One file is three things at once: a bug report that reproduces itself, a regression test with no screenshots in it, and a macro.

What this app offers, in the repository

$ spark contract --write app.html
$ spark contract --check app.html      # fails when it drifts

control   button        #tb_run    uname -a       read write invoke
control   slider        #vol       Lautstaerke    read write invoke
offer     #tb_run
space     #tb_run -> pressed

A renamed id, a widget that changed tag, a tightened agent=: one-character edits that nothing notices at build time and that every script, test and agent written against the app notices at once, after it shipped. So what the app offers its four callers is written down beside it and checked in.

Above the marker is what every system must agree about; below it is what each system answers for itself — where Tab goes, what the space bar does, and where Enter lands — which is what spark cross compares. Nothing runtime goes in the file.

On its first run across the three systems it found four defects that the layout check could not see — every one with the pixels matching, the keyboard order identical and the four-caller count full: a Windows field read back its own placeholder as a value, a Linux slider read back nothing, a Linux <select> reported nothing while visibly showing an item, and a progress bar's number had become its accessible name.

<button default> is the button Enter presses. Every system has the idea and no two spell it the same way, so the document says which button it is once and each host does its own thing with the mark: a Return key equivalent on AppKit, BS_DEFPUSHBUTTON on Win32, and the suggested-action class on GTK.

Your window is an MCP server

$ spark mcp notes.html
spark: 5 tool(s) from Notes
  press_save     Press save — the button labelled “Save”.
  fill_what      Type into what — the input labelled “a note”.
  read_screen    Every control: role, name, value, position.

An app with named controls, a list of what can be pressed, and a rule for what a stranger may touch is most of the way to being an MCP server already. So this is a translator and nothing else: a button becomes a tool, a field becomes a tool that takes a value, and the tree becomes read_screen, which is what replaces a screenshot. There is nothing to recognise and nothing to guess at.

The list is built from the running app, so it is the real controls and not a description of them somebody has to remember to update. Rename a button and the tool is renamed.

It opens the app with --agent rather than --serve, which is the point: a model is a stranger, so the agent= rules above stand between it and the controls. A field it may not write never becomes a tool. The bridge can only translate what the app already offers.

Which also means the window is a window. Somebody can watch it, and use it, while the model works in it.

A request that does not stop the window

spark.fetch(api + "/games", { into: "games" });

<text>{{games}}</text>

Returns at once. When the server answers, the signal is set and everything bound to it follows — no callback crosses the JavaScript bridge, and an app that already knows how to show a signal has nothing new to learn.

No event loop, no thread and no second HTTP stack: curl is started detached and leaves a marker, and the window looks for it on the frames it is already drawing. A window with a request out is a window that is not idle, and it goes back to sleep the moment the answer lands.

Talking to a server

var r = spark.fetch(api + "/games", {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: "Bearer " + token },
  body: JSON.stringify(game)
});
if (r === null) offline();          // the request never happened
else if (r.ok) done(JSON.parse(r.body));

One implementation, not three: every system ships curl (Windows since 2018), and NSURLSession, WinHTTP and libcurl would be three chances to disagree about a redirect. Nothing is passed on a command line, the url, method, headers and body go into a config file curl reads, which is also the only way to carry a header containing a quote without inventing an escaping rule.

It waits for the answer, the way spark.run does. A request that takes a second holds the window for a second.

What the system already runs

spark.file.write(spark.dir("config") + "/settings.json", text);
spark.notify("Build finished", name);
spark.tray("", "Running");
spark.hotkey("ctrl+alt+G");
spark.sound("sounds/done.wav", 0.5);
spark.openFile();  spark.run("git status");  spark.writeClipboard(text);

Dialogs, the clipboard, notifications, the status area, global shortcuts, standard folders, running a program, opening a URL. Every one of them is the system's own, and every one answers null where the system has no such thing, so an app can ask and carry on.

npm packages, without shipping a runtime

<script src="main.js" bundle></script>

The developer's own esbuild and Babel translate the script down to es5 before it is baked in, never on the machine that runs the app. They have Node because they installed the packages; the person using the app needs neither.

Measured, on ten pieces of modern syntax: JavaScriptCore on macOS and Linux accepts all ten. The JScript that ships with Wine accepts JSON and nothing else, no let, no arrow, no class, no Promise. That is why the target is es5 and why this exists.