import { useSyncExternalStore } from 'react' /** * The grain lab — point at anything on the page, give it grain, and leave a * note on it for whoever is editing the source. * * Reachable only with `?grain` on the URL. It exists because grain is a value * you can only judge on screen: the same `--grain-op` reads as texture on a * small dense card and as a dirty screen on a full-bleed band, and no amount * of reasoning about the number in source settles it. * * Everything is mirrored to `.lab/state.json` through the dev server, so both * the grain settings and the notes survive a restart — which is the point, * since the restart is usually *caused* by the fix a note asked for. */ export type GrainCfg = { /** how much grain, 0 to 1 */ op: number /** how it composites with what is under it */ blend: string /** tile size in px — bigger is a coarser, slower-moving speck */ size: number } export const BLENDS = [ 'normal', 'soft-light', 'multiply', 'screen', 'overlay', 'hard-light', ] as const /* `normal` and a frank opacity on purpose: soft-light over a transparent element composites against nothing and shows nothing, so a first click on a bare div used to look like the lab was broken. Start visible, then dial. */ export const GRAIN_DEFAULT: GrainCfg = { op: 0.35, blend: 'normal', size: 180 } export type Entry = { selector: string; cfg: GrainCfg } export type Note = { id: string /** the shared selector, which is what you *see* — often `.btn` */ selector: string route: string text: string done: boolean at: string /* Identity. Optional because notes written before this existed are still on disk and still have to load. */ /** a path matching this one element only */ path?: string /** the element's own words — how a human finds it in the source */ label?: string tag?: string /** how many elements on this page shared `selector` */ shared?: number /** set from outside, by whoever is making the change this note asks for */ working?: boolean /** images pasted into the note, as paths under `.lab/shots/` */ shots?: string[] } /** * Everything known about a picked element. * * The lab needs two different answers about the same click and used to give * one. 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 on the site", and that is exactly what went wrong — a note left on * the contact page's submit was carried out on the home hero's CTA. */ /** * A conversation pinned to one element, shown on the PAGE under it rather than * in the panel — the Figma move. * * A note and a thread are different acts and that is why both exist. A note is * one instruction, written once, ticked off: "make this ember". A thread is an * open question about a component that may take several turns to settle, and it * stays anchored to the thing it is about while it does. * * ── the agent half ────────────────────────────────────────────────────────── * * Each thread is a UNIT OF WORK that one agent takes on its own. `status` is * the claim: `open` is unclaimed, `working` means an agent has it, `done` means * it finished. `agent` records which one, so two never take the same thread and * so a half-finished claim is traceable to something rather than nobody. * * That is the whole reason the status lives on disk next to the messages * instead of in whichever process happens to be watching: agents are * independent and short-lived, the file is not. */ export type ThreadMessage = { id: string /** 'you' is whoever is looking at the page; 'claude' is an agent on it */ from: 'you' | 'claude' text: string at: string /** which agent wrote it, when more than one has worked the thread */ agent?: string /** images pasted into the message, as paths under `.lab/shots/` */ shots?: string[] } /* ── one moment in an element's life ────────────────────────────────────── A curated read of how the element was actually painted, stamped. Not the source, not the variant that was showing — what the page was doing. The list is deliberately SHORT and deliberately excludes `width`, `height` and `position`. Every one of those is computed from the layout around the element, so writing them back would freeze a responsive element at the size it happened to be, and the replay would lie in a way that looks like a bug in the page. What is here is the things a design tweak actually changes. */ export type Snap = { at: string; style: Record } const TRAIL_PROPS = [ 'color', 'background-color', 'background-image', 'border-radius', 'border-width', 'border-style', 'border-color', 'box-shadow', 'opacity', 'font-family', 'font-size', 'font-weight', 'font-style', 'letter-spacing', 'line-height', 'text-align', 'text-transform', 'text-decoration-line', 'text-decoration-color', 'text-underline-offset', 'padding', 'margin', 'max-width', 'gap', 'display', 'flex-direction', 'align-items', 'justify-content', '-webkit-text-stroke', ] /** how this element is painted, right now */ export function snapOf(el: Element): Record { const cs = getComputedStyle(el) const out: Record = {} for (const k of TRAIL_PROPS) { const v = cs.getPropertyValue(k) if (v) out[k] = v } return out } const same = (a: Record, b: Record) => TRAIL_PROPS.every((k) => a[k] === b[k]) /** how many moments one element keeps */ const TRAIL_MAX = 14 /** * Record a moment, if it is a new one. * * Called from the placement pass, which runs on every resize and every push — * so the guard is the whole design: an identical read writes nothing, and the * page's style only changes when the SOURCE does. In practice that means one * entry when the thread opens and one more each time an agent's edit reaches * the browser, which is exactly the history worth having. * * It lives on the thread and therefore on disk, and that is not incidental. * An agent's edit restarts the dev server; anything held in memory is gone at * the moment you most want the before. */ export function noteTrail(id: string, style: Record): void { const t = state.threads.find((x) => x.id === id) if (!t) return const trail = t.trail ?? [] if (trail.length && same(trail[trail.length - 1].style, style)) return const next = [...trail, { at: new Date().toISOString(), style }].slice(-TRAIL_MAX) set( { threads: state.threads.map((x) => (x.id === id ? { ...x, trail: next } : x)) }, 'now', ) } /** where the scrubber is parked, per thread. Local: where you have dragged a slider back to is a thing you are looking at, not a fact about the page. */ export const scrubTo = (id: string, i: number | null) => set({ scrub: { ...state.scrub, [id]: i } }, false) /* ── undo ──────────────────────────────────────────────────────────────── Scrubbing is a PREVIEW: it pins the element to a past moment with injected CSS and changes nothing on disk, so the moment you let go of the slider — or the moment an agent touches the file again — it is gone. Undo is the other half, and it has to go through an agent, because the lab can read computed style but has no idea which rule in which file produced it. So undo does not revert anything itself. It says, precisely, what moving back would mean — every property that drifted, with both values — and hands that to whoever picks the thread up. A request an agent can act on without guessing beats a silent write the lab could only fake. */ /** the properties that differ between two moments, written now → then */ const driftOf = (then: Record, now: Record) => TRAIL_PROPS.filter((k) => (then[k] ?? '') !== (now[k] ?? '')).map( (k) => `${k}: ${clip(now[k])} → ${clip(then[k])}`, ) /* Computed values run long — a box-shadow or a font stack is a paragraph on its own, and six of those buries the one line that matters. */ const clip = (v?: string) => (!v ? '—' : v.length > 46 ? `${v.slice(0, 45)}…` : v) /** how many drifted properties the message spells out before counting */ const DRIFT_SHOWN = 8 /** * Ask for a past moment back. * * Posts the request as an ordinary message on the thread, which means it * reopens a finished one — and a finished thread is the normal case, because * you want the change back that an agent just landed and closed. * * It also DROPS THE SCRUB. Leaving the element pinned to the past while the * request is outstanding would show the undo as already done, and then mask * whatever the agent actually wrote when it landed — the same invisible-state * failure as the abandoned composer that deafened the picker. The page goes * back to telling the truth, and the field over the component says the work * is out. */ export function undoTo(id: string, i: number): void { const t = state.threads.find((x) => x.id === id) const trail = t?.trail ?? [] const then = trail[i] const now = trail[trail.length - 1] /* the live end is not a past moment, and undoing to it is a no-op */ if (!t || !then || !now || i >= trail.length - 1) return const back = trail.length - 1 - i const drift = driftOf(then.style, now.style) const shown = drift.slice(0, DRIFT_SHOWN) const more = drift.length - shown.length sayOnThread( id, `Undo — put ${t.selector} back to how it was ${back} change${ back === 1 ? '' : 's' } ago (${then.at}). Change it in the source, not with an override.` + (shown.length ? `\n${shown.join('\n')}${more > 0 ? `\n…and ${more} more` : ''}` : '\nNothing in the tracked properties drifted — check layout and copy.'), ) scrubTo(id, null) } /** * The override for any thread scrubbed back into its past. * * `null` or the last index means live, and emits nothing — the page showing * its own truth is always the default here, the same rule the variant preview * follows. */ export function trailCss( threads: Thread[], scrub: Record, ): string { const out: string[] = [] for (const t of threads) { const trail = t.trail ?? [] const at = scrub[t.id] if (at == null || at >= trail.length - 1 || !trail[at]) continue const body = Object.entries(trail[at].style) .map(([k, v]) => ` ${k}: ${v};`) .join('\n') out.push(`/* trail ${t.id} · ${trail[at].at} */\n${t.path} {\n${body}\n}`) } return out.join('\n\n') } export type Thread = { id: string /** the shared selector, which is what you see — often `.btn` */ selector: string /** a path matching this one element only: how it is found again */ path: string /** how this element has been painted over time — see `noteTrail` */ trail?: Snap[] /** the element's own words — how a human finds it in the source */ label?: string tag?: string route: string messages: ThreadMessage[] /** * What the agent working this thread has done so far, appended as it goes. * * A claim tells you a thread is taken; it does not tell you whether anything * is happening. On a thread that takes several minutes those are very * different questions, and the honest answer to the second one is a list the * agent writes itself rather than a spinner that means nothing. */ steps?: { id: string; at: string; text: string }[] /** the claim. `open` is nobody's, and the only state an agent may take. */ status: 'open' | 'working' | 'done' /** whoever claimed it */ agent?: string at: string } export type Target = { /** shared class selector — what grain is applied to */ sel: string /** a path that matches this element and nothing else */ path: string /** the element's own words, trimmed */ label: string tag: string /** how many elements on the page share `sel` */ shared: number } /** * A set of alternatives for one element, written by the AGENT and decided by * the reader. * * The loop this closes: "give me three versions of this button" used to mean * the agent picks one, ships it, and you say no — three round trips to see two * options. Here all three exist at once as CSS, you flip between them on the * real page at the real size against the real background, and the one you * click is the one that gets written into the source. * * CSS-only on purpose. A variant that can change markup would need the agent * to ship three components and a switch, which is a branch in the source for * something that is meant to be a question. Nearly everything anyone asks for * variants OF — a button, a card, a heading, a rule — is a ruleset. */ export type VariantOption = { /** short and typeable: 'a', 'b', 'c' */ key: string label: string /** the ruleset. Unlayered, so it beats the app's `@layer components`. */ css: string /** one line on what this one is doing differently */ note?: string } /** one line of the conversation hanging off a variant set */ export type VariantMessage = { id: string /** 'you' is whoever is looking at the page; 'claude' is whoever is editing */ from: 'you' | 'claude' text: string at: string } export type VariantSet = { id: string selector: string /** the element's own words, so you can tell two `.btn`s apart */ label?: string route: string /** what was asked for, verbatim */ ask: string /** * The thread this set answers, when it was asked for on one. * * Set it and the chips live in that thread's bubble, under the element they * are about, instead of in the panel's variants tab — which put the question * and its answers on opposite corners of the screen and made you compare * five flight paths by memory. Leave it off and the set behaves as it always * did, so sets written before threads existed still show up. */ thread?: string /** when the choice was made — see `chooseVariant`. Absent on sets decided before this existed, which read as "no pick yet" and simply keep the older behaviour. */ decidedAt?: string options: VariantOption[] /** * The decision. `null` while still comparing, `[]` for "keep the original", * otherwise the one key that was kept. * * ONE. It was briefly multi-select, on the theory that a set is rarely a set * of finished answers — one has the right layout, another the right colour — * and that being made to pick one throws half the answer away. In practice * the chips stopped reading as a choice at all: nothing said whether you * were looking at one treatment or three stacked, the commit said "use these * 3", and what the combination actually cascaded to was anyone's guess. * Asking for the combination in words is the honest version of that, and the * thread is right there for it. * * Still typed as a list and still written as one, because both shapes are on * disk already and `chosenKeys` reads either. Nothing writes more than one * entry any more. */ chosen: string | string[] | null /** the thread, when words were needed as well as chips */ messages?: VariantMessage[] at: string } /** the decision as a list, whichever shape it was written in */ export const chosenKeys = (v: VariantSet): string[] | null => v.chosen === null ? null : Array.isArray(v.chosen) ? v.chosen : v.chosen ? [v.chosen] : [] type State = { /** insertion-ordered, so the copied CSS reads in the order it was built */ entries: Entry[] notes: Note[] /** which entry the sliders are driving */ active: string | null /** the individual element behind `active`, when one was actually clicked */ target: Target | null /** * Components clicked in comment mode that have not been said anything about * yet — one composer each, open on the page at once. * * A LIST, and that is the whole point. The picker used to disarm the moment * you picked, and `target` held one element, so annotating four things meant * four rounds of arm-click-type-send with the page's clicks turned off and * on in between. You can now walk the page dropping composers on everything * that is wrong and fill them in afterwards, which is the order the thought * actually arrives in. * * Separate from `target` rather than replacing it: `target` is also what the * grain tab and the note box are about, and those are genuinely * one-at-a-time. */ pending: Target[] /** alternatives the agent has offered, keyed by set id */ variants: VariantSet[] /** conversations pinned to elements on the page */ threads: Thread[] /** which thread's bubble is open, if any. Local: what you have expanded is nobody else's business. */ /** where each thread's scrubber is parked — see `scrubTo`. Local only: where you have dragged a slider back to is something you are looking at, not a fact about the design. */ scrub: Record openThread: string | null /** which options each set is CURRENTLY showing — a preview, not a decision, and a LIST so two of them can be looked at stacked on each other. Local only: what you are looking at is nobody else's business until you commit to it. */ showing: Record /** Clicking the page picks an element instead of following the link. Defaults to FALSE, and that default is load-bearing. Pick mode swallows clicks in the capture phase, and `labOn()` remembers the flag in `sessionStorage` for the whole tab — so with `picking: true` as the opening state, a tab that once saw `?grain` had every link on the site dead from then on, at a URL with nothing in it to explain why. Arming a mode that changes what clicking MEANS has to be a deliberate act. */ picking: boolean /** false until the saved state has been pulled back from disk */ loaded: boolean saving: boolean } let state: State = { entries: [], notes: [], active: null, target: null, pending: [], variants: [], threads: [], scrub: {}, openThread: null, showing: {}, picking: false, loaded: false, saving: false, } const listeners = new Set<() => void>() const emit = () => listeners.forEach((l) => l()) function set(patch: Partial, persist: boolean | 'now' = true) { state = { ...state, ...patch } emit() if (persist && state.loaded) save(persist === 'now') } /* ── disk ──────────────────────────────────────────────────────────────── The dev server owns the file; this only ever posts the whole document. Debounced because a slider drag fires on every pixel. */ let timer: ReturnType | undefined /** * `now` skips the debounce. * * The debounce exists for slider drags, which fire on every pixel. A note is * the opposite: one discrete act, and the 250ms it used to wait sat at the * front of a chain — save, then the watcher notices, then the work starts — * where it was the only delay nobody had a reason to pay. */ function save(now = false): void { clearTimeout(timer) set({ saving: true }, false) const post = () => { void fetch('/__lab/state', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ entries: state.entries, notes: state.notes, variants: state.variants, threads: state.threads, }), }) .catch(() => undefined) .finally(() => set({ saving: false }, false)) } if (now) post() else timer = setTimeout(post, 250) } export async function loadLab(): Promise { try { const r = await fetch('/__lab/state') const d = (await r.json()) as { entries?: Entry[] notes?: Note[] variants?: VariantSet[] threads?: Thread[] } set( { entries: d.entries ?? [], notes: d.notes ?? [], variants: d.variants ?? [], threads: d.threads ?? [], loaded: true, }, false, ) } catch { set({ loaded: true }, false) } } /** * Pull the file back in when the tab regains focus. * * The file has two writers: this page, and whoever is editing the source — * who ticks a note off on disk once the change it asked for is made. Without * this, the page's next save posts its whole in-memory document and silently * un-ticks every note that was marked done while the tab was in the * background, which is exactly the half of the loop that runs while you are * looking somewhere else. * * Focus is the right moment and not a timer: the page cannot be edited while * it is not on screen, so there is nothing local to lose, and a save is at * most 250ms behind — `saving` guards even that. */ /** * The live half: the dev server pushes the file at us whenever something * other than this page changes it. * * The other writer is whoever is acting on the notes. They mark a note * `working` when they pick it up and `done` when the change lands, and until * this existed the page had no way to know either happened — you wrote a note, * nothing moved, and the only signal that anything was happening was the page * reloading some time later. * * `import.meta.hot` only exists in dev, which is the only place the lab runs. */ export function watchPush(): void { if (!import.meta.hot) return import.meta.hot.on('phx:lab', (d: unknown) => { /* Every collection the file holds, and that has to stay true. THREADS were missing here for their whole first day: the server sends the whole file, this handler read three of the four keys, and a thread's claim, its steps and the agent's replies reached the page only when you switched tabs and `watchDisk` re-read from disk. Live progress you have to alt-tab to see is not live progress — reported as a bubble still saying `open` on a thread an agent had already taken. Typed as a slice of `State` rather than an inline shape, so the next collection added to the store does not get to be silently absent. */ const next = d as Partial> if (!next || typeof next !== 'object') return set( { entries: next.entries ?? [], notes: next.notes ?? [], variants: next.variants ?? [], threads: next.threads ?? [], }, false, ) }) } export function watchDisk(): () => void { const sync = () => { if (document.visibilityState !== 'visible' || state.saving) return void loadLab() } document.addEventListener('visibilitychange', sync) window.addEventListener('focus', sync) return () => { document.removeEventListener('visibilitychange', sync) window.removeEventListener('focus', sync) } } /** * Candidate selectors for a clicked element, most useful first. * * A class is nearly always the right answer here — this codebase styles by * role (`.btn`, `.card`, `.frame`), so graining `.card` is a decision about * every card, which is what a design change actually is. The bare tag is * offered last as an escape hatch for something unclassed. */ export function candidates(el: Element): string[] { const out: string[] = [] for (const c of Array.from(el.classList)) { if (c.startsWith('gl-')) continue out.push(`.${c}`) } const tag = el.tagName.toLowerCase() if (!out.length || ['button', 'a'].includes(tag)) out.push(tag) return [...new Set(out)] } /** * A selector matching the clicked element and nothing else. * * Walks up to the nearest ancestor with an id, or to the root, adding * `:nth-of-type` only where a tag actually repeats among its siblings — so the * common case stays short and readable rather than a wall of indices. The * result is verified against the document before it is returned; a path that * matches more than one element is no use for the job it exists to do. */ function uniquePath(el: Element): string { const parts: string[] = [] let node: Element | null = el while (node && node !== document.documentElement) { if (node.id) { parts.unshift(`#${CSS.escape(node.id)}`) break } const parent: HTMLElement | null = node.parentElement let part = node.tagName.toLowerCase() if (parent) { const same = Array.from(parent.children).filter((c) => c.tagName === node!.tagName) if (same.length > 1) part += `:nth-of-type(${same.indexOf(node) + 1})` } parts.unshift(part) node = parent } const path = parts.join(' > ') try { return document.querySelectorAll(path).length === 1 ? path : `${path} /* ambiguous */` } catch { return path } } /* How many elements share a selector, memoised. * * `document.querySelectorAll('.flex')` walks the whole document, and this is * asked for on hover — once per distinct selector is fine, once per mouse * event is not. Cleared when the page navigates, since the answer is about * the document that is currently on screen. */ const shareCache = new Map() function sharedCount(sel: string): number { const hit = shareCache.get(sel) if (hit !== undefined) return hit let n = 1 try { n = document.querySelectorAll(sel).length } catch { /* an exotic class name — treat it as unique rather than failing */ } shareCache.set(sel, n) return n } export const forgetShares = () => shareCache.clear() /** * The cheap half, for hover. * * Everything in here has to survive being called at the mouse's frame rate, * so it reads the class list and a memoised count and stops. It does NOT * build a unique path: that walks the DOM and then runs a second * whole-document query, and doing both per `mousemove` starved the main * thread badly enough that the page's own IntersectionObserver never got * round to revealing its cards. */ export function hoverInfo( el: Element, ): { sel: string; label: string; shared: number } | null { const sel = candidates(el)[0] if (!sel) return null /* `textContent` allocates the element's whole subtree of text, so only the part that will be shown is kept and huge containers are left alone */ const raw = el.textContent ?? '' return { sel, label: (raw.length > 400 ? raw.slice(0, 400) : raw) .replace(/\s+/g, ' ') .trim() .slice(0, 44), shared: sharedCount(sel), } } /** * Everything the lab needs to know about one click. * * `label` is the part that earns its keep: the element's own words are how * anyone — a person or whoever reads the notes file — finds it in the source. * `.btn` is nine buttons; `.btn` reading "Send request" is one line of one * file. `shared` is shown in the panel so it is visible at a glance that a * selector covers more than the thing under the cursor. */ export function describe(el: Element): Target { const sel = candidates(el)[0] ?? el.tagName.toLowerCase() return { sel, shared: sharedCount(sel), path: uniquePath(el), label: (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 80), tag: el.tagName.toLowerCase(), } } /* ── grain ───────────────────────────────────────────────────────────── */ export function addEntry(selector: string): void { const existing = state.entries.find((e) => e.selector === selector) set({ entries: existing ? state.entries : [...state.entries, { selector, cfg: { ...GRAIN_DEFAULT } }], active: selector, }) } /** * A click on the page selects an element. It does NOT grain it. * * It used to do both, and that was wrong in a way that hid itself: most clicks * here are someone picking a thing to write a note about, and every one of * them was quietly adding a grain entry on a generic selector — `.flex`, * `.wrap`, `.grid`. Twenty notes left seventeen grain plates behind. They went * unnoticed only because a Tailwind selector in the list was invalidating the * whole rule; the moment that was fixed, the site came back veiled in grey. * * Grain is now an explicit act, from the grain tab, on a selector you already * have selected. */ export function pick(el: Element | Target): void { /* takes a described target too, so a caller that already needed one — the comment-mode click, which must also open a composer — is not made to walk the DOM for the same answer twice */ const target = 'path' in el ? el : describe(el) set({ active: target.sel, target }, false) } /** * Add a composer for a clicked component, unless one is already on it. * * The de-dupe is by `path`, which is the unique one — two clicks on the same * button should put the caret back in the composer that is already there, not * stack a second empty one behind it. */ export function addPending(t: Target): void { if (state.pending.some((p) => p.path === t.path)) return set({ pending: [...state.pending, t] }, false) } /* ── which composers have been typed into ────────────────────────────────── A module-scope Set rather than store state, because NOTHING RENDERS from it. It exists so the off switch can tell an abandoned composer from one with a sentence in it, and a value nothing draws does not need to make React re-render when it changes. The drafts themselves stay where they are, local to `Pins` and keyed by path — this records only whether there is one. */ const touched = new Set() /** called as a composer's draft or attachments change */ export const markComposer = (path: string, has: boolean) => { if (has) touched.add(path) else touched.delete(path) } /** composer answered, or dismissed */ export const dropPending = (path: string) => { touched.delete(path) set({ pending: state.pending.filter((p) => p.path !== path) }, false) } export const clearPending = () => { touched.clear() set({ pending: [] }, false) } /* ── leaving comment mode ────────────────────────────────────────────────── Throw away the composers nobody typed into; keep the ones with something in them. Both halves are load-bearing. A composer that survives is INVISIBLE outside comment mode — the pin layer is handed an empty list — while still counting as a pick in progress, and a pick in progress suspends the picker. So an empty box left behind by a stray click meant the picker came back armed and permanently deaf: the ring was up, the cursor was drawn, and nothing could be picked ever again in that tab. And it cannot simply clear everything, because Escape is a panic key. A half-written sentence thrown away by the key you press when the page stops responding is the tool deleting your work at the worst possible moment. */ export function clearIdlePending(): void { const keep = state.pending.filter((p) => touched.has(p.path)) if (keep.length === state.pending.length) return set({ pending: keep }, false) } export function updateActive(patch: Partial): void { set({ entries: state.entries.map((e) => e.selector === state.active ? { ...e, cfg: { ...e.cfg, ...patch } } : e, ), }) } export function removeEntry(selector: string): void { set({ entries: state.entries.filter((e) => e.selector !== selector), active: state.active === selector ? null : state.active, }) } /* Selecting from the grain list is a selector-level choice, so it clears the individual target — a note written after it would otherwise claim to be about whatever element happened to be clicked last. */ export const setActive = (selector: string | null) => set({ active: selector, target: null }, false) export const setPicking = (picking: boolean) => set({ picking }, false) export const clearGrain = () => set({ entries: [], active: null }) /* ── variants ───────────────────────────────────────────────────────────── The sets themselves only ever arrive from disk — the agent writes them, the browser shows them and records one decision. Nothing here invents a set. */ /** * Put one option on screen. A preview, not a decision. '' is the original. * * Exactly one at a time: the chips are a choice, and a choice you can answer * three times at once is not one. Stacking them was tried and the preview * stopped meaning anything — you could not tell by looking whether what was on * screen was one treatment or three, and the combination cascaded in an order * nobody had picked. If two of them are half-right, say so in the thread. */ export const showVariant = (setId: string, key: string) => set({ showing: { ...state.showing, [setId]: key ? [key] : [] } }, false) /** a line in a set's thread, for the combination no chip can express */ export const sayVariant = (setId: string, text: string) => { const body = text.trim() if (!body) return const msg: VariantMessage = { id: `m${Date.now().toString(36)}`, from: 'you', text: body, at: new Date().toISOString(), } set( { variants: state.variants.map((v) => v.id === setId ? { ...v, messages: [...(v.messages ?? []), msg] } : v, ), }, 'now', ) } /** * Commit. This is the only part that is written back, and it is the whole * point of the feature: the agent is waiting on exactly this field. * * Takes a list and is always given nought or one — see `chosen`. */ /* `decidedAt` is what makes a PICK count as taking your turn. The field over a component stops when the last word on the thread is the agent's, so an agent that answers and does not close the thread cannot leave the component dithering. A pick is an answer — but it is not a message, so nothing moved and the field stayed stopped through exactly the window where the agent is writing the choice into the source. Reported as: "after i picked, there is no loading animation". Stamping the decision lets the pin compare it against the last message and work out whose turn it actually is. */ export const chooseVariant = (setId: string, keys: string[]) => set( { variants: state.variants.map((v) => v.id === setId ? { ...v, chosen: keys, decidedAt: new Date().toISOString() } : v, ), }, 'now', ) /** thrown away once the choice is in the source, by whoever put it there */ export const dropVariantSet = (setId: string) => set({ variants: state.variants.filter((v) => v.id !== setId) }, 'now') /** * The CSS for whatever is currently being previewed. * * The option a set has switched on, if any. Only sets still being decided are * emitted — once `chosen` is set the agent is writing it in, and having the * overlay keep painting it would hide whether that landed. * * Still written as a loop over a list: `showing` holds at most one key now, * but every set ever written holds a list and the shape has to keep reading. */ export function variantCss( sets: VariantSet[], showing: Record, ): string { const out: string[] = [] for (const v of sets) { if (v.chosen !== null) continue const keys = showing[v.id] ?? [] if (!keys.length) continue // nothing picked yet: the page shows its own truth /* The element eases between treatments rather than snapping. A set plays itself when nobody is steering it (see the loop in `Pins`), and a snap every few seconds reads as the page glitching; the same easing makes a manual comparison legible too, because you SEE which way a measurement moved instead of inferring it from two stills. `all` is blunt and is the only honest option: the set's CSS is written by an agent and can touch anything, so there is no list to name here. It is a dev-only preview of one element, which is where `all` is affordable. */ out.push( `/* variant ${v.id} · easing */\n${forCss(v.selector)} { transition: all 420ms cubic-bezier(0.2, 0.8, 0.2, 1); }`, ) for (const o of v.options) { if (keys.includes(o.key)) out.push(`/* variant ${v.id} · ${o.label} */\n${o.css}`) } } return out.join('\n\n') } /* ── threads ────────────────────────────────────────────────────────────── Pinned conversations. Everything here is a WRITE the page makes; the claim fields (`status`, `agent`) are written from the other side, by whichever agent picks the thread up — which is why they are plain data on disk and not a lock held by a process. */ /** an id that sorts by creation and cannot collide inside one tick */ const stamp = (prefix: string) => `${prefix}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` /** start a conversation on the element that was just picked */ /* a composer that became a thread is not a draft any more — see `touched` */ export function openThreadOn( t: Target, text: string, route: string, shots: string[] = [], ): void { const body = text.trim() /* an image on its own is a complete thought here — "this, but not like this" with a screenshot attached needs no sentence */ if (!body && !shots.length) return const thread: Thread = { id: stamp('t'), selector: t.sel, path: t.path, label: t.label || undefined, tag: t.tag, route, status: 'open', messages: [ { id: stamp('m'), from: 'you', text: body, at: new Date().toISOString(), ...(shots.length && { shots }), }, ], at: new Date().toISOString(), } touched.delete(t.path) set( { threads: [...state.threads, thread], openThread: thread.id, /* only THIS composer closes; the others are still waiting to be typed into, and sweeping them up here would throw away the walk */ pending: state.pending.filter((p) => p.path !== t.path), }, 'now', ) } /** another line on an existing thread */ export function sayOnThread(id: string, text: string, shots: string[] = []): void { const body = text.trim() if (!body && !shots.length) return const msg: ThreadMessage = { id: stamp('m'), from: 'you', text: body, at: new Date().toISOString(), ...(shots.length && { shots }), } set( { threads: state.threads.map((t) => t.id === id ? { ...t, messages: [...t.messages, msg], /* A reply REOPENS a finished thread. Whoever is reading has just said the answer was not the end of it, and a `done` thread is one no agent will look at again. */ status: t.status === 'done' ? 'open' : t.status, } : t, ), }, 'now', ) } /** fold a thread away without answering it — the reader's call, not an agent's */ export function resolveThread(id: string): void { set( { threads: state.threads.map((t) => (t.id === id ? { ...t, status: 'done' } : t)), openThread: state.openThread === id ? null : state.openThread, /* Any chips still undecided on this thread go back to the PANEL. A resolved thread is filtered out of the pin layer, so a set left attached to one becomes unreachable — still live, still previewing CSS on the page, with nothing left on screen that can answer it. Detaching is the non-destructive half of the choice: resolving a thread says you are done talking about the element, not that the five options you never looked at should be thrown away. Dismiss them in the panel if that is what you meant. */ variants: state.variants.map((v) => v.thread === id && v.chosen === null ? { ...v, thread: undefined } : v, ), }, 'now', ) } /** thrown away entirely — for a thread opened on the wrong element */ export function dropThread(id: string): void { set( { threads: state.threads.filter((t) => t.id !== id), openThread: state.openThread === id ? null : state.openThread, /* the thread was a mistake, so the chips asked for on it go with it — unlike `resolveThread`, where they are handed back to the panel */ variants: state.variants.filter((v) => v.thread !== id), }, 'now', ) } /** which bubble is expanded. Local only; never written to disk. */ export const showThread = (id: string | null) => set({ openThread: id }, false) /* ── notes ───────────────────────────────────────────────────────────── */ /** * Send a pasted image to the dev server and get back the path it was stored * at. * * Posted as raw bytes with the blob's own content type — no multipart, no * form, nothing to parse on the other side. The clipboard carries no * filename, so the server generates one; anything it accepted from here would * be a path traversal waiting to happen. */ export async function putShot(file: Blob): Promise { try { const r = await fetch('/__lab/shot', { method: 'POST', headers: { 'content-type': file.type }, body: file, }) const d = (await r.json()) as { ok?: boolean; path?: string } return d.ok && d.path ? d.path : null } catch { return null } } /** where the panel reads a stored shot back from */ export const shotUrl = (p: string) => `/__lab/shot/${p.split('/').pop() ?? ''}` export function addNote(selector: string, text: string, shots: string[] = []): void { const t = state.target?.sel === selector ? state.target : null const note: Note = { id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, selector, route: typeof location === 'undefined' ? '/' : location.pathname, text: text.trim(), done: false, at: new Date().toISOString(), ...(shots.length && { shots }), ...(t && { path: t.path, label: t.label, tag: t.tag, shared: t.shared }), } set({ notes: [...state.notes, note] }, 'now') } /** One note as a line someone can act on without opening the file: which element, in its own words, on which page. The selector alone was what sent a note about the contact form's submit to the home page's hero CTA. */ export const noteLine = (n: Note): string => [ n.selector, n.label ? `"${n.label}"` : '', `(${n.route})`, n.shared && n.shared > 1 ? `[1 of ${n.shared} sharing ${n.selector}]` : '', `: ${n.text.replace(/\n/g, '\n ')}`, n.path ? `\n path: ${n.path}` : '', n.shots?.length ? `\n shots: ${n.shots.join(', ')}` : '', ] .filter(Boolean) .join(' ') .replace(' : ', ': ') /* Ticking a note by hand also clears `working`: if you have decided it is done, whatever was mid-flight on it is no longer the truth. */ export const toggleNote = (id: string) => set({ notes: state.notes.map((n) => n.id === id ? { ...n, done: !n.done, working: false } : n, ), }) export const removeNote = (id: string) => set({ notes: state.notes.filter((n) => n.id !== id) }) const subscribe = (l: () => void) => { listeners.add(l) return () => { listeners.delete(l) } } const snapshot = () => state export function useGrainLab(): State { return useSyncExternalStore(subscribe, snapshot, snapshot) } /** * The live stylesheet, which is also exactly what gets copied. * * Every selector shares one `::after` block rather than repeating the recipe, * because that is how it would be written by hand — and `border-radius: * inherit` matters more than it looks: without it the plate squares off the * corners of every pill and rounded card it lands on. */ /** * A picked selector, escaped for CSS. * * Tailwind class names contain colons — `.lg:col-span-7` reads `:col-span-7` * as a pseudo-class and is simply invalid. That is not a quiet failure: an * invalid selector anywhere in a comma-separated list **invalidates the whole * rule**, so one Tailwind utility in the list silently killed the grain on * every other selector in it. Escaped here rather than at capture, so what is * stored and shown stays the name a person would type. */ const forCss = (sel: string): string => sel.startsWith('.') && !/[\s>+~,[\]()]/.test(sel) ? `.${CSS.escape(sel.slice(1))}` : sel export function toCss(entries: Entry[]): string { if (!entries.length) return '' const sels = entries.map((e) => forCss(e.selector)).join(',\n') const after = entries.map((e) => `${forCss(e.selector)}::after`).join(',\n') const vars = entries .map( (e) => `${forCss(e.selector)} {\n --grain-op: ${e.cfg.op};\n` + ` --grain-blend: ${e.cfg.blend};\n --grain-size: ${e.cfg.size}px;\n}`, ) .join('\n') return `/* grain, dialled in the lab */ ${sels} { position: relative; isolation: isolate; } ${after} { content: ''; position: absolute; inset: 0; z-index: 0; border-radius: inherit; background-image: var(--grain-img); background-size: var(--grain-size, 180px); background-repeat: repeat; mix-blend-mode: var(--grain-blend, soft-light); opacity: var(--grain-op, 0.3); pointer-events: none; } ${vars}` } const LAB_KEY = 'phx-grain-lab' /** * Whether the lab is armed. Opt-in, so nothing reaches a reader who did not * ask for it — but armed for the whole TAB, not just the one URL. * * `?grain` alone was not enough. Every in-app link is a react-router * navigation to a bare path, so the search string is dropped the moment you * click anything on the site: the lab vanished on the first link you followed * and the only way back was to retype the URL. Session storage lasts exactly * as long as the tab, which is the right lifetime for a dev overlay. * * `?grain=0` turns it back off. */ export function labOn(): boolean { // belt and braces: even reached, this is false in a build, and it stops the // session flag being written on a production origin if (!import.meta.env.DEV) return false if (typeof location === 'undefined') return false const asked = new URLSearchParams(location.search).get('grain') const on = asked !== null && asked !== '0' && asked !== 'off' try { if (asked !== null) { sessionStorage.setItem(LAB_KEY, on ? '1' : '') return on } return sessionStorage.getItem(LAB_KEY) === '1' } catch { // private mode, or storage blocked — fall back to the URL alone return on } }