dev-only zero bytes in production six files, no build

Point at a thing. Type what's wrong.

LiGMA is a dev-only overlay for Vite + React. Click an element on your page, say what's off, press enter. Your coding agent is working on that element, not one that shares its class, before you've looked away.

node scripts/watch-notes.mjs

This page is wearing the overlay. Hover a component to peek · click it to open a thread · f f arms the picker, then drag a box · a pins · esc off

Lab.tsxthe overlay: pick, annotate, draw, three modes
lab.tsthe store, element identity, the css generator
lab.cssthe panel's own styles, namespaced gl-
vite-plugin-lab.tsdev-only endpoint, file writer, live push
lab.mjsthe agent's hand: take, step, finish, variants
watch-notes.mjsthe agent's ear: one line per note

Reviewing a design with an agent is mostly pointing.

You see the wrong button. The agent sees nothing. So you type a paragraph locating it, and the sentence about the actual change comes last. The locating is 80% of the typing and 100% of the misunderstandings. The obvious fix, tell it the class, is a trap.

what you'd type
.btn: redesign this button → .btn is nine buttons. the agent will do the wrong one, confidently.
what LiGMA writes
LAB NOTE mu7k6bf1 .btn "Contact" [/] (1 of 4) lets redesign this button path: #root > header > div > span:nth-of-type(2) > a → six words typed. one line of one file.

Hover is the whole address.

The identity card carries the selector, how many elements share it, and the element's own words. .btn is nine buttons. .btn reading “Contact” is one. Dashed is a peek, solid is a pick. Try it: you are peeking right now.

Pricing Contact Docs

Comments pinned to the component.

Click an element and a thread opens under it, on the page, the Figma move. The bubble is the whole conversation: history, what the agent is doing, the chips. Drag it by its header, double-click to put it back. ✓ resolves and keeps the record; ✕ removes it, and asks twice.

season pass
All access
$12 / mo
Get it

Choose between versions on the real page.

Ask for versions. The agent offers them as rulesets and you get chips that swap the live stylesheet. original is always a chip, because comparing against a memory of what was there is how a set talks you into a change you didn't want. A set nobody is steering plays itself. Try it: every component on this page has five on offer.

Get it

Still means waiting. Moving means working.

The moment you send, the component wears an outline and a chip reading queued. When an agent claims it the chip says who, and the component runs a pixel field: value noise through an 8×8 Bayer matrix, drawn as jittered circles with rings crossing it. Something is happening to it. When the change lands it ticks itself off, pushed over Vite's own HMR socket.

Every seat, every season.
One pass for the whole stadium, on every device.

This, but like this.

A pencil beside send opens a pad: pen, line, arrow, eight shapes, text and an eraser, five inks, three weights, a select tool with handles, a clipboard that outlives the pad, undo and redo. What you draw lands beside the message as a PNG. A screenshot is evidence; a drawing is intent. Try it: open the pad from any thread's pencil.

⌘V a screenshot into any box.

The bytes go to .lab/shots/, the path rides on the note, and the agent gets a file it can open rather than your description of one. A picture on its own sends: “not like this” with a shot attached is a complete thought. Try it: paste an image into a composer here.

shot: .lab/shots/mu7m0zvx-l811.png

Drag a box to take several.

A click picks one thing; a drag picks everything the box closes over, which is what a pass across a row of cards actually is. Only what it fully encloses, and only the outermost, so three cards do not open forty composers. Capped at twenty. Try it: f f, then drag across the tiles up top.

1
plan
Basic
2
plan
Pro
3
plan
Team

Scrub an element through its own past.

Every time the page paints a component differently, the moment is recorded on its thread. Drag left to walk it back through states it has actually been in; the right end is live. It survives the dev-server restart an agent's edit causes. Found the one you wanted? undo to here posts the exact drift, now → then. Try it below, and in any thread after a chip.

Every seat.
Get the pass
4 of 4 · 14:02:11 live

Dial a texture you can only judge on screen.

The original reason this existed: opacity, tile and blend sliders on any selector, live. The same --grain-op reads as texture on a dense card and as a dirty screen on a full-bleed band, and no reasoning about the number in source settles it. copy css gives you the rule, selector escaped.

Season pass · all access

A card that goes where you put it, or a drawer.

Grain, the notes list and the sets that have no thread live in a panel: a floating card dragged by its bar and resized by its corner, both remembered, or a drawer on the right edge that leaves a tab behind. It is see-through to the picker: hover it while armed and it fades, so the element under it can be picked.

A picker that is eating your clicks has to look like it.

Armed, the FAB fills and pulses, the viewport takes a ring, and the native cursor gives way to a reticle with a word on it. It stays armed between picks and stands down while a composer is open. f f, twice, because a single letter that swallows every click on the site is a trap. Escape always gives the page back.

Nothing of it ships. Provably.

The overlay, its store and its stylesheet sit behind a build-time gate, so the bundler drops them entirely. Build, grep dist/, find nothing. A runtime flag would leave the whole thing one ?grain away from a stranger's browser.

$ npm run build ✓ 212 modules transformed · built in 1.9s $ grep -o "__lab/state\|gl-panel" dist/assets/*.js dist/assets/*.css $ # no output = the overlay is not in your bundle # 97.8 → 93.4 KB of js, 18.9 → 17.2 KB of css, on the hub it came from

The file is the contract.

.lab/state.json has two writers, the browser and the agent, and everything else is plumbing around that one fact. No server to run, no socket to own: Vite's dev server carries the endpoint and its HMR channel carries the answer back. Nothing in it expires on its own; lab.mjs stale lists what is still open and the decision stays with whoever is looking at the page.

browser?grain
  • click an element
  • type a note · ⌘V · draw · ⏎
  • outline + chip on the element
  • the page hot-reloads
  • the note ticks itself
diskthe contract
  • .lab/state.json
  • .lab/shots/*.png
  • written atomically, under a lock
  • two writers, one file
  • nothing expires
agentany of them
  • watch-notes.mjs reads it out
  • lab.mjs take ‹id›
  • lab.mjs step ‹id› "…"
  • edits the source
  • lab.mjs finish ‹id›

One line in. A handful of commands out.

Keep the watcher running and each note arrives as one line, actionable with nothing else attached. One agent takes one thread: a thread carries a claim and the name of the agent holding it, so several agents can watch one file and never collide. The first thing an agent does, before it reads a single file, is say so.

$ node scripts/watch-notes.mjs watching .lab/state.json LAB NOTE mu7k6bf1 .btn "Contact" [/] (1 of 4) lets redesign this button path: #root > header > div > span:nth-of-type(2) > a shot: /app/.lab/shots/mu7m0zvx-l811.png $ node scripts/lab.mjs take mu7k6bf1 ~ mu7k6bf1 · claimed by claude · the element wears its chip $ node scripts/lab.mjs step mu7k6bf1 "found it · src/components/Header.tsx:42" $ node scripts/lab.mjs finish mu7k6bf1 "outlined → filled, kept the width" x mu7k6bf1 · done · the note ticks itself off in the browser $
lab.mjs take ‹id› First, before reading a file. Puts the agent's name on the chip; fails if another agent has it.
lab.mjs step ‹id› "…" What is happening, as it happens. Shown in the bubble; nothing animates.
lab.mjs finish ‹id› "…" Five short sentences at most. Replies over 400 characters are refused, not cut.
lab.mjs variants set.json Offer treatments as rulesets, on a thread. The chip reads your call until one is chosen. Also reply, say, attach, drop, stale.
f f arm the picker Twice, inside 400ms. An armed picker eats every click, so one letter is a trap.
a every pin, on or off Show me all of them, or get them out of my way.
esc the off switch Picker off, pins hidden, bubble closed. From wherever you were, it only ever hides.
enter · ⇧ enter send · newline Saved to disk immediately, not debounced. The box grows and caps before it pushes send away.
⌘ V paste a shot Uploads on paste. The thumbnail appears so you know it took.
drag a box, while armed Takes what it fully closes over, outermost only, twenty at most.
v p l a s t e the pad's tools Select, pen, line, arrow, shapes, text, eraser. The key is printed on each button.
⇧ drag constrain Squares, circles, lines on eight angles. ⇧-click adds to a selection.
⌘ Z · ⌘ ⇧ Z undo · redo One state, not two: every operation is “the sheet becomes this sheet”. Eighty back.
⌘ C X V D A the clipboard Lives in storage, so it outlives the pad. ⌘D duplicates without touching it.
⌫ · ← ↑ → ↓ delete · nudge A pixel, ten with ⇧, on the whole selection.
esc cancels the shape Never the pad. A drawing is minutes of work with no undo once it is gone; it closes on its own buttons only.

Six files, no build, ten minutes.

Target: a Vite + React + TypeScript app. Nothing here needs a router, a state library, Tailwind, or any dependency at all. The one step to get right is the gate, and the last step proves you got it right. Several apps? scripts/sync.mjs pushes this source to every install and runs each one's own prettier.

1
Copy the files Lab.tsx imports @/lib/lab. No alias? Change that one line; it is the only cross-file import in the set.
cp -r LiGMA/src/lib/lab.ts            <app>/src/lib/lab.ts
cp -r LiGMA/src/components/Lab.tsx    <app>/src/components/Lab.tsx
cp -r LiGMA/src/lab.css               <app>/src/lab.css
cp    LiGMA/vite-plugin-lab.ts        <app>/vite-plugin-lab.ts
cp    LiGMA/scripts/lab.mjs           <app>/scripts/lab.mjs
cp    LiGMA/scripts/watch-notes.mjs   <app>/scripts/watch-notes.mjs
2
Register the Vite plugin apply: 'serve', so it does not exist in a build. It serves the endpoint, writes the file atomically, and pushes outside changes over HMR.
// vite.config.ts
import { labPlugin } from './vite-plugin-lab'

export default defineConfig({
  plugins: [react(), labPlugin()],
})
3
Mount it behind a build-time gate The condition wraps the import(), not the JSX. Guard only the render and Rollup still emits the chunk for an import it can see.
// App.tsx
const Lab = import.meta.env.DEV
  ? lazy(() => import('@/components/Lab').then((m) => ({ default: m.Lab })))
  : null

{Lab && <Suspense fallback={null}><Lab /></Suspense>}
4
Verify it Do not take this on trust.
npm run build
grep -o "__lab/state\|gl-panel" dist/assets/*.js dist/assets/*.css
# no output = the overlay is not in your bundle
5
Open the page with ?grain Read once, remembered for the tab. ?grain=0 turns it off. Then paste the block from docs/AGENT.md into your CLAUDE.md so the agent knows the loop exists.
http://localhost:5173/?grain

Point. Type. Enter.

The agent is working on it before you've looked away. Six files, no build, nothing in production.

© 2026 LiGMA · no dependencies, no hue readme ↗