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):
| Specifier | Resolves to | Owner |
|---|---|---|
roots/runtime | runtime/core/index.lisp: reactivity re-exports + components + template helpers + createApp | R |
roots/backend | runtime/dom/index.lisp for web, pwa, ext, ios, android; runtime/term/index.lisp for term | R / T |
roots/router | runtime/router/index.lisp | R |
roots/ui | runtime/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.md | UI |
roots/platform | runtime/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/data | runtime/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:
storage.T.*storage.<family>.*, where the family isdom,browser,mobileorterm(see*target-families*)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:
_ctxis 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 toitem.value/index.value.index(and an object'skey) is a ref.itemis the list's record of the item: an object whose.valueis the item, tracked like a ref's but read-only, and not a ref toisRef(§3forBlock). - 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 av-foritem 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.Namewhen 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.defcomponentin 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 Functionorwith, in compiled code or in the runtime.
3. Template helpers (roots/runtime, R implements) #
| Helper | Meaning |
|---|---|
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.
| Helper | Emitted for | Meaning |
|---|---|---|
setElText(node, value) | setText where node is the element that hosts the text | as setText (written once; false and nothing written for an object) |
bindElText(node, getter) | bindText where node is the element that hosts the text | as 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 takes | a 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 forBlock | the 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 function | listen 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,requiredandvalidatorare checked in development builds only (warningsR202,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) #
| Primitive | DOM | Terminal | Props | Events |
|---|---|---|---|---|
Box | <div class="r-box">, a flex column by default (like React Native) | flex box | class, style | click |
Text | <span> | inline text; nested Text = styled span | class, style | click |
Button | <button> | focusable, bordered | label, disabled | click (enter/space/mouse) |
Input | <input> | single-line text field with cursor | value, placeholder, type (text/password/number/email), disabled | input, change, keydown |
Checkbox | <input type=checkbox> | [x] label | checked, label, disabled | change |
Image | <img> | alt text placeholder | src, alt | |
Link | <a> | underlined, focusable | href, to (router) | click |
Spacer | flex-grow box | flex-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, …);buttonto Button;inputto Input/Checkbox by type;imgto Image;brto 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,firstChildandisElementgets static templates (§3) copied from a prototype it built itself; the others get them built node by node. WithisElementalone 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 constantCLONE_BACKENDtrue, andclonecopies 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 withfirstChildandnextSiblingto find the collected nodes, for a backend that reads its nodes faster itself:opsare the moves0(first child),1(next sibling),2(back to the parent) and3(put the node inout, next free slot);outhas the right length.adopt(node, tag)is for a backend whosecreateElementkeeps state with the element of a host primitive thatcloneNodedoes not copy. Templates may containBox,Text,Button,ImageandSpacer(§3); after copying one,clonecallsadoptonce for every collected node whose spec tag isBoxorSpacer(tagis that name), before the render code binds anything to it, and the backend gives the copy the statecreateElement(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'sclassattribute 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 fromcreateElement) leavesadoptout.setTextContent(node, text)(needsfirstChild) replacesnode's children with the one text nodetext: 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 nodefirstChildfinds. Without it the runtime creates the text node withcreateTextand inserts it.listen(node, event, handler, data, invoke)isaddEventfor the runtime's own listeners: no remover, and the backend keepshandleranddatawith the node and callsinvoke(handler, data, event)when the event reaches the node, exactly as a listener onnodewould 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).invokeis 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 callsaddEvent.listenElement(node, event, handler, data, invoke?)islistenfor 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 helpersonElandonRow, §3), never a primitive whose listeners belong to an inner control (the DOM'sCheckbox). The backend may skip whatlistenhas to find out about a node. Withoutinvoke,handleris a descriptor: an object shared by many nodes that carries its own invoker and is called ashandler.invoke(handler, data, event)(rowHandler: one descriptor per handler site and render of a list;datais the item's row). Order with the node's other listeners of the event is registration order, as forlisten. Without it the runtime callslisten(with the descriptor ashandlerand itsinvoke), elseaddEvent.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 wherecloneNodeis cheaper. It must not change what a copy is, only where it is made.clearwithfirstChildgets 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.ownerDocumentis the inert document andownerDocument.defaultViewisnulluntil the node is inserted into the page, which adopts it. (isConnectedis false andgetRootNode()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.hrefis"rel/x"anda.protocolis":"until the node is inserted (so areimg.src,form.action,link.href); afterwards, and where prototypes are not moved, they are resolved against the page. ReadgetAttributein a function ref or a directive'screatedhook, or read the property inmounted. - Where prototypes are moved, an
<img>with a staticsrcin 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
isattribute) 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,networkStatestaysNETWORK_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
ResizeObserverattached 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-3is 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 withterm:.<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-2is two cells); vertical ones (gap-N,py-N,my-N,p-Nwith N > 1) usually do. The scale itself is the same everywhere and is not rounded per surface.
| Group | Utilities |
|---|---|
| Layout | flex-row, flex-col, flex-1, grow, shrink-0, wrap, items-start|center|end|stretch, justify-start|center|end|between|around, self-* |
| Gap | gap-N, gap-x-N, gap-y-N |
| Spacing | p-N, px-N, py-N, pt|pr|pb|pl-N, m-N, mx-N, my-N, mt|mr|mb|ml-N (N = 0–12) |
| Size | w-N, h-N, w-full, h-full, w-1/2, min-w-N, max-w-N |
| Position & visibility | hidden, absolute, relative, overflow-hidden |
| Border | border, border-round (rounded on DOM, ╭╮ in the terminal), border-double, border-<color> |
| Color | bg-<color>, text-<color> |
| Type | bold, italic, underline, dim, strike, text-left|center|right, truncate, text-sm|lg|xl|2xl (size on DOM; bold for lg+ in the terminal) |
| Effects | rounded, 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-warningis the readable warning color (--roots-warning-text):warningitself is a fill, with dark text on it. A form control's boundary is--roots-control-border(about 3:1 against the background);borderstays 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")ofroots/platform(build.md). On the DOM the choice is the attributedata-themeon<html>; every dark rule (the theme's dark variables and eachdark: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 thedark: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 inpublic/defines is not unknown; the terminal drops such a class, and any class behinddom:ormobile:, without a warning. Other classes that are not utilities are reported there once (R361). - The router's Link classes
r-link-activeandr-link-exact-activeare 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:themeleaves 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:
.vueand.roots. <template>: HTML with Vue syntax.<script setup lang="ts|js|lisp">: Lisp uses the Roots dialect, and its top-leveldefvar/defun/defconstantbecome 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.titleaccesses, so it stays reactive.
9. Errors #
Toolchain:
- compile errors carry
file:line:columnand a code:C1xxparser,C2xxtemplate,C3xxSFC,B1xxbuild - package manager:
PM1xx(packages.md)
Runtime:
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.