Skip to content
enumeratio edited this page Oct 1, 2026 · 4 revisions

Design: the AST as a vdom

Status: landed (structural tree, generics, options; 2026-09-16). The Vue and React wrappers and <Notatio> are gone (#439); the written form of this tree is now FullForm as JSX, read and printed by @enumeratio/formats/markup (Speculative Vdom Markup). The pseudo-Vue spellings below predate it. Companion to components-and-symbols.md: that note says a symbol and its component are the same thing seen from two ends; this one writes out what the tree looks like from the component end, so the shape can be judged before anything is built.

A vdom node is { tag, props, children }. A MathJSON node is [head, ...args]. The claim is that these are the same tree under a renaming -- tag is notatio- + the head, kebab-cased; children are the arguments -- and that the interesting part is not the renaming but the lowering: where a component has an API of its own (a plot's domain), some arguments become props, and where a variable is declared by a control, the rest of the tree reads it as a wildcard.

Two trees appear below for each example:

  • structural -- the AST verbatim as a vdom. Every head is a tag, every argument a child, a run of atoms one text child of their tokens, as the markup reader reads them (Speculative Vdom Markup §1). An expression that is itself an atom is a leaf tag (Integer, Real, String, Symbol). Nothing is lost; nothing is interpreted.
  • realized -- what actually draws: renderingOf today. A head with a component of its own has its arguments lowered into that component's props; a head without one wraps itself in <notatio-out> and typesets; a tree with controls in it gets a scope.

h(tag, props, children) below is any framework's: Vue's h, React's createElement, Lit's html by way of unsafeStatic.

1. A plain application: Binomial(n, 2)

MathJSON: ["Binomial", "n", 2]

Structural:

h("notatio-binomial", {}, ["n 2"]);

Realized -- there is no component for Binomial, so the root typesets the whole and the inner tags are structure (display: contents):

h("notatio-binomial", {}, ["n 2"]);
// which renders, inside itself, the equivalent of
h("notatio-out", { format: "mathjson", value: '["Binomial","n",2]' });

The rule for a generic element (one generated per symbol, no hand-written class): its expression is its head over its children's expressions; a text child is whitespace-separated atoms, as the vdom markup reads it (<notatio-binomial>n 2</notatio-binomial>), and value holds the arguments as Epsil (value="n, 2", Speculative Vdom Markup §1); only the outermost generic element typesets. A built component (a plot, a control) reads a text child the same way.

2. Nesting: Sin(x)^2 + 1

MathJSON: ["Add", ["Power", ["Sin", "x"], 2], 1]

Structural:

h("notatio-add", {}, [
  h("notatio-power", {}, [h("notatio-sin", {}, ["x"]), "2"]),
  "1",
]);

Realized: identical -- none of these heads draws. The notatio-add at the root typesets \sin^2 x + 1; the tree under it is the expression's own shape, addressable (a hover on the notatio-sin could highlight the \sin x in the typeset output, which is the TreeForm folding <notatio-out> does today, but from the other end).

3. A head with a component: Plot(Sin(k x), (x, 0, 10))

MathJSON: ["Plot", ["Multiply", "k", ["Sin", "x"]]…, ["Tuple", "x", 0, 10]]

Structural:

h("notatio-plot", {}, [
  h("notatio-sin", {}, [h("notatio-multiply", {}, ["k x"])]),
  h("notatio-tuple", {}, ["x 0 10"]),
]);

Realized -- Plot has a component whose API is attributes, so its arguments are lowered into props (this is VISUAL_SYMBOLS in symbols.ts):

h("notatio-plot", { value: "Sin(k * x)", var: "x", domain: "0,10" });

Pseudo-Vue, both spellings:

<Plot value="Sin(k * x)" var="x" domain="0,10" />
<!-- or, structurally -->
<Plot><Sin><Multiply>k, x</Multiply></Sin><Tuple>x, 0, 10</Tuple></Plot>

The second spelling is the one every generic element already has; a built component supports it because the element adopts its structural children (structure.ts in components) -- the same lowering, applied by the element to its own children instead of by renderingOf to the AST. One map, used from both sides. A head with a fixed signature also takes its arguments by name, from the reference entry's parameters: <Binomial n="5" k="2" />.

Options

Options are Wolfram's rules, and the rules are Wolfram's: PlotRange -> (-1, 1) after the positional arguments, singly or in (nested) lists, flattened, the leftmost setting of a name winning (OptionsPattern). The expression is never rewritten to hold them -- no dictionary form; a [K -> v] list stays a list of KeyValuePair (or the Tuple the engine canonicalises a symbol-keyed pair to). optionsOf in formats reads them off; withOptions writes them back.

In the vdom an option is a prop, kebab-cased: PlotRange -> (-1, 1) is h("notatio-plot", { "plot-range": "-1,1" }). An option whose value has a rendering of its own is a slotted child instead, slot naming the option; one whose value is a graphics primitive (Epilog -> Point((1, 0.5))) travels as its Epsil text, since it is marks on the plot's own axes rather than a picture of its own. A component that spells an option differently (PlotLabel is the plot's label) maps it in VisualSymbol.options; an element's own attributes are its options too, read back by name (plot-range on the element is PlotRange on the expression).

<Plot value="Sin(x)" var="x" domain="0,10" plot-range="-1,1" epilog="Point((1, 0.5))" />
<!-- or, structurally -->
<Plot plot-range="-1,1" epilog="Point((1, 0.5))"><Sin>x</Sin><Tuple>x, 0, 10</Tuple></Plot>

4. Controls and a scope: Row([Slider((k, 2), (0, 5)), Dynamic(k^2)])

MathJSON: ["Row", ["List", ["Slider", ["Tuple", "k", 2], ["Tuple", 0, 5]], ["Dynamic", ["Power", "k", 2]]]]

Structural:

h("notatio-row", {}, [
  h("notatio-list", {}, [
    h("notatio-slider", {}, [
      h("notatio-tuple", {}, ["k 2"]),
      h("notatio-tuple", {}, ["0 5"]),
    ]),
    h("notatio-dynamic", {}, [h("notatio-power", {}, ["k 2"])]),
  ]),
]);

Realized -- three lowerings at once. The slider's arguments become its props. The List inside Row is spread into the row's children. And because a control declares k, every other read of k becomes the wildcard _k, and the whole is wrapped in a scope:

h("notatio-dynamic-module", {}, [
  h("notatio-row", {}, [
    h("notatio-slider", { name: "k", value: "2", min: "0", max: "5" }),
    h("notatio-dynamic", { value: "_k ^ 2" }),
  ]),
]);

Pseudo-Vue:

<DynamicModule>
  <Row>
    <Slider name="k" value="2" min="0" max="5" />
    <Dynamic value="_k ^ 2" />
  </Row>
</DynamicModule>

This is where the two trees differ most, and deliberately: the structural tree has no _k and no DynamicModule -- those are the realization of "a control binds a variable", and they belong to the renderer, not to the expression. An author writing markup by hand writes the realized form (as the playground pages do); an author writing an expression never sees it.

5. Manipulate(Plot(Sin(a x), (x, 0, 10)), (a, 1, 5))

Realized (as today):

h("notatio-manipulate", { params: "{a, 1, 5}" }, [
  h("notatio-plot", { value: "Sin(_a * x)", var: "x", domain: "0,10" }),
]);

The same shape as §4 with Manipulate playing DynamicModule: the trailing tuples are the declarations, the body is the child, the body's a is _a. A Manipulate is a scope with a control strip; the general form in §4 is the scope without the strip.

6. Data: [1, 2, 3] and Histogram([1, 2, 2, 3])

Structural: h("notatio-list", {}, ["1 2 3"]).

Realized for the bare list: the same, typeset as [1, 2, 3]. Realized for the histogram: h("notatio-chart", { type: "histogram", data: "[1,2,2,3]" }) -- the data is lowered into one JSON prop, since the chart's API takes it that way.

Three things the examples raised

_k is Epsil, not LaTeX and not a regex. <Dynamic value="_k ^ 2" /> is right as written: the attribute is an expression written in Epsil, and _k is Epsil's wildcard -- compute-engine's slot notation, the same _ the pattern matcher uses. LaTeX is what goes in $…$ islands (value="$\sin(kx)$"), and a wildcard can sit inside one too. The renderer writes _k where a control declares k; an author writes it by hand. What the rule does insist on is that anything LaTeX be fenced.

Atoms take value, and so does everything else. <Integer value="2" /> rather than <Integer>2</Integer> -- value is the text the symbol's constructor takes: an atom's literal, and for any other head the whole expression as notatio. Children are the other spelling, the argument form. Both are accepted by every generic element, and value is what a scope reads and writes, so it is also how a generic element becomes a template: the outermost publishes its children's expression as value, and a _k in it follows a knob. This generalises to any carrier with a text representation it can serialise.

The <DynamicModule> is for isolation, not for binding. The page is a scope: a control and a readout with no wrapper find each other through it, and a page assembled by a framework binds as it mounts. A dynamic module (or a Manipulate) is an explicit scope over its subtree, for the case that matters on a page of examples -- two of them each calling their knob n. renderingOf still wraps an expression that declares controls in one, since an expression is self-contained by intent; hand-written markup needs none.

Where the lowering lives

Only in the elements. A host that renders structuralOf (toVNode over any h) does nothing else: heads to tags, arguments to children, options to props. A <Plot> that reaches the DOM with a <Sin> inside it is lowered by <notatio-plot> when it adopts its children (structure.ts), and a <Dynamic> beside a <Slider> reads the slider's name as a wildcard through the same step. The frameworks know no component's API, and adding one is a change to one table in the base.

What the examples say

  1. Children are arguments, props are options -- or, for a fixed signature, named arguments. The structural tree carries an option as a prop, an argument as a child (or an atom's value); a component's own API only appears when the element lowers. Every other attribute on a generic element is an option.
  2. The lowering is one table, read from two sides. renderingOf applies it to an AST; a component reading its own children applies it to markup. Neither needs to know about the other.
  3. Scopes are realization, not structure. DynamicModule and _k never appear in an expression; the renderer adds them where a control declares a variable.
  4. Atoms are tokens in a parent's text, generated or hand-written. 2 inside a parent's argument text is what a person types and what structuralOf writes; a leaf tag (<notatio-integer value="2" />) only stands for a whole expression that is an atom.
  5. Only the outermost generic element typesets. Inner ones are structure with display: contents; they exist so the tree is addressable, not so each draws.

The packages

Two, split along the dependency that matters:

package what depends on
@enumeratio/frontend the base: symbols.ts (head → tag, the lowering), vdom.ts, the control contract, the arithmetic of scrubbing and playback, the pure SVG renderers, the shared engine compute-engine, formats
@enumeratio/components every notatio-* element, the DOM half of the contract (define.ts), structural adoption, the frame loops, bindings, styles, popovers, MathLive, xterm frontend, lit, mathlive

The base has no UI framework and no DOM at import; the CLI draws through it in Node. It had Vue and React glue (./vue, ./react: <Notatio expr> and generated per-symbol wrappers) until #439 removed it: the elements are the component form, and the markup is framework-free. The lit package stays separate because MathLive and xterm are heavy and DOM-only, and a Node consumer must not install them.

Where it is

  • vdom.ts in the base: structuralOf(expr) for the verbatim tree and toVNode(h) over it, for any framework's h.
  • markup.ts in formats: the tree written as FullForm in JSX, readMarkup / markupOf, engine-free (Speculative Vdom Markup).
  • options.ts in formats: optionsOf / withOptions; lowerOptions and VisualSymbol.options in the base's symbols.ts; primitives.ts for what Epilog carries.
  • Generic elements in components (generic.ts): one class per head in the base's HEADS (head-names.ts: every head in the symbol manifest, Manifest, and the drawing heads, with PARAMS for the fixed signatures), registered at its tag unless a hand-written element owns it; expression from value, the children or the named attributes; only the outermost typesets. structure.ts adopts the same spelling on the hand-written components.
  • The page scope (scope.ts): <notatio-dynamic-module> and the page share one Scope; the page's re-reads merge, since an applied template has its result where its wildcard was.

Home

Project

Design: every design page, settled, speculative and archived

Clone this wiki locally