Skip to the contentSkip to the navigation
RootsGitHubMenu
On this page

Roots 0.1 contract

This file is the interface between the parts of Roots. Implementations follow it exactly; changes to it are coordinated (edit this file first, then the code).

 app source                       toolchain (CL, SBCL)                       output per target
 ───────────                      ─────────────────────                      ─────────────────
 src/App.vue  ┐   parse (P1)      roots.parse   JS/TS parser, type strip,     dist/web/  index.html + ESM
 src/*.ts     ├─► template (P2)   roots.template HTML templates → JS          dist/pwa/  + manifest + sw.js
 src/*.lisp   ┘   sfc (P2)        roots.sfc     .vue/.roots → ES module       dist/ext/  MV3 extension
 roots.lisp       build (B)       roots.build   graph, targets, dev server    dist/ios|android/ native shell
                                                CSS, adapters                 dist/term/ node app
 runtime (Lisp → JS):  core (R) · dom backend (R) · router (R) · term backend (T) · platform adapters (B)

1. Module specifiers #

Compiled app code imports only these bare specifiers (the build rewrites them to paths inside the output):

SpecifierResolves toOwner
roots/runtimeruntime/core/index.lisp: reactivity re-exports + components + template helpers + createAppR
roots/backendruntime/dom/index.lisp for web, pwa, ext, ios, android; runtime/term/index.lisp for termR / T
roots/routerruntime/router/index.lispR
roots/uiruntime/ui/index.lisp: the UI kit (components, useToast, createUI, headless machines and keyboard tables); its skin and surface adapter resolve per target family (skin.dom.lisp / skin.term.lisp), see ui.mdUI
roots/platformruntime/platform/<target>.lisp (storage, clipboard, openUrl, notify, info, theme, screen); with the Capacitor shell platform/capacitor-<ios|android>.lisp (the target's target-platform-module)B
roots/dataruntime/data/index.lisp: SQLite (open, migrations, live queries); its driver is the per-target runtime/data/driver.<term|browser|mobile>.lisp (data.md)D

Relative imports work as usual. Per-target files: when building for target T, ./storage (or ./storage.ts) resolves to the first file that exists among:

  1. storage.T.*
  2. storage.<family>.*, where the family is dom, browser, mobile or term (see *target-families*)
  3. storage.*

This is the main tool for keeping platform ifs out of app code. Other bare imports are npm packages: they resolve only to a package the importer declares (the app's package.json, or the importing package's own), through its exports / module / main, and are emitted under npm/<name>@<version>/; an undeclared one is a build error naming how it is reachable (B123). See packages.md. node: (and, for roots/data under Bun, bun:) modules are allowed on term only.

2. Compiled component module (P2 emits, R executes) #

An SFC compiles to an ES module like this (exact names matter):

import * as $ from "roots/runtime";
import TodoItem from "./TodoItem.js";          // .vue/.lisp/.ts imports are rewritten to .js

export default /* @__PURE__ */ $.defineComponent({
  name: "TodoList",                            // from the file name
  props: { title: { type: String, default: "Todos" } },   // defineProps (runtime form)
  emits: ["select"],                           // defineEmits
  setup(__props, __setup) {                    // <script setup> body, TS stripped
    const props = __props;                     // when the script binds defineProps()
    const emit = __setup.emit;                 // when the script binds defineEmits()
    const todos = $.ref([]);
    function add(text) { todos.value.push({ id: Date.now(), text, done: false }) }
    return { props, emit, todos, add, TodoItem };   // every top-level binding (incl. imports)
  },
  render(_ctx) {                               // the template, compiled
    const _c1 = $.clone(_tpl0), n2 = _c1[0], n3 = _c1[1];   // _tpl0: hoisted above the component,
    $.setAttr(n2, "class", "flex-col gap-1");               // $.template(["Box", 0, 1, ["Text", 0, 0, 0]])
    $.bindText(n3, () => "You have " + $.toDisplayString(_ctx.todos.length) + " todos");
    const b4 = $.forBlock(() => _ctx.todos, (todo, i) =>
      $.component(_ctx.TodoItem, { todo: () => todo.value, onToggle: () => _ctx.toggle(todo.value) }, {}),
      (todo) => todo.id);
    $.append(n2, b4);
    return n2;
  },
});

Rules:

  • _ctx is the instance proxy. It reads setup bindings first (refs are auto-unwrapped on get, and set writes .value), then props, then $slots, $attrs, $emit, $el (DOM only).
  • Free identifiers in template expressions are prefixed with _ctx.. Exceptions: locals (v-for aliases, slot props, arrow parameters, $event) and globals (Math, Date, JSON, Number, String, Boolean, Array, Object, parseInt, parseFloat, isNaN, isFinite, undefined, NaN, Infinity, console, window, document, navigator).
  • v-for aliases are read through .value: the item render function receives (item, index), and alias uses compile to item.value / index.value. index (and an object's key) is a ref. item is the list's record of the item: an object whose .value is the item, tracked like a ref's but read-only, and not a ref to isRef (§3 forBlock).
  • Event handlers: a method name compiles to (...a) => _ctx.inc(...a); an inline statement compiles to ($event) => { …statements… }. On DOM targets a handler of a static-template node in a v-for item is hoisted out of the item function and receives the row first: $.rowHandler((row, $event) => { … }), (row, ...a) => _ctx.inc(...a) (§3, lean helpers).
  • Targets: the render code above is what every target can run. For DOM targets (web, pwa, ext, ios, android) the compiler replaces some calls by the lean helpers of §3; the terminal target's output never contains them.
  • Text runs: adjacent text and interpolations compile to one text, kept by one binding whose getter concatenates the parts: static text verbatim as string literals, each interpolation through $.toDisplayString ("You have " + $.toDisplayString(_ctx.todos.length) + " todos"); a lone interpolation keeps the bare getter. Where the run is all its element holds, the binding is $.bindText(element, getter) (§3), else one $.dynamicText(getter) or one collected text of a static template. An interpolation that throws takes its run with it, not only its own part.
  • Capitalized tags: a name in the host primitive list (§4) → $.element(name) or an element of a static template (§3), unless a setup binding with that name exists. Otherwise _ctx.Name when bound, else $.resolveComponent(_ctx, "Name"). Lowercase tags → $.element(tag).
  • <template> roots: a single root returns the node; several roots return $.fragment(...).
  • <style>: collected by the build into the target's CSS (DOM targets). Ignored with a warning for term in 0.1.
  • Pure marks: $.defineComponent({…}) and the hoisted static templates (const _tplN = /* @__PURE__ */ $.template([…]), §3) carry /* @__PURE__ */: both only build data, so a bundler (roots build, esbuild, rollup) drops the module of a component nothing imports. defcomponent in Lisp emits the same mark, and (declaim (pure f)) gives any Lisp function the matching /* @__NO_SIDE_EFFECTS__ */ (README, "The Lisp → JS dialect").
  • No eval, new Function or with, in compiled code or in the runtime.

3. Template helpers (roots/runtime, R implements) #

HelperMeaning
element(tag)host node via the active backend
text(s) / dynamicText(getter)static / reactive text node (toDisplayString rules: null/undefined → "", objects → JSON)
append(parent, ...children)children are nodes or Blocks
fragment(...children)Block of several children
setAttr(node, name, value)static prop/attribute (class string, style string/object, id, disabled, value, …)
bindAttr(node, name, getter, over?)reactive prop; a first value of null, undefined or a removing false is not written to a new node (docs/runtime.md §2). over: true says the node may hold name already, so that first value is written too: the compiler passes it after a static attribute of the same name or an object v-bind on the element, and for every key of an object v-bind (for (const $k in obj) $.bindAttr(n, $k, () => obj[$k], true)). A component as node (its root element) is always written
bindClass(node, staticClass, getter) / bindStyle(node, staticStyle, getter)merge static and dynamic (string | array | object normalization like Vue)
on(node, event, handler, modifiers?)a listener; with modifiers it is onWith (below), which holds the modifier code: the compiler calls onWith itself on DOM targets, so an app whose templates use no modifier (and no on with a fourth argument by hand) does not carry that code. Modifiers: stop prevent self once; system keys ctrl shift alt meta (exact requires exactly these); key filters enter esc space up down left right tab delete
show(node, getter)v-show
model(node, getter, setter, modifiers?)v-model on Input (value + input), Checkbox (checked + change), select; modifiers trim, number, lazy
template(spec) / clone(template)static structure, hoisted to module level by the compiler; clone copies it (backend cloneNode, else built node by node) and returns the collected nodes in document order, the root first. A backend may keep the prototype it copies from where copies are cheaper (home, §5): on the DOM a new copy then belongs to an inert document until it is inserted (§5, "Template prototypes on the DOM"). Spec element: [tag, staticAttrs | 0, collected (0 | 1), ...children]; tag is a lowercase element or one of the host primitives Box, Text, Button, Image, Spacer (§4; Input, Checkbox and Link need the state element() gives them and stay $.element calls, inserted as holes). After copying, clone hands every collected Box and Spacer to the backend's adopt (§5), so a copied primitive behaves as the one createElement made (on the DOM: it keeps r-box / r-spacer when a class is bound or falls through later). Child: "text", ["#", "text"] (collected text), 0 (collected empty text node) or an element. A 0 that is the only child of its element stands for the element: the copy has no text node there, and clone returns the element in that position (so an element can appear twice: once for itself when it is collected, once for its text). A sole 0 therefore promises that nothing else is inserted into that element at run time; where a block or component goes beside the interpolation (<td><Comp/>{{ x }}</td>), the compiler emits ["#", ""], a collected text node with no text yet, which the hole is inserted before
bindText(node, getter)keep a text run (§2) equal to toDisplayString(getter()): node is its text node in a static template, or the element the run is all the content of, cloned or made by element() (then the first run writes the element's text content, which makes the text node, and later runs write that text node)
setText(node, value)a template interpolation whose value cannot change while its node lives (it shows the key of the keyed v-for it is in, and a kept item only ever receives an item with the same key): written once with the toDisplayString rules, no render effect, and true is returned. An object as value (a key that is an object: its text reads state that can change) is not written and false is returned; the compiler emits `$.setText(n, v)
insert(parent, child, before)insert a node or Block before the host node before (holes of a template); R224 when before is parent itself (a development check)
ifBlock(cond, renderThen, renderElse?)v-if / v-else-if (nested) / v-else; renders return a node or Block
forBlock(list, renderItem, key?, single?)keyed list; list is a getter returning an array, number (v-for="n in 5") or object. Each item renders as its own scope (its record: the effects, onScopeDispose callbacks and nested components or blocks of the item end with it; the list has one owner for all its items). renderItem gets the record as its item argument: item.value is the item, and reading it in a binding or an effect depends on the record, which is triggered when the list gives the record another item object under the same key (so an item costs no ref of its own; the index and an object's key are refs, made only when renderItem declares them). The list's Block has no nodes of its own: they are its records' nodes, which a Block around the list walks when it moves or unmounts the list as a whole. single: true (DOM targets) is the compiler's promise that renderItem always returns exactly one host element (never a Block, text, an array or null), so the list inserts and removes it as it is; key is undefined then for a list without :key
component(Comp, props, slots)props: { name: value or getter }, where getters are functions starting with () =>; the compiler wraps every dynamic binding in a getter. Events are props named onX. Slots: { default: (slotProps) => node | Block }
dynamicComponent(getter, props, slots)<component :is>; a string is a registered component or a tag name, except the document-level tags (script style iframe frame frameset object embed base meta link: R306)
slot(_ctx, name, propsGetters, fallback?)<slot> outlet
resolveComponent(_ctx, name)global registration lookup; throws R201 if unknown
templateRef(node, _ctx, name)ref="name": sets the setup ref
portal(render, target?)content in an overlay layer (target, else backend.layer, §5), owned by the current component; only an anchor stays in place (not emitted by the template compiler; libraries such as the UI kit call it)

Lean helpers. Emitted by the template compiler for DOM targets only, where it knows what a general helper tests at run time. node is always an element collected from a static template (a lowercase element or Box, Text, Button, Image, Spacer): never a text node, a component, or a Checkbox, Input or Link. They do what the general helper does for such a node, on every backend (a module compiled for a DOM target also runs on the testing backend). Hand-written render functions use the general helpers.

HelperEmitted forMeaning
setElText(node, value)setText where node is the element that hosts the textas setText (written once; false and nothing written for an object)
bindElText(node, getter)bindText where node is the element that hosts the textas bindText for an element-hosted run
bindClassEl(node, getter)bindClass(node, null, getter)a :class with no static class beside it
onEl(node, event, handler)on(node, event, handler)a listener without modifiers
onWith(node, event, handler, modifiers)on(node, event, handler, modifiers), on any node on takesa listener with modifiers (a non-empty array); on itself is then only called without
rowHandler(fn)once per handler site and render of a list, beside its forBlockthe descriptor of a handler of v-for items: fn(row, event) is called in the instance current at this call, with the batching and error routing of every handler
onRow(node, event, handler, row)on(node, event, closure) in a v-for item functionlisten with a rowHandler result; row (the item function's first argument) is what fn receives first. Several listeners of one type on a node, also mixed with on / onEl / onWith, all run, in registration order
bindSelected(node, selector, key, apply)in front of bindClassEl: $.bindSelected(n, sel, key, apply) || $.bindClassEl(n, getter)when selector is a createSelector result: keep node's class equal to apply(selector(key)) (apply: selected → class value, normalized as bindClass does; a module-level pure function), written first now and then in the render flush whenever the selection moves to or from key, without a render effect; returns true. For anything else nothing is done and false is returned. key is constant while the node lives (the key of the keyed v-for)

Component model: defineComponent({ name, props, emits, setup, render }).

  • props: declared as an array of names or { name: { type, default, required } }. Type, required and validator are checked in development builds only (warnings R202, R203).
  • setup(props, { emit, slots, attrs, expose }): returns bindings (or a render function).
  • Lifecycle: onMounted, onBeforeUnmount, onUnmounted.
  • Other: provide/inject, nextTick.
  • App: createApp(Root, rootProps?) returns { mount(backend, target, options?), unmount(), component(name, C), provide(k, v), use(plugin), config: { errorHandler } }.

Every component instance owns an effectScope, and unmounting stops it; a list item's record is the scope of what rendered in the item, owned by the list (docs/runtime.md §1). An error in render or setup goes to the nearest onErrorCaptured, then to app.config.errorHandler, and is never swallowed.

4. Host primitives (every backend) #

PrimitiveDOMTerminalPropsEvents
Box<div class="r-box">, a flex column by default (like React Native)flex boxclass, styleclick
Text<span>inline text; nested Text = styled spanclass, styleclick
Button<button>focusable, borderedlabel, disabledclick (enter/space/mouse)
Input<input>single-line text field with cursorvalue, placeholder, type (text/password/number/email), disabledinput, change, keydown
Checkbox<input type=checkbox>[x] labelchecked, label, disabledchange
Image<img>alt text placeholdersrc, alt
Link<a>underlined, focusablehref, to (router)click
Spacerflex-grow boxflex-grow box
  • Plain HTML tags are allowed on DOM targets. The terminal maps containers (div section main header footer nav article aside form ul ol li) to Box; inline/text tags (span p h1–h6 strong b em i label small code pre a) to Text with default styles (h1 bold, …); button to Button; input to Input/Checkbox by type; img to Image; br to a newline. Anything else warns and becomes a Box.
  • Events are DOM-shaped on every target: $event.target.value, $event.target.checked, $event.key (KeyboardEvent.key names: Enter, Escape, ArrowUp, a), ctrlKey/shiftKey/altKey/metaKey, preventDefault(), stopPropagation().
  • Focus: native on DOM. The terminal uses Tab / Shift+Tab over Button, Input, Checkbox, Link and anything with a tabindex.

5. Backend interface (roots/backend exports backend) #

interface Backend<N> {
  name: "dom" | "term"
  createElement(tag: string): N
  createText(text: string): N
  setText(node: N, text: string): void
  createAnchor(): N                      // invisible marker for dynamic ranges
  insert(parent: N, child: N, anchor: N | null): void
  remove(child: N): void
  parentNode(node: N): N | null
  nextSibling(node: N): N | null
  setProp(node: N, key: string, value: unknown): void   // class (string), style (object), attrs, props
  addEvent(node: N, event: string, handler: (e: any) => void): () => void
  mount(root: N, target: unknown, options?: object): { unmount(): void }
  isElement?(node: N): boolean           // optional: an element (not a text node, not an anchor)
  cloneNode?(node: N): N                 // optional, with firstChild and isElement: deep copy of a node tree
  firstChild?(node: N): N | null         // (attributes, text, children; not listeners)
  collect?(ops: number[], root: N, out: unknown[]): void   // optional: the template walk, in one call
  adopt?(node: N, tag: string): void     // optional, with cloneNode: a copied Box or Spacer gets its per-node state
  home?(proto: N): void                  // optional, with cloneNode: a finished template prototype, to be kept where copies are cheap
  setTextContent?(node: N, text: string): void             // optional, with firstChild: the children of node become the text
  listen?(node: N, event: string, handler: (e: any) => unknown, data: unknown,
          invoke: (handler: (e: any) => unknown, data: unknown, e: any) => void): void  // optional: addEvent without a remover
  listenElement?(node: N, event: string, handler: unknown, data: unknown,
          invoke?: (handler: (e: any) => unknown, data: unknown, e: any) => void): void  // optional: listen for a new element
  clear?(parent: N): void                // optional: remove every child of parent
  layer?(node: N): N | null             // optional: where overlays go for the tree holding node
}

isElement, cloneNode, firstChild, collect, adopt, home, setTextContent, listen, listenElement and clear are optional:

  • A backend with cloneNode, firstChild and isElement gets static templates (§3) copied from a prototype it built itself; the others get them built node by node. With isElement alone the runtime keeps no registry of the elements it made (attrs fall through onto elements only; without it, it has to remember which nodes are elements). In a bundle built for a DOM target (web, pwa, ext, ios, android) the three are not asked for: the build defines the runtime's exported constant CLONE_BACKEND true, and clone copies without the test (docs/build.md). A backend of your own that such a bundle mounts through must therefore have all three; --no-bundle, dev builds and the terminal target keep the test.
  • collect(ops, root, out) is the walk the runtime does over a copy with firstChild and nextSibling to find the collected nodes, for a backend that reads its nodes faster itself: ops are the moves 0 (first child), 1 (next sibling), 2 (back to the parent) and 3 (put the node in out, next free slot); out has the right length.
  • adopt(node, tag) is for a backend whose createElement keeps state with the element of a host primitive that cloneNode does not copy. Templates may contain Box, Text, Button, Image and Spacer (§3); after copying one, clone calls adopt once for every collected node whose spec tag is Box or Spacer (tag is that name), before the render code binds anything to it, and the backend gives the copy the state createElement(tag) would have given it. Nodes that are not collected are never written to after copying and are not adopted; an element collected only as the host of its text is not either. The DOM backend restores the base class there (r-box, r-spacer), which it puts before every class it writes; the prototype's class attribute already holds it, so a copy nothing is bound to is right as copied. A backend that keeps no such state (or builds templates node by node, where every element comes from createElement) leaves adopt out.
  • setTextContent(node, text) (needs firstChild) replaces node's children with the one text node text: the first run of an interpolation that is its element's only child is written this way (the DOM makes the text node itself), later runs write the text node firstChild finds. Without it the runtime creates the text node with createText and inserts it.
  • listen(node, event, handler, data, invoke) is addEvent for the runtime's own listeners: no remover, and the backend keeps handler and data with the node and calls invoke(handler, data, event) when the event reaches the node, exactly as a listener on node would be called (several listeners per event run in registration order; the DOM backend keeps them in symbol-keyed properties and delegates, docs/runtime.md §6). invoke is one function for every listener (the runtime's batching and error routing), so nothing has to be allocated per registration. Without it the runtime wraps the handler and calls addEvent.
  • listenElement(node, event, handler, data, invoke?) is listen for a node the runtime made through the backend a moment ago and nothing else has seen: an element copied from a static template (the lean helpers onEl and onRow, §3), never a primitive whose listeners belong to an inner control (the DOM's Checkbox). The backend may skip what listen has to find out about a node. Without invoke, handler is a descriptor: an object shared by many nodes that carries its own invoker and is called as handler.invoke(handler, data, event) (rowHandler: one descriptor per handler site and render of a list; data is the item's row). Order with the node's other listeners of the event is registration order, as for listen. Without it the runtime calls listen (with the descriptor as handler and its invoke), else addEvent.
  • home(proto) is called once per static template and backend, after the prototype is built and before its first copy: the backend may move it to a place where cloneNode is cheaper. It must not change what a copy is, only where it is made.
  • clear with firstChild gets a list that is all its parent holds emptied in one operation.

Template prototypes on the DOM. The DOM backend's home puts a prototype into the content of a <template>, that is into the inert document templates have (Chromium copies a subtree there about 1.15× faster on row creation, WebKit slightly faster; docs/performance.md). What follows for code that looks at a node between clone and its insertion (a function ref, a hand-written render function; compiled bindings do not):

  • node.ownerDocument is the inert document and ownerDocument.defaultView is null until the node is inserted into the page, which adopts it. (isConnected is false and getRootNode() is the copy's own root before insertion, as they always were.) Do not rely on either document: in Firefox, where adoption costs more than the copy saves, prototypes are not moved and a new copy belongs to the page's document from the start, as in Roots 0.3.2.
  • For the same reason a property that resolves a URL against the document answers the unresolved attribute on a new copy: a.href is "rel/x" and a.protocol is ":" until the node is inserted (so are img.src, form.action, link.href); afterwards, and where prototypes are not moved, they are resolved against the page. Read getAttribute in a function ref or a directive's created hook, or read the property in mounted.
  • Where prototypes are moved, an <img> with a static src in a template starts loading when its copy is inserted, not when it is made, and the prototype itself never loads: nothing loads in the inert document.
  • A template that contains a custom element (a tag with a dash, or an is attribute) is not moved: its copies are made in the page's document and upgraded when they are made, before any binding runs, as before.
  • A template that contains a <video> or an <audio> is not moved either: WebKit does not select the <source> of a media element copied in the inert document when the page adopts it (nothing is requested, networkState stays NETWORK_EMPTY). Its copies are made in the page's document and load as in Roots 0.3.2.
  • Listeners work as before: the backend delegates a copy's listeners to the page's document. A ResizeObserver attached to a copy fires once it is in the page.

setProp(node, key, value): null, undefined and false remove the prop; false is a value (shown as "false") for aria-*, data-*, contenteditable, draggable and spellcheck. The runtime relies on a node fresh from createElement having no prop set: it does not send a binding's first value when that value would only remove (docs/runtime.md §2 bindAttr, §4).

setProp and createElement are where a backend enforces the security rules, because keys and tags can come from data (the keys of v-bind="obj", fallthrough attributes, the string of <component :is>), which the compiler never sees. The DOM backend compares attribute names as the DOM stores them (lower-cased on HTML elements) and refuses innerHTML/outerHTML/srcdoc (R303), every on* name (R304), <script>/<style> (R306), javascript:/vbscript: URLs and framed data: documents (R307) and names that are no attribute names (R308); see docs/runtime.md §6. The runtime itself refuses document-level tags as <component :is> strings (R306) and drops a value that is not a function under an onX key of a component or a dynamic element.

addEvent may deliver events through a shared listener instead of one per node, as long as handlers see the event as a listener on node would: the DOM backend delegates the common bubbling events (docs/runtime.md §6).

layer (optional) returns the node that overlay content (portal(), §3) is inserted into for the tree containing node, or null while that tree is not mounted: the DOM returns <body> once the node is connected, the terminal the screen's root (absolute children are placed against the whole terminal). Without it, portal content renders in place.

The DOM target is a selector or element. The terminal target is { stdout, stdin, columns, rows }; when omitted, the process terminal is used.

6. Styling vocabulary (B: CSS generation; T: terminal mapping) #

One meaning on every target. Units are spacing steps: 1 step = 0.25rem on the DOM, and 1 cell horizontally / 1 row vertically in the terminal.

One step is not one size on both surfaces. gap-3 is 0.75rem on the DOM (a little air) and three blank rows in the terminal (a lot). A component that serves both says both: the DOM value first, then the terminal's with term:.

<Box class="p-4 gap-3 term:p-1 term:gap-0">   <!-- DOM: 1rem, 0.75rem; terminal: 1 cell, none -->

A term: class produces no CSS on the DOM and wins in the terminal (variants are applied after plain utilities). Horizontal steps rarely need a twin (px-2 is two cells); vertical ones (gap-N, py-N, my-N, p-N with N > 1) usually do. The scale itself is the same everywhere and is not rounded per surface.

GroupUtilities
Layoutflex-row, flex-col, flex-1, grow, shrink-0, wrap, items-start|center|end|stretch, justify-start|center|end|between|around, self-*
Gapgap-N, gap-x-N, gap-y-N
Spacingp-N, px-N, py-N, pt|pr|pb|pl-N, m-N, mx-N, my-N, mt|mr|mb|ml-N (N = 0–12)
Sizew-N, h-N, w-full, h-full, w-1/2, min-w-N, max-w-N
Position & visibilityhidden, absolute, relative, overflow-hidden
Borderborder, border-round (rounded on DOM, ╭╮ in the terminal), border-double, border-<color>
Colorbg-<color>, text-<color>
Typebold, italic, underline, dim, strike, text-left|center|right, truncate, text-sm|lg|xl|2xl (size on DOM; bold for lg+ in the terminal)
Effectsrounded, shadow (DOM only; ignored in the terminal)
  • Colors are theme tokens: primary, accent, muted, surface, border, focus, success, warning, error, info, text, bg. The literal escape is [#rrggbb].
  • Colors as text. text-warning is the readable warning color (--roots-warning-text): warning itself is a fill, with dark text on it. A form control's boundary is --roots-control-border (about 3:1 against the background); border stays the quiet line of dividers and cards.
  • Variants: term:, dom:, mobile:, hover:, focus:, dark:, sm:, md:, lg:.
  • Dark is the system's setting (the terminal's, from COLORFGBG) until the app chooses: theme.set("dark" | "light" | "system") of roots/platform (build.md). On the DOM the choice is the attribute data-theme on <html>; every dark rule (the theme's dark variables and each dark: utility) is written for :root[data-theme="dark"] and for @media (prefers-color-scheme: dark) under :root:not([data-theme="light"]). In the terminal the choice switches the dark: variants and the colors of :theme :dark.
  • Unknown classes are a build warning (an error with --strict). A class that a component <style> or a CSS file in public/ defines is not unknown; the terminal drops such a class, and any class behind dom: or mobile:, without a warning. Other classes that are not utilities are reported there once (R361).
  • The router's Link classes r-link-active and r-link-exact-active are known everywhere; in the terminal the exact match is bold.
  • The theme comes from roots.lisp :theme, as CSS variables on the DOM and a color table in the terminal. A text color that :theme leaves under 4.5:1 against its background is the build warning B168, with the measured ratio.
  • Component CSS (the UI kit) is not utilities: the DOM skin ships its own stylesheet, written with the theme variables, through the build (a runtime module's sibling .css, build.md); the terminal skin uses the utilities above plus the terminal's own (terminal.md). The kit adds no utilities.

7. Project & build (B) #

my-app/
  roots.lisp        project data (never evaluated: read with *read-eval* nil)
  src/App.vue       entry component (or App.lisp / App.ts exporting a component)
  src/…             .vue .roots .ts .js .lisp files; per-target variants (x.term.ts …)
  public/           copied verbatim to DOM targets
  icon.png          app icon (optional)
(:name "todo" :title "Todo" :version "0.1.0" :id "com.example.todo"
 :entry "src/App.vue"
 :theme (:primary "#42b883")
 :targets (:web () :pwa (:theme-color "#42b883") :ext () :ios () :android () :term ()))

Commands:

roots new <dir>
roots build --target web|pwa|ext|ios|android|term [--out dir]
roots dev [--target web|term]
roots run [--target term]

Every DOM target's index.html uses a strict CSP (script-src 'self'; no inline scripts or styles), so extensions and CSP-strict hosting work unchanged.

Dev server rules:

  • binds to 127.0.0.1 only
  • serves only built files, plus public/
  • checks the Host and Origin headers
  • sends no CORS headers
  • never serves dotfiles
  • the live-reload channel is notification-only (SSE)

8. SFC format (P2) #

  • Files: .vue and .roots.
  • <template>: HTML with Vue syntax.
  • <script setup lang="ts|js|lisp">: Lisp uses the Roots dialect, and its top-level defvar/defun/defconstant become bindings.
  • Optional plain <script> for named exports.
  • <style>: plain CSS.
  • Compiler macros: defineProps (runtime array/object form, and TS type-literal form), defineEmits, defineModel, withDefaults.
  • Reactive props destructure: const { title = "x" } = defineProps(…) compiles to __props.title accesses, so it stays reactive.

9. Errors #

Toolchain:

  • compile errors carry file:line:column and a code: C1xx parser, C2xx template, C3xx SFC, B1xx build
  • package manager: PM1xx (packages.md)

Runtime:

  • R1xx reactivity (existing)
  • R2xx components
  • R3xx backends
  • R4xx router
  • R5xx platform adapters
  • R6xx UI kit (warnings, ui.md §7)
  • D1xx data (roots/data and roots db, data.md)

10. Native bridge (shells ↔ runtime) #

The Roots shells (ios, android) inject globalThis.RootsNative into the app's own pages: { platform: "ios" | "android", version: "1", call(plugin, method, args?): Promise<unknown>, on(event, handler): () => void }. Messages are JSON ({id, plugin, method, args}, replies {id, ok, result | error: {code, message}}, events {event, data}); rejections carry {code, message}; plugins are allow-listed in roots.lisp (:native :plugins). Runtime code checks for RootsNative and falls back when it is absent. The full ABI, the built-in plugins (sqlite, storage, clipboard, browser, app) and their error codes: native.md.