# Architecture

Six parts, one contract. If you only remember one thing: **`.lab/state.json`
has two writers**, and every other design decision in here follows from that.

---

## The document

```jsonc
{
  "entries": [                    // grain, keyed by selector
    { "selector": ".card", "cfg": { "op": 0.35, "blend": "multiply", "size": 180 } }
  ],
  "notes": [
    {
      "id": "mu7k6bf1-tu5e",      // time-ordered, collision-resistant enough
      "selector": ".btn",          // the SHARED selector — what you see
      "route": "/",                // what the page was showing
      "text": "redesign this button",
      "done": false,
      "working": true,             // written by the AGENT, read by the browser
      "at": "2026-09-18T22:57:10.477Z",

      // ── identity: what makes this note actionable ──
      "path": "#root > header > div > span:nth-of-type(2) > a",
      "label": "Contact",          // the element's own words
      "tag": "a",
      "shared": 4,                 // how many elements share `selector`
      "shots": [".lab/shots/mu7m0zvx-l811.jpg"]   // pasted images
    }
  ],
  "variants": [
    {
      "id": "vmu7n04he-np88",
      "selector": ".ghero-cta .btn-lead",
      "label": "See it in your brand",
      "route": "/",
      "ask": "give me 3 variants of the hero button",
      "options": [
        { "key": "a", "label": "ember", "note": "brand fill", "css": "… { … }" }
      ],
      "chosen": null          // ← null while comparing; the key once decided
    }
  ]
}
```

Every identity field is **optional** on read. Notes written before identity
existed are still on disk and still have to load. Do not make them required.

---

## 1 · The store — `src/lib/lab.ts`

A plain module-scope object plus a `Set` of listeners, exposed to React with
`useSyncExternalStore`. No context, no reducer, no library.

```
state ──▶ emit() ──▶ useSyncExternalStore ──▶ render
  │
  └─▶ save() ──▶ POST /__lab/state ──▶ disk
```

`set(patch, persist)` is the only mutator. `persist` is three-valued and that
matters:

| `persist`  | Meaning                                                    |
| ---------- | ---------------------------------------------------------- |
| `true`     | save, debounced 250ms — slider drags, which fire per pixel  |
| `'now'`    | save immediately — a note, which is one discrete act        |
| `false`    | do not save — selection, picking mode, load-completion      |

### What the document holds

| key        | what it is                                                     |
| ---------- | -------------------------------------------------------------- |
| `entries`  | grain, per selector                                             |
| `notes`    | one instruction each, ticked off once                           |
| `variants` | sets of alternatives an agent wrote; `chosen` is the answer     |
| `threads`  | conversations pinned to a component, one agent each             |

and three that are **local only**, never written to disk, because what you
have expanded or half-typed is nobody else's business:

| key          | what it is                                                   |
| ------------ | ------------------------------------------------------------ |
| `target`     | the element selected for the panel's own note field          |
| `pending`    | components clicked in comment mode with nothing said yet     |
| `openThread` | which bubble is expanded                                     |
| `showing`    | which option each set is previewing                          |

`pending` is a LIST and `target` is not, deliberately: the grain tab and the
note box are genuinely one-element-at-a-time, and composers are not.

### Two answers about one click

This is the heart of the tool and the thing most likely to be got wrong on a
re-derivation.

```
                 ┌─ candidates(el)[0] ──▶ ".btn"      → what GRAIN targets
click an element │
                 └─ uniquePath(el) ─────▶ "#root > … > a"  → what a NOTE targets
                    + el.textContent ──▶ "Contact"
                    + sharedCount(sel) ▶ 4
```

Grain wants the **shared** selector: graining `.card` is a decision about every
card, which is what a design change *is*. A note wants the **individual**
element: "redesign this button" on `.btn` reads as "redesign all nine buttons",
and that is exactly what an agent will do.

`uniquePath` walks to the nearest ancestor with an id (or the root), adding
`:nth-of-type` **only where a tag actually repeats among its siblings** — so
the common case stays short and readable instead of a wall of indices. The
result is verified with `querySelectorAll(path).length === 1` before it is
returned; a path that matches two things is no use for the job it exists to do.

### Hot path vs cold path

`describe()` — unique path, text, share count — is **click only**. Hover uses
`hoverInfo()`, which reads the class list and a memoised count and stops.

This split is not tidiness. See [GOTCHAS §6](GOTCHAS.md): running the full
`describe()` per `mousemove` fired two whole-document `querySelectorAll` calls
at 120Hz, starved the main thread, and stopped the host page's own
IntersectionObserver from ever revealing its content. The site looked broken.

`sharedCount` is memoised in a `Map`, cleared on unmount — the answer is about
the document currently on screen.

### The CSS generator

`toCss(entries)` emits exactly what you copy out. One rule per selector, not
one comma-joined list, and every selector passed through `forCss()`:

```ts
const forCss = (sel) =>
  sel.startsWith('.') && !/[\s>+~,[\]()]/.test(sel)
    ? `.${CSS.escape(sel.slice(1))}`
    : sel
```

Tailwind class names contain colons. `.lg:col-span-7` parses `:col-span-7` as a
pseudo-class and is simply **invalid** — and an invalid selector anywhere in a
comma-separated list invalidates the **whole rule**. One utility class in the
list silently killed the grain on every other selector in it, for hours,
completely invisibly. See [GOTCHAS §3](GOTCHAS.md).

Escaping happens at generation, not at capture, so what is stored and shown
stays the name a person would type.

### Variants

The one part of the document the agent writes and the browser answers.
Everything else flows browser → disk → agent; this flows the other way and
back:

```
agent ──▶ variants[].options   (three rulesets)
                 │
browser ─────────┤ showing[setId]   local, a preview — never persisted
                 │
browser ──▶ variants[].chosen  ◀── the one field the agent is waiting on
```

`showing` is deliberately **not** on disk. What you are currently looking at
is nobody else's business until you commit to it, and persisting it would make
every idle chip-click a file write and a notification.

`variantCss()` emits **one** option per set, and only for sets where
`chosen === null`. Once a choice is made the agent is writing it into the
source, and an overlay still painting it would hide whether that landed — you
would be looking at the preview and calling it the result.

The rules are injected **unlayered**, so they beat anything the host declares
in `@layer components`. That is the whole reason a variant can be a plain
ruleset instead of a fight with specificity.

---

## 2 · The panel — `src/components/Lab.tsx`

### Three modes, one exclusive control

```
comment  a click on the page opens a composer ON the element clicked
browse   the page behaves normally
preview  the overlay folds away, leaving one way back
```

There was a fourth, `pick`, which selected an element into the panel's note
field. It is gone. Once every clicked component could carry its own thread,
a second mode that put the same click somewhere else was two answers to one
question — see [§ The pin layer](#6--the-pin-layer).

`comment` **stays armed between picks and suspends during one.** It disarmed
on the first pick once, and annotating four things was then four rounds of
arm, click, type, send with the page's clicks going off and on in between.

`composing = pending.length > 0` is the suspension, and it is a dependency of
the picking effect rather than a flag tested inside four handlers — so the
whole thing tears down when a composer opens and builds back when it closes,
which is one lifetime to reason about instead of four conditionals. `peek`
takes the same dependency: it is the same highlight following the same
pointer. The armed ring and the stand-in cursor are gated on it too; the ring
says "this page is eating your clicks", and leaving it up while the picker is
suspended would make it a lie about the one thing it exists to tell the truth
about.

The cost is that the unmask walk re-runs per compose cycle rather than per
arming. It is one pass over the document and the effect was already paying it
once.

**Keys.** `ff` (twice inside 400ms) arms and disarms. Double, because an
armed picker swallows every click on the site and a single letter that turns
one on is a trap. **Escape is the off switch** — picker off, pins hidden,
open bubble closed, from wherever you were, except from preview which is
already the quiet state. `a` toggles the pins only; arming from a letter is
not something a letter should do by accident. Both are ignored while you are
typing, Escape excepted — it blurs the field on the way through.

### Capture-phase listeners

```ts
document.addEventListener('click', onClick, true)   // ← the `true`
```

Capture phase, because a click on a link has to land here **before** the router
navigates. Without it the page is gone before the element can be inspected.
Same for `mousemove`, for consistency.

The overlay's own surfaces are excluded by `closest(LAB_UI)` — one list
naming the panel, the FAB, the pins, the armed ring, the highlight and the
stand-in cursor. It was `.gl-panel` alone while the panel was the only thing
the tool drew, and the FAB exposed that: the pick handler is capture-phase, so
a second click meant to disarm reached it before the button's own handler and
opened a thread on the FAB's own `<span>`. Anything new drawn at the top level
belongs in that list.

### The live stylesheet

One `<style>` element, created once via a ref, with its **text replaced** on
change:

```ts
if (!sheet.current) { sheet.current = document.createElement('style'); head.append(...) }
sheet.current.textContent = css
```

Recreating the element per change is what made the first version look
unreactive — removing and re-appending a stylesheet drops it to the end of the
cascade and back, so some edits appeared to do nothing at all.

### Picking does not grain

`pick(el)` **selects**. It does not create a grain entry. It used to do both,
and since most clicks are someone choosing a thing to write a note about, every
one of them quietly added grain to a generic container — `.flex`, `.wrap`,
`.grid`. Twenty notes left seventeen grain plates behind. See
[GOTCHAS §4](GOTCHAS.md). Graining is now an explicit `+ grain <selector>`
button in the grain tab.

---

## 3 · The server — `vite-plugin-lab.ts`

`apply: 'serve'`. Four responsibilities.

**Serve state.** `GET /__lab/state` → the file, or `{entries:[],notes:[]}`.

**Write state atomically.** `POST` → temp file, then `rename`. The panel saves
on every slider drag; a half-written JSON read back at the next boot would
silently empty the whole session's work.

**Take pasted images.** `POST /__lab/shot` with the blob's own content type
and raw bytes as the body — no multipart, nothing to parse. Written under
`.lab/shots/` with a **generated** name: the clipboard carries no filename,
and a name taken from the wire is a path traversal waiting to happen. The
response is the path the note stores. `GET /__lab/shot/<name>` serves them
back for the thumbnails, and `path.basename` is the whole guard there — nothing
from the URL reaches the filesystem except one file name. Capped at 12MB;
a clipboard screenshot is a few hundred KB and anything past that is a
mistake the dev server should not hold in memory.

**Push outside changes.** `fs.watch` on the **directory** (not the file — the
atomic rename means the inode a file watcher holds is gone after the first
save), debounced 80ms, then:

```ts
server.ws.send({ type: 'custom', event: 'phx:lab', data: JSON.parse(body) })
```

The plugin remembers the last body it wrote from a POST (`mine`) and skips
pushing it back. Without that the page's own save returns to it as an update
and fights whatever is being typed.

---

## 4 · The two-writer problem

The browser holds the whole document in memory and POSTs all of it. The agent
edits the same file from the other side. Two independent hazards:

**Agent → browser.** The agent marks a note `working` or `done` and the page
must show it *now*. Solved by the HMR push above, plus `watchDisk()` — a
re-read on tab focus, as a belt-and-braces for when the socket dropped.

**Browser → agent.** The page's next save posts its stale in-memory copy and
silently un-ticks everything the agent marked. This one bit before it was
fixed: notes marked done reverted, repeatedly, with nothing in the logs.
`watchDisk()` is the fix — focus is the right moment because the page cannot be
edited while it is not on screen, so there is nothing local to lose.

```ts
const sync = () => {
  if (document.visibilityState !== 'visible' || state.saving) return
  void loadLab()
}
document.addEventListener('visibilitychange', sync)
window.addEventListener('focus', sync)
```

---

## 5 · The agent's two scripts

**`watch-notes.mjs`** — one `stat` at 10Hz, parse only when mtime moves. Emits
identity on the line itself so the agent does not need a round-trip to the file
before it can start. Seeded from disk so a restart does not replay the backlog.

**`lab.mjs`** — writes atomically, the same way the plugin does, and
**serialised**, which is a different guarantee. Atomic means nobody reads a
half-written file. It says nothing about two agents reading the same document
and both writing it back, which is what this script does on every command —
and which silently lost 5 of 12 concurrent writes when measured. A lockfile
(`mkdir`, stale after 2s) is taken before the read and held to exit. That is
what makes SEVERAL AGENTS ON SEVERAL THREADS safe rather than merely likely
to work; the document already allowed it, since every thread carries its own
`agent` and `take` claims one.

```
notes     list · working · done · open
variants  variants <spec.json> · say · attach · drop
threads   threads · stale · take · step · reply · finish
```

Three of those exist because of something that went wrong rather than
something that was planned. `attach` moves a set that already exists onto a
thread, for the set offered before the thread did. `stale` reports what is
still open and how to clear it, because **nothing here expires** — deleting
somebody's note because it got old is the one thing this tool must not do, so
leftovers accumulate instead and something has to be able to name them.
`step` appends progress, because a claim says a thread is taken and says
nothing about whether anything is moving.

`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 the reader
is trying to look at. It refuses rather than truncates: a cut-off sentence is
worse than being made to write a shorter one, and the detail almost always
belongs in a code comment next to the change.

Marking `working` is not bookkeeping. It is the **only** signal the person who
wrote the note gets between pressing enter and the page changing. Without it
the tool feels broken during exactly the window when it is working hardest.

Which is also why the claim goes FIRST, before the agent reads a single file.
Claim after the work and the indicator is live for a second: the person
watching sees nothing and reports the animation as broken. It was not broken;
it was told too late.

---

## 6 · The pin layer — `Pins` in `Lab.tsx`

Everything that happens **on the page** rather than in the panel: composers,
thread bubbles, and the field that runs over a component while it is being
worked. One portal onto `<body>`, because the sections it sits over have
opaque grounds and would eat it a section at a time.

### Document space, so there is no scroll listener

Every pin is `position: absolute` at `rect.top + scrollY`. Scrolling then
costs nothing — the layer moves with the page because it IS on the page. The
alternative, `fixed` plus a scroll handler, is a render per scroll frame over
a document the tool is supposed to be helping you look at.

The consequence is that placement has to be recomputed when the page reflows,
and **a `ResizeObserver` on `document.body` cannot see that**. A body observer
fires when the BODY's box changes; an element resizing inside a page already
taller than the viewport changes nothing about the body. A live variant is
exactly that case — the stylesheet is rewritten, the card changes size, the
body does not. Every anchored element is observed too. See
[GOTCHAS](GOTCHAS.md).

### Where a bubble goes

The pin sits on the element's **top-left** corner, not the bottom: on a card
the height of the screen the bottom edge is below the fold, and the pin went
with it.

The bubble goes **outside the element's box**. `place()` measures the room on
each of the four sides — `innerWidth - r.right`, `r.left`, and the vertical
pair, each against the bubble's own size — and takes the largest that clears a
gutter, as `at-right` / `at-left` / `at-up` / `at-down`. Every offset in the
stylesheet is expressed off `--gl-w` / `--gl-h`, the element's measured box,
written onto the pin.

It used to hang down-and-right off the pin and flip only to stay on screen.
Since the pin is the element's own top-left corner, that put the bubble
*inside* anything bigger than it: you picked a headline and the box landed on
the headline.

**`at-over` is the case with no answer.** An element the size of the screen
has no side with room on it, so the bubble stops pretending to be anchored and
parks `position: fixed` in the viewport's bottom-left — clear of the FAB and
of a docked drawer. What keeps it connected to its element is the dashed
outline, and that is the dependency worth knowing: the outline is what makes
an unanchored bubble legible, so it cannot be removed without bringing the
overlap back.

The side is decided **once**, when the bubble first appears, and frozen in a
ref. Re-deciding per placement pass made bubbles hop between sides on scroll.

The header is a drag handle, and the offset is a `translate` layered on top of
whatever the flip chose, so the two compose. Local and unsaved: where you
shoved a bubble to see behind it is not a fact about the design.

### The trail — an element's own history

`Thread.trail` is a list of stamped snapshots of how the element was actually
PAINTED: a curated ~29 computed properties, read in `place()` because that is
the one pass holding both the thread and its live element, and because it
re-runs after an agent's edit reaches the browser — the moment worth catching.

`noteTrail` writes nothing when the read is unchanged, which is almost always,
so the chatter is one entry when the thread opens and one per real change.
Capture is skipped while a preview is showing or a scrubber is parked, or the
history fills with states the design never had.

**It is on the thread, so it is on disk, and that is the whole point.** An
agent's edit restarts the dev server; anything held in memory is gone at
exactly the moment you want the before.

**`width`, `height` and `position` are deliberately not in the list.** 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 bug in the page rather than in the tool.

`scrub` is local only — where you dragged a slider back to is something you
are looking at, not a fact about the design. The last stop stores `null`
rather than its index: an index would keep emitting a rule pinning the element
to a snapshot of itself, and the page would stop reacting to whatever an agent
did next.

### One bubble, three panes

History, then what the agent has done (`lab.mjs step`), then the chips for any
set asked for on this thread — in the order the work actually happens. Drafts
and pasted images are keyed by thread, because several bubbles are open at
once and a half-typed line belongs to one of them.

### Resolve and remove are different verbs

`resolveThread` sets `done` and hands any undecided set back to the panel —
the conversation stays on disk. `dropThread` deletes the thread and every set
asked for on it, for a pin opened on the wrong element. The bubble carries
both: ✓ and a two-click ✕, armed on the first press because there is no undo
and it sits beside `send`.

### The field, and when it runs

A canvas over the component: React Bits' `PixelBlast`, written out rather than
pulled in, because that component is WebGL with three.js behind it and an
install here is six files with no build and no dependencies.

**It runs only while an agent is CHANGING the component**, and stops the
moment the turn passes back to the reader:

```
working, last word yours, no undecided sets  ──▶ the field runs
chips on the table undecided                 ──▶ stopped, "your call"
last word is the agent's                     ──▶ stopped, "your call"
open (nobody claimed it)                     ──▶ outline only, "queued"
```

The middle two are the point. Chips exist to be COMPARED and you cannot judge
a component through a stipple; and an agent that replies without closing the
thread would otherwise leave the component running for as long as the page is
open. The claim itself is untouched in both, so nothing else picks the thread
up — only the motion stops.

The ink is **black or white and nothing else**, and which one is decided by
what the field SITS ON rather than by the page theme. `groundIsLight()` walks
up from the element for the first ancestor with a background over half
opacity — a component is usually transparent and inherits the band behind it —
and falls back to the theme only when nothing the whole way up is painted.

Keying it off the theme got the common case backwards: a white pill inside an
ember hero is a light ground in a dark-ish band, and the field has to stay
legible against the COMPONENT. It was also violet on both themes, a hue the
tool owns nowhere, which read as a stain on somebody's design rather than as
the tool.

The same measurement colours the OUTLINE round the component — white on a
dark card, ink on a pale sheet — and each line carries a companion ring in the
opposite value, so a wrong reading is still legible rather than invisible. The
outline was white always, which vanished on the hub's own cream sections: a
border drawn on somebody's design that they cannot see is worse than none,
because the pin then points at nothing.

The ground arrives as a prop, so it is measured in `place()` where the element
already is, and a change to it re-runs the canvas effect. That is why `Pins`
also watches `data-theme`: a theme switch can flip the ground under every pin
without a single box changing size, and the other observers only watch
geometry. It is capped near 24fps, stopped while the tab is
hidden, and draws a single frame under `prefers-reduced-motion`.

### One look

There were three (`stream`, `quiet`, `term`), switched by `data-gl-skin` on
`<html>`, written to find a better answer than the original panel. The answer
is folded into the base now and the attribute, the switch and the `Skin` type
are gone. What survived: sans for prose, mono for identity, solid surfaces, one
hue. The tokens are `--gl-*` custom properties at the top of `lab.css`.

### Peek

The identity card without the picker. A `peek` flag (`localStorage`, `h`, the
eye on the FAB) installs a SECOND copy of the picker's `move`/`reseat` pair —
capture-phase `mousemove`, passive `scroll`, rAF-gated, `hoverInfo()` only —
and deliberately none of the rest: no stand-in cursor, no `cursor: none`, no
capture-phase click, no unmask walk. It is a second copy rather than a shared
helper so the picker's hot path, which has already cost an afternoon once, is
not touched. The effect returns early while the picker is armed or in
preview, so exactly one of the two is ever listening.

### Placement

`dock` (`localStorage`, `'float' | 'drawer'`). The panel node is **keyed on
it**: the geometry callback (`panelRef`) is a ref callback that runs once per
node and installs the saved corner, the drag on the bar and the resize
observer, and a drawer wants none of those. Re-keying makes a toggle a fresh
mount rather than four listeners to attach and detach in place. The callback
reads `dockRef` — written during render, not in an effect, because the
callback runs in the commit before any effect would have synced it — and
returns early for a drawer, leaving placement to the stylesheet.

A closed drawer keeps `display: flex` and slides off with `translate`, so it
can slide back; a closed card is `display: none` as before. `.gl-dock-tab` is
the way back into a closed drawer and is in `LAB_UI`, like every surface the
overlay draws at the top level.

`ff` and the FAB's main button share one `toggleTool()` — the same act, so not
two copies to drift — and it carries the one asymmetry worth knowing: in a
drawer the panel opens and closes with the picker, and Escape shuts it with
everything else. A floating card does neither. Armed is armed either way; what
differs is that a card lands on top of whatever you just armed the picker to
look at, while a drawer takes the edge, so only one of them can afford to
appear uninvited.

### Seeing through the panel

Both placements cover page, and the picker bails on anything matching
`LAB_UI`, so everything under the panel was unpickable — a whole column of it
in a drawer. While armed, `under(target, x, y)` resolves a hover or a click
that lands on `.gl-panel` against `document.elementsFromPoint`, taking the
first entry that is not one of the overlay's own surfaces. CSS fades the panel
on `:hover` and lifts `.gl-hi` above it for that moment.

`pointer-events: none` would have been the shorter version and does not work:
it takes `:hover` with it, so the panel cannot notice the pointer it needs to
fade away from, and anything deriving its own visibility from hit-testing
flickers at the boundary. Reflowing the page does not work either — fixed
elements do not move with the body, and writing layout into the page under
review is GOTCHAS' whole subject. Only `.gl-panel` is seen through; the FAB
stays solid because it is how a swallowing picker gets disarmed.

---

## Why a file, and not a socket

A note outlives the thing that caused it. The usual sequence is: write note →
agent changes code → **dev server restarts** → page reloads. Any design where
the note lives in memory on either side loses the note precisely when the fix
lands. The file is the only participant that survives the restart, which is why
it is the contract and everything else is plumbing.
