# Installing LiGMA

Target: a **Vite + React + TypeScript** app. Nothing here needs a router, a
state library, Tailwind, or any dependency at all — the whole tool is four
source files and two scripts, and it adds **zero bytes** to your production
bundle if you wire step 4 the way it says.

Ten minutes end to end.

---

## 1 · Copy the files

```
cp -r LiGMA/src/lib/lab.ts            <app>/src/lib/lab.ts
cp -r LiGMA/src/components/Lab.tsx    <app>/src/components/Lab.tsx
cp -r LiGMA/src/lab.css               <app>/src/lab.css
cp    LiGMA/vite-plugin-lab.ts        <app>/vite-plugin-lab.ts
cp    LiGMA/scripts/lab.mjs           <app>/scripts/lab.mjs
cp    LiGMA/scripts/watch-notes.mjs   <app>/scripts/watch-notes.mjs
```

`Lab.tsx` imports `@/lib/lab`. If your project has no `@` alias, change that
one line to a relative path — it is the only cross-file import in the set.

---

## 2 · Register the Vite plugin

```ts
// vite.config.ts
import { labPlugin } from './vite-plugin-lab'

export default defineConfig({
  plugins: [react(), labPlugin()],
})
```

`labPlugin` is `apply: 'serve'`, so it does not exist in a build. It does three
things: serves `GET/POST /__lab/state`, writes `.lab/state.json` atomically,
and pushes outside changes to the open page over Vite's own HMR socket.

---

## 3 · Define the grain texture (only if you want the grain half)

The panel's grain plate paints `var(--grain-img)`. If your project does not
already have one, add a tiling SVG noise as a data URI:

```css
:root {
  --grain-img: url("data:image/svg+xml;utf8,\
<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'>\
<filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/>\
<feColorMatrix type='saturate' values='0'/></filter>\
<rect width='180' height='180' filter='url(%23n)' opacity='0.5'/></svg>");
}
```

Skip this and the annotation half still works perfectly; only the grain
sliders become no-ops.

---

## 4 · Mount it behind a **build-time** gate

This is the step to get right. Do it the obvious way and the whole overlay
ships to production — see [GOTCHAS §1](GOTCHAS.md).

```tsx
// App.tsx
import { lazy, Suspense } from 'react'

const Lab = import.meta.env.DEV
  ? lazy(() => import('@/components/Lab').then((m) => ({ default: m.Lab })))
  : null

// ...in the tree
{Lab && (
  <Suspense fallback={null}>
    <Lab />
  </Suspense>
)}
```

Three things about that shape, each of which was a bug first:

- **The condition wraps the `import()`, not the JSX.** Guard only the render
  and `lazy(() => import(...))` still evaluates at module scope — Rollup emits
  the chunk for an import it can still see.
- **Do not import the styles from `main.tsx`.** `Lab.tsx` imports `../lab.css`
  itself, so the stylesheet is part of the chunk and leaves with it. Imported
  from the entry it is in every production build's CSS.
- **`?grain` is checked inside the panel**, not at the mount site. Two
  different questions: the mount site's gate is *should this exist in this
  build*, the panel's is *is it wanted right now*. Conflating them is exactly
  what puts the overlay in production.

`labOn()` reads `?grain` off the URL **once** and then remembers it in
`sessionStorage` for the rest of the tab. That second half is not a
convenience — see [GOTCHAS §9](GOTCHAS.md): every client-side `<Link>`
navigates to a bare path, so a URL-only flag dies on the first link you click.
`?grain=0` turns it back off; it expires with the tab.

**Verify it.** Do not take this on trust — build and grep:

```bash
npm run build
grep -o "__lab/state\|gl-panel" dist/assets/*.js dist/assets/*.css
# no output = the overlay is not in your bundle
```

Doing this to the apps it was extracted from took the hub's production
bundle from 97.8 KB to 93.4 KB of JS and 18.9 KB to 17.2 KB of CSS.

---

## 5 · Ignore the state file

```gitignore
# LiGMA scratch (dev tool state: grain settings + notes)
.lab/
```

It is session scratch, not source — notes, grain settings, and any images
pasted into notes (`.lab/shots/`, which is where a few hundred KB per
screenshot accumulates). Anything in it that mattered has already become a
code change.

---

## 6 · Wire the agent

Two halves. **Ear:**

```bash
node scripts/watch-notes.mjs
```

In Claude Code, run that inside a persistent `Monitor` so each new note
arrives as a notification. Anything that turns stdout lines into alerts works.

**Hand:**

```bash
node scripts/lab.mjs list                    # notes and variant sets, with state
node scripts/lab.mjs working <id|text>       # picked it up  → spinner in the browser
node scripts/lab.mjs done    <id|text>       # change landed → ticks itself off
node scripts/lab.mjs open    <id|text>       # put it back

node scripts/lab.mjs variants <set.json>     # offer alternatives to choose from
node scripts/lab.mjs drop     <setId>        # clear a set once its pick is in the source
```

`variants` is the other half of the loop — see [AGENT.md](AGENT.md) for the
file's shape and when to reach for it.

Matching is id → exact text → substring, narrowest wins, and an ambiguous
substring **refuses** rather than moving the wrong note. (It once marked two
notes at once because `"redesign this button"` is a prefix of
`"redesign this button completely"`.)

Finally paste [AGENT.md](AGENT.md) into your `CLAUDE.md` so the agent knows
the loop exists.

---

## 7 · Use it

```
pnpm dev
open http://localhost:5173/?grain
```

Panel, bottom right. `pick` → click anything → type → ⏎.

---

## Porting to something that is not Vite

Only `vite-plugin-lab.ts` is Vite-specific, and it is 100 lines. To port it
you need three things behind a dev-only flag:

1. `GET /__lab/state` → the JSON file (or `{entries:[],notes:[]}`).
2. `POST /__lab/state` → write the body, **atomically** (temp file + rename).
3. A push channel to the page for outside changes. Vite gives this away via
   `server.ws.send({type:'custom', ...})`; anywhere else, a 1s poll of `GET`
   is a fine substitute — the client side is one function (`watchPush`) and
   you swap its body.

Everything else — the store, the identity logic, the panel, the CSS, the two
scripts — is framework-agnostic and depends only on the DOM.
