TinyVolt
Your markdown editor for STEM.
What is Nova?
Nova is a markdown editor focused on STEM. Powered by a declarative graphics DSL, native autodiff and reactivity, it turns a static markdown into an interactive canvas - including LaTeX strings and even the markdown text itself.
Editor: A VS Code-style editor where one can write and preview content locally.
Platform: The invisible scaffold that handles publishing, hosting and serving. The same article lives in two places:
- Written & stored on GitHub as markdown, e.g. vinsis/test-pygeomatic/articles/article1.md
- Published on Nova as an interactive page at /nova/vinsis/test-pygeomatic/article1
The authoring format
Code in a ```pygeomatic block runs without the reader seeing it. If you want the reader to set something off themselves, give those lines a name with with group("name"):, then write {label}(ref:name) in your text. That becomes a link. The reader clicks it, and those lines run. For a single command you don't need a group: put the command inside the link itself: {set the scale}(scale = gm.scalar(1)). Everything else is normal markdown, math included.
# Drawing a walk ```pygeomatic origin = gm.p0 a = gm.point(3, 0) walk = gm.line(origin, a) gm.hide(walk) with group("walk-x"): gm.highlight(walk) gm.show(walk) ``` Reach the point by {moving a distance}(ref:walk-x) of $3$ units. Or reset it inline: {set scale to 1}(scale = gm.scalar(1)).
Binding LaTeX to live nodes
Give a formula an id (a %id: line inside the $$…$$ block), reach it with gm.tex("id"), then attach a store node. Whatever drives the node reflows the formula. Two modes:
bind - show a node's value in a slot
- A number inside the formula tracks a node (integral limit, fraction numerator).
- Address the slot as
tex.family.slot(e.g.int.upper,frac.num) and call.bind(node).
$$ %id:energy \int_{a}^{b} x^2 \, dx $$ ```pygeomatic b = gm.scalar(3, out="b") energy = gm.tex("energy") # matches %id:energy energy.int.upper.bind(b) # upper limit now shows b ``` Change it: {b = 5}(b = gm.scalar(5))
highlight - paint matrix cells by position
- Paint a row, column, diagonal, or region of a matrix.
- Build a selector over each cell's grid position; it moves as its node changes.
$$ %id:M \begin{pmatrix} a & b & c \\ d & e & f \\ g & h & i \end{pmatrix} $$ ```pygeomatic r = gm.scalar(0, out="r") M = gm.tex("M") M.highlight(M.rows() == r, color="pink") # row r M.triu().highlight(color="blue") # upper triangle ``` Move it: {row 1}(r = gm.scalar(1)) · {row 2}(r = gm.scalar(2))
- Boxes:
M[3:, 4:]. Regions:M.diag()/M.triu()/M.tril(). - Arithmetic
cols - rows > 0, combine with&/|, gate with.scale(node).
Controls your reader can move
A slider is just a number the reader can change. Make one with gm.ui.slider, then put it in a sentence using an f-string. You need gm.md(…) to write that sentence, because a ```pygeomatic block produces no text on its own, and an f-string only works inside python.
Moving the slider doesn't re-run your code. It changes the number, and anything built from that number updates on its own: the drawing, and any formula you bound to it above.
```pygeomatic r = gm.ui.slider(1, 5, step=0.5, value=3, label="radius") c = gm.circle(gm.p0, r) # r is an ordinary Scalar gm.md(f"Drag to resize: {r}") # the slider appears here ```
r is an ordinary number - exactly what gm.scalar(3) would have given you. So r * 2, gm.circle(gm.p0, r) and .bind(r) all work as usual.
The six controls
gm.ui.slider(start, stop, step=, value=, label=, show_value=)→ a number, for sliding through a range.gm.ui.number(start=, stop=, value=, step=, label=)→ a number, for typing an exact one. Start and stop are optional.gm.ui.checkbox(value, label=)→ true or false.gm.ui.dropdown(options, value=, label=)→ the chosen option; text options give text, number options give a number.gm.ui.radio(options, value=, label=)→ the same as a dropdown, but every option is on screen.gm.ui.text(value, label=, placeholder=)→ whatever the reader types.
Give each control its own name. {r} looks up the control by its name, so two controls sharing one name would be ambiguous.
Arranging controls into a panel
One control sits in a sentence. Several need arranging, so put them in a with gm.ui.col(): (stacked) or gm.ui.row() (side by side). Whatever you build appears where the block sits. You don't need gm.md for it.
```pygeomatic with gm.onpageload(): with gm.ui.col(gap=2): gm.ui.label("Radius", width="12ch") r = gm.ui.slider(1, 5, value=3, grow=1) with gm.ui.row(gap=1): with gm.ui.button("reset"): # runs when pressed r = gm.scalar(3, out="r") with when(gm.cond.ge(r, 4)): gm.ui.label("large") gm.circle(gm.p0, r) ```
What you can put in one
- Arranging:
gm.ui.col,gm.ui.row(both takegapandalign), andgm.ui.boxfor a bordered or shaded container. - Showing:
gm.ui.label("radius = ${r}")- plain text, where${r}shows a live value. Andgm.ui.math("x^2")for a formula. - The six controls, exactly as before.
- Pressing:
with gm.ui.button("reset"):- the commands inside run every time the reader presses it. Unlike a link in the text, which runs once in reading order. - Hiding:
with when(…)works here too, and hides the elements inside it.
Sizing
Every one of them takes the same four: width, height, grow and pad. A width is either a number (a step on the site's spacing scale), "fill", or a length in px, ch, % or rem. There is no free-form CSS, on purpose - it keeps every article looking like the site.
grow=1 means "take the leftover space". "12ch" means "twelve characters wide", which is usually what you want for a label.
Making several of something
There is no special repeat block. A normal python loop over an gm.array writes out the rows, and you can hide the spare ones behind a count the reader controls:
```pygeomatic n = gm.ui.number(0, 3, value=2, label="rows") with gm.ui.col(): for i in range(3): with when(gm.cond.lt(i, n)): gm.ui.label(f"row {i}") ```
You can also let the reader pick an item: arr[k] works when k is a slider, so one row shows whichever item they choose. What you can't do is let them add rows you didn't write - the number of rows is fixed when the article is built.
Two things to watch
- A control goes in one place. Once it's inside a panel,
{r}in your text no longer puts it in the sentence - the panel is where it lives. - A panel can only read values your article makes. If it reads something nothing defines, the compile fails and tells you which name. That includes values only a button makes: those don't exist until it's pressed.
Text that appears on demand
with when(…) hides some text until a condition is true. The condition is checked as the reader moves things, so ticking a box or dragging a slider makes text appear and disappear. There is nothing to click.
```pygeomatic show = gm.ui.checkbox(False, label="Show the proof") gm.md(f"{show}") with when(show): # a Bool node on its own gm.md("Because $ab=ba$, the map commutes.") with when(gm.cond.ge(r, 4)): # or a comparison gm.md("**Large:** the radius is 4 or more.") ```
Writing the condition
- A tick box on its own is true when it is ticked. A number on its own is true when it isn't zero.
- To compare, use
gm.cond.ge / gt / le / lt / eq / ne. Text can only useeqandne. - To join conditions, use
&,|and~, orgm.cond.all_(…)andgm.cond.any_(…).
Write gm.cond.ge(r, 4), not r >= 4. In geomatic, r >= 4 already means something else - it builds a new value. The two are kept apart on purpose.
Two things to watch
- Don't put clickable links inside a
whenblock. Links run in the order they appear on the page, and hiding one doesn't take it out of that order - so the reader gets stuck waiting to click something they can't see. Text, math and controls are all fine. - Drawing still happens. Only the text is hidden. Anything you draw inside the block still shows up, so hiding text never quietly changes the picture.
Shapes the reader can click
with gm.ui.onclick(node): attaches pygeomatic commands to a shape drawn in the canvas. The reader clicks it on the canvas and those commands run.
```pygeomatic label = gm.annotate_text_box("where does it land?", 2, 3) with gm.ui.onclick(label): # runs when the reader clicks the box p = gm.point(2, 3) far = gm.gt(gm.distance(p, gm.p0), 1) with when(far): gm.md("The point landed outside the unit circle.") ```
The one rule
One can define a new node or redefine / overwrite an existing one.
What a handler won't take
- A target that isn't drawn: a number, a true/false, a piece of text. There is nothing on screen to click.
- An empty block, or one block inside another.
- A control (
gm.ui.sliderand friends). The control has to exist before the reader touches anything. - A
group(…), orgm.md(…). Prose is written once, when the article is built; gate it withwheninstead.
Handlers live with the published article, not with the commands. Copying a command list into the editor carries the drawing but not its clicks.
What the page starts with
Everything above waits for a click. with gm.onpageload(): doesn't: those commands run the moment the page loads, before the reader touches anything. Use it for the picture you want the article to open on, and for controls - a slider is on screen straight away, so the number behind it has to be there too.
```pygeomatic with gm.onpageload(): # runs before any link r = gm.ui.slider(1, 5, step=0.5, value=3, label="radius") gm.circle(gm.p0, r) gm.md(f"Drag to resize: {r}") ```
The rest of the article can build on what the block made - here, later commands can move or recolour that circle. A handler works the other way round, because its commands haven't run yet.
Three rules
- It goes in a
```pygeomaticblock, like everything else. - One per article. Put everything the page starts with in it.
- It must be in the first block, before anything else is drawn. The block runs ahead of every link, so nothing may come before it.
Start over brings it back. Clearing the canvas - the Start over button, or a \clear of your own - rebuilds the block first, so the reader lands back on the page as they first found it.
Something clickable from the start
Put a with gm.ui.onclick(…) inside the block and the shape is on the canvas, waiting to be clicked, before the reader does anything. The shape is built at load; what the click does still waits for the click.
```pygeomatic with gm.onpageload(): box = gm.annotate_text_box("what happens here?", 2, 3) with gm.ui.onclick(box): # still waits for the click p = gm.point(2, 3) ```