skip to content
lav.ren / writing / 2026-04-building-an-annotation-editor
Published
2026.04.26
essay

Building an annotation editor that writes its own YAML

A dev-only visual tool for authoring the margin notes, stamps, and arrows on lav.ren. Drag to position, click to create, auto-saves to MDX frontmatter.

Every page on lav.ren has decorative annotations — margin notes in Caveat cursive, rubber stamps, coffee ring stains, arrows pointing at things. They’re positioned with CSS values in MDX frontmatter YAML, like this:

- kind: note
  content: 'five years of this, <strong>still</strong> calling it tinkering.'
  position: { bottom: '6vh', left: '1vw' }
  rotate: -4
  width: 170

For the first few days of building the annotation system, I positioned them by editing those values, running npm run dev, eyeballing the result, going back to the file, adjusting, and repeating. Each annotation took about twenty guess-and-check cycles. The home page has eleven annotations. You can do the math.

The tool I needed

I wanted something like the position handles in Figma — drag the thing where you want it, done. But the values live in YAML inside MDX files, not in a design tool. So I built a dev-only visual editor that lives inside Astro’s dev toolbar.

Toggle it on, and every annotation gets a red dashed outline. A floating control bar appears at the top of the page with buttons for each annotation kind (Note, Stamp, Arrow, Path, Coffee, Reg). Click one to enter placement mode, click the page to drop it. Click an existing annotation to select it, press Delete to remove it. Drag to reposition. A blue rotation handle appears when selected. Press U to cycle between vw/vh, percentage, and pixel units.

Every change auto-saves to the MDX frontmatter file.

How the save loop works

The interesting engineering is in the save. The positioner script runs in the browser, but the YAML lives in a file on disk. Bridging that gap uses Vite’s HMR WebSocket — the same channel that powers hot module replacement.

When you drop an annotation, the client calls:

import.meta.hot.send('positioner:save', {
  pathname: location.pathname,
  idx: 3,
  position: { bottom: '6.22vh', left: '1.04vw' },
});

On the server, an Astro integration registers a Vite plugin that listens for that event. It maps the URL pathname to a content source file (/ becomes src/content/singletons/home.mdx), finds the right annotation block in the frontmatter by counting - kind: entries, and surgically replaces the position: line using positional string slicing — not regex replace, because the same YAML text could appear in the MDX body.

The file write triggers Astro’s content watcher, which reloads the page. The positioner re-activates automatically via a localStorage flag, so the drag-and-drop-and-save cycle feels seamless.

Finding annotations in the DOM

One bug took a while to find. The positioner discovers annotations by walking the children of .annotation-overlay. But in dev mode, Astro injects <style> and <script> elements inside that overlay. The positioner was counting them as annotations, which shifted every index by three — dragging annotation #0 would save to annotation #3 in the YAML. The fix was a two-line guard:

const tag = child.tagName;
if (tag === 'STYLE' || tag === 'SCRIPT') continue;

React islands add another wrinkle. Each hydrated annotation is wrapped in an <astro-island> element that may contain a <template> child used for SSR fallback. The discovery code skips templates and finds the actual rendered element inside the island.

Path arrows

The newest addition is kind: 'path' — bezier curve arrows with draggable start, end, and bend control points. Unlike the old directional arrows (eight preset rotations of a static SVG), path arrows connect any two points on the page with a smooth curve.

The coordinates use a different system from other annotations. Instead of CSS values like "6vh", path arrows use percentages of the overlay container — start: { x: 5, y: 50 } means 5% from the left, 50% from the top. The SVG renders inside a viewBox="0 0 100 100" that maps percentages to coordinates without any runtime JavaScript.

The arrowhead is a separate positioned <div> containing a small fixed-size SVG triangle. This was the fix for a warping problem — when the line SVG uses preserveAspectRatio="none" to stretch percentages correctly, any marker element inside it gets distorted. Splitting the arrowhead into its own element keeps the triangle crisp regardless of the arrow’s aspect ratio.

When you select a path arrow in the positioner, three colored handles appear: green for the start point, red for the end point, yellow for the bend control. Dragging any handle updates the SVG path in real-time — the curve, the bounding box, and the arrowhead all follow the drag before the save triggers a rebuild.

Zero production cost

The entire positioner ships zero bytes to production. The client script loads via if (import.meta.env.DEV) import(...), which Vite tree-shakes to nothing during astro build. The Astro integration and toolbar app are stripped automatically. The path arrow renderer (ArrowPath.astro) is a zero-JS Astro component — it outputs static SVG at build time with no hydration cost.

Building dev tools for yourself is a bet that pays off fast. The positioner took about a day to build and has already saved me hours of guess-and-check positioning. Every new annotation is five seconds of drag-and-drop instead of twenty minutes of YAML archaeology.