Guide
The whole model, in the order you meet it.
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.
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 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.
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.
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> |
| behaviour | a function over ids |
| one system's own API | <native class=""> inside
<platform os=""> |
| a whole new widget | a 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.
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.
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.
JSON and nothing else, no let, no
arrow, no class, no Promise. That is why the target is es5 and why
this exists.