cd /news/developer-tools/svg-sketch-source · home topics developer-tools article
[ARTICLE · art-76009] src=tomlarkworthy.github.io ↗ pub= topic=developer-tools verified=true sentiment=· neutral

SVG Sketch Source

A new SVG editor, built on bidirectional lens theory from Foster et al. 2007 and Hofmann et al. 2012, synchronizes code and graphical UI in real time, preserving unknown semantics like SVG animations without parsing. Developed by Tom Larkworthy, the tool uses a novel prompting strategy that requests an LLM to apply property checking against mathematical laws, achieving strong implementation consistency across an expansive domain.

read31 min views1 publishedJul 27, 2026

The drawing syncs to the code and vice versa. Switch between using code and UI. Built on composable lens [Foster et al. 2007] and edit-lens [Hofmann et al. 2012] theory, but applied in the string domain so unknown semantics like SVG animations are preserved without parsing. Follows the path of Sketch-n-Sketch [Chugh et al. 2016] [Hempel et al. 2019] but applied to an existing image format: SVG. Generated with a novel prompting strategy of requesting an LLM to use property checking against mathematical laws as a architecture, leading to strong implementation consistency over an expansive domain.

gesture what it does lens
drag a shape moves it transform
tap a polygon or path, drag a handle moves one vertex or anchor points , path d
tap anything else, drag a corner or the rotate handle scale or rotate from the box transform
drag the pivot dot moves the point the box scales and rotates around transform
double-click an edge / a vertex / empty canvas add a vertex / remove one / drop in a shape children
tap again in the same place steps down through stacked shapes
shift-tap, or rubber-band adds to the selection

A selection also carries chips: the same commands, one tap away, drawn only when they apply:

chip chip
duplicate
smooth ↔ corner the anchor
swap fill and stroke
give it a gradient (minted into defs )
≡ (drag)
stroke width
put an arrowhead on it
⊚ / ◠
close / open the path

The panel under the drawing edits fill

, stroke

, width, opacity and the dash as typed inputs; each change commits through the same write path, so the source keeps the notation you wrote.

key
R E L
drag out a rect, an ellipse, a line
P
place path anchors; click the first again to close, double-click to finish open
S
scribble a freehand path; it is fitted to Béziers on release
V / Esc
back to selecting (a finished tool returns by itself)
⌘G / ⇧⌘G
group / ungroup the selection
⌘D
duplicate
⌘C ⌘X ⌘V / ⇧⌘V
copy, cut, paste / paste in place
⌘A
select all
1 / 2
reset the view / fit it to the selection
[ ] / { }
lower, raise / send to back, front
Delete
removes the selection
⌘Z / ⇧⌘Z
undo, redo (byte for byte), and declines if something else wrote to the cell first
arrows, shift+arrows
nudge by one unit, by ten
alt while dragging
ignore snapping
import {svgLens} from "@tomlarkworthy/svg-lens"
viewof picture = svgLens(svg`<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="20" fill="tomato"/>
</svg>`)

What follows is the reasoning behind the editor above: what a lens is, which laws it keeps, how we combine to cover a complex domain like SVG, how the maths of sync is kept apart from the UX, and additionally how to apply when the drawing is itself a parameterized function.

You can create computer graphics by typing code or by a graphical user interface, each has advantages in certain situations. If we can switch between these two modes fluidly, bidirectional, the advantage of each can be leveraged at that moment in time and the combined sum is greater than the parts in isolation. Keeping both means keeping them in step, which is the view-update problem [Bancilhon & Spyratos 1981] recast for programs [Chugh et al. 2016]. Feedback in the other direction, from program to picture, is the older half of the problem and has a standard vocabulary [Tanimoto 1990]; what is at issue here is the return path.

Propagation is the hard part. Sketch-n-Sketch infers program changes from output changes by tracing values back to the expressions that produced them [Chugh et al. 2016] [Hempel et al. 2019], which works for programs the tracer understands.

This notebook takes a different approach. The update path is a composition of bidirectional lenses [Foster et al. 2007], which gives it laws, a stated domain, and property tests ( §2.1 The laws).

What is editable is a drawing whose shape is written literally; interpolated values are rendered but left to their source ( §7 Interpolation and holes).

A lens between a source type S and a view type A is a pair of functions [Foster et al. 2007]:

and that is exactly the chain a drag runs down: cell source → template literal → attribute string → typed value, then back up again. Composition of well-behaved lenses is well-behaved, so the laws are established once, per level, and hold for the composite.

Two laws make a lens well-behaved [Foster et al. 2007]:

GetPut (left) says a null edit is a null write: drag a shape back to where it started and the cell is byte-identical to before, not merely equivalent. PutGet (right) says the view you asked for is the view you get, so a drag of 40 pixels moves the shape 40 pixels and the number in the source agrees.

A third law, PutPut, makes a lens very well-behaved:

That is, the last write wins and intermediate ones leave no trace. Most of these lenses keep it; some deliberately do not ( §2.2 Residue, and why PutPut bends).

SVG's syntax is not canonical. "0,0 100 100"

and "0 0 100 100"

are the same rectangle; rotate(45)

and matrix(0.707 0.707 -0.707 0.707 0 0)

are the same transform. So get is not injective: a whole fibre of source strings maps to one view.

Any put has to pick a representative from that fibre. Reprinting canonically is the obvious choice, and it reformats the file every time a shape is nudged. The rule used here instead:

Skip rule.If a=get(s), return s unchanged.

This implements GetPut rather than merely satisfying it, and it is what keeps comments, spacing and rotate(45)

intact across an edit. Everything the view does not determine is residue, and residue survives.

It costs strict PutPut. Take a2=get(s) and a1=get(s), with s written non-canonically. On the left the first put reprints, the second skips, and the non-canonical spelling is gone; on the right the single put skips and the spelling survives, giving two different strings that encode the same drawing. What holds is PutPut up to observation:

and strict PutPut holds whenever a2=get(s). This is a deliberate trade rather than a defect: canonical printing would restore the law and remove the property that makes the editor usable on source a human wrote. Weakening a law to a quotient is the usual move when residue matters [Czarnecki et al. 2009]. test_putput_skip_rule_corner

in the appendix targets this case directly, since random generators are unlikely to reach it.

Two things make SVG harder than the string examples in [Foster et al. 2007]: the source is nested, and every address is relative to a source that editing can change.

Notation. Composition is written in diagrammatic order throughout: ℓ1∘ℓ2 applies ℓ1 first, so the outer lens is on the left. This is the reverse of the usual convention for functions, and matches the direction a gesture travels.

The tower. Four types and three lenses:

Function.prototype.toString()

; Doc is the bytes of the svg

template literal inside it; Attr is one attribute value; A is what the gesture actually manipulates: a number, a point list, an affine matrix, a list of path operations. A drag of a shape is the composite ℓlit∘ℓattr(p,transform)∘ℓtransform:Src⇀R6 and structural editing branches off one level up, at ℓkids(p)=childrenLens(p):Doc⇀Elem∗, whose view is the list of child source slices, so that every child carries its own residue and only the children that changed are reprinted.

Addressing is dependent, and that is the interesting part. For a document s let Path(s) be the set of addresses of its elements. ℓattr is not a lens but a family indexed by that set, and the set depends on the source:

The two kinds of put differ exactly here:

Path(putℓattr(p,n)(a,s))Path(putℓkids(p)(k,s))=Path(s)=Path(s)in generalAn attribute edit preserves the address space; a structural edit does not. That one inequality is the reason the editor holds a selection as a path and re-resolves it after every put instead of caching a node, and the reason handles are refreshed after an attribute edit but cleared after a structural one.

Partiality is part of the type. ℓlit refuses text that would not survive re-entry into the template literal; ℓkids refuses a string that is not exactly one element. So put is partial, put:A×S⇀S, and composition intersects the domains:

The editor's contract is that this partiality is visible: a gesture whose put is undefined is declined before it previews, and the handle is drawn locked with the reason ( §7 Interpolation and holes). Refusing is the domain being honest, not a failure.

Composition preserves the laws. If ℓ1 and ℓ2 are well-behaved on their domains then so is ℓ1∘ℓ2. GetPut:

using GetPut of ℓ2 then of ℓ1; PutGet is the same argument read downwards. This is why the laws are established once per level and never re-argued for the chain. It is also why a new microsyntax lens is a self-contained obligation: prove it at its own level and the tower is unaffected.

The weakened form composes too. Each level keeps PutPut only up to observation ( §2.2 Residue, and why PutPut bends), and since (ℓ1∘ℓ2).get=ℓ2.get∘ℓ1.get, the composite's quotient is exactly the composite's own

get

, so "up to observation" at each level gives "up to observation" of the whole chain, with no further loss.One rule separates the maths from the pointer:

Tools emit commands, commands are lens puts, one writer applies them.

A tool is a pointer state machine (press, move, release). It may draw a preview into an overlay layer and mutate the live DOM; it may not persist anything. What it produces at the end of a gesture is a command: a pure function from the document text to new document text, or an attribute value to write at an address. The writer is the only code in the notebook that redefines a cell.

The layers, what lives in each, and what each is allowed to know are shown in the figure below; it is itself an svgLens

drawing, so the stack can be edited by the editor it describes. Nothing below L4 touches the DOM or the pointer, so L0–L3 is testable outside of a browser, and is ( §2.1 The laws). Two consequences worth stating:

The source is the truth; the DOM is a projection. [Larkworthy 2026] calls the general arrangement source-last: the live runtime is canonical and text is recovered from it on demand, and this editor applies it one level down, to the bytes inside one cell. A gesture is therefore an ordinary edit; what that buys, and why the resulting feedback loop terminates, is §4.1 Propagation, and why the loop closes.

The browser stays the authority on geometry. Hit testing is elementsFromPoint

, bounding boxes are getBBox

, screen-to-user conversion is getScreenCTM

. The lenses never re-implement SVG semantics; they only rewrite the text that produced them.

Commands are endomorphisms of the source. Currying a put gives

so every lens at every level of §3 The SVG tower: addressing and nesting induces a source-to-source function, and the command layer needs no vocabulary of its own: insert, delete, reorder and the gizmo operations are all of this shape. Commands compose by ordinary function composition, and the skip rule says a null edit is the identity of that monoid:

This has an operational consequence, not just an algebraic one: the writer compares the produced source with the old one and returns before touching the runtime when they are equal, so a gesture that ends where it started causes no recompute at all.

Disjoint addresses commute. Dragging n selected shapes is n commands, one per address, and for p, q neither a prefix of the other

because an attribute put splices a byte range inside its own element and leaves the element list (and therefore every other address) untouched. So "one put per shape" needs no ordering discipline, and test_commands_commute

in the appendix checks it on random documents. Structural puts are excluded from the claim, and genuinely do not commute: they renumber the addresses the other put is holding.

Tools are folds over pointer events. A tool is a state machine

where δ may draw into the overlay and mutate the live DOM, and ε is consulted once, at release. The preview is a projection that is thrown away; the command is the only thing that survives the gesture. Tools sit in a priority list and the first whose hit test claims the pointer owns the gesture, so adding a tool is adding an element to a list; it cannot interfere with the levels below because nothing in Q can reach S.

Propagation is the runtime's, not ours. The writer applies the command and installs the result with Variable.define

:

Nothing in the editor notifies anything. editor-5

's buffer, the change history and every dependent cell update because the runtime recomputed a variable, which is what it does whenever a definition changes.

Why the loop closes in one step. The editor is the value of the cell it rewrites, so a gesture edits its own producer: the drawing is downstream of the gesture that changed it. That is a feedback loop, and it terminates for a reason worth naming: PutGet. The recomputed view is already the view the gesture asked for,

so the new node has nothing left to write and emits no second command. One gesture, one command, one recompute. Had the design gone the other way, mutating the DOM and deriving the source from it, the loop would need a fixed point and an argument that it converges. Here the law does that work.

The smallest useful lens: one attribute of one shape. The triangle's three corners live in a single points

attribute; the rest of the source (a comment, the fill) must come through every edit untouched.

attrTextLens(i, name)

views the text of one attribute of element i; parsePoints

views the numbers inside that text and puts them back into the same slots. Composing them gives a lens from the whole document to the corner coordinates, and that composite is what a drag on a vertex writes through.

Click the triangle, then drag a corner. The readout below reports the put that just happened: which attribute changed, and its value before and after. Two things hold, computed on each drag rather than asserted:

points

attribute is written, and only the corner you grabbed changes value; the fill, the comment and everything else in the source are left exactly as typed, because put splices the new value into the existing string instead of reprinting the whole element;Attributes are the easy half. The structural lens is harder, because inserting an element is where a naive implementation reformats the document.

childrenLens(p):Document⇄String∗The view is a list of child source strings, not parsed nodes. With strings, insert

, delete

and reorder

are splice

on an array, and a moved child carries its own bytes with it: attribute order, inner spacing, its own comments. With parsed nodes the commands would be tidier and every structural edit would reprint half the file.

The view does not contain the text between children: newlines, indentation, comments sitting in the gaps. That is residue again, and put

has to re-thread it: each surviving gap stays with its neighbour, and a genuinely new child gets only the indentation of the gap it lands in. An early version gave a new child the whole gap, so every insert duplicated the comment above the first child; four inserts produced five copies. The regression test is in the appendix, and the button below runs the same operation.

The commands are pure functions of text, with no DOM and no pointer, so the same code runs under Node in CI and under a finger here ( §4 Tools, commands, one writer). Working at the granularity of operations rather than states is the move edit lenses make

Use [

and ]

to move the elements forwards and backwards in the child list

Everything so far assumed the drawing is written out literally. A drawing may also interpolate values from elsewhere: transform="translate(${shift} 0)"

reads shift

from a slider. The editor tolerates interpolation. The drawing renders, and the literal text around a hole stays editable; what the editor will not do is write back into a hole.

The source text of an attribute is cut into slots. Each slot is either a literal run of digits or an interpolation ${...}

, and only the first kind is somewhere a gesture can write:

slot can a gesture write it?
literal digits yes: spliced in place, exactly as in
§5 A first lens: one attribute of one shape

${...}

A gesture that touches only literal slots commits as usual. A gesture that would have to change a hole is declined: any literal slots it also moved are written, the hole is left byte for byte, and the readout reports the mix. When the whole value is one hole there is nothing to write, so the gesture is refused outright.

Refusing is a guarantee, not effort withheld. A hole could in principle be inverted: resolve ${shift}

to the slider and set its value, or solve ${Math.sin(t)}

numerically. Inversion by reflection into an upstream view was tried and removed. It is fragile, it rarely holds, and its result is indistinguishable on screen from an exact edit until it silently drifts. Declining instead keeps every law in §2.1 The laws true of every accepted gesture, and the handle says which case it is in. The term

Three rectangles, two sliders. The first is written literally, so a drag rewrites this cell's source and the box moves. The second carries transform="translate(${shift} 0)"

: move the slider and it follows, because the drawing interpolates the value; but drag the box and the editor declines, because the number lives in the hole and this editor does not write back into an interpolation. The third carries rotate(${spin} 32 100)

and behaves the same way for rotation.

Interpolation is tolerated, not inverted: the slider feeds the drawing, and a gesture that lands on a hole is refused rather than guessed. Watch the table underneath: every gesture reports whether it wrote literal source or hit a locked hole.

The lenses so far answer one question: given a new view, what source produces it. A tool asks something narrower and carries a memory with it: given a pointer delta and the context a gesture accumulates, what edit does the source get? That memory is what a state-based lens lacks, and naming it as one value is what lets a single law describe every tool's edit instead of one law per tool.

The generalisation is already in the literature the paper cites. Where a state-based lens maps states to states, an edit lens [Hofmann et al. 2012] maps edits to edits through a complement (a memory the translation is allowed to keep), and [Johnson & Rosebrugh 2016] axiomatises it alongside the asymmetric delta lens of [Diskin et al. 2011]. The two frameworks do two jobs here. The delta lens is the shape of the writer: the source is authoritative, the drawing is derived. The edit lens is the shape of a tool, because what a tool has that a state-based lens lacks is exactly a complement. The dictionary is one line. The complement is that memory:

edit lens svg-lens
X, MX the cell source; the edits a command can make to it
Y, MY the rendered drawing; pointer deltas
C ctx.state.drag : the origin, the grabbed targets, the hit list, the snap boxes
q:MY×C⇀MX×C the tool
K "the DOM on screen is what this source renders to"

Making the delta a value is the whole of the framework. A tool returns a gestureDelta

(one of attr

, command

, select

, view

, clip

), and three functions consume it: previewDelta

paints the live DOM, commitDelta

writes the source once, revertDelta

throws the preview away. One write path, so every law in §8.1 The laws a tool owes is stated about the delta and checked in one place rather than re-proved per tool. It is also what lets the registry stay

svgLens(node)

uses the default tools, svgLens(node, { tools: [toolVertex] })

pins a vertex-only editor, and svgLens(node, { tools: (d) => [myTool, ...d] })

extends without restating the defaults: a test runs one tool in isolation and a figure shows a reduced editor by the same mechanism.Five laws come straight from the axioms [Johnson & Rosebrugh 2016]. The null gesture is the sharpest.

law what it says here what it rules out
T1
p(1,c)=(1,c) a null gesture writes nothing and returns its memory untouched
a click that grabs nothing creating a shape, or a gesture losing the context it holds
T2
p(mm′,c)=(nn′,c′′) translating a composite gesture equals composing the translations float drift — two gestures ending at the same point committing different bytes
T3
PutGet the gesture you made is the gesture the committed source shows a preview that looks right on screen but not once the source is reloaded
T4
PutInc a gesture commits against the state it began from
a commit that mints a new node letting a follow-up write land on a stale document
T5
consistency if the DOM agreed with the source before, it agrees after makes T3's screen-level check a consequence, not a separate assertion

Partiality is the formal content of "a tool must decline cleanly." A gesture outside a lens's domain is undefined, so its translation is undefined too: the action is partial. onPointerDown

returning false

is that partiality — a tool with nothing to say about a press declines it rather than falling through to drop a shape on an empty click, which is why an empty-canvas tool gates on ctx.pick

first. The same partiality runs through every surface that reaches the DOM: a command declines by returning null

from its plan, and an affordance chip greys out exactly when its command declines. One notion of "not applicable here" spans pointer, command and chrome.

Six more laws are ours, not the papers':

false

has changed nothing, and installing it cannot change what earlier tools do. Registry order is priority, so tool sets form a non-commutative monoid under concatenation. This is the law that makes a tool a rebase(path, cmd)

equals re-locating the same element after the command runs: operational transformation's TP1, one-sided.ctx.pick

, so they cannot drift apart. It also pins the group policy: a click takes the outermost unopened container, a double-click descends a level, Escape ascends.ctx

and getScreenCTM

already carries the viewBox, with alignment snapping off. (Snapping is magnetism measured in Three things that looked like special cases are textbook constructions with textbook laws: the shape registry is a family of prisms (tag dispatch is a sum type; preview∘review=Just is what each rect

/circle

/path

entry owes), hit-testing is an affine (a focus that may fail, whose law is that a failed get makes put a no-op, T1 again), and multi-selection is a traversal (so "set the fill on the selection" is traversal ∘ lens, and align, distribute and group-edit come with it). All of them run live: test_gesture_*

and gestureLaws

check the laws above over a random corpus of gestures, on the same forAll

harness the lens laws in §2.1 The laws use.

Bidirectional programming. The lens formulation, the laws and the well-behaved / very well-behaved distinction are Foster et al.'s [Foster et al. 2007], addressing the view-update problem posed first for databases [Bancilhon & Spyratos 1981]. [Czarnecki et al. 2009] survey how many fields arrived at the same structure independently. What differs here is the source: [Foster et al. 2007] lens trees and strings, this notebook lenses the executing function's own text, so the residue that must survive is the author's formatting and put

splices into bytes that are also a running program.

Sketch-n-Sketch. The closest system [Chugh et al. 2016] [Hempel & Chugh 2016] [Hempel et al. 2019]: an SVG editor where you write a program, manipulate the output, and the system updates the program. It is considerably more expressive, handling abstraction, loops and recursion via trace-based provenance and later output-directed synthesis [Hempel et al. 2019], and it can produce a program the user never typed. Three differences, none a claim of superiority:

Lenses in practice. Cambria applies lenses to schema evolution in local-first documents [Litt et al. 2020]; edit lenses [Hofmann et al. 2012] shift the currency from states to edits, which is the same instinct as our command layer, though we implement it concretely as splices on child source strings ( §6 The structural lens).

The host this depends on. The editor relies on the system it runs in being source-last [Larkworthy 2026]: Lopecode keeps no canonical saved file, treats the live runtime as the source of truth, and recovers each cell's text on demand from Function.prototype.toString()

. There is no file on a server that the definition is a copy of, and no save step for an edit to fall out of. Read the other way, this notebook is a test of that paper's claim that a plurality of editing surfaces follows from runtime primacy: editor-5

edits a cell as text, editable-md

as prose, sticky

edits a literal by manipulation, and svgLens

edits one as a drawing. None is privileged, and since all four go through Variable.define

( §4 Tools, commands, one writer), each one's edits are visible to the others.

Notebooks and liveness. Liveness has a standard scale [Tanimoto 1990], but it measures feedback from program to output; the quality at issue here is the reverse. [Horowitz & Heer 2023] name persistence as what separates a rich widget from a programming system: interactions with a rendered tool "cannot be 'saved' back to the notebook". This is one answer for one domain: the widget's output is the cell, so there is nothing to save back. [Edwards et al. 2019] ask for costs to be reported alongside benefits, which is what §10 Future work is for.

The literal lens is not specific to SVG. literalLens

finds a tagged template in a cell's own definition and lenses its text; everything above it is SVG-specific, everything below is not. The same primitive points at markdown in an md

cell, CSS in a css

cell, or JSON in a data cell: a click-to-edit prose editor and a colour picker that rewrites a stylesheet differ only in the microsyntax layer. This is the generalization worth doing next.

Writing through holes. Today a hole is tolerated and locked ( §7 Interpolation and holes). Two ways to make one writable are visible from here, both deliberately out of scope: resolving a hole to another cell's literal, which makes the lens target the dataflow graph rather than one cell, and numerical inversion for holes feeding arithmetic, the approximate solve

@tomlarkworthy/manipulate

already implements. Reflection into an upstream view was tried and removed for being fragile; either replacement would have to keep reporting that its guarantee is weaker than an exact edit's.Structure the domain refuses. An interpolation in element position is out of scope, because document-order indices would stop matching the DOM. Addressing children by identity rather than position would lift the restriction, at the cost of putting ids into a drawing the author is writing.

What it costs. Reported in the manner [Edwards et al. 2019] argue for: the editor reaches only drawings written literally; the parser is a deliberate subset of XML (no CDATA, no entity decoding, no nested <svg>

); PutPut holds only up to observation ( §2.2 Residue, and why PutPut bends); undo declines rather than merging when another writer has touched the cell; each gesture commits once, so dragging a 200-element selection is 200 puts; and since a commit recomputes the cell (

The tests come first because they are the specification: every law quoted above is a cell below, run on each load with a seeded generator. After them, the implementation in dependency order: lens core, SVG microsyntax, source lenses, commands, then the editor.

Deterministic replacement of fast-check: a seeded PRNG (mulberry32

), domain generators (arb

), and forAll

which throws the counterexample on failure. Fixed seeds keep every test cell rerunnable with identical results. arb.anyFinite

draws random 64-bit patterns, so the exact round-trip claims are exercised across the full double range, subnormals and -0 included.

Each test_*

cell property-checks one lens (or one exactness claim) with a fixed seed. The dashboard near the top of the notebook aggregates them.

All printing uses String(n)

, all parsing uses Number(s)

. Lenses are total on their stated domains and throw outside them (parseable strings, nodes carrying the focused attribute/child, matrices with det ≠ 0, path data with separated numbers, with no 10-5

abutment or compressed arc flags).

Affine matrices as a flat [a,b,c,d,e,f]

, their product, and the screen-to-user map (getScreenCTM

) that every measurement is taken through.

The transform

attribute as an editable list of operations (translate, rotate, scale) plus the gizmo that rotates or scales about a point held fixed.

The determinant, the matrix inverse and its involution law (a screen delta sent back through an element's own frame), and the small accessors that read an attribute or child out of a parsed node.

The d

attribute parsed into absolute commands and printed back: the lens the pen and vertex tools write a path through.

Everything above focuses inside an attribute. These focus outward, until the source is the JavaScript of the cell itself:

Lens Source View
literalLens(alias)
a cell definition's source text the template literal inside its alias(…) call
attrTextLens(i, name, dflt)
SVG document text element i 's attribute string (dflt when absent)
cellAttrLens(alias, i, name, dflt)
cell source that attribute, addressed from the cell

literalLens

locates the literal by parsing the definition with acorn and taking byte offsets, the same technique @tomlarkworthy/sticky

uses for its persistence slot. Its domain is definitions containing one alias(…)

call whose argument is a template literal with no ${}

interpolations, and views that contain no backtick, backslash or ${

(they would not survive re-parsing).

Composing outward from a typed view gives a lens from cell source straight to matrix:

compose(cellAttrLens("svgLens", 4, "transform", "matrix(1 0 0 1 0 0)"), transformLens)

That composite is what a drag commits, and test_cellSourceLens_laws

checks its laws.

A forgiving scanner over the SVG text, the tree it builds, and nodeAt

/ pathOfIndex

for addressing an element by a path that survives structural edits.

Reading and splicing a single attribute, the style=""

and unit-bearing-number lenses, setProperty

(which writes where the property already lives), and refsOf

for the url(#id)

a shape points at.

Paint and stroke properties as data, so the panel and the on-canvas chips read one list and adding a property is a line here, not a branch in a form.

The list of an element's children as a lawful view, and the pure document-to-document commands built on it: insert, delete, reorder, group, ungroup, the copy/paste codec, and minting into defs

.

Segments, subdivision and anchor removal for polygons and paths, and the address rebasing that keeps a vertex selected across an edit that changes the command list.

svgLens(node)

returns the node it was given, with pointer handling attached. During a gesture only the live DOM changes: no source is written, so dragging is as cheap as setAttribute

. On release the gesture commits once, through compose(cellAttrLens(…), transformLens)

, and the DOM then adopts the source's exact bytes: if you drag a shape back to where it started, the readable translate(228 128) rotate(-4)

is still there, because GetPut says so.

The new definition is built with realize

(which routes through importShim

on lopecode) and installed with Variable.define

, the same call editor-5

makes when you type in a cell.

Gestures call define

, the same kind of event as typing: the cell is invalidated, recomputes, and produces a new node.

That has a cost (the node the cell handed out is replaced on every commit) so state that must outlive a gesture cannot live in the node's closure. Undo history, the selection and the active tool are held in lensState

, keyed by the Variable

, which define

mutates in place. The selection is restored by path rather than index, which is why it is addressed that way.

Rendering evaluates the template rather than parsing its text, so an interpolated svg<circle r="${r}"/>``

renders the same way a literal one does; what a handle over a hole may write to is a separate question ( §7 Interpolation and holes).

applySource

re-reads _definition

after the await

and abandons the put if it changed underneath, since editor-5

may have rewritten the cell mid-gesture.

svgLens

is wiring and nothing else. The parts it wires:

Cell Owns
svgTarget
which variable this node is, the parameter name the cell calls svgLens by, and the document text
svgWriter
applySource / commit / runCommand : the only code that assigns _definition
svgOverlay
the handle layer, and the isOwn predicate that keeps the renderer off it
svgFocus
which element is selected and where its handles are drawn
svgTools
the tool registry: move, vertex, transform, marquee, the shape and pen tools, and the affordance dispatcher

A tool is {id, onPointerDown, onPointerMove, onPointerUp, onDblClick}

. onPointerDown

returns true to claim the gesture; registry order is priority. Tools read the document, preview in the live DOM, and hand a command or a commit to the writer; they never write the source themselves, and none of them knows what a lens is. Adding a tool is adding a cell:

svgTools.push({ id: "rect", onPointerDown(ctx, e) { … } });
viewof svgTools.dispatchEvent(new Event("input"));

Or take the set at the callsite, which pins one drawing to exactly the tools you name: how a test runs a tool in isolation, and how a figure shows a reduced editor. The selection chrome (the affordance chips) is the same kind of registry, so affordances

strips it the same way:

svgLens(node, { tools: [toolVertex] }) // a vertex-only editor
svgLens(node, { tools: (d) => [myTool, ...d] }) // extend without restating the defaults
svgLens(node, { tools: [toolMove], affordances: [] }) // a bare drawing: handles, no chips

The figures above use exactly this: each loads only the tool its point needs and no chips, so the chrome does not distract from the one gesture being shown.

The writer stays ignorant of selection: it reports what it did on the lens-put

event, and the handles follow, refreshed after an attribute edit and cleared after a structural one, because a structural edit shifts every address after it.

The draggable points a selected polygon or path exposes: where the vertex tool grabs.

Per-tag geometry as data (what each shape reads, writes and offers as handles) so a new shape is an entry here rather than a branch in every tool.

The four pieces svgLens

wires: which variable this node is, the only code that assigns _definition

, the handle layer, and which element is selected.

gestureDelta

values and the three functions that consume them (previewDelta

paints, commitDelta

writes once, revertDelta

discards): the one write path every tool goes through.

Each tool claims a gesture through onPointerDown

, previews in the live DOM, and hands a delta to the writer: move, vertex, transform, marquee, scope, the shape and pen tools, and scribble.

A headless fixture that plays a recorded gesture and checks T1–T11 over a random corpus, plus the per-frame budget that catches a dropped selection or a scale flash.

Keyboard/menu commands whose plan

returns a delta or declines, and the chips that surface those same commands on a selection, both writing through commitDelta

.

svgLens

itself: the state that outlives a remount, and the wiring that turns all of the above into an editor attached to one node.

── more in #developer-tools 4 stories · sorted by recency
── more on @tom larkworthy 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/svg-sketch-source] indexed:0 read:31min 2026-07-27 ·