# Gotchas

Every one of these actually happened while building and using this tool. None
are hypothetical. Several look like the _host site_ has broken rather than the
tool, which is what makes them expensive — you go looking in the wrong place.

Ordered roughly by how much time each one costs if you hit it cold. The later
entries are not bugs in the tool — they are things that go wrong in the HOST
project while you are acting on notes, which the tight loop makes you hit far
more often than you otherwise would.

---

## 1 · The overlay ships to production

**Symptom.** None, until someone appends `?grain` to the live site and gets a
picker. Before that it is simply dead weight in every visitor's bundle.

**Cause.** The obvious mount is a runtime flag:

```tsx
import { Lab } from '@/components/Lab' // ← static
{
  labOn() && <Lab />
}
```

`labOn()` is a runtime call, so the bundler cannot prove the branch is dead and
keeps the whole overlay — component, store and stylesheet. On the live site the
panel then renders and talks to `/__lab/state`, which exists only on the dev
server, so it half-works: picks elements, saves nothing.

**Fix.** A build-time condition wrapping the **import**, plus the styles
travelling with the component. The exact shape is in
[INSTALL §4](INSTALL.md) — and two near-misses on the way to it are worth
knowing, because both look right and neither works:

- Guarding only the JSX (`{import.meta.env.DEV && <Lab />}`) still leaves
  `lazy(() => import(...))` evaluating at module scope. The chunk is emitted.
- Importing `lab.css` from `main.tsx` puts the panel's stylesheet in the entry
  CSS whatever you do with the component.

**Verify, do not assume.** `grep -o "__lab/state\|gl-panel" dist/assets/*` after
a build. It took two rounds of this to actually get to zero.

---

**Symptom.** You click the Contact page's submit button, write "redesign this
button", and the agent redesigns the home page's hero CTA instead.

**Cause.** The first version recorded only `candidates(el)[0]` — the first class
name. That is `.btn`, which is every button on the site. The note was not
ambiguous to the agent; it was _wrong_, and the agent carried it out
confidently.

**Fix.** Record identity alongside the selector: a verified-unique DOM path,
the element's own text, its tag, and how many elements share the class. Show
the share count in the panel (`1 of 4`) and in the hover chip (`.btn ×4`) so it
is visible _before_ you click that a selector is shared.

**The general lesson.** Grain wants the shared selector; a note wants the
individual element. One click, two different answers. Any version of this tool
that returns one answer is broken for one of its two jobs.

---

## Claim before the work, or the indicator is a lie

The claim is what starts the field over the component, and that field is the
only thing on the page saying anything is happening.

Read the files first and then claim and reply in the same breath, and the
field is live for a second or two. The person watching sees nothing at all and
reports the animation as broken — which is what happened, with a screenshot,
on a tool that was working perfectly. It was not broken; it was told too late.

`take` first, before opening a single file. A claim placed after the work is a
claim that describes the past.

## Nothing here expires, and that is the design

Deleting somebody's note because it got old is the one thing this tool must
never do. The cost is that leftovers accumulate: a thread answered and never
closed keeps drawing its outline on the component for as long as the page is
open, and a set nobody picked from sits there forever. Reported as "there are
stale pickers", pointed at a card whose work had finished an hour earlier.

There is no sweeper and there should not be one. `lab.mjs stale` prints what is
still open, why it is waiting, and the command to clear each; the decision
stays with whoever is looking at the page. The habit that avoids needing it:
`finish` the thread when the work lands, `drop` the set once its choice is in
the source.

## A count that only goes up is not a count

The FAB's badge was `threads.length` — every thread ever opened on the page,
resolved ones included. It only ever rose, so it said nothing you could act
on. Unfinished only, and the pin layer stops drawing when there is nothing
unfinished left.

The same shape of mistake made `LAB TAKEN` fire on `finish`: the test was
"not open", and a `done` thread is not open either, so completing a thread
announced it as though another agent had just picked it up.

## A body ResizeObserver cannot see a component resize

The pin layer measures each anchored element once and draws the work-in-progress
outline at that size. Keeping the measurement fresh looks like it only needs a
`ResizeObserver` on `document.body` — and that is wrong in exactly the case
this tool creates.

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, and a
LIVE VARIANT is precisely that: the stylesheet is rewritten, the card changes
size, the body does not, and the outline keeps the size the card used to have.
Reported as the mark sitting off its component by a centimetre, with the
variant chips open at the time.

Every anchored element is observed as well. Keep a `Set` of what is already
observed — re-observing from inside the callback on every frame of a
transition is how a ResizeObserver loop starts.

## Resolving a thread must not orphan its chips

A resolved thread is filtered out of the pin layer. A variant set attached to
one is therefore unreachable the moment the thread closes — still on disk,
still undecided, still emitting its preview CSS, with nothing left on screen
that can answer it. It happened the first day threads owned sets: five
treatments for a CTA, the thread ticked off, and the chips simply gone.

`resolveThread` hands any UNDECIDED set back to the panel (clears `thread`).
Resolving says you are done talking about the element, not that the five
options you never looked at should be thrown away.

`dropThread` deletes them instead, because that one means the thread was a
mistake and everything asked for on it goes with it.

## The live push has to carry every collection

`vite-plugin-lab.ts` sends the WHOLE state file on every disk change; the
client handler in `watchPush` picks the keys out of it. Miss one and that
collection simply never updates live — it is not an error, nothing logs, and
the page looks right because `watchDisk` quietly repairs it the next time you
switch tabs.

`threads` was missing for its whole first day. A claim, the agent's steps and
its replies all landed on disk and none of them reached the open page; the
bubble sat there saying `open` on a thread that was already taken, and the
progress list — whose entire job is to show you something is moving WHILE you
watch — only filled in if you alt-tabbed away and back.

The handler is typed `Partial<Pick<State, …>>` now so a new collection at
least has to be named. Adding one to the store means adding it there too.

## 2 · A shared class is not an element

**Symptom.** You click the Contact page's submit button, write "redesign this
button", and the agent redesigns the home page's hero CTA instead.

**Cause.** The first version recorded only `candidates(el)[0]` — the first
class name. That is `.btn`, which is every button on the site. The note was not
ambiguous to the agent; it was _wrong_, and the agent carried it out
confidently.

**Fix.** Record identity alongside the selector: a verified-unique DOM path,
the element's own text, its tag, and how many elements share the class. Show
the share count in the panel (`1 of 4`) and in the hover chip (`.btn ×4`) so it
is visible _before_ you click that a selector is shared.

**The general lesson.** Grain wants the shared selector; a note wants the
individual element. One click, two different answers. Any version of this tool
that returns one answer is broken for one of its two jobs.

---

## 3 · One invalid selector kills the whole rule

**Symptom.** Seventeen grain entries in the panel. None of them do anything.
No error anywhere.

**Cause.** `toCss` joined every selector into one comma-separated list. One of
them was `.lg:col-span-7` — a Tailwind utility. In CSS that parses as class
`.lg` followed by pseudo-class `:col-span-7`, which does not exist, so the
selector is invalid — **and an invalid selector anywhere in a list invalidates
the entire rule**. Sixteen valid selectors were silently dead because of the
seventeenth.

**Fix.** `CSS.escape` the class part at generation time (`.lg\:col-span-7`),
_and_ emit one rule per selector so a legacy bad one can only break itself.

**The sting in the tail.** Fixing this made gotcha §4 visible, instantly, all
over the site. Two bugs had been cancelling each other out.

---

## 4 · Selecting must not be a side effect

**Symptom.** After fixing §3, the entire site is covered in grey translucent
plates. Every container is grainy.

**Cause.** `pick()` created a grain entry on every click. But most clicks are
someone choosing an element to _annotate_, not to grain. Twenty notes had
quietly produced seventeen grain entries on generic containers — `.flex`,
`.wrap`, `.grid`, `.lg:col-span-7`.

**Fix.** `pick()` only selects. Graining is an explicit `+ grain <selector>`
button. If you inherit a state file from before the fix, clear `entries`.

**The general lesson.** A tool with two jobs must not let the cheap gesture for
one silently perform the other.

---

## 5 · The reveal-class deadlock

**Symptom.** Whole sections of the host site render blank — the heading and the
body text show, but every card is invisible. Looks exactly like the redesign
you just shipped broke the page.

**Cause.** Not the tool at all, but it _surfaced_ through it. The host had:

```ts
document.documentElement.classList.add('js-reveal') // main.tsx, unconditional
```

```css
html.js-reveal [data-reveal] {
  opacity: 0;
} /* hidden until revealed */
```

```ts
if (!canAnimate()) return // the observer, bailing
```

With `prefers-reduced-motion: reduce`, the class is added and the observer that
removes the hiding **never runs**. Every revealed element on the site stays at
`opacity: 0` forever. Only elements carrying `data-reveal` vanish, which is why
it looks like one section broke rather than the whole page.

**Fix.** Gate the class on the same predicate as the observer, and have the
observer strip the class if the preference is turned on after load.

**Worth auditing in your own project before you install this**, because you
will hit it while debugging something else and blame the wrong thing.

---

## 6 · Hover is on the frame budget

**Symptom.** The host page becomes hard to scroll. Sections stop revealing.
Everything feels like it is running through treacle.

**Cause.** `mousemove` called the full `describe()`, which walks the DOM
building a unique path and then runs `document.querySelectorAll` **twice** —
once to verify the path, once for the share count. At 120Hz that saturates the
main thread, and the host page's own IntersectionObserver never gets a turn.

**Fix, three parts:**

1. Hover calls `hoverInfo()` — class list plus a **memoised** share count. The
   expensive `describe()` runs on click only.
2. Bail immediately when the hovered element has not changed.
3. Throttle the remaining work to one `requestAnimationFrame`.

**The general lesson.** An inspector shares a main thread with the thing it is
inspecting. Anything on `mousemove` that touches the document as a whole will
be felt as _the page_ being slow, not the tool.

---

## 7 · Never recreate the live `<style>` element

**Symptom.** Slider changes appear to do nothing, or work intermittently.

**Cause.** The first version removed and re-appended the `<style>` node on
every change. That moves it to the end of the cascade and back, so whether your
rule wins depends on when you looked.

**Fix.** Create the element once, hold it in a ref, replace `textContent`.

---

## 8 · Debounce the drags, not the acts

**Symptom.** The agent responds a second later than it should. It feels laggy
in a way you cannot locate.

**Cause.** Every save was debounced 250ms — correct for a slider that fires per
pixel, wrong for a note, which is one discrete act. The 250ms sat at the front
of a chain: save → watcher notices → agent starts.

**Fix.** `set(patch, 'now')` for `addNote`, debounce for everything else.
Separately: the watcher was polling at 2s and re-parsing JSON every tick. One
`stat` at 10Hz, parse only when mtime moves. Worst case went ~2.3s → ~0.1s.

---

## 9 · A URL flag dies on the first link

**Symptom.** The panel is there when you load `/?grain` and gone the moment you
click any link on the site.

**Cause.** `labOn()` read `?grain` from `location.search`. Every client-side
`<Link to="/contact">` navigates to a bare path with no search string.

**Fix.** Read the URL once, remember in `sessionStorage`, honour either.
`?grain=0` turns it off. Session scope, so it dies with the tab — right
lifetime for a dev overlay.

---

## 10 · Atomic writes break file watchers

**Symptom.** The live-push works exactly once, then never again.

**Cause.** The writer does temp-file-then-`rename`, which is correct — a
half-written JSON read at the next boot would silently empty the session. But
it means the **inode** is replaced, and `fs.watch` on the _file_ is holding the
old one. It goes permanently deaf after the first save.

**Fix.** Watch the **directory** and filter by name (for the push), or `stat`
the path on an interval (for the watcher). Never watch the file handle.

---

## 11 · The page will clobber your edits

**Symptom.** You mark notes done from the terminal. Some time later they are
open again. Nothing in any log.

**Cause.** The browser holds the whole document in memory and POSTs all of it.
Any edit made on disk while the tab is open is overwritten by the page's next
save.

**Fix.** `watchDisk()` — re-read on `focus` / `visibilitychange`, guarded on
`saving`. Focus is the right moment precisely because the page cannot be edited
while it is not on screen, so there is nothing local to lose.

---

## 12 · Ambiguous matching moves the wrong note

**Symptom.** `lab.mjs working "redesign this button"` marks two notes.

**Cause.** Substring matching, and `"redesign this button"` is a prefix of
`"redesign this button completely"`.

**Fix.** A ladder: exact id → exact text → substring, narrowest match that hits
anything wins, and **refuse** on an ambiguous substring rather than guessing.
Print the candidates and their ids so the next command can be exact.

---

## 13 · Blending against transparency shows raw noise

Not a tool bug — a grain bug you will hit the moment you start using it — but
the tool is what surfaces it, so it belongs here.

A grain plate composited with `mix-blend-mode` or `background-blend-mode`
against something **transparent** has nothing to blend with, so the raw grey
noise comes through at full strength. This bites in three places:

- **A default of `soft-light`** on a first pick over a bare div: shows nothing
  at all, so the tool looks broken. The default here is `normal` at a frank
  opacity for that reason — start visible, then dial.
- **An outlined button** (`background-color: transparent`) that inherits the
  solid button's grain: reads as a dirty screen rather than a textured surface.
- **A full-bleed glow** (a radial fading to transparent): the grain shows at
  full strength across the whole strip, including where there is no glow. Mask
  the grain layer with a copy of the glow's own gradient instead.

And the mirror image: **`soft-light` is neutral at 50% grey, so over near-white
it is close to a no-op.** A white card grained with soft-light keeps its tone
and shows no texture at any opacity. Use `multiply` at a lower opacity (~0.22)
and accept the fraction of a tone step.

---

## 14 · A broken `<img alt="">` is invisible

**Symptom.** Pasting an image appears to do nothing. No thumbnail, no error.
The file is on disk and the endpoint returns it perfectly.

**Cause.** Two independent versions of this. First: the thumbnail markup was
simply missing from one of the two apps — a `str.replace()` in a patch script
that silently no-ops when the anchor text does not match. Second, and the one
worth remembering: an `<img>` with `alt=""` that fails to load renders
**nothing at all** in Chrome. No broken-image icon, no alt text, no box. A
failed image and an absent image look identical.

**Fix.** Say it in words as well as pictures — `3 images attached` above the
strip, and `attaching 2…` on the frame of the paste, before the upload even
finishes. A count that comes from state cannot fail to render the way an image
can.

**The general lesson.** Any feedback that is _only_ an image can fail silently.
Pair it with text that comes from application state.

---

## 15 · Two installs drift

**Symptom.** A feature works in one app and not the other, with the same tool
in both.

**Cause.** Inevitable. The moment LiGMA is installed twice it has two copies
of six files, and a fix applied to one is a fix missing from the other.

**Fix.** Treat `.workspace/LiGMA/` (or wherever you keep the package) as the
source of truth, and `diff` the installs against it rather than eyeballing:

```bash
for f in src/lib/lab.ts src/components/Lab.tsx vite-plugin-lab.ts \
         scripts/lab.mjs scripts/watch-notes.mjs; do
  diff -q "$PKG/$f" "$APP/$f" || echo "DIFFER $f"
done
```

`lab.css` is the exception when the host inlines it into a global stylesheet —
diff from its `══ grain lab ══` banner to the end.

---

## 16 · SVG filter primitives are in viewBox units

Also not a tool bug, but it is the single most expensive mistake available when
acting on a note about SVG artwork.

`feOffset dy`, `feGaussianBlur stdDeviation` and `feTurbulence baseFrequency`
are in the element's **user units**, not pixels. A viewBox 75.76 wide rendered
at 490px is a **6.5× multiplier**: `dy="2.4"` is a 15px offset and
`stdDeviation="1.9"` a 12px blur — an embossed glow where you wanted a 2px lip.

Two consequences:

- Read the viewBox before touching any filter number, and convert to px in
  your head or in a comment.
- Porting a filter between two components with different viewBoxes means
  **scaling every primitive** by the ratio of the boxes.

And put the filter **outside** any `transform="scale(...)"` on the geometry —
inside it, the primitives take their units from the scaled coordinate system
and every number is wrong by that factor.

If you are tuning one of these, build sliders. Every value guessed from reading
the source was wrong; every value dialled on screen was right first time. That
is the whole thesis of this tool, applied to itself.

---

## 17 · Retiring a rule by out-declaring it

**Symptom.** You remove a border. It is still there. You remove it again.

**Cause.** The removal was `stroke: none` on `.phx .phx-line` — and forty
lines later sat `.band .phx-line { stroke: … }`. Identical specificity (0,2,0),
later in the file, so the old rule won and the border came straight back.

**Fix.** **Delete the treatment, do not override it.** A rule that still
exists is a rule somebody's cascade will find — including yours, twice.

**Why the loop makes this worse.** Notes arrive faster than a mental model of
the stylesheet does. You reach for a one-line override because it is one line,
and each override is another rule for the next note to trip on. When a note
says "remove X", go and find every rule that makes X and take them all out.

---

## 18 · Removing a rule can expose a default

**Symptom.** Immediately after deleting the dead rules from §17, the logo
rendered as a **solid black glyph a metre wide**.

**Cause.** The block that was deleted also carried `fill: transparent`, and
the `<path>` elements have no `fill` attribute of their own. With the rule
gone they fell back to SVG's default fill — black.

**Fix.** The markup and the rule that makes it invisible have to leave
together. If a rule is the only thing suppressing an element, deleting it does
not remove the element, it reveals it.

**Worth a habit:** after removing styles, ask what the element looks like with
_no_ styles. For SVG that is black fill and no stroke; for an `<img>` it is a
broken-image box; for a `<dialog>` it is nothing at all.

---

## 19 · `animation-fill-mode: forwards` blocks everything after it

**Symptom.** A `:hover` rule on an animated element does nothing. No error, no
override in devtools you can point at — the declaration is simply not winning.

**Cause.** An animation with `forwards` leaves the element holding its final
keyframe, and **a filled animation value beats an ordinary declaration**. Any
later rule touching the same property — hover, a media query, a state class —
is silently outranked for the life of the page.

**Fix.** Put the resting state in the base rule and fill **`backwards`**
instead. The animation then shows its `from` state during its delay and hands
control back when it finishes:

```css
.mark {
  opacity: 0.4; /* ← the resting state lives here */
  animation: arrive 1.5s var(--d) backwards;
}
@keyframes arrive {
  from {
    opacity: 0;
    scale: 1.05;
  } /* no `to` — the rule above is the `to` */
}
.parent:hover .mark {
  opacity: 0.62;
} /* now this works */
```

Dropping the `to` is the other half: with `forwards` you have two places
claiming to define the resting state, and they drift.

---

## 20 · An armed picker eats every link on the site

**Symptom.** You click a link. Nothing happens. You click another. Nothing.
The URLs are right, the markup is right, the dev server is serving the correct
values — and every link on the page is dead.

**Cause.** The armed picker, working exactly as designed. It listens in the **capture
phase** and calls `preventDefault()` + `stopPropagation()` so an element can be
inspected before the router navigates away. With the picker armed, that is
every click on the page, including the ones you meant.

**Why it is so hard to spot:** nothing on screen attributes it to the overlay.
The panel is in the corner minding its own business and the page just feels
broken. It survived an entire session of use before anyone said anything.

**Fix, three parts:**

1. **`browse` mode exists for this** — the page behaves normally, panel stays
   up. `a` toggles to preview; the segmented control has all three.
2. **Modifier clicks fall through.** ⌘/Ctrl/Shift and the middle button are
   not picks, they are someone trying to use the page, and they now reach it —
   the way they do in any browser inspector.
3. **Say it in the hint.** The mode line under the control reads
   `click selects · ⌘-click follows links` rather than only describing the
   picking half.

**The general lesson.** An overlay that suppresses a fundamental page
behaviour has to advertise that it is doing so, at the moment it is doing it.
A mode that silently changes what clicking means is indistinguishable from a
broken site.

## Decorative artwork and `pointer-events: none`

An element the browser will not hit-test is never an `event.target`, so the
picker used to walk straight past every ornament on a site — a corner
animation, a bleed illustration, a `.grain` plate — and select the section
behind it. It reads as the tool ignoring your click, because the highlight
does land on _something_.

While the picker is armed, `Lab.tsx` walks the document once and marks every
`pointer-events: none` element `.gl-pickable`, which hands it back its pointer
events; the marks are removed on disarm, so the page's real behaviour is
untouched the moment the picker disarms.

The size guard is the part not to remove. A full-page grain film or a fixed
colour wash is also `pointer-events: none`, and unmasking it makes it the
answer to every pick on the page — so anything covering more than 80% of the
viewport is left masked, and the artwork under it stays reachable.

## Preview unmounts the panel

`preview` does not hide the panel, it returns a different tree — the float
button and nothing else. Coming back mounts a **new** `.gl-panel` node.

Anything that reaches for that node has to be tied to the node's own lifetime,
not to a mode flag. The drag handling was first written as an effect keyed on
whether the lab was on; after one trip through preview its `pointerdown`
listener was still attached to the old detached bar and the panel had silently
stopped being draggable. It is a ref callback now: React hands it the node and
calls the function it returns when that node goes away.

The same trap is waiting for anything else that grabs a DOM node here. Also
note that a node kept in `useState` cannot be written to — the React Compiler's
immutability rule rejects `el.style.x = …` on a state value — which is a second
reason the ref callback is the right shape.

## The pointer disappears while the picker is armed, on purpose

The armed picker hides the native cursor and draws its own. If the replacement fails
to render — an old copy of `lab.css`, a CSP that blocks the data URI, a browser
that ignores `cursor: none` — the result is a page with **no visible pointer at
all**, which reads as the tool having crashed.

Two rules follow. The `cursor: none` and the `.gl-cursor` markup must ship
together, so never sync one file of this tool without the others. And the
override has to be `!important` across the whole tree: every link and button on
a host site sets its own cursor, so an inherited `none` is lost, and a
half-applied rule gives you the worst case — the native arrow on some elements
and nothing on the rest.

The panel keeps a real cursor deliberately. Inside it you are operating a UI,
not picking, and a crosshair over a text field is a lie about what a click will
do.

## The comment picker disarms itself; the click handler needs a ref

In comment mode the picker is armed only until a component is picked — one
click, one pin, then the global "every element is a target" state ends.

The handler that does this is bound ONCE, when the picker arms, so a captured
`commenting` would be whatever it was at that moment. It reads a ref instead.
A stale capture here is not a visible bug: it simply never disarms, and every
subsequent click opens another composer elsewhere on the page, which looks like
the tool having lost track rather than like a closure problem.

## A watcher outlives the feature it was started for

`watch-notes.mjs` is a long-running process; the file it runs is not. Add an
event type, update every copy on disk, and the watcher that has been up since
this morning carries on announcing the old set — silently, because a watcher
with nothing to say looks exactly like a quiet afternoon.

It has cost this project twice. A whole set of pinned threads went unread
because the running watcher predated them. A crash that had been fixed stayed
crashed in a process nobody thought to restart.

The watcher now stats its OWN source alongside the state file, and exits with
code 75 and a `LAB STALE` line the moment it changes. It cannot restart itself
— nothing in it can — but a loud stop is recoverable and a silent miss is not.

**So: after changing anything in `scripts/`, restart the watchers.** The sync
script updates the files; only you can restart the processes reading them.

## The FAB is fixed; the bubbles are not. Do not swap them

When the client said the picker was "moving when i scroll the screen with the
screen", the FAB looked like the culprit — it is the one draggable control, and
`position: fixed` is literally "moves with the screen". It was rebuilt in
document space. That was wrong, and the correction was immediate:

> for fuck sakes make the fab a fucking fab, it should stick to the screen not
> the fucking page... its scrolling away now

**The split is not a detail, it is the design.** A control exists to be
reachable, so it belongs to the WINDOW and is `position: fixed` with viewport
coordinates. An annotation is about a thing on the page, so it belongs to the
DOCUMENT and is absolutely positioned at `rect.top + scrollY`. Anything that
travels with the wrong one of those two is a bug, in both directions.

The actual cause of that report is the next entry: the bubbles were in document
space all along, but were re-deciding which SIDE to open on ten times a second.

What survived the revert, because it was a real bug either way: the drag
threshold measures from where the pointer went down (`sx`/`sy` captured in
`pointerdown`) rather than comparing `e.clientX - dx` against `el.offsetLeft`.
Those agree only while the element is fixed, so the old form was a trap waiting
for exactly this kind of change.

## A placement that re-decides itself is a thing that moves

`place()` in `Pins` re-measures every anchored element and it runs on every
push from the watcher — ten a second while anything is in flight. That is
correct for the POSITION: it is what keeps a pin on a component whose size just
changed under a live variant.

It was also deciding, on every one of those passes, **which side the bubble
opens on**:

```js
flipX: r.left > window.innerWidth * 0.55,
flipY: r.top > window.innerHeight * 0.45,
```

Both read the element's position _on screen right now_. So scrolling past an
open bubble re-crossed those lines and the bubble hopped from below its pin to
above it, then back, ten times a second. From the outside that is not a flip —
it is a bubble travelling with the screen. The client, on a long page:

> the ligma fucking picker is fucking moving when i scroll the screen with the
> screen. pelase fucking make sure it doesnt move unless its dragged

**The rule this is an instance of: re-measure what the page controls, never
re-decide what the reader controls.** Position is the page's. Which side a
bubble opens on is a choice that was made once, and the only thing allowed to
change it afterwards is the person dragging it.

**Fixed 2026-09-20:** the side lives in a `side` ref keyed by thread id, taken
on the pass that first places that bubble and reused on every pass after.
Entries are deleted when the bubble goes away, so a thread picked up again
later is placed against the screen it is picked up on. The ref is deliberately
outside `box` state — putting it in state would make the placement effect
depend on its own output.

### The one overlay that is allowed to be fixed

`.gl-hi`, the highlight drawn on the element under the pointer while the picker
is armed, IS `position: fixed` and measured in viewport coordinates. It has to
be: it keeps up with a pointer, and converting to document space every mousemove
buys nothing. The cost is that a scroll left it hanging in mid-air while the
component slid out from under it. It now re-measures from `last` — the element
it is drawn on — on a passive `scroll` listener, for as long as the picker is
armed. Everything else per-component (`.gl-pins`, `.gl-pin-at`, `.gl-thread`)
is in document space and needs no listener at all.

---

## A bare `.gl-x` loses to the base chip, silently

**Symptom.** A control you wrote explicit "unboxed" rules for renders as a
boxed chip. The composer's dismiss `×` was a heavy bordered button that went
red on hover; the panel's bar buttons were chips; `use this` — the one filled
commit control in the whole tool — rendered as a plain outline in both states.
Every declaration is right there in `lab.css` and none of them apply.

**Cause.** The base chip is

```css
.gl-panel button,
.gl-thread button { … }
```

which is **one class plus an element** — specificity (0,1,1). A bare override
like `.gl-var-use { … }` is (0,1,0) and loses. So does its hover:
`.gl-var-use:hover:not(:disabled)` is (0,3,0) against the base's
`.gl-panel button:hover:not(:disabled)` at (0,3,1) — level on classes, beaten
on the element column.

It is invisible because nothing errors and the file reads correctly. You look
at the rule, the rule says `border: 0`, and the border is there.

**Fix.** Scope every override under `.gl-panel` or `.gl-thread` to reach
(0,2,0), and scope **rest and hover at matching weight** — a scoped hover over
an unscoped rest means the control is right under the pointer and wrong the
rest of the time, which is worse than being wrong consistently because it
looks deliberate.

**Why it recurs.** The markup passes bare `<button>`s on purpose — the three
button weights are told apart by WHERE they sit, not by a class — and that is
what makes the base rule need an element selector in the first place. The
convenience and the trap are the same decision.

---

## A composer nobody typed in deafens the picker

**Symptom.** The picker arms — ring up, stand-in cursor drawn, FAB filled —
and nothing on the page can be picked. `ff` off and on again does not help.
Nothing in the panel looks wrong. It lasts for the life of the tab.

**Cause.** Three correct decisions meeting.

1. A pick in progress suspends the picker, so the highlight stops chasing the
   pointer while you answer the box.
2. The pin layer is handed an empty compose list outside comment mode, so
   composers do not hang over a page you are trying to browse.
3. Leaving comment mode did not clear `pending`.

So: stray click opens a composer, `ff` or Escape to leave, and the composer
survives — INVISIBLE, because of (2) — while still counting as a pick in
progress, because of (1). The suspension is permanent and there is nothing on
screen that could be dismissed to lift it.

**Fix.** Leaving comment mode sweeps composers nobody typed into
(`clearIdlePending`). Ones with a draft or a pasted image stay, because
Escape is a panic key and losing a half-written sentence to it is the tool
deleting your work at the worst moment — they come back with the mode and go
on suspending the picker, which is correct: there is unfinished business, and
clearing it is one click on the box's own `×`.

**The general shape.** An invisible thing that still gates behaviour. Any
state that (a) suppresses input and (b) is not drawn in every mode it can
survive into will do this, and it is unfalsifiable from the screen — which is
why it reads as "the picker is broken" rather than "there is a box open".

---

## Atomic writes are not serialised writes

**Symptom.** Two agents work two threads. A claim, a step or a reply is
missing afterwards. Nothing errored, nothing logged, and the file is valid
JSON — it is simply a version that does not contain somebody's change.

**Cause.** `lab.mjs` reads the whole document at startup, mutates it in
memory, and writes it back whole. The write is atomic — temp file, then
rename — which guarantees nobody ever reads a half-written file and
guarantees nothing at all about two processes doing read-modify-write over
each other. A reads, B reads, A writes, B writes: A's change is gone.

Measured, twelve concurrent `step` commands on one thread:

```
without the lock:   7 of 12 landed
with the lock:     12 of 12 landed
```

Five silent losses out of twelve, on the tool whose entire job is to say what
is happening.

**Fix.** A lockfile, taken before the read and held for the life of the
process — every command here is read, change, write, exit. `mkdir` is the
primitive because it is atomic everywhere: it either creates the directory or
fails because someone else has it. Stale locks are broken after two seconds,
or one crashed command would wedge every agent on the machine.

**Why it matters more than it looks.** The document has always been designed
for several agents — `threads` carry one `agent` each, and `take` exists to
claim one. This was the thing that made that a nice idea rather than a
working one.

## Data hung off a thread disappears when the thread is finished

`Pins` draws from `here`, which is `threads.filter(t => … t.status !== 'done')`.
That is right for pins and bubbles — a finished conversation should stop
cluttering the page. It was silently wrong for the history trail, which is
_also_ stored on the thread: the trail is recorded while an agent works, and
the thread is marked `done` the moment the agent lands the change. So the
history became unreachable at exactly the point it became worth having. It was
never lost — `state.json` held 14 snapshots for a thread nothing rendered.

The fix is not to keep done threads on screen. It is to reach the data by what
it describes rather than by the thread that happens to hold it:

```ts
const pastOf = (path: string) =>
  threads.filter((t) => t.path === path && (t.trail?.length ?? 0) > 1).at(-1)
```

A composer opened on an element now finds that element's past whatever became
of the conversation that recorded it, and `place()` keeps measuring those
threads so the trail continues to grow while you are looking at it.

**The general shape:** when a lifecycle flag gates _rendering_, check what else
hangs off the same record. Anything that outlives the conversation — history,
attachments, decisions — needs its own way in, keyed by the thing it is about.

## A `?? []` fallback is a new array every render

```ts
const items = past.hist[past.at] ?? [] // fresh identity when undefined
```

It reads as a default and behaves as a dependency change. `items` fed both
the canvas repaint and the effect that saves the drawing to local storage, so
the pad wrote storage on **every render** rather than on every change — and
nothing looked wrong, because the value written was always correct.

The lint rule says it plainly ("could make the dependencies change on every
render") and is easy to wave through as pedantry, because most of the time the
left side is defined and the fallback never runs. That is exactly what makes
it worth reading: the identity is unstable whether or not the fallback runs,
since React compares the result, not the branch taken.

`useMemo(() => past.hist[past.at] ?? [], [past])` fixes it. **Memoised for
identity, not for cost** — the computation is an array index.

The same shape hides in `props.items ?? []`, `data?.rows ?? []` and every
`{...x, y: y ?? {}}` that reaches a dependency array.
