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.