{"slug": "svg-sketch-source", "title": "SVG Sketch Source", "summary": "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.", "body_md": "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.\n\n| gesture | what it does | lens |\n|---|---|---|\n| drag a shape | moves it | `transform` |\n| tap a polygon or path, drag a handle | moves one vertex or anchor | `points` , path `d` |\n| tap anything else, drag a corner or the rotate handle | scale or rotate from the box | `transform` |\n| drag the pivot dot | moves the point the box scales and rotates around | `transform` |\n| double-click an edge / a vertex / empty canvas | add a vertex / remove one / drop in a shape | `children` |\n| tap again in the same place | steps down through stacked shapes | — |\n| shift-tap, or rubber-band | adds to the selection | — |\n\nA selection also carries **chips**: the same commands, one tap away, drawn only when they apply:\n\n| chip | chip | ||\n|---|---|---|---|\n⧉ |\nduplicate | ∿ |\nsmooth ↔ corner the anchor |\n⇄ |\nswap fill and stroke | ◑ |\ngive it a gradient (minted into `defs` ) |\n≡ (drag) |\nstroke width | ➤ |\nput an arrowhead on it |\n⊚ / ◠ |\nclose / open the path |\n\nThe panel under the drawing edits `fill`\n\n, `stroke`\n\n, width, opacity and the dash as typed inputs;\neach change commits through the same write path, so the source keeps the notation you wrote.\n\n| key | |\n|---|---|\nR E L |\ndrag out a rect, an ellipse, a line |\nP |\nplace path anchors; click the first again to close, double-click to finish open |\nS |\nscribble a freehand path; it is fitted to Béziers on release |\nV / Esc |\nback to selecting (a finished tool returns by itself) |\n⌘G / ⇧⌘G |\ngroup / ungroup the selection |\n⌘D |\nduplicate |\n⌘C ⌘X ⌘V / ⇧⌘V |\ncopy, cut, paste / paste in place |\n⌘A |\nselect all |\n1 / 2 |\nreset the view / fit it to the selection |\n[ ] / { } |\nlower, raise / send to back, front |\nDelete |\nremoves the selection |\n⌘Z / ⇧⌘Z |\nundo, redo (byte for byte), and declines if something else wrote to the cell first |\narrows, shift+arrows |\nnudge by one unit, by ten |\nalt while dragging |\nignore snapping |\n\n```\nimport {svgLens} from \"@tomlarkworthy/svg-lens\"\nviewof picture = svgLens(svg`<svg viewBox=\"0 0 100 100\">\n<circle cx=\"50\" cy=\"50\" r=\"20\" fill=\"tomato\"/>\n</svg>`)\n```\n\n*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.*\n\nYou 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.\n\nPropagation 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.\n\nThis 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](#)).\n\nWhat is editable is a drawing whose shape is written literally; interpolated values are rendered but left to their source ([ §7 Interpolation and holes](#)).\n\nA **lens** between a source type S and a view type A is a pair of functions [[Foster et al. 2007]](#):\n\nand 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.\n\nTwo laws make a lens *well-behaved* [[Foster et al. 2007]](#):\n\n**GetPut** (left) says a null edit is a null write: drag a shape back to where it started and the cell\nis byte-identical to before, not merely equivalent. **PutGet** (right) says the view you asked for is\nthe view you get, so a drag of 40 pixels moves the shape 40 pixels and the number in the source\nagrees.\n\nA third law, **PutPut**, makes a lens *very* well-behaved:\n\nThat is, the last write wins and intermediate ones leave no trace. Most of these lenses keep it; some\ndeliberately do not ([ §2.2 Residue, and why PutPut bends](#)).\n\nSVG's syntax is not canonical. `\"0,0 100 100\"`\n\nand `\"0 0 100 100\"`\n\nare the same rectangle;\n`rotate(45)`\n\nand `matrix(0.707 0.707 -0.707 0.707 0 0)`\n\nare the same transform. So get\nis not injective: a whole *fibre* of source strings maps to one view.\n\nAny 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:\n\nSkip rule.If a=get(s), return s unchanged.\n\nThis implements GetPut rather than merely satisfying it, and it is what keeps comments, spacing and\n`rotate(45)`\n\nintact across an edit. Everything the view does not determine is **residue**, and\nresidue survives.\n\nIt costs strict PutPut. Take a2=get(s) and a1=get(s), with\ns written non-canonically. On the left the first put reprints, the second skips, and the\nnon-canonical spelling is gone; on the right the single put skips and the spelling survives, giving two\ndifferent strings that encode the same drawing. What holds is PutPut *up to observation*:\n\nand strict PutPut holds whenever a2=get(s). This is a deliberate trade rather\nthan a defect: canonical printing would restore the law and remove the property that makes the editor\nusable on source a human wrote. Weakening a law to a quotient is the usual move when residue matters\n[[Czarnecki et al. 2009]](#). `test_putput_skip_rule_corner`\n\nin the appendix targets this case\ndirectly, since random generators are unlikely to reach it.\n\nTwo 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.\n\n**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.\n\n**The tower.** Four types and three lenses:\n\n`Function.prototype.toString()`\n\n; Doc is the bytes of the `svg`\n\ntemplate 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\nℓlit∘ℓattr(p,transform)∘ℓtransform:Src⇀R6\nand 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.\n\n**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:\n\nThe two kinds of put differ exactly here:\n\nPath(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.\n\n**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:\n\nThe 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.\n\n**Composition preserves the laws.** If ℓ1 and ℓ2 are well-behaved on their domains then so is ℓ1∘ℓ2. GetPut:\n\nusing 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.\n\nThe 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\n\n`get`\n\n, 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:\n\nTools emit commands, commands are lens puts, one writer applies them.\n\nA 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.\n\nThe layers, what lives in each, and what each is allowed to know are shown in the figure below; it is itself an `svgLens`\n\ndrawing, 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:\n\n**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](#).\n\n**The browser stays the authority on geometry.** Hit testing is `elementsFromPoint`\n\n, bounding boxes are `getBBox`\n\n, screen-to-user conversion is `getScreenCTM`\n\n. The lenses never re-implement SVG semantics; they only rewrite the text that produced them.\n\n**Commands are endomorphisms of the source.** Currying a put gives\n\nso 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:\n\nThis 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.\n\n**Disjoint addresses commute.** Dragging n selected shapes is n commands, one per address, and for p, q neither a prefix of the other\n\nbecause 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`\n\nin 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.\n\n**Tools are folds over pointer events.** A tool is a state machine\n\nwhere δ 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.\n\n**Propagation is the runtime's, not ours.** The writer applies the command and installs the result with `Variable.define`\n\n:\n\nNothing in the editor notifies anything. `editor-5`\n\n'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.\n\n**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,\n\nso 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.\n\nThe smallest useful lens: one attribute of one shape. The triangle's three corners live in a single\n`points`\n\nattribute; the rest of the source (a comment, the fill) must come through every edit untouched.\n\n`attrTextLens(i, name)`\n\nviews the *text* of one attribute of element i; `parsePoints`\n\nviews\nthe numbers inside that text and puts them back into the same slots. Composing them gives a lens from\nthe whole document to the corner coordinates, and that composite is what a drag on a vertex writes\nthrough.\n\nClick 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:\n\n`points`\n\nattribute is written, and only the corner you grabbed changes value; the fill, the\ncomment and everything else in the source are left exactly as typed, because put\nsplices 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.\n\nchildrenLens(p):Document⇄String∗The view is a **list of child source strings**, not parsed nodes. With strings, `insert`\n\n, `delete`\n\nand `reorder`\n\nare `splice`\n\non 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.\n\nThe view does not contain the text *between* children: newlines, indentation, comments sitting in the gaps. That is residue again, and `put`\n\nhas 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.\n\nThe 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\n\nUse `[`\n\nand `]`\n\nto move the elements forwards and backwards in the child list\n\nEverything so far assumed the drawing is written out literally. A drawing may also *interpolate*\nvalues from elsewhere: `transform=\"translate(${shift} 0)\"`\n\nreads `shift`\n\nfrom a slider. The editor\ntolerates interpolation. The drawing renders, and the literal text around a hole stays editable; what\nthe editor will not do is write back into a hole.\n\nThe source text of an attribute is cut into **slots**. Each slot is either a literal run of digits or\nan interpolation `${...}`\n\n, and only the first kind is somewhere a gesture can write:\n\n| slot | can a gesture write it? |\n|---|---|\n| literal digits | yes: spliced in place, exactly as in\n§5 A first lens: one attribute of one shape |\n\n`${...}`\n\nA 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.\n\nRefusing is a guarantee, not effort withheld. A hole could in principle be *inverted*: resolve\n`${shift}`\n\nto the slider and set its value, or solve `${Math.sin(t)}`\n\nnumerically. Inversion by\nreflection into an upstream view was tried and removed. It is fragile, it rarely holds, and its result\nis indistinguishable on screen from an exact edit until it silently drifts. Declining instead keeps\nevery law in [ §2.1 The laws](#) true of every accepted gesture, and the handle says which case it is in.\nThe term\n\nThree rectangles, two sliders. **The first** is written literally, so a drag rewrites this cell's\nsource and the box moves. **The second** carries `transform=\"translate(${shift} 0)\"`\n\n: move the slider\nand it follows, because the drawing interpolates the value; but *drag* the box and the editor declines,\nbecause the number lives in the hole and this editor does not write back into an interpolation.\n**The third** carries `rotate(${spin} 32 100)`\n\nand behaves the same way for rotation.\n\nInterpolation 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.\n\nThe 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.\n\nThe 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:**\n\n| edit lens | svg-lens |\n|---|---|\n| X, MX | the cell source; the edits a command can make to it |\n| Y, MY | the rendered drawing; pointer deltas |\n| C | `ctx.state.drag` : the origin, the grabbed targets, the hit list, the snap boxes |\n| q:MY×C⇀MX×C | the tool |\n| K | \"the DOM on screen is what this source renders to\" |\n\nMaking the delta a value is the whole of the framework. A tool returns a `gestureDelta`\n\n(one of `attr`\n\n, `command`\n\n, `select`\n\n, `view`\n\n, `clip`\n\n), and three functions consume it: `previewDelta`\n\npaints the live DOM, `commitDelta`\n\nwrites the source once, `revertDelta`\n\nthrows 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\n\n`svgLens(node)`\n\nuses the default tools, `svgLens(node, { tools: [toolVertex] })`\n\npins a vertex-only editor, and `svgLens(node, { tools: (d) => [myTool, ...d] })`\n\nextends 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.\n\n| law | what it says here | what it rules out | |\n|---|---|---|---|\nT1 |\np(1,c)=(1,c) | a null gesture writes nothing and returns its memory untouched |\na click that grabs nothing creating a shape, or a gesture losing the context it holds |\nT2 |\np(mm′,c)=(nn′,c′′) | translating a composite gesture equals composing the translations | float drift — two gestures ending at the same point committing different bytes |\nT3 |\nPutGet | 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 |\nT4 |\nPutInc | a gesture commits against the state it began from |\na commit that mints a new node letting a follow-up write land on a stale document |\nT5 |\nconsistency | if the DOM agreed with the source before, it agrees after | makes T3's screen-level check a consequence, not a separate assertion |\n\n**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`\n\nreturning `false`\n\nis 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`\n\nfirst. The same partiality runs through every surface that reaches the DOM: a command declines by returning `null`\n\nfrom its plan, and an affordance chip greys out exactly when its command declines. One notion of \"not applicable here\" spans pointer, command and chrome.\n\nSix more laws are ours, not the papers':\n\n`false`\n\nhas 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)`\n\nequals re-locating the same element after the command runs: operational transformation's TP1, one-sided.`ctx.pick`\n\n, 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`\n\nand `getScreenCTM`\n\nalready 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`\n\n/`circle`\n\n/`path`\n\nentry 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_*`\n\nand `gestureLaws`\n\ncheck the laws above over a random corpus of gestures, on the same `forAll`\n\nharness the lens laws in [ §2.1 The laws](#) use.\n\n**Bidirectional programming.** The lens formulation, the laws and the well-behaved / very\nwell-behaved distinction are Foster et al.'s [[Foster et al. 2007]](#), addressing the view-update\nproblem posed first for databases [[Bancilhon & Spyratos 1981]](#). [[Czarnecki et al. 2009]](#) survey how many\nfields arrived at the same structure independently. What differs here is the source:\n[[Foster et al. 2007]](#) lens trees and strings, this notebook lenses the executing function's own\ntext, so the residue that must survive is the author's formatting and `put`\n\nsplices into bytes that\nare also a running program.\n\n**Sketch-n-Sketch.** The closest system [[Chugh et al. 2016]](#) [[Hempel & Chugh 2016]](#)\n[[Hempel et al. 2019]](#): an SVG editor where you write a program, manipulate the output, and the system\nupdates the program. It is considerably more expressive, handling abstraction, loops and recursion via\ntrace-based provenance and later output-directed synthesis [[Hempel et al. 2019]](#), and it can produce a\nprogram the user never typed. Three differences, none a claim of superiority:\n\n**Lenses in practice.** Cambria applies lenses to schema evolution in local-first documents\n[[Litt et al. 2020]](#); edit lenses [[Hofmann et al. 2012]](#) shift the currency from\nstates to edits, which is the same instinct as our command layer, though we implement it concretely\nas splices on child source strings ([ §6 The structural lens](#)).\n\n**The host this depends on.** The editor relies on the system it runs in being *source-last*\n[[Larkworthy 2026]](#): Lopecode keeps no canonical saved file, treats the live runtime as the\nsource of truth, and recovers each cell's text on demand from `Function.prototype.toString()`\n\n. There\nis no file on a server that the definition is a copy of, and no save step for an edit to fall out of.\nRead the other way, this notebook is a test of that paper's claim that a plurality of editing surfaces\nfollows from runtime primacy: `editor-5`\n\nedits a cell as text, `editable-md`\n\nas prose, `sticky`\n\nedits a literal by manipulation, and `svgLens`\n\nedits one as a drawing. None is privileged, and since\nall four go through `Variable.define`\n\n([ §4 Tools, commands, one writer](#)), each one's edits are visible to the\nothers.\n\n**Notebooks and liveness.** Liveness has a standard scale [[Tanimoto 1990]](#), but it measures\nfeedback from program to output; the quality at issue here is the reverse. [[Horowitz & Heer 2023]](#)\nname persistence as what separates a rich widget from a programming system: interactions with a\nrendered tool \"cannot be 'saved' back to the notebook\". This is one answer for one domain: the\nwidget's output *is* the cell, so there is nothing to save back. [[Edwards et al. 2019]](#) ask for costs\nto be reported alongside benefits, which is what [ §10 Future work](#) is for.\n\n**The literal lens is not specific to SVG.** `literalLens`\n\nfinds a tagged template in a cell's own\ndefinition and lenses its text; everything above it is SVG-specific, everything below is not. The same\nprimitive points at markdown in an `md`\n\ncell, CSS in a `css`\n\ncell, or JSON in a data cell: a\nclick-to-edit prose editor and a colour picker that rewrites a stylesheet differ only in the\nmicrosyntax layer. This is the generalization worth doing next.\n\n**Writing through holes.** Today a hole is tolerated and locked ([ §7 Interpolation and holes](#)). Two ways to make one\nwritable are visible from here, both deliberately out of scope: resolving a hole to another cell's\nliteral, which makes the lens target the dataflow graph rather than one cell, and numerical inversion\nfor holes feeding arithmetic, the approximate solve\n\n`@tomlarkworthy/manipulate`\n\nalready implements.\nReflection into an upstream view was tried and removed for being fragile; either replacement would have\nto 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\ndocument-order indices would stop matching the DOM. Addressing children by identity rather than\nposition would lift the restriction, at the cost of putting ids into a drawing the author is writing.\n\n**What it costs.** Reported in the manner [[Edwards et al. 2019]](#) argue for: the editor reaches only\ndrawings written literally; the parser is a deliberate subset of XML (no CDATA, no entity decoding, no\nnested `<svg>`\n\n); PutPut holds only up to observation ([ §2.2 Residue, and why PutPut bends](#)); undo declines rather than\nmerging when another writer has touched the cell; each gesture commits once, so dragging a 200-element\nselection is 200 puts; and since a commit recomputes the cell (\n\nThe 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.\n\nDeterministic replacement of fast-check: a seeded PRNG (`mulberry32`\n\n), domain generators (`arb`\n\n), and `forAll`\n\nwhich throws the counterexample on failure. Fixed seeds keep every test cell rerunnable with identical results. `arb.anyFinite`\n\ndraws random 64-bit patterns, so the exact round-trip claims are exercised across the full double range, subnormals and -0 included.\n\nEach `test_*`\n\ncell property-checks one lens (or one exactness claim) with a fixed seed.\nThe dashboard near the top of the notebook aggregates them.\n\nAll printing uses `String(n)`\n\n, all parsing uses `Number(s)`\n\n. Lenses are total on their stated\ndomains and throw outside them (parseable strings, nodes carrying the focused attribute/child,\nmatrices with det ≠ 0, path data with separated numbers, with no `10-5`\n\nabutment or compressed arc flags).\n\nAffine matrices as a flat `[a,b,c,d,e,f]`\n\n, their product, and the screen-to-user map (`getScreenCTM`\n\n) that every measurement is taken through.\n\nThe `transform`\n\nattribute as an editable list of operations (translate, rotate, scale) plus the gizmo that rotates or scales about a point held fixed.\n\nThe 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.\n\nThe `d`\n\nattribute parsed into absolute commands and printed back: the lens the pen and vertex tools write a path through.\n\nEverything above focuses *inside* an attribute. These focus *outward*, until the source is the\nJavaScript of the cell itself:\n\n| Lens | Source | View |\n|---|---|---|\n`literalLens(alias)` |\na cell definition's source text | the template literal inside its `alias(…)` call |\n`attrTextLens(i, name, dflt)` |\nSVG document text | element `i` 's attribute string (`dflt` when absent) |\n`cellAttrLens(alias, i, name, dflt)` |\ncell source | that attribute, addressed from the cell |\n\n`literalLens`\n\nlocates the literal by parsing the definition with acorn and taking byte offsets, the\nsame technique `@tomlarkworthy/sticky`\n\nuses for its persistence slot. Its domain is definitions\ncontaining one `alias(…)`\n\ncall whose argument is a template literal with no `${}`\n\ninterpolations,\nand views that contain no backtick, backslash or `${`\n\n(they would not survive re-parsing).\n\nComposing outward from a typed view gives a lens from **cell source** straight to **matrix**:\n\n```\ncompose(cellAttrLens(\"svgLens\", 4, \"transform\", \"matrix(1 0 0 1 0 0)\"), transformLens)\n```\n\nThat composite is what a drag commits, and `test_cellSourceLens_laws`\n\nchecks its laws.\n\nA forgiving scanner over the SVG text, the tree it builds, and `nodeAt`\n\n/ `pathOfIndex`\n\nfor addressing an element by a path that survives structural edits.\n\nReading and splicing a single attribute, the `style=\"\"`\n\nand unit-bearing-number lenses, `setProperty`\n\n(which writes where the property already lives), and `refsOf`\n\nfor the `url(#id)`\n\na shape points at.\n\nPaint 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.\n\nThe 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`\n\n.\n\nSegments, 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.\n\n`svgLens(node)`\n\nreturns 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`\n\n. On release the gesture commits once, through `compose(cellAttrLens(…), transformLens)`\n\n, 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)`\n\nis still there, because GetPut says so.\n\nThe new definition is built with `realize`\n\n(which routes through `importShim`\n\non lopecode) and installed with `Variable.define`\n\n, the same call `editor-5`\n\nmakes when you type in a cell.\n\nGestures call `define`\n\n, the same kind of event as typing: the cell is invalidated, recomputes, and produces a new node.\n\nThat 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`\n\n, keyed by the `Variable`\n\n, which `define`\n\nmutates in place. The selection is restored by *path* rather than index, which is why it is addressed that way.\n\nRendering evaluates the template rather than parsing its text, so an interpolated `svg`<circle r=\"${r}\"/>``\n\nrenders the same way a literal one does; what a handle over a hole may write to is a separate question ([ §7 Interpolation and holes](#)).\n\n`applySource`\n\nre-reads `_definition`\n\nafter the `await`\n\nand abandons the put if it changed underneath, since `editor-5`\n\nmay have rewritten the cell mid-gesture.\n\n`svgLens`\n\nis wiring and nothing else. The parts it wires:\n\n| Cell | Owns |\n|---|---|\n`svgTarget` |\nwhich variable this node is, the parameter name the cell calls `svgLens` by, and the document text |\n`svgWriter` |\n`applySource` / `commit` / `runCommand` : the only code that assigns `_definition` |\n`svgOverlay` |\nthe handle layer, and the `isOwn` predicate that keeps the renderer off it |\n`svgFocus` |\nwhich element is selected and where its handles are drawn |\n`svgTools` |\nthe tool registry: move, vertex, transform, marquee, the shape and pen tools, and the affordance dispatcher |\n\nA tool is `{id, onPointerDown, onPointerMove, onPointerUp, onDblClick}`\n\n. `onPointerDown`\n\nreturns 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:\n\n```\nsvgTools.push({ id: \"rect\", onPointerDown(ctx, e) { … } });\nviewof svgTools.dispatchEvent(new Event(\"input\"));\n```\n\nOr 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`\n\nstrips it the same way:\n\n```\nsvgLens(node, { tools: [toolVertex] }) // a vertex-only editor\nsvgLens(node, { tools: (d) => [myTool, ...d] }) // extend without restating the defaults\nsvgLens(node, { tools: [toolMove], affordances: [] }) // a bare drawing: handles, no chips\n```\n\nThe 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.\n\nThe writer stays ignorant of selection: it reports what it did on the `lens-put`\n\nevent, and the handles follow, refreshed after an attribute edit and cleared after a structural one, because a structural edit shifts every address after it.\n\nThe draggable points a selected polygon or path exposes: where the vertex tool grabs.\n\nPer-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.\n\nThe four pieces `svgLens`\n\nwires: which variable this node is, the only code that assigns `_definition`\n\n, the handle layer, and which element is selected.\n\n`gestureDelta`\n\nvalues and the three functions that consume them (`previewDelta`\n\npaints, `commitDelta`\n\nwrites once, `revertDelta`\n\ndiscards): the one write path every tool goes through.\n\nEach tool claims a gesture through `onPointerDown`\n\n, previews in the live DOM, and hands a delta to the writer: move, vertex, transform, marquee, scope, the shape and pen tools, and scribble.\n\nA 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.\n\nKeyboard/menu commands whose `plan`\n\nreturns a delta or declines, and the chips that surface those same commands on a selection, both writing through `commitDelta`\n\n.\n\n`svgLens`\n\nitself: the state that outlives a remount, and the wiring that turns all of the above into an editor attached to one node.", "url": "https://wpnews.pro/news/svg-sketch-source", "canonical_source": "https://tomlarkworthy.github.io/lopebooks/notebooks/tomlarkworthy_svg-lens.html#view=S100(@tomlarkworthy/svg-lens)", "published_at": "2026-07-27 21:14:54+00:00", "updated_at": "2026-07-27 21:22:26.933179+00:00", "lang": "en", "topics": ["developer-tools", "generative-ai"], "entities": ["Tom Larkworthy", "Foster et al. 2007", "Hofmann et al. 2012", "Sketch-n-Sketch", "Chugh et al. 2016", "Hempel et al. 2019", "SVG"], "alternates": {"html": "https://wpnews.pro/news/svg-sketch-source", "markdown": "https://wpnews.pro/news/svg-sketch-source.md", "text": "https://wpnews.pro/news/svg-sketch-source.txt", "jsonld": "https://wpnews.pro/news/svg-sketch-source.jsonld"}}