# LiGMA

**Li**ve **G**rain **M**arkup **A**nnotator — a dev-only overlay that turns
"this bit looks wrong" into a durable, unambiguous instruction your coding
agent acts on within a second, without you writing a single sentence about
*which* element you meant.

Point at a thing. Type what's wrong. Press enter. The agent is working on it
before you've looked away.

---

## The problem it solves

Reviewing a design with an AI agent is mostly spent on **deixis** — the
pointing. You see something wrong; the agent cannot see anything. So you type a
paragraph locating the element ("the second button in the hero, the outlined
one, not the filled one") and then a sentence about the actual change. The
locating is 80% of the typing and 100% of the misunderstandings.

Worse, the obvious fix — "tell it the CSS class" — is a trap. `.btn` is every
button on the site. A note that says `.btn: redesign this button` is a note the
agent will carry out on the wrong button, confidently, and you will not find
out until you scroll past it. **This happened. It is why the identity half of
this tool exists.**

LiGMA removes the pointing entirely:

```
LAB NOTE  mu7k6bf1  .btn  "Contact"  [/]  (1 of 4)  lets redesign this button
          path: #root > header > div > span:nth-of-type(2) > a
```

That is generated by clicking the thing and typing six words. `.btn` is nine
buttons; `.btn` reading *"Contact"* at that path is one line of one file.

---

## The loop

```
  ┌─ browser ──────────────┐        ┌─ disk ─────────┐       ┌─ agent ─────────┐
  │                        │  POST  │                │ stat  │                 │
  │  click element ────────┼───────▶│ .lab/          │◀──10Hz┤ watch-notes.mjs │
  │  type note + ⌘V + ⏎    │        │   state.json   │       │       │         │
  │                        │        │   shots/*.png  │       │  (reads them)   │
  │                        │        │                │       │       ▼         │
  │  spinner on the note  ◀┼────────┤                │◀──────┤ lab.mjs working │
  │  (live, via HMR)       │  push  │                │ write │       │         │
  │                        │        │                │       │    edits code   │
  │  page hot-reloads     ◀┼────────┴────────────────┘       │       │         │
  │  note ticks itself    ◀┼─────────────────────────────────┤ lab.mjs done    │
  └────────────────────────┘                                 └─────────────────┘
```

Six moving parts, each doing one job:

| Part                     | Job                                                         |
| ------------------------ | ----------------------------------------------------------- |
| `src/components/Lab.tsx` | the overlay: pick, annotate, dial grain, three modes         |
| `src/lib/lab.ts`         | the store, the element-identity logic, the CSS generator     |
| `src/lab.css`            | the panel's own styles, namespaced `gl-`                     |
| `vite-plugin-lab.ts`     | dev-only HTTP endpoint + file writer + live push to the page |
| `scripts/lab.mjs`        | the agent's hand: move a note, offer a set of variants        |
| `scripts/watch-notes.mjs`| the agent's ear: one stdout line per new note                |

**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.

---

## What you get

**Pointing.** Hover highlights the element with its selector, how many elements
share that selector, and the element's own words. Click selects it.

**Notes.** Type, press enter. Saved to disk immediately (not debounced — see
[GOTCHAS §8](docs/GOTCHAS.md)) with the element's identity attached: a unique
DOM path, its text, its tag, its route, and the share count.

**Pasted images.** ⌘V a screenshot or a reference straight into the note
field. The bytes go to `.lab/shots/` and the path rides on the note, so the
agent gets a file it can actually open rather than your description of one —
the same lossy round-trip this tool exists to remove, applied to pictures.
Thumbnails in the panel, click to preview full size.

**Variants you choose on the page.** Write *"give me 3 versions of this
button"*; the agent offers them as three rulesets and the panel gives you
chips — `original` · A · B · C — that swap the live stylesheet. You judge the
real element, at its real size, on its real background, then click **use
this** and the decision goes back in the file. The agent writes the one you
picked into the source.

That replaces the worst loop in design-by-agent: ship one, hear "no", ship
another. Three round trips to see two options, each one a build. `original` is
always the first chip, because comparing alternatives against a *memory* of
what was there is how a set of variants talks you into a change you did not
want.

**Live feedback.** The agent marks a note `working` when it picks it up; the
note grows a spinner and a lit edge **in your browser**, pushed over Vite's HMR
socket. When the change lands, the note ticks itself off. You never wonder
whether anything is happening.

**Grain dialling.** The original reason this existed: opacity / tile / blend
sliders on any selector, live, with a "copy css" button. Grain is a value you
can only judge on screen — the same `--grain-op` reads as texture on a dense
card and as a dirty screen on a full-bleed band, and no amount of reasoning
about the number in source settles it.

**Three modes, one control.** `pick` (a click selects), `browse` (the page
works normally), `preview` (the panel folds away). `a` toggles the two ends.

**Zero bytes in production, provably.** The panel, its store and its
stylesheet live behind a build-time gate, so the bundler drops them entirely —
`grep` your `dist/` and there is nothing to find. Getting this wrong is the
easiest mistake here and the first entry in
[GOTCHAS](docs/GOTCHAS.md): a runtime flag leaves the whole overlay in the
bundle, one `?grain` away from a stranger's browser.

---

## Read next

- **[docs/INSTALL.md](docs/INSTALL.md)** — wiring it into a Vite + React
  project, in about ten minutes.
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** — how the five parts talk,
  and which decisions are load-bearing.
- **[docs/GOTCHAS.md](docs/GOTCHAS.md)** — **read this one.** Every bug this
  tool hit on the way to working, with the cause. Several of them will bite you
  again if you re-derive the tool rather than copying it; two of them broke the
  host site badly enough to look like the framework had failed.
- **[docs/AGENT.md](docs/AGENT.md)** — the block to paste into your
  `CLAUDE.md` so the agent knows the loop exists and how to drive it.

---

## Provenance

Extracted from a production landing page (Vite 7 · React 19 · TS ·
Tailwind v4), where it was built in one session and then used, in that same
session, to drive several dozen design changes across two sites — and
installed into the second of them straight from this folder, which is how the
install instructions got tested.

Every gotcha in `docs/GOTCHAS.md` is a thing that actually went wrong, not a
thing that might. The last three are not tool bugs at all: they are CSS
failures in the host project that the loop makes you hit far more often,
because notes arrive faster than a mental model of the stylesheet does.

## The panel and the pointer

**The panel goes where you put it.** Drag it by its bar, resize it from its own
bottom-right corner, and both are remembered in `localStorage` — it ships in
the bottom right, which is the wrong corner exactly when the thing you are
annotating is under it. The geometry is written straight onto the node rather
than held in React state: dragging through state re-renders every note and
variant in the panel on every mouse frame, which is the same mistake the hover
path already had to have taken out of it.

**The armed picker has its own pointer.** While the picker is armed the native cursor
is hidden and a crosshair rides under it with a small button beside it reading
`pick` — it breathes while armed and presses down on mousedown, so the mode is
legible from the pointer itself rather than only from the panel in the corner.
The panel keeps a normal cursor: that is the one place you are still operating
a UI instead of picking.

## The button

One button, fixed bottom right. **Click it, click a component, type.** That is
the whole tool from cold.

**Drag it anywhere** — a fixed corner is the wrong corner about a third of the
time, usually when the thing you want to comment on is the footer CTA the
button is sitting on. It remembers where it was put. The click and the drag
share one pointer: a press only becomes a drag after a few pixels, and the
click is suppressed for exactly that case.

Armed, the button fills and the viewport takes a ring — a picker that is
swallowing clicks and does not look like it is the failure this tool has
already lost an afternoon to. Click a component and the picker stands down and
that component keeps a pin of its own; there is no global picking state to come
back to.

Beside it: a count of live threads (click to show or hide them all, same as
`a`), and `≡` for the panel — the grain sliders, the note list and the variant
chips are all still there, as a drawer you open rather than a slab of chrome
the page wears the whole time.

## Threads: comments pinned to a component

`comment` mode is the fourth mode beside pick, browse and preview. Click an
element and a thread opens **under it, on the page** — the Figma move — rather
than in the panel. Existing threads show as small pins wherever their element
is; click a pin to expand it, reply, or resolve.

**Three asks are one click.** `5 variants`, `what is this` and `direction`
sit above the composer's box, and each one SENDS — the thread opens with that
sentence as its first message, and a half-typed draft is replaced rather than
joined, because a preset is a whole instruction and not a phrase to build one
out of. Anything pasted comes with it. The text goes on disk verbatim and the
watcher reads it out, so each is written to be actionable with nothing else
attached.

The box is a textarea that grows with its content, capped so a long note
cannot push the send off the bottom of the bubble. Enter sends, shift+enter is
a newline — the habit the panel's own note field already set. It was a
single-line input, which scrolled sideways out of sight, and a preset drops a
whole sentence in at once.

**Drag a box to take several at once.** 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.

Two rules make it usable. It takes only what it FULLY closed over, not what it
touched — a box that grabs half-crossed neighbours needs a second drag to
correct, where widening this one is the same gesture continued. And it takes
only the OUTERMOST: every element inside the box qualifies, so a card would
bring its heading, its paragraph and its every span with it, and three cards
would open forty composers. An element whose parent is also inside is a child
of something already taken, so it drops out, leaving the layer you were
pointing at. Capped at twenty.

**The picker stays armed between picks, and suspends during one.** It does not
disarm when you pick something — that was the first version, and it made
annotating four things four rounds of arm, click, type, send with the page's
clicks going off and on in between. But it does stop hunting while a composer
is open: the highlight, the stand-in cursor, click-to-pick and the armed ring
all stand down until you send that box or dismiss it, and then re-arm on their
own. Nothing needs pressing.

Without that the highlight chases your pointer across the page while you are
trying to read it, and a stray click drops another composer somewhere you did
not mean. The ring goes down with the rest on purpose: it is there to say "the
picker is eating your clicks", and while it is suspended that is no longer
true.

Each box keeps its own draft, `×` on one you decide against, and Escape or the
FAB disarms the picker for good when you are done.

Outside comment mode a pick is still one element and then done — the panel's
grain and note boxes are about one thing at a time.

**`ff` arms and disarms the picker** — `f` twice inside 400ms. The FAB's main
button does the same, and is filled with a ring round the viewport while the
picker is on, so the state is legible even when the keyboard did it.

Double, deliberately. An armed picker swallows every click on the site, so a
single letter that turns one on is a trap: you reach for find-in-page, the page
stops responding, and nothing on screen connects the two. Two taps is not
something a hand does by accident and is still far less work than finding the
button. It is ignored while you are typing, like `a` and for the same reason.
**Escape is the off switch**: picker off, pins hidden, open bubble closed, from
wherever you were. That one matters more than it looks, because an armed
picker swallows every click on the site and now stays armed until told
otherwise — the key someone presses when the page stops responding has to be
the key that gives it back.

**Work in progress survives the toggle.** Hiding the pins hides what you
*asked for*; it must not hide what is *happening*. An unfinished thread draws
a **white outline round the component itself**, plus a chip at its corner
reading `queued` or the name of the agent on it. Clicking the chip brings the
pins back and opens that thread.

**Still means waiting, moving means working.** The outline appears the moment
the thread exists — you sent a note, the thing you sent it about is marked,
immediately — and the chip's dot starts breathing when an agent claims it. It
used to appear only on the claim, which left a gap of seconds or minutes where
you had sent something and nothing on the page said so. Two questions, two
answers: *did that land on the right element* is answered by the mark being
there, *is anything actually happening* by it moving. Only the dot moves: text
that pulses is text you wait to be able to read.

**It says a word because a colour is a code.** This went dot → bigger dot →
chip, and the size was never the problem: nothing on the page says what a
coloured ring means, so there is nothing to read even once you can see it. `queued` and an
agent's name need no key.

**No colour at all.** Everything here is white on ink; the
mark was amber for a while and that was a hue this tool had no claim to — the
pages it sits on are ember and violet, so the one colour belonging to neither
read as a warning about something. Claimed is said by the fill and by the
word, not by a second hue.

**A light colour needs an edge.** The chip is an INK plate with white text,
and the outline carries a dark ring outside its white one — light on light is
what
made every earlier version dissolve, and near-black is the one ground that
holds against a white sheet and a violet card alike.

**A claimed component runs a pixel field** — value noise cut through an 8×8
ordered Bayer matrix and drawn as a grid of jittered circles, with rings
crossing it. React Bits' `PixelBlast`. A border that pulses says something is
*true of* the element; this says something is *happening to* it, which is the
situation: an agent is rewriting the component while you look at it. A
progress bar would claim to know how far along that is, and nothing here does.

It is a **2D canvas, not a shader**. That component is WebGL with three.js
behind it, and an install of this tool is a copy of six source files with no
build and no dependencies. The constants are named after its own props
(`pixelSize`, `patternScale`, `patternDensity`, `pixelSizeJitter`, `speed`,
`edgeFade`, `ripple*`), so the two read against each other.

Three details carry the look. **The Bayer step** is the whole trick — value
noise alone gives soft blobs, and quantising it against a different cut-off
per cell in an 8×8 tile is what turns a gradient into a stipple with
structure. **The jitter** is hashed from the cell rather than re-rolled per
frame, so a circle keeps its size and the field reads as alive instead of as
static. **The ripples run on a timer**, not on clicks: the original throws one
from every pointer press, and this layer must never take the pointer, because
the component underneath is the thing you are meant to be clicking.

**The ink follows the ground.** `#b497cf` is the configured colour and it is
right on dark — on a white sheet it is 1.9:1, present but invisible — so the
light theme takes the same hue carried down to where it reads. A field that
cannot be seen on half a site's pages is not an indicator. The theme is read
per frame (`data-theme` first, since a site's own toggle beats the OS), so a
flip costs nothing to follow and there is no observer to leak.

**No frame.** `edgeFade` dissolves the field into the element's own edges, so
a stroke round it would be a second one. The `queued` state keeps the plain
white rule, because there is no field there to dissolve.

Cheap on purpose: ~24fps, stopped while the tab is hidden, device pixel ratio
capped at 2, at most four rings alive. Under `prefers-reduced-motion` it draws
a single frame and subscribes to theme changes instead of looping.

Cheap on purpose: one cell per three screen pixels, scaled up with
`image-rendering: pixelated`, capped at ~24fps, stopped while the tab is
hidden. Under `prefers-reduced-motion` it draws a single frame — the look
survives, the motion does not.

The outline is the part that does the work, and it is round the COMPONENT
rather than beside it: an agent editing the page under you is the one thing
you cannot infer by looking at it, and only the element's own box says which
element that is. It breathes rather than spins — a spinner claims progress it
cannot back up, and the steps in the bubble are where progress actually
lives.

**`a` toggles every pin on the page, `esc` clears them.** Once each component
carries its own thread, that is the question worth a keystroke — show me all of
them, or get them out of my way — rather than which single mode the page is in,
which is what those keys used to mean. Escape only ever hides: a key that
sometimes puts a dozen bubbles back is one you cannot use without first
remembering what state you were in.

The point of a thread rather than a note: **one agent takes one thread**. A
thread carries a claim (`open` → `working` → `done`) and the name of the agent
holding it, so several agents can watch one file and never collide. The pin
fills while someone is on it. See [docs/AGENT.md](docs/AGENT.md) for the
protocol.

**A pin sits on its element's top-left corner and the bubble opens toward the
middle of the screen.** Both were wrong to start with and in the same way: the
pin hung off the element's BOTTOM edge, which on a card the height of the
screen is below the fold, and the bubble always opened down-and-right, which
is off the screen for anything in the bottom half of the page or the
right-hand column — half of everything in a two-column layout. The side is
measured when a bubble opens, not on every scroll: the whole pin layer is
positioned in document space precisely so it needs no scroll listener.

**✓ resolves, ✕ removes**, and the difference is what happens to the record.
Resolve means the work is done: the thread goes `done`, stops drawing, and
stays in the file as what was asked and what was answered. Remove means the
thread should never have existed — opened on the wrong element, or a note you
no longer want — so it is deleted outright, along with any chips asked for on
it. ✕ takes two clicks: the first arms it and says `remove?`, because there is
no undo and it sits next to `send`.

**Nothing expires.** Deleting a note because it got old is the one thing this
tool must never do — so leftovers accumulate instead, and an answered thread
nobody closed keeps drawing its outline on the component. `lab.mjs stale`
prints what is still open and the command to clear each one; the decision
stays with whoever is looking at the page. The FAB's count is of UNFINISHED
threads, not every thread ever opened — a number that only goes up is not a
count of anything you can act on.

A thread whose element has moved in the source is **not** dropped — it keeps
its place in the file and the panel, because a conversation about a component
that was just rewritten is the one you least want thrown away.

**Paste a screenshot into any box.** ⌘V an image into a composer or a reply
and it uploads on paste — the thumbnail appears under the field, so you know
it took, and the message carries a path rather than a blob by the time you
press enter. A picture on its own sends: *"not like this"* with a shot
attached is a complete thought and needs no sentence. The watcher prints the
path on the message's own line.

**The bubble's header is a drag handle.** It opens on its element, which is
where the thing you are talking about is, so it covers it — and no placement
rule can know which side you actually wanted it on. Drag it anywhere,
double-click the header to put it back. The offset is local and unsaved:
where you shoved a bubble to see behind it is not a fact about the design.

### Each bubble is the whole conversation

A pin is not just a comment box. Every thread carries its own three panes,
stacked in the order the work happens in:

1. **the history** — everything said on this component, yours and the agent's,
   in its own scroller so twenty lines do not push the reply box off-screen;
2. **what the agent is doing** — the steps it writes as it goes
   (`lab.mjs step <id> "…"`). A claim tells you the thread is taken; it does
   not tell you anything is *moving*, and on work that runs for minutes those
   are different questions. Nothing animates: a pulsing element on a page you
   are trying to look at is the thing this tool exists to prevent;
3. **the variant picker** — the chips for sets asked for on this thread, with
   the same multi-select and the same commit as the panel's tab.

That last one is why they are here at all. Variants used to land in the panel,
which put the question in one corner and the element it was about in another,
and comparing five flight paths meant holding one of them in your head while
you looked at the other. Under the pin they are the same object.

Several threads can be open at once and each keeps its own draft, its own
progress and its own chips — which is what makes running four agents on four
components at once legible rather than a single queue of notifications.

## The armed picker has its own pointer

While the picker is armed the native cursor is **hidden** and a crosshair rides
under the pointer with a small key-cap beside it. The cap breathes while armed
and presses down on mousedown, so the mode is legible from the pointer itself
rather than only from the panel in the far corner — which is easy to stop
seeing, and was how an armed picker silently ate every click on a site once.

**The overlay is not the page.** Every surface the tool draws — the panel, the
FAB, the pins, the armed ring, the stand-in cursor — is excluded from picking
by one list, `LAB_UI` in `Lab.tsx`. That list was `.gl-panel` alone while the
panel was the only thing the tool drew, and the FAB is what exposed it: the
pick handler runs in the capture phase, so clicking the FAB a second time to
disarm was swallowed before the button's own handler saw it, and opened a
thread on the FAB's own `<span>` instead. Anything new drawn at the top level
goes in that list.

Three things about it are load-bearing:

- **`cursor: none` has to be shouted at the whole tree.** Every link, button and
  text run sets its own cursor, and an inherited `none` loses to all of them.
  The panel is exempt: that is the one place you are still operating a UI.
- **It is positioned by writing to the node**, from the mousemove handler that
  was already running — not through React state. A render per mouse frame is
  exactly the cost the hover path had to have taken out of it.
- **The key cap is an image**, inlined as a data URI in `lab.css`. That keeps
  the install what `INSTALL.md` says it is — source files, no asset pipeline —
  and it costs a host site nothing, because this stylesheet is only ever
  imported by the overlay, which is a dev-only lazy chunk.

The pointer is a ring, a crosshair and a chip reading `pick`, drawn in CSS
with a dark companion stroke so it reads on a white sheet and a violet card
alike.

**The FAB wears the LiGMA mark.** It was a drawn ring with a bite out of its
lower left — the pin shape the threads use — which said "this makes pins" and
nothing about whose tool it is. The mark says both, because the drop in it IS
that pin shape.

It is cut to its own bounds and inlined as a data URI so an install stays
source files with no asset pipeline; the cut originals are in `src/assets/`.
The COUNTER — the arch inside the letter — is transparent rather than white:
a counter is negative space, and left filled it arrives as a blob with a
window punched in it.

There are TWO cuts, because the button inverts. At rest the FAB is ink and
wears the light one; armed it fills white and takes the dark one. One cut with
`filter: invert()` would have been a line shorter and would have taken the
orange drop with it, and the drop is the half of the mark that is not a
letter.

## Variants: one chip, and a thread for everything else

Chips are **one at a time**. `original` is always there and clears the set.

They were briefly multi-select, on the theory that a set of five is rarely five
finished answers — one has the right layout, another the right surface — and
being made to pick one throws half of it away. It did not survive contact:
nothing on screen said whether you were looking at one treatment or three
stacked, the commit read *"use these 3"*, and what the combination actually
cascaded to was a guess. A choice you can answer three times at once stops
reading as a choice.

When the answer really is a combination — *"the second one's layout but keep
the first one's colour"* — say it. Every set carries a **thread**. Type
it under the chips and it goes to `.lab/state.json` like everything else here;
`watch-notes.mjs` prints it as `LAB SAY` and whoever is editing replies with:

```bash
node scripts/lab.mjs say <set id> "keeping the banner, losing the ember"
```

The reply appears under the chips, next to the things being talked about,
rather than in a terminal the person asking cannot see.

A set asked for **on a thread** (`"thread": "<id>"` in the spec) renders in
that thread's bubble instead of the panel — see above. The panel still counts
them and says so, so hiding the pins cannot hide a pending question. Everything
else about them is identical, the commit included.

`chosen` is still a LIST on disk and still read either way (`chosenKeys`), so
sets written under both shapes keep working. Nothing writes more than one entry
any more.

**Escape works from a focused field**, `a` does not. Escape fires with the
caret in the note box and blurs it on the way through, so you never have to
click out to reach preview. `a` stays guarded, because it is a letter first and
a shortcut second — letting it through leaves the note box unable to type the
commonest letter in English, which is a worse tool than one that needs a click.

**Escape is the off switch**, from wherever you were — picker off, pins
hidden, open bubble closed. It used to land on `preview` instead and, for a
while, did not disarm at all: Escape hid the pins and left the picker on,
which is the sticky-global-picker failure wearing a different hat. Disarming
is the FIRST thing it does now, because an armed picker swallows every click
and the key someone presses when the page stops responding has to be the one
that gives it back. From preview it does nothing — that is already the quiet
state, and an off switch that turns the chrome back on is not one.

## Parked, for now

Two surfaces are switched off behind flags at the top of `Lab.tsx` —
`SHOW_PANEL` and `SHOW_PEEK`. Set either to `true` to bring it back.

**The panel** (grain, the notes list, the selected-element note box, the
variants tab, and the `≡` and dock controls that reach it). Everything it did
has a better home now: a note is a thread pinned to its component, and variant
chips live in that component's bubble. What was left was the old
one-element-at-a-time flow and the grain dialler.

**Peek**, and the `h` that toggled it.

Flags rather than commented-out code, deliberately. Commenting a block this
size stops it being compiled, type-checked and formatted, and the next change
to the store rots it silently — this file has already lost an afternoon to a
JSX comment that swallowed the tag after it. Gated, everything still builds.

One consequence to know before parking the panel again: a variant set **not**
attached to a thread is only reachable there, so parking strands it.
`lab.mjs stale` lists those and `drop` clears them.

## The look, and where it sits

One look. The overlay used to ship three complete designs switchable from the
FAB — `stream`, `quiet`, `term` — written to find a better answer than the
original dark-monospace panel. This is that answer, folded in, and the switch
is gone: sans for prose, mono for identity (selectors, paths, the element's
own words — the things you copy and compare), solid ink surfaces with one
raised step, and **no hue at all**.

The tool used to be cyan. Every colour on the pages it sits over is spoken for
— games violet, predict blue, sports green, gamification orange, lottery
amber, affiliates pink, platform indigo, studio violet, the hub's ember — and
Retail's teal had drifted close enough to that cyan that the overlay read as
one more product badge on the page it was inspecting. A tool that owns a hue
here will always be mistaken for something in the design.

So it owns none. Near-black surfaces, white type, and WHITE as the live
colour: the fill on a pin, an armed FAB, the one primary control per surface.
State is said with fill and weight instead of a second hue, which is also what
keeps it legible on a violet card and a white sheet alike. The one exception
is destructive, in warm red, and that is not decoration.

White needs an edge: on the hub's white sheet a white pin is nothing, so
everything white carries a dark companion ring (`--gl-halo`) in the same
declaration. Miss it anywhere and the control vanishes on exactly one kind of
page and looks fine on every other, which is the worst way to be wrong.

**Peek.** The identity card — selector, share count, the element's words —
follows the pointer **without arming the picker**. Toggle it from the eye on
the FAB or with `h`. The picker swallows every click on the site, which is the
right price for "click a component and comment on it" and the wrong price for
"what is that"; peek answers the second question on its own and the page keeps
every click. It runs the same rAF-gated `hoverInfo()` path the picker does
(see [GOTCHAS §6](docs/GOTCHAS.md)), and stands down while the picker is armed.
The card is dashed under peek and solid under the picker, so you can tell a
look from a pick.

**Floating card or drawer.** The panel is either a card that goes where it is
put — dragged by its bar, resized by its corner, both remembered — or a drawer
on the right edge that slides in and out and leaves a tab behind when closed.
Switch from the FAB (`⇥` / `⇤`) or the panel's own bar.

**In a drawer, `ff` opens and closes it** along with the picker, and Escape
shuts it — the drawer is the tool's presence on screen, and arming a picker
while the one surface that says what is happening stays behind its tab is half
a tool. A floating card is left alone by both, deliberately: it sits ON the
page, over the thing you just armed the picker to look at, and it has its own
`≡` a centimetre away. The card is right until
it is over the thing you are reading; the drawer is right when you are working
through a list and want the page to hold still beside it. In the drawer the
lists grow to the full height instead of capping at a few rows.

**The panel is see-through to the picker.** It covers page — a corner when it
floats, a whole column when it is docked — and everything under it used to be
unreachable, because the picker bails on anything that belongs to the overlay.
While the picker is armed, a hover or a click landing on the panel is resolved
against what is underneath it, and the panel fades out from under the pointer
so you can see the element you are about to pick. The FAB stays solid: it is
the one control that must always be clickable, since it is how you disarm a
picker that is swallowing every click.

Pushing the page over instead — what a docked devtool does — was the other
option and is worse here twice over: a host page's `position: fixed` elements
do not move with the body and stay buried anyway, and writing layout into the
page under review is the class of bug that makes the tool look like the site
broke.

Both are preferences about the tool rather than states of the page, so both
live in `localStorage` and nothing about them is written to `.lab/state.json`.

## Scrub an element through its own past

Every time the page paints a component differently, that moment is recorded on
its thread — so the bubble carries a **history slider**. Drag left to walk the
element back through the states it has actually been in; the right-hand end is
live.

The history survives a dev-server restart, which is not a detail: an agent's
edit causes the restart, so anything held in memory would be lost at the exact
moment you want to see the before.

What is recorded is how the element was PAINTED, not the source — a curated
list of visual properties. `width`, `height` and `position` are deliberately
left out: each is computed from the layout around the element, so writing one
back would freeze a responsive element at whatever size it happened to be, and
the replay would lie in a way that looks like a page bug rather than a tool
one.

## A set nobody has touched plays itself

Five treatments only help if they are seen, and a row of chips asks you to do
the seeing — click, look, click, look, hold the last one in your head. So an
undecided set cycles on its own: **original, then each option, then round
again, every 3.69 seconds**, with the element easing between them and the
treatment's name rising on the component as it changes.

The original is one of the stops, because a treatment only means anything
against what is already there.

**Opening the bubble stops it.** The loop is for when you are not looking at
the chips — it shows you five treatments you would otherwise click through one
at a time. Open the bubble and that job is done: you are there to choose, the
chips are in front of you, and a timer changing the component while you
compare is the tool talking over you. It resumes if you close without picking,
because then nobody is looking again.

**The first chip click stops it**, for that set and for good. The loop exists
to show you the options; once you are steering, a timer moving the page under
you is the opposite of help.

**A composer over the component stops it too, and drops the set back to the
original.** A composer means you are writing about that component: the page
must not change mid-sentence, and what you are describing has to be the real
design — a note reading "make this bigger", written while the set happened to
be showing `bigger names`, is a note about something nobody chose, and it
reaches an agent as though it were about the page. Only sets that were
cycling on their own are reset; a treatment you steered to yourself is a
choice, and discarding it because you went to comment on it would be the tool
overruling you.

The easing is injected with the set's own CSS as `transition: all` on its
selector. Blunt, and the only honest option — the rules are written by an
agent and can touch anything, so there is no property list to name. It is a
dev-only preview of one element, which is where `all` is affordable.

## What an agent can say, and how much

`reply` and `say` **refuse over 400 characters**. The bubble is 19rem wide and
sits ON the component it is about, so a long reply covers the thing you are
trying to look at. Five short sentences is the shape: what changed, and the
one thing you could not see for yourself. The reasoning belongs in a code
comment next to the change, where whoever reads it next is already looking.

It refuses rather than truncates — a cut-off sentence is worse than being made
to write a shorter one.

## Keeping the installs in step

An install is a copy of six files, so the tool's standing failure is a fix that
lands in one app and not the others. It has already cost a crash in a running
watcher and a missed note.

```bash
node scripts/sync.mjs --check   # what has drifted, writes nothing
node scripts/sync.mjs           # push this source to every install
```

The sync does the landing app's `Lab` → `GrainLab` rename on the way out, and
runs each target's own prettier afterwards — the landing wraps at 90 and the
monorepo root at 100, so without that a byte-identical copy still shows as a
large diff. It **overwrites** the installs, prose included, so run `--check`
first and lift anything an app's copy says about its own context back into this
source before syncing.
