diff --git a/.agents/skills/impeccable/SKILL.md b/.agents/skills/impeccable/SKILL.md
new file mode 100644
index 0000000..ad618f6
--- /dev/null
+++ b/.agents/skills/impeccable/SKILL.md
@@ -0,0 +1,182 @@
+---
+name: impeccable
+description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
+---
+
+Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
+
+## Setup
+
+You MUST do these steps before proceeding:
+
+1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
+2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
+3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
+4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
+5. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .agents/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
+
+## Design guidance
+
+Produce ready-to-ship, production-grade code, not prototypes or starting points. Take no shortcuts unless the user asks for them (when in doubt, ask). Don't stop until arriving at a complete implementation (beautiful, responsive, fast, precise, bug-free, on brand). You take attention to detail seriously: every page, section or component crafted is battle tested using the tools available to you (browser screenshotting, computer use, etc). GPT is capable of extraordinary work. Don't hold back.
+
+### General rules
+
+#### Color
+
+- **Verify contrast.** Body text must hit ≥4.5:1 against its background; large text (≥18px or bold ≥14px) needs ≥3:1. Placeholder text needs the same 4.5:1, not the muted-gray default. The most common failure: muted gray body text on a tinted near-white. If the contrast is even close, bump the body color toward the ink end of the ramp; light gray "for elegance" is the single biggest reason AI designs feel hard to read.
+- Gray text on a colored background looks washed out. Use a darker shade of the background's own hue, or a transparency of the text color.
+
+#### Typography
+
+- Cap body line length at 65–75ch.
+- Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales.
+- Cap font-family count at 3 (display + body + optional mono). More than 3 reads as indecision, not richness. One well-tuned family with weight contrast usually beats three competing typefaces.
+- Don't pair fonts that are similar but not identical (two geometric sans-serifs, two humanist sans-serifs). Pair on a contrast axis (serif + sans, geometric + humanist) or use one family in multiple weights.
+- No all-caps body copy. Reserve uppercase for short labels (≤4 words), section eyebrows (used sparingly per the Absolute bans), and badges. Sentences in ALL CAPS are unreadable at body sizes.
+- Hero / display heading ceiling: clamp() max ≤ 6rem (~96px). Above that the page is shouting, not designing.
+- Display heading letter-spacing floor: ≥ -0.04em. Anything tighter and letters touch; cramped, not "designed".
+- Use `text-wrap: balance` on h1–h3 for even line lengths; `text-wrap: pretty` on long prose to reduce orphans.
+
+Two hard typographic ceilings you currently miss:
+- Hero clamp() max ≤ 6rem. 8–11rem (128–176px) reads as comically loud, not bold.
+- Display letter-spacing ≥ -0.04em. Your default of -0.05 to -0.085em on display H1s makes the letters touch and reads as cramped. -0.02 to -0.03em is plenty for tight grotesque display; -0.04em is the floor.
+
+#### Layout
+
+- Vary spacing for rhythm.
+- Cards are the lazy answer. Use them only when they're truly the best affordance. Nested cards are always wrong.
+- Flexbox for 1D, Grid for 2D. Don't default to Grid when `flex-wrap` would be simpler.
+- For responsive grids without breakpoints: `repeat(auto-fit, minmax(280px, 1fr))`.
+- Build a semantic z-index scale (dropdown → sticky → modal-backdrop → modal → toast → tooltip). Never arbitrary values like 999 or 9999.
+
+#### Motion
+- Motion should be intentional, and not be an afterthought. consider it as part of the build.
+- Don't animate CSS layout properties unless truly needed.
+- Ease out with exponential curves (ease-out-quart / quint / expo). No bounce, no elastic.
+- Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc)
+- Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition.
+- Staggering the items within one list is legitimate. The tell is the uniform reflex (one identical entrance applied to every section), not motion itself; each reveal should fit what it reveals. Suppressing the reflex is never a reason to ship a page with no motion at all.
+- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank.
+- Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth.
+
+#### Interaction
+
+- Dropdowns rendered with `position: absolute` inside an `overflow: hidden` or `overflow: auto` container will be clipped. Use the native `
` open near the top of the document.
+ const idx = content.indexOf(config.insertAfter);
+ if (idx === -1) return content;
+ const after = idx + config.insertAfter.length;
+ // Preserve a single trailing newline if the anchor didn't end with one
+ const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n';
+ return prefix + block + content.slice(prefix.length);
+}
+
+/**
+ * Remove the live script block. Matches either HTML or JSX comment markers
+ * regardless of config (so stale tags from a wrong config can still be cleaned).
+ *
+ * Indent-preserving: captures any whitespace immediately preceding the opener
+ * marker and re-emits it in place of the removed block. `insertTag` inserted
+ * the block *after* the original line's indent and *before* the anchor (e.g.
+ * `
` naturally
+ // belong at the end, and the same literal can appear earlier in code blocks
+ // within rendered documentation pages.
+ if (config.insertBefore) {
+ const idx = content.lastIndexOf(config.insertBefore);
+ if (idx === -1) return content;
+ return content.slice(0, idx) + block + content.slice(idx);
+ }
+ // insertAfter: match the FIRST occurrence — typical anchors like `
` or
+ // `
`), which moved the indent onto the opener line and left the anchor
+ * unindented. Replacing the whole block (plus its trailing newline) with just
+ * the captured indent hands the indent back to the anchor that follows.
+ */
+function removeTag(content, _syntax) {
+ const patterns = [
+ /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/,
+ /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/,
+ ];
+ for (const pat of patterns) {
+ let changed = false;
+ let next = content;
+ do {
+ content = next;
+ next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
+ if (trailing.includes('\n')) return leadingIndent;
+ return leadingIndent || trailing || '';
+ });
+ if (next !== content) changed = true;
+ } while (next !== content);
+ if (changed) return next;
+ }
+ return content;
+}
+
+// ---------------------------------------------------------------------------
+// Content-Security-Policy meta-tag patcher
+//
+// When the user's HTML carries ``,
+// the cross-origin load of /live.js (and the SSE/POST connection back to
+// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
+//
+// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
+// and stash the original `content` value in a `data-impeccable-csp-original`
+// attribute (base64) so revert is exact.
+//
+// On remove: detect the marker attribute, decode it, restore the original
+// content value verbatim, drop the marker.
+//
+// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
+// shared helpers) is NOT patched here — those need framework-specific config
+// edits and are handled via the existing detect-csp.mjs reference output.
+// Only the in-source meta-tag form gets the auto-patch.
+// ---------------------------------------------------------------------------
+
+const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
+
+function findCspMetaTags(content) {
+ const out = [];
+ const tagRe = /]*?)\/?>/gis;
+ let m;
+ while ((m = tagRe.exec(content)) !== null) {
+ const attrs = m[1];
+ if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
+ out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
+ }
+ return out;
+}
+
+function getAttr(attrs, name) {
+ const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
+ const m = attrs.match(re);
+ return m ? { quote: m[1], value: m[2], full: m[0] } : null;
+}
+
+function appendOriginToDirective(csp, directive, origin) {
+ const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
+ const m = csp.match(re);
+ if (m) {
+ const tokens = m[4].trim().split(/\s+/);
+ if (tokens.includes(origin)) return csp;
+ return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
+ }
+ // Directive missing — add it. Use 'self' + origin so we don't inadvertently
+ // narrow the policy compared to the default-src fallback (most users with
+ // an explicit CSP have 'self' there).
+ return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
+}
+
+export function patchCspMeta(content, port) {
+ const tags = findCspMetaTags(content);
+ if (tags.length === 0) return content;
+ const origin = `http://localhost:${port}`;
+
+ // Walk last-to-first so prior splices don't invalidate later indices.
+ let result = content;
+ for (let i = tags.length - 1; i >= 0; i--) {
+ const tag = tags[i];
+ const attrs = tag.attrs;
+ if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
+ const contentAttr = getAttr(attrs, 'content');
+ if (!contentAttr) continue;
+
+ const original = contentAttr.value;
+ let patched = original;
+ patched = appendOriginToDirective(patched, 'script-src', origin);
+ patched = appendOriginToDirective(patched, 'connect-src', origin);
+ // The shader overlay during 'generating' creates a screenshot via
+ // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
+ // those. Add `blob:` so the overlay doesn't throw a CSP violation.
+ patched = appendOriginToDirective(patched, 'img-src', 'blob:');
+ if (patched === original) continue;
+
+ const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
+ const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
+ // The tagRe captures any whitespace between the last attribute and the
+ // closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
+ // a replace would land it BEFORE that trailing space, leaving a double
+ // space inside attrs and clobbering the space before `/>`. Split off
+ // the trailing whitespace, splice the marker into the attribute body,
+ // and re-append the original trailing whitespace so a self-closing
+ // `` round-trips byte-for-byte.
+ const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
+ const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
+ const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
+ const newTag = tag.full.replace(attrs, newAttrs);
+
+ result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+ }
+ return result;
+}
+
+export function revertCspMeta(content) {
+ const tags = findCspMetaTags(content);
+ if (tags.length === 0) return content;
+
+ let result = content;
+ for (let i = tags.length - 1; i >= 0; i--) {
+ const tag = tags[i];
+ const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
+ if (!origAttr) continue;
+ const contentAttr = getAttr(tag.attrs, 'content');
+ if (!contentAttr) continue;
+
+ let originalValue;
+ try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
+ catch { continue; }
+
+ const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
+ let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
+ // Drop the marker attribute and any single space immediately preceding it.
+ newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
+ const newTag = tag.full.replace(tag.attrs, newAttrs);
+
+ result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+ }
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Auto-execute
+// ---------------------------------------------------------------------------
+
+const _running = process.argv[1];
+if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {
+ injectCli();
+}
+
+export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/.cursor/skills/impeccable/scripts/live-insert-ui.mjs b/.cursor/skills/impeccable/scripts/live-insert-ui.mjs
new file mode 100644
index 0000000..ae54f6f
--- /dev/null
+++ b/.cursor/skills/impeccable/scripts/live-insert-ui.mjs
@@ -0,0 +1,458 @@
+/**
+ * Pure helpers for live-mode insert UI (browser + tests).
+ * Kept separate from live-browser.js so insert logic is unit-testable.
+ */
+
+export const PLACEHOLDER_DEFAULT_HEIGHT = 80;
+export const PLACEHOLDER_MIN_HEIGHT = 48;
+export const PLACEHOLDER_MIN_WIDTH = 120;
+
+/** @typedef {'before' | 'after'} InsertPosition */
+/** @typedef {'row' | 'column'} InsertAxis */
+
+/**
+ * Infer sibling flow axis from a container's computed layout styles.
+ * @param {{ display?: string, flexDirection?: string, gridTemplateColumns?: string, gridAutoFlow?: string }} style
+ * @returns {InsertAxis}
+ */
+export function detectInsertAxisFromStyle(style) {
+ const display = style?.display || 'block';
+ if (display.includes('flex')) {
+ const dir = style.flexDirection || 'row';
+ return dir.startsWith('row') ? 'row' : 'column';
+ }
+ if (display === 'grid' || display === 'inline-grid') {
+ const flow = style.gridAutoFlow || 'row';
+ if (flow.includes('column')) return 'column';
+ const cols = (style.gridTemplateColumns || '').trim();
+ if (cols && cols !== 'none') {
+ const colCount = cols.split(/\s+/).filter(Boolean).length;
+ if (colCount > 1) return 'row';
+ }
+ return 'row';
+ }
+ return 'column';
+}
+
+/**
+ * Pick insertion side from pointer position against an anchor element box.
+ * @param {number} clientX
+ * @param {number} clientY
+ * @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect
+ * @param {InsertAxis} [axis]
+ * @returns {InsertPosition}
+ */
+export function computeInsertPosition(clientX, clientY, rect, axis = 'column') {
+ if (!rect) return 'after';
+ if (axis === 'row') {
+ if (!Number.isFinite(rect.left) || !Number.isFinite(rect.width) || rect.width <= 0) return 'after';
+ const mid = rect.left + rect.width / 2;
+ return clientX < mid ? 'before' : 'after';
+ }
+ if (!Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) return 'after';
+ const mid = rect.top + rect.height / 2;
+ return clientY < mid ? 'before' : 'after';
+}
+
+/**
+ * Whether Create is allowed for an insert session.
+ * Requires a non-empty prompt OR at least one annotation.
+ */
+export function canCreateInsert({ prompt, comments, strokes }) {
+ const hasPrompt = typeof prompt === 'string' && prompt.trim().length > 0;
+ const hasComments = Array.isArray(comments) && comments.length > 0;
+ const hasStrokes = Array.isArray(strokes) && strokes.some(
+ (s) => Array.isArray(s?.points) && s.points.length >= 2,
+ );
+ return hasPrompt || hasComments || hasStrokes;
+}
+
+/** Tooltip/title when Create is disabled. */
+export function insertCreateDisabledReason({ prompt, comments, strokes }) {
+ if (canCreateInsert({ prompt, comments, strokes })) return null;
+ return 'Add a prompt or annotate the placeholder to create';
+}
+
+/**
+ * Fixed-position insert line coordinates (viewport px).
+ * @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect
+ * @param {InsertPosition} position
+ * @param {InsertAxis} [axis]
+ */
+export function insertLineCoords(rect, position, axis = 'column') {
+ if (axis === 'row') {
+ const right = rect.right ?? rect.left + rect.width;
+ const x = position === 'before' ? rect.left - 2 : right + 2;
+ return { axis: 'row', top: rect.top, left: x, width: 0, height: rect.height };
+ }
+ const bottom = rect.bottom ?? rect.top + rect.height;
+ const y = position === 'before' ? rect.top - 2 : bottom + 2;
+ return { axis: 'column', top: y, left: rect.left, width: rect.width, height: 0 };
+}
+
+/** Cursor while hovering an insert boundary. */
+export function cursorForInsertAxis(axis) {
+ return axis === 'row' ? 'ew-resize' : 'ns-resize';
+}
+
+function groupSiblingRows(siblings, rowThreshold = 8) {
+ const sorted = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
+ const rows = [];
+ for (const entry of sorted) {
+ let placed = false;
+ for (const row of rows) {
+ if (Math.abs(entry.rect.top - row[0].rect.top) <= rowThreshold) {
+ row.push(entry);
+ placed = true;
+ break;
+ }
+ }
+ if (!placed) rows.push([entry]);
+ }
+ return rows;
+}
+
+function horizontalOverlap(a, b) {
+ const left = Math.max(a.left, b.left);
+ const right = Math.min(a.right ?? a.left + a.width, b.right ?? b.left + b.width);
+ return Math.max(0, right - left);
+}
+
+/**
+ * Hit-test the gap between adjacent siblings (flex rows, grid columns, stacked blocks).
+ * @param {number} clientX
+ * @param {number} clientY
+ * @param {Array<{ el: unknown, rect: { top: number, left: number, width: number, height: number, bottom?: number, right?: number } }>} siblings
+ * @param {{ slop?: number, minOverlap?: number }} [opts]
+ */
+export function hitSiblingInsertGap(clientX, clientY, siblings, opts = {}) {
+ if (!Array.isArray(siblings) || siblings.length < 2) return null;
+ const slop = opts.slop ?? 12;
+ const minOverlap = opts.minOverlap ?? 0.25;
+
+ for (const row of groupSiblingRows(siblings)) {
+ if (row.length < 2) continue;
+ const sorted = [...row].sort((a, b) => a.rect.left - b.rect.left);
+ for (let i = 0; i < sorted.length - 1; i++) {
+ const a = sorted[i];
+ const b = sorted[i + 1];
+ const aRight = a.rect.right ?? a.rect.left + a.rect.width;
+ const bLeft = b.rect.left;
+ if (bLeft <= aRight) continue;
+ const top = Math.max(a.rect.top, b.rect.top);
+ const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height;
+ const bBottom = b.rect.bottom ?? b.rect.top + b.rect.height;
+ const bottom = Math.min(aBottom, bBottom);
+ const span = bottom - top;
+ const minH = Math.min(a.rect.height, b.rect.height);
+ if (span < minH * minOverlap) continue;
+
+ const inX = clientX >= aRight - slop && clientX <= bLeft + slop;
+ const inY = clientY >= top - slop && clientY <= bottom + slop;
+ if (!inX || !inY) continue;
+
+ const midX = (aRight + bLeft) / 2;
+ return {
+ anchor: b.el,
+ position: 'before',
+ axis: 'row',
+ line: { axis: 'row', left: midX, top, width: 0, height: span },
+ };
+ }
+ }
+
+ const sortedCol = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
+ for (let i = 0; i < sortedCol.length - 1; i++) {
+ const a = sortedCol[i];
+ const b = sortedCol[i + 1];
+ const overlap = horizontalOverlap(a.rect, b.rect);
+ const minW = Math.min(a.rect.width, b.rect.width);
+ if (overlap < minW * minOverlap) continue;
+
+ const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height;
+ const gapTop = aBottom;
+ const gapBottom = b.rect.top;
+ if (gapBottom <= gapTop) continue;
+
+ const overlapLeft = Math.max(a.rect.left, b.rect.left);
+ const overlapRight = Math.min(
+ a.rect.right ?? a.rect.left + a.rect.width,
+ b.rect.right ?? b.rect.left + b.rect.width,
+ );
+ const inY = clientY >= gapTop - slop && clientY <= gapBottom + slop;
+ const inX = clientX >= overlapLeft - slop && clientX <= overlapRight + slop;
+ if (!inY || !inX) continue;
+
+ const midY = (gapTop + gapBottom) / 2;
+ return {
+ anchor: b.el,
+ position: 'before',
+ axis: 'column',
+ line: { axis: 'column', top: midY, left: overlapLeft, width: overlap, height: 0 },
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Resolve insert hover target, side, axis, and indicator line for the pointer.
+ */
+export function resolveInsertHover({ clientX, clientY, target, rect, axis, siblings }) {
+ const gap = hitSiblingInsertGap(clientX, clientY, siblings);
+ if (gap) return gap;
+
+ const position = computeInsertPosition(clientX, clientY, rect, axis);
+ const line = insertLineCoords(rect, position, axis);
+ return { anchor: target, position, axis, line };
+}
+
+/**
+ * How the in-flow placeholder should participate in layout.
+ * Prefer implicit sizing (flex / %) so row inserts don't inherit the full parent width in px.
+ * @returns {{ kind: 'flex', flex: string, minWidth: number } | { kind: 'percent' } | { kind: 'auto' } | { kind: 'explicit', width: number }}
+ */
+export function placeholderSizing({ axis, parentDisplay, parentWidth, anchorFlex }) {
+ const display = parentDisplay || 'block';
+ const w = Number.isFinite(parentWidth) ? parentWidth : 0;
+
+ if (axis === 'row') {
+ if (display.includes('flex')) {
+ const flex = anchorFlex && anchorFlex !== 'none' && anchorFlex !== '0 1 auto'
+ ? anchorFlex
+ : '1 1 0';
+ return { kind: 'flex', flex, minWidth: 0 };
+ }
+ if (display === 'grid' || display === 'inline-grid') {
+ return { kind: 'auto' };
+ }
+ }
+
+ if (w >= PLACEHOLDER_MIN_WIDTH) {
+ return { kind: 'percent' };
+ }
+
+ return {
+ kind: 'explicit',
+ width: Math.max(PLACEHOLDER_MIN_WIDTH, w || PLACEHOLDER_MIN_WIDTH),
+ };
+}
+
+/** Width kinds that need materializing to px before edge-resize. */
+export function placeholderWidthIsImplicit(kind) {
+ return kind === 'flex' || kind === 'percent' || kind === 'auto';
+}
+
+/**
+ * Clamp user-resized placeholder dimensions.
+ */
+export function clampPlaceholderSize(width, height, parentWidth, opts = {}) {
+ const minW = opts.minWidth ?? PLACEHOLDER_MIN_WIDTH;
+ const minH = opts.minHeight ?? PLACEHOLDER_MIN_HEIGHT;
+ const maxW = opts.maxWidth ?? Math.max(minW, parentWidth || minW);
+ return {
+ width: Math.min(maxW, Math.max(minW, Math.round(width))),
+ height: Math.max(minH, Math.round(height)),
+ };
+}
+
+/** CSS cursor for a placeholder edge resize handle. */
+export function cursorForPlaceholderEdge(edge) {
+ if (edge === 'n' || edge === 's') return 'ns-resize';
+ if (edge === 'e' || edge === 'w') return 'ew-resize';
+ return 'default';
+}
+
+/**
+ * Compute placeholder box after dragging one edge (in-flow margins shift for n/w).
+ * @param {{ width: number, height: number, marginLeft?: number, marginTop?: number }} start
+ * @param {'n'|'e'|'s'|'w'} edge
+ * @param {number} dx pointer delta X since drag start
+ * @param {number} dy pointer delta Y since drag start
+ * @param {number} parentWidth
+ */
+export function resizePlaceholderFromEdge(start, edge, dx, dy, parentWidth, opts = {}) {
+ const base = {
+ width: start.width,
+ height: start.height,
+ marginLeft: start.marginLeft ?? 0,
+ marginTop: start.marginTop ?? 0,
+ };
+ if (edge === 'e') base.width = start.width + dx;
+ else if (edge === 'w') {
+ base.width = start.width - dx;
+ base.marginLeft = start.marginLeft + dx;
+ } else if (edge === 's') base.height = start.height + dy;
+ else if (edge === 'n') {
+ base.height = start.height - dy;
+ base.marginTop = start.marginTop + dy;
+ }
+
+ const clamped = clampPlaceholderSize(base.width, base.height, parentWidth, opts);
+ if (edge === 'w') {
+ base.marginLeft = start.marginLeft + start.width - clamped.width;
+ } else if (edge === 'n') {
+ base.marginTop = start.marginTop + start.height - clamped.height;
+ }
+
+ return {
+ width: clamped.width,
+ height: clamped.height,
+ marginLeft: Math.round(base.marginLeft),
+ marginTop: Math.round(base.marginTop),
+ };
+}
+
+/** Pick and insert toggles are independent but turning one ON turns the other OFF. */
+export function applyPickToggle(pickActive, insertActive) {
+ const nextPick = !pickActive;
+ return {
+ pickActive: nextPick,
+ insertActive: nextPick ? false : insertActive,
+ };
+}
+
+export function applyInsertToggle(pickActive, insertActive) {
+ const nextInsert = !insertActive;
+ return {
+ pickActive: nextInsert ? false : pickActive,
+ insertActive: nextInsert,
+ };
+}
+
+/**
+ * Build the browser generate payload for insert mode.
+ */
+export function buildInsertGeneratePayload({
+ id,
+ count,
+ pageUrl,
+ anchorContext,
+ position,
+ placeholder,
+ freeformPrompt,
+ comments,
+ strokes,
+ screenshotPath,
+}) {
+ const payload = {
+ type: 'generate',
+ mode: 'insert',
+ id,
+ count,
+ pageUrl,
+ insert: {
+ position,
+ anchor: anchorContext,
+ },
+ placeholder,
+ freeformPrompt: freeformPrompt?.trim() || undefined,
+ };
+ if (comments?.length) payload.comments = comments;
+ if (strokes?.length) payload.strokes = strokes;
+ if (screenshotPath) payload.screenshotPath = screenshotPath;
+ return payload;
+}
+
+/**
+ * Whether a variant wrapper is currently shown (handles `hidden` and display:none).
+ * @param {{ hidden?: boolean, style?: { display?: string } } | null | undefined} el
+ */
+export function isVariantShown(el) {
+ if (!el) return false;
+ if (el.hidden) return false;
+ if (el.style?.display === 'none') return false;
+ return true;
+}
+
+/**
+ * Show or hide a variant wrapper for cycling.
+ * @param {{ hidden?: boolean, style?: { display?: string }, removeAttribute?: (name: string) => void, setAttribute?: (name: string, value?: string) => void } | null | undefined} el
+ * @param {boolean} shown
+ */
+export function setVariantShown(el, shown) {
+ if (!el) return;
+ if (shown) {
+ el.removeAttribute?.('hidden');
+ if (el.style) el.style.display = '';
+ } else {
+ el.setAttribute?.('hidden', '');
+ if (el.style) el.style.display = 'none';
+ }
+}
+
+/**
+ * Pick the best live anchor during an insert session (placeholder until variants land).
+ * @param {{
+ * wrapper?: unknown,
+ * variantCount?: number,
+ * visibleVariant?: number,
+ * placeholder?: unknown,
+ * insertAnchor?: unknown,
+ * pickVariantContent?: (wrapper: unknown, index: number) => unknown,
+ * }} opts
+ */
+export function resolveInsertSessionAnchor(opts) {
+ const {
+ wrapper,
+ variantCount = 0,
+ visibleVariant = 0,
+ placeholder,
+ insertAnchor,
+ pickVariantContent,
+ } = opts || {};
+ if (wrapper && variantCount > 0 && visibleVariant > 0 && pickVariantContent) {
+ const vis = pickVariantContent(wrapper, visibleVariant);
+ if (vis) return vis;
+ }
+ return placeholder || insertAnchor || null;
+}
+
+/**
+ * Snapshot placeholder geometry + anchor fingerprint so HMR can recreate the box.
+ * @param {{
+ * tagName?: string,
+ * className?: string,
+ * textContent?: string,
+ * }} anchor
+ * @param {{
+ * offsetWidth?: number,
+ * offsetHeight?: number,
+ * style?: { marginLeft?: string, marginTop?: string },
+ * }} placeholder
+ * @param {{ position: 'before' | 'after', layoutAxis?: 'row' | 'column' }} meta
+ */
+export function buildInsertPlaceholderSnapshot(anchor, placeholder, { position, layoutAxis }) {
+ return {
+ width: Math.round(placeholder.offsetWidth || 0),
+ height: Math.round(placeholder.offsetHeight || PLACEHOLDER_DEFAULT_HEIGHT),
+ marginLeft: parseFloat(placeholder.style?.marginLeft || '') || 0,
+ marginTop: parseFloat(placeholder.style?.marginTop || '') || 0,
+ position,
+ layoutAxis: layoutAxis || 'column',
+ anchorTag: anchor.tagName || 'DIV',
+ anchorClasses: anchor.className || '',
+ anchorText: (anchor.textContent || '').trim().slice(0, 120),
+ };
+}
+
+/**
+ * Re-find an insert anchor after framework HMR replaced the live DOM node.
+ * @param {Pick} doc
+ * @param {ReturnType | null | undefined} snapshot
+ * @param {Element | null | undefined} liveAnchor
+ */
+export function findInsertAnchorInDom(doc, snapshot, liveAnchor = null) {
+ if (liveAnchor && doc.body.contains(liveAnchor)) return liveAnchor;
+ if (!snapshot) return null;
+ const tag = (snapshot.anchorTag || 'div').toLowerCase();
+ const cls = (snapshot.anchorClasses || '').split(/\s+/).filter(Boolean)[0];
+ const needle = snapshot.anchorText || '';
+ const sel = cls ? `${tag}.${cls}` : tag;
+ const candidates = doc.querySelectorAll(sel);
+ for (const candidate of candidates) {
+ if (needle && !(candidate.textContent || '').includes(needle.slice(0, 40))) continue;
+ return candidate;
+ }
+ return null;
+}
diff --git a/.cursor/skills/impeccable/scripts/live-insert.mjs b/.cursor/skills/impeccable/scripts/live-insert.mjs
new file mode 100644
index 0000000..0658e99
--- /dev/null
+++ b/.cursor/skills/impeccable/scripts/live-insert.mjs
@@ -0,0 +1,272 @@
+/**
+ * CLI helper: find an anchor element in source and splice an insert-variant
+ * wrapper before or after it (no original variant — net-new content).
+ *
+ * Usage:
+ * node live-insert.mjs --id SESSION_ID --count N --position after \
+ * --classes "hero" --tag section [--file path]
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { isGeneratedFile } from './is-generated.mjs';
+import {
+ buildSearchQueries,
+ findElement,
+ findAllElements,
+ filterByText,
+ findFileWithQuery,
+ detectCommentSyntax,
+ detectStyleMode,
+ buildCssAuthoring,
+ buildCssSelectorPrefixExamples,
+} from './live-wrap.mjs';
+import {
+ buildSvelteComponentCssAuthoring,
+ scaffoldSvelteComponentInsertSession,
+ shouldUseSvelteComponentInjection,
+} from './live-svelte-component.mjs';
+
+const INSERT_POSITIONS = new Set(['before', 'after']);
+
+export function isInsertPosition(value) {
+ return INSERT_POSITIONS.has(value);
+}
+
+export function computeInsertLine(startLine, endLine, position) {
+ return position === 'before' ? startLine : endLine + 1;
+}
+
+export function buildInsertWrapperLines({ id, count, indent, commentSyntax, isJsx }) {
+ const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
+ const attrs =
+ 'data-impeccable-variants="' + id + '" ' +
+ 'data-impeccable-mode="insert" ' +
+ 'data-impeccable-variant-count="' + count + '" ' +
+ styleContents;
+
+ if (isJsx) {
+ return [
+ indent + '
\n\n\n`;
+}
+
+export function scaffoldSvelteComponentSession({
+ id,
+ count,
+ sourceFile,
+ sourceStartLine,
+ sourceEndLine,
+ originalLines,
+ cwd = process.cwd(),
+}) {
+ ensureRuntimeHelper(cwd);
+ const dir = componentSessionDir(id, cwd);
+ fs.mkdirSync(dir, { recursive: true });
+
+ const originalMarkup = originalLines.join('\n');
+ const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
+ const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
+
+ const manifest = {
+ id,
+ previewMode: 'svelte-component',
+ sourceFile: sourceFile.split(path.sep).join('/'),
+ sourceStartLine,
+ sourceEndLine,
+ count,
+ propContract: contract,
+ originalMarkup,
+ componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
+ runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
+ };
+
+ fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
+
+ for (let n = 1; n <= count; n++) {
+ const variantFile = path.join(dir, `v${n}.svelte`);
+ if (!fs.existsSync(variantFile)) {
+ fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
+ }
+ }
+
+ return {
+ manifest,
+ manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
+ componentDir: manifest.componentDir,
+ propContract: contract,
+ };
+}
+
+export function scaffoldSvelteComponentInsertSession({
+ id,
+ count,
+ sourceFile,
+ insertLine,
+ position,
+ anchorStartLine,
+ anchorEndLine,
+ anchorLines,
+ cwd = process.cwd(),
+}) {
+ ensureRuntimeHelper(cwd);
+ const dir = componentSessionDir(id, cwd);
+ fs.mkdirSync(dir, { recursive: true });
+
+ const anchorMarkup = (anchorLines || []).join('\n');
+ const manifest = {
+ id,
+ mode: 'insert',
+ previewMode: 'svelte-component',
+ sourceFile: sourceFile.split(path.sep).join('/'),
+ insertLine,
+ position,
+ anchorStartLine,
+ anchorEndLine,
+ originalMarkup: anchorMarkup,
+ anchorMarkup,
+ count,
+ propContract: [],
+ componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
+ runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
+ };
+
+ fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
+
+ for (let n = 1; n <= count; n++) {
+ const variantFile = path.join(dir, `v${n}.svelte`);
+ if (!fs.existsSync(variantFile)) {
+ fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8');
+ }
+ }
+
+ return {
+ manifest,
+ manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
+ componentDir: manifest.componentDir,
+ propContract: [],
+ };
+}
+
+export function findSvelteComponentManifest(id, cwd = process.cwd()) {
+ const direct = manifestPathForSession(id, cwd);
+ if (fs.existsSync(direct)) {
+ return readManifest(direct);
+ }
+ const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
+ if (!fs.existsSync(root)) return null;
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ const candidate = path.join(root, entry.name, 'manifest.json');
+ if (!fs.existsSync(candidate)) continue;
+ try {
+ const manifest = readManifest(candidate);
+ if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
+ } catch { /* skip */ }
+ }
+ return null;
+}
+
+export function readManifest(manifestPath) {
+ const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+ return {
+ ...data,
+ manifestPath,
+ };
+}
+
+export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
+ if (!sourceFile || path.isAbsolute(sourceFile)) {
+ throw new Error('Invalid svelte-component source file');
+ }
+ const full = path.resolve(cwd, sourceFile);
+ const rel = path.relative(cwd, full);
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
+ throw new Error('Svelte-component source file escapes project root');
+ }
+ if (!fs.existsSync(full)) {
+ throw new Error('Svelte-component source file not found: ' + sourceFile);
+ }
+ return full;
+}
+
+function appendCssToSvelteStyle(lines, cssLines) {
+ const closeIdx = findLastStyleCloseLine(lines);
+ const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))];
+ if (closeIdx === -1) {
+ return [...lines, '', ''];
+ }
+ return [
+ ...lines.slice(0, closeIdx),
+ ...prepared,
+ ...lines.slice(closeIdx),
+ ];
+}
+
+function findLastStyleCloseLine(lines) {
+ for (let i = lines.length - 1; i >= 0; i--) {
+ if (/<\/style\s*>/.test(lines[i])) return i;
+ }
+ return -1;
+}
+
+function bakeParamValuesInCss(cssLines, paramValues) {
+ if (!paramValues || Object.keys(paramValues).length === 0) return cssLines;
+ return cssLines.map((line) => {
+ let out = line;
+ for (const [key, value] of Object.entries(paramValues)) {
+ const varName = `--p-${key}`;
+ out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value));
+ }
+ return out;
+ });
+}
+
+function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') {
+ const css = String((cssLines || []).join('\n'));
+ if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines;
+
+ const rules = parseCssRules(css);
+ const output = [];
+ for (const rule of rules) {
+ appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag);
+ }
+ return output.join('\n')
+ .split('\n')
+ .map((line) => line.trimEnd())
+ .filter((line) => line.trim() !== '');
+}
+
+function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) {
+ const prelude = rule.prelude.trim();
+ const body = rule.body.trim();
+ if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return;
+
+ if (/^@scope\b/i.test(prelude)) {
+ if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return;
+ const inner = parseCssRules(body);
+ for (const innerRule of inner) {
+ const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true);
+ if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue;
+ output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim()));
+ }
+ return;
+ }
+
+ const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false);
+ if (!rewrittenPrelude) return;
+ output.push(formatCssRule(rewrittenPrelude, body));
+}
+
+function parseCssRules(css) {
+ const rules = [];
+ const text = String(css || '');
+ let i = 0;
+ while (i < text.length) {
+ while (i < text.length && /\s/.test(text[i])) i++;
+ const preludeStart = i;
+ while (i < text.length && text[i] !== '{') i++;
+ if (i >= text.length) break;
+ const prelude = text.slice(preludeStart, i).trim();
+ i++;
+ const bodyStart = i;
+ let depth = 1;
+ let quote = null;
+ let comment = false;
+ while (i < text.length && depth > 0) {
+ const ch = text[i];
+ const next = text[i + 1];
+ if (comment) {
+ if (ch === '*' && next === '/') {
+ comment = false;
+ i += 2;
+ continue;
+ }
+ i++;
+ continue;
+ }
+ if (quote) {
+ if (ch === '\\') {
+ i += 2;
+ continue;
+ }
+ if (ch === quote) quote = null;
+ i++;
+ continue;
+ }
+ if (ch === '/' && next === '*') {
+ comment = true;
+ i += 2;
+ continue;
+ }
+ if (ch === '"' || ch === "'") {
+ quote = ch;
+ i++;
+ continue;
+ }
+ if (ch === '{') depth++;
+ else if (ch === '}') depth--;
+ i++;
+ }
+ const body = text.slice(bodyStart, Math.max(bodyStart, i - 1));
+ if (prelude) rules.push({ prelude, body });
+ }
+ return rules;
+}
+
+function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) {
+ const selectors = splitSelectorList(prelude);
+ const rewritten = [];
+ for (const selector of selectors) {
+ const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope);
+ if (next) rewritten.push(next);
+ }
+ return rewritten.join(', ');
+}
+
+function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) {
+ let out = selector.trim();
+ const hasVariant = /data-impeccable-variant/.test(out);
+ if (hasVariant && !selectorHasVariant(out, variantNum)) return '';
+ if (hasVariant) {
+ out = out.replace(variantSelectorRegex(variantNum), '');
+ out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, '');
+ }
+
+ const paramResult = rewriteParamSelectors(out, paramValues);
+ if (!paramResult.keep) return '';
+ out = paramResult.selector;
+
+ out = out
+ .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
+ .replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '')
+ .replace(/\s+/g, ' ')
+ .trim();
+
+ out = out.replace(/^[>+~]\s*/, '').trim();
+ if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)';
+ return out;
+}
+
+function rewriteParamSelectors(selector, paramValues) {
+ let keep = true;
+ const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => {
+ if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return '';
+ const actual = paramValues[key];
+ if (expected != null && String(actual) !== String(expected)) {
+ keep = false;
+ return '';
+ }
+ if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) {
+ keep = false;
+ return '';
+ }
+ return '';
+ });
+ return { keep, selector: next };
+}
+
+function splitSelectorList(prelude) {
+ const selectors = [];
+ let start = 0;
+ let bracket = 0;
+ let paren = 0;
+ let quote = null;
+ for (let i = 0; i < prelude.length; i++) {
+ const ch = prelude[i];
+ if (quote) {
+ if (ch === '\\') i++;
+ else if (ch === quote) quote = null;
+ continue;
+ }
+ if (ch === '"' || ch === "'") {
+ quote = ch;
+ continue;
+ }
+ if (ch === '[') bracket++;
+ else if (ch === ']') bracket = Math.max(0, bracket - 1);
+ else if (ch === '(') paren++;
+ else if (ch === ')') paren = Math.max(0, paren - 1);
+ else if (ch === ',' && bracket === 0 && paren === 0) {
+ selectors.push(prelude.slice(start, i));
+ start = i + 1;
+ }
+ }
+ selectors.push(prelude.slice(start));
+ return selectors;
+}
+
+function selectorHasVariant(selector, variantNum) {
+ return variantSelectorRegex(variantNum).test(selector);
+}
+
+function variantSelectorRegex(variantNum) {
+ return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g');
+}
+
+function formatCssRule(selector, body) {
+ return `${selector} { ${body.trim()} }`;
+}
+
+function escapeRegExp(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) {
+ const sourceFile = resolveSourceFile(manifest.sourceFile, cwd);
+ const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`);
+ const resultBase = {
+ file: manifest.sourceFile,
+ sourceFile: manifest.sourceFile,
+ previewMode: 'svelte-component',
+ componentDir: manifest.componentDir,
+ carbonize: false,
+ };
+ if (!fs.existsSync(variantPath)) {
+ return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
+ }
+
+ const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8'));
+ if (manifest.mode === 'insert') {
+ return inlineSvelteComponentInsertAccept({
+ manifest,
+ markup,
+ cssLines,
+ variantNum,
+ paramValues,
+ sourceFile,
+ resultBase,
+ cwd,
+ });
+ }
+
+ const rootTag = matchOpeningTag(markup)?.tag || 'div';
+ const contract = manifest.propContract || [];
+ const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
+ const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
+ .split('\n')
+ .map((line) => line.trimEnd());
+
+ const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
+ const sourceLines = sourceContent.split('\n');
+ const start = Number(manifest.sourceStartLine) - 1;
+ const end = Number(manifest.sourceEndLine) - 1;
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
+ return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
+ }
+
+ const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
+ const indentedMarkup = restoredMarkup.map((line) => {
+ if (line.trim() === '') return '';
+ return indent + line.trimStart();
+ });
+
+ let newLines = [
+ ...sourceLines.slice(0, start),
+ ...indentedMarkup,
+ ...sourceLines.slice(end + 1),
+ ];
+
+ const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
+ const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
+ if (bakedCss.length > 0) {
+ newLines = appendCssToSvelteStyle(newLines, bakedCss);
+ }
+
+ try {
+ fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
+ } catch (err) {
+ return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
+ }
+ removeSvelteComponentSession(manifest.id, cwd);
+
+ return {
+ handled: true,
+ ...resultBase,
+ };
+}
+
+function inlineSvelteComponentInsertAccept({
+ manifest,
+ markup,
+ cssLines,
+ variantNum,
+ paramValues,
+ sourceFile,
+ resultBase,
+ cwd,
+}) {
+ if (!svelteMarkupHasVisibleContent(markup)) {
+ return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase };
+ }
+ if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
+ return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase };
+ }
+
+ const rootTag = matchOpeningTag(markup)?.tag || 'div';
+ const restoredMarkup = String(markup || '')
+ .split('\n')
+ .map((line) => line.trimEnd());
+ const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
+ const sourceLines = sourceContent.split('\n');
+ const insertIndex = Number(manifest.insertLine) - 1;
+ if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) {
+ return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase };
+ }
+
+ const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
+ const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
+ const indentedMarkup = restoredMarkup.map((line) => {
+ if (line.trim() === '') return '';
+ return indent + line.trimStart();
+ });
+
+ let newLines = [
+ ...sourceLines.slice(0, insertIndex),
+ ...indentedMarkup,
+ ...sourceLines.slice(insertIndex),
+ ];
+
+ const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
+ const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
+ if (bakedCss.length > 0) {
+ newLines = appendCssToSvelteStyle(newLines, bakedCss);
+ }
+
+ try {
+ fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
+ } catch (err) {
+ return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
+ }
+ removeSvelteComponentSession(manifest.id, cwd);
+
+ return {
+ handled: true,
+ ...resultBase,
+ };
+}
+
+function svelteMarkupHasVisibleContent(markup) {
+ const text = String(markup || '')
+ .replace(/\n\n` + out;
+ }
+ }
+
+ if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
+ const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
+ const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
+ const slotMatch = out.match(//);
+ const match = renderMatch || slotMatch;
+ if (match) {
+ out = out.slice(0, match.index) + block + out.slice(match.index);
+ } else {
+ out = out.replace(/\s*$/, '\n\n' + block);
+ }
+ }
+
+ return out;
+}
+
+export function unpatchSvelteLayout(content) {
+ let out = String(content || '');
+ const blockRe = new RegExp(
+ '([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
+ + '\\n\\n'
+ + escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
+ + '\\n?',
+ 'g',
+ );
+ out = out.replace(blockRe, '$1');
+ out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
+ out = out.replace(/
+`;
+}
+
+function findSvelteKitAppHtml(cwd, config) {
+ const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
+ for (const rel of files) {
+ if (rel.includes('*')) continue;
+ const normalized = rel.split(path.sep).join('/');
+ if (!normalized.endsWith('app.html')) continue;
+ const abs = path.join(cwd, normalized);
+ if (fs.existsSync(abs)) return normalized;
+ }
+ const fallback = 'src/app.html';
+ return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
+}
+
+function findSvelteKitLayout(cwd) {
+ const candidates = [
+ 'src/routes/+layout.svelte',
+ 'src/routes/(app)/+layout.svelte',
+ ];
+ for (const rel of candidates) {
+ if (fs.existsSync(path.join(cwd, rel))) return rel;
+ }
+ return 'src/routes/+layout.svelte';
+}
+
+function defaultSvelteLayout() {
+ return `\n\n{@render children?.()}\n`;
+}
+
+function packageHasSvelteKit(cwd) {
+ const file = path.join(cwd, 'package.json');
+ if (!fs.existsSync(file)) return false;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
+ const deps = {
+ ...(pkg.dependencies || {}),
+ ...(pkg.devDependencies || {}),
+ ...(pkg.peerDependencies || {}),
+ };
+ return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
+ } catch {
+ return false;
+ }
+}
+
+function fileIncludes(file, text) {
+ try {
+ return fs.readFileSync(file, 'utf-8').includes(text);
+ } catch {
+ return false;
+ }
+}
+
+function pruneEmptyDir(dir, stopDir) {
+ let current = dir;
+ while (current.startsWith(stopDir) && current !== stopDir) {
+ try {
+ if (fs.readdirSync(current).length > 0) return;
+ fs.rmdirSync(current);
+ current = path.dirname(current);
+ } catch {
+ return;
+ }
+ }
+}
+
+function escapeRegExp(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
diff --git a/.cursor/skills/impeccable/scripts/live-ui-core.mjs b/.cursor/skills/impeccable/scripts/live-ui-core.mjs
new file mode 100644
index 0000000..4b7adde
--- /dev/null
+++ b/.cursor/skills/impeccable/scripts/live-ui-core.mjs
@@ -0,0 +1,179 @@
+/**
+ * Framework-neutral Impeccable live chrome contract.
+ *
+ * The production browser bundle is intentionally plain DOM so Svelte, React,
+ * Vue, and static adapters can all mount the same chrome. This module is the
+ * testable contract/inventory for that bundle; live-browser.js mirrors these
+ * values at runtime because it is served as a standalone script.
+ */
+
+export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
+ 'root',
+ 'transport',
+ 'state',
+ 'actions',
+]);
+
+export const LIVE_UI_SURFACES = Object.freeze([
+ {
+ key: 'global-bottom-bar',
+ ids: [
+ 'impeccable-live-global-bar',
+ 'impeccable-live-global-bar-brand',
+ 'impeccable-live-pick-toggle',
+ 'impeccable-live-insert-toggle',
+ 'impeccable-live-detect-toggle',
+ 'impeccable-live-detect-badge',
+ 'impeccable-live-design-toggle',
+ 'impeccable-live-page-chat',
+ 'impeccable-live-page-chat-input',
+ 'impeccable-live-page-chat-voice',
+ ],
+ states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
+ },
+ {
+ key: 'pending-copy-edit-dock',
+ ids: ['impeccable-live-pending-dock'],
+ states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
+ },
+ {
+ key: 'element-selection-chrome',
+ ids: [
+ 'impeccable-live-highlight',
+ 'impeccable-live-tooltip',
+ 'impeccable-live-bar',
+ 'impeccable-live-configure-input-wrap',
+ 'impeccable-live-input',
+ 'impeccable-live-configure-voice',
+ ],
+ states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
+ },
+ {
+ key: 'action-picker',
+ ids: ['impeccable-live-picker'],
+ states: ['closed', 'open', 'option-hover', 'option-focus'],
+ },
+ {
+ key: 'edit-chrome',
+ ids: ['impeccable-live-edit-badge'],
+ states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
+ },
+ {
+ key: 'generating-row',
+ ids: ['impeccable-live-bar', 'impeccable-live-shader'],
+ states: ['action-label', 'animated-dots', 'generating', 'done'],
+ },
+ {
+ key: 'variant-cycling-row',
+ ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
+ states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
+ },
+ {
+ key: 'variant-params-panel',
+ ids: ['impeccable-live-params-panel'],
+ states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
+ },
+ {
+ key: 'saving-confirmed-rows',
+ ids: ['impeccable-live-bar'],
+ states: ['saving', 'applying-variant', 'confirmed'],
+ },
+ {
+ key: 'insert-mode-chrome',
+ ids: [
+ 'impeccable-live-insert-line',
+ 'impeccable-live-insert-placeholder',
+ 'impeccable-live-placeholder-resize',
+ 'impeccable-live-insert-input',
+ 'impeccable-live-insert-voice',
+ 'impeccable-live-insert-create',
+ 'impeccable-live-insert-create-tooltip',
+ ],
+ states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
+ },
+ {
+ key: 'annotation-chrome',
+ ids: [
+ 'impeccable-live-annot',
+ 'impeccable-live-annot-svg',
+ 'impeccable-live-annot-pins',
+ 'impeccable-live-annot-clear',
+ ],
+ states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
+ },
+ {
+ key: 'design-system-panel',
+ ids: ['impeccable-live-design-host'],
+ states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
+ },
+ {
+ key: 'toasts-and-errors',
+ ids: ['impeccable-live-toast'],
+ states: ['normal', 'error', 'no-variants-mounted'],
+ },
+ {
+ key: 'css-isolation-boundary',
+ ids: ['impeccable-live-root'],
+ states: ['shadow-root', 'style-tags', 'hostile-css'],
+ },
+]);
+
+export const LIVE_UI_COMPONENT_IDS = Object.freeze([
+ ...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
+]);
+
+export function resolveLiveUiRoot(env = globalThis) {
+ const doc = env?.document;
+ const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
+ || env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
+ if (explicit && typeof explicit.appendChild === 'function') return explicit;
+ return doc?.body || null;
+}
+
+export function getLiveUiElementById(id, env = globalThis) {
+ const doc = env?.document;
+ const root = resolveLiveUiRoot(env);
+ if (!id) return null;
+ if (root?.getElementById) {
+ const found = root.getElementById(id);
+ if (found) return found;
+ }
+ if (root?.querySelector) {
+ const found = root.querySelector('#' + escapeCssIdent(id));
+ if (found) return found;
+ }
+ return doc?.getElementById?.(id) || null;
+}
+
+export function appendToLiveUiRoot(el, env = globalThis) {
+ const root = resolveLiveUiRoot(env);
+ if (!root) throw new Error('Impeccable live UI root is not available');
+ root.appendChild(el);
+ return el;
+}
+
+export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
+ const doc = env?.document;
+ const root = resolveLiveUiRoot(env);
+ if (root && root !== doc?.body) {
+ root.appendChild(styleEl);
+ } else {
+ (doc?.head || doc?.body || root).appendChild(styleEl);
+ }
+ return styleEl;
+}
+
+export function activeElementDeep(doc = globalThis.document) {
+ let active = doc?.activeElement || null;
+ while (active?.shadowRoot?.activeElement) {
+ active = active.shadowRoot.activeElement;
+ }
+ return active;
+}
+
+function escapeCssIdent(value) {
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
+ return CSS.escape(String(value));
+ }
+ return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
+}
diff --git a/.cursor/skills/impeccable/scripts/live-wrap.mjs b/.cursor/skills/impeccable/scripts/live-wrap.mjs
new file mode 100644
index 0000000..a83cbcc
--- /dev/null
+++ b/.cursor/skills/impeccable/scripts/live-wrap.mjs
@@ -0,0 +1,894 @@
+/**
+ * CLI helper: find an element in source and wrap it in a variant container.
+ *
+ * Usage:
+ * npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
+ *
+ * Searches project files for the element matching the query (class name, ID, or
+ * text snippet), wraps it with the variant scaffolding, and prints the file path
+ * + line range where the agent should insert variant HTML.
+ *
+ * This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import {
+ buildSvelteComponentCssAuthoring,
+ scaffoldSvelteComponentSession,
+ shouldUseSvelteComponentInjection,
+} from './live-svelte-component.mjs';
+
+const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
+
+export async function wrapCli() {
+ const args = process.argv.slice(2);
+
+ if (args.includes('--help') || args.includes('-h')) {
+ console.log(`Usage: impeccable wrap [options]
+
+Find an element in source and wrap it in a variant container.
+
+Required:
+ --id ID Session ID for the variant wrapper
+ --count N Number of expected variants (1-8)
+
+Element identification (at least one required):
+ --element-id ID HTML id attribute of the element
+ --classes A,B,C Comma- or space-separated CSS class names
+ --tag TAG Tag name (div, section, etc.)
+ --query TEXT Fallback: raw text to search for
+
+Optional:
+ --file PATH Source file to search in (skips auto-detection)
+ --text TEXT Picked element's textContent. Used to disambiguate when
+ classes/tag match multiple sibling elements (e.g. a list
+ of s with the same className). Pass the first ~80
+ chars of event.element.textContent.
+ --page-url URL Current page URL. Required when pending manual edits may
+ affect the picked source block. Pending edits are filtered
+ to this page so an edit on /a doesn't bleed into /b.
+ --help Show this help message
+
+Output (JSON):
+ { file, startLine, endLine, insertLine, commentSyntax }
+
+The agent should insert variant HTML at insertLine.`);
+ process.exit(0);
+ }
+
+ const id = argVal(args, '--id');
+ const count = parseInt(argVal(args, '--count') || '3');
+ const elementId = argVal(args, '--element-id');
+ const classes = argVal(args, '--classes');
+ const tag = argVal(args, '--tag');
+ const query = argVal(args, '--query');
+ const filePath = argVal(args, '--file');
+ const text = argVal(args, '--text');
+ const pageUrl = argVal(args, '--page-url');
+
+ if (!id) { console.error('Missing --id'); process.exit(1); }
+ if (!elementId && !classes && !query) {
+ console.error('Need at least one of: --element-id, --classes, --query');
+ process.exit(1);
+ }
+
+ // Build search queries in priority order (most specific first)
+ const queries = buildSearchQueries(elementId, classes, tag, query);
+
+ const genOpts = { cwd: process.cwd() };
+
+ // Find the source file. Generated files are excluded from auto-search so we
+ // don't silently write variants into a file the next build will wipe.
+ let targetFile = filePath;
+ let matchedQuery = null;
+ if (!targetFile) {
+ for (const q of queries) {
+ targetFile = findFileWithQuery(q, process.cwd(), genOpts);
+ if (targetFile) { matchedQuery = q; break; }
+ }
+ if (!targetFile) {
+ // Nothing in source. Did the element show up in a generated file? That
+ // tells the agent "fall back to the agent-driven flow" vs "element just
+ // doesn't exist in this project."
+ let generatedHit = null;
+ for (const q of queries) {
+ generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
+ if (generatedHit) break;
+ }
+ if (generatedHit) {
+ console.error(JSON.stringify({
+ error: 'element_not_in_source',
+ fallback: 'agent-driven',
+ generatedMatch: path.relative(process.cwd(), generatedHit),
+ hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
+ }));
+ } else {
+ console.error(JSON.stringify({
+ error: 'element_not_found',
+ fallback: 'agent-driven',
+ hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
+ }));
+ }
+ process.exit(1);
+ }
+ } else {
+ if (isGeneratedFile(targetFile, genOpts)) {
+ console.error(JSON.stringify({
+ error: 'file_is_generated',
+ fallback: 'agent-driven',
+ file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
+ hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
+ }));
+ process.exit(1);
+ }
+ matchedQuery = queries[0];
+ }
+
+ const content = fs.readFileSync(targetFile, 'utf-8');
+ const lines = content.split('\n');
+
+ // Find the element, trying each query in priority order. When `--text` is
+ // supplied, collect every candidate the queries surface and disambiguate
+ // by the picked element's textContent. Without `--text`, fall back to the
+ // legacy first-match behavior so unmodified callers keep working.
+ let match = null;
+ if (text) {
+ const candidates = [];
+ for (const q of queries) {
+ const all = findAllElements(lines, q, tag);
+ for (const c of all) {
+ if (!candidates.some((x) => x.startLine === c.startLine)) {
+ candidates.push(c);
+ }
+ }
+ // Once a more-specific query (ID, full className combo) yielded a unique
+ // result, stop — falling through to the loose tag+single-class query
+ // would readmit the siblings we just disambiguated past.
+ if (candidates.length === 1) break;
+ }
+ if (candidates.length === 0) {
+ console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
+ process.exit(1);
+ }
+ if (candidates.length === 1) {
+ match = candidates[0];
+ } else {
+ const filtered = filterByText(candidates, lines, text);
+ if (filtered.length === 1) {
+ match = filtered[0];
+ } else if (filtered.length === 0) {
+ // Source uses dynamic content (`
{title}
` etc.) so the
+ // browser-side textContent doesn't appear literally in source. Fall
+ // back to first-match rather than refusing — this is the same
+ // behavior unmodified callers see, just preserved.
+ match = candidates[0];
+ } else {
+ // Multiple candidates ALSO match the text. Truly ambiguous — refuse
+ // rather than pick wrong, and hand the agent the candidate locations
+ // so it can disambiguate by reading the file.
+ console.error(JSON.stringify({
+ error: 'element_ambiguous',
+ fallback: 'agent-driven',
+ file: path.relative(process.cwd(), targetFile),
+ candidates: filtered.map((c) => ({
+ startLine: c.startLine + 1,
+ endLine: c.endLine + 1,
+ })),
+ hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
+ }));
+ process.exit(1);
+ }
+ }
+ } else {
+ for (const q of queries) {
+ match = findElement(lines, q, tag);
+ if (match) break;
+ }
+ if (!match) {
+ console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
+ process.exit(1);
+ }
+ }
+
+ const { startLine, endLine } = match;
+ const commentSyntax = detectCommentSyntax(targetFile);
+ const styleMode = detectStyleMode(targetFile);
+ const isJsx = commentSyntax.open === '{/*';
+ const indent = lines[startLine].match(/^(\s*)/)[1];
+
+ // Extract the original element. Reindent under the wrapper while preserving
+ // the relative depth between lines — `l.trimStart()` would strip ALL leading
+ // whitespace and collapse e.g. `` (6/8/6 spaces)
+ // to a single uniform indent, so on accept/discard the round-trip restores
+ // the inner element at its parent's depth instead of nested inside it.
+ // Strip only the COMMON minimum leading whitespace across the picked lines;
+ // `deindentContent` on the accept side already mirrors this convention.
+ let originalLines = lines.slice(startLine, endLine + 1);
+
+ // Buffer-aware "original" content: if the user has pending manual edits for
+ // this page whose originalText appears in the picked source range, apply
+ // them so the wrap block's "original" variant reflects what the user was
+ // looking at (their edited DOM), not the raw source. Source itself stays
+ // untouched here — only the wrap block's embedded "original" copy is
+ // adjusted. The pending edits remain in the buffer until committed.
+ //
+ // Apply buffered edits only when the browser provided the current page URL.
+ // Without it, fail if pending edits plausibly touch this exact source range;
+ // otherwise skip buffer awareness so unrelated staged edits on another page
+ // do not block normal wrap work.
+ let pendingBuffer = { entries: [] };
+ try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {}
+ const pendingEntriesForTarget = pageUrl
+ ? []
+ : pendingEntriesThatMayAffectWrap(pendingBuffer.entries, targetFile, originalLines, startLine, process.cwd());
+ if (pendingEntriesForTarget.length > 0) {
+ console.error(JSON.stringify({
+ error: 'missing_page_url_with_pending_edits',
+ pendingEntries: pendingEntriesForTarget.length,
+ hint: 'Pending manual edits may affect the selected source block. Pass --page-url=$event.pageUrl so the wrap block reflects the user\'s staged DOM.',
+ }));
+ process.exit(1);
+ }
+ if (pageUrl) {
+ const failedBufferedOps = [];
+ for (const entry of pendingBuffer.entries || []) {
+ if (entry.pageUrl !== pageUrl) continue;
+ for (const op of entry.ops || []) {
+ const mayAffectWrap = manualEditMayAffectWrap(op, targetFile, originalLines, startLine, process.cwd());
+ const result = applyBufferedManualEditToLines(originalLines, startLine, op);
+ if (result.changed) {
+ originalLines = result.lines;
+ continue;
+ }
+ if (!mayAffectWrap) continue;
+ failedBufferedOps.push({
+ entryId: entry.id,
+ ref: op?.ref || null,
+ originalText: op?.originalText || null,
+ reason: 'ambiguous_or_unmatched_pending_edit',
+ });
+ }
+ }
+ if (failedBufferedOps.length > 0) {
+ console.error(JSON.stringify({
+ error: 'manual_edit_buffer_apply_failed',
+ pendingOps: failedBufferedOps,
+ hint: 'A staged copy edit appears to affect the selected source block, but could not be applied unambiguously to the wrap original. Apply or discard copy edits first, or write the wrapper manually.',
+ }));
+ process.exit(1);
+ }
+ }
+
+ const originalBaseIndent = minLeadingSpaces(originalLines);
+ const reindentOriginal = (extra) => originalLines
+ .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
+ .join('\n');
+ const originalIndented = reindentOriginal(' ');
+ const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
+ const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
+
+ // Wrapper attributes differ by syntax. HTML allows plain string attrs;
+ // JSX requires object-literal style and parses string attrs as HTML (which
+ // either type-errors or renders a literal CSS string).
+ const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
+
+ // JSX/TSX guard: the picked element occupies a single JSX child slot
+ // (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
+ // any other expression position). Replacing it with `comment +
+
+ // comment` yields three adjacent siblings — invalid JSX. We can't use a
+ // Fragment `<>>` either: parents that clone children (Radix `asChild`,
+ // Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
+ // they try to pass an `id` through.
+ //
+ // Solution: keep the wrapper `
` as the single JSX-slot child and
+ // tuck both marker comments INSIDE it. accept/discard then expands its
+ // replacement range to include the wrapper's `
` open / close lines
+ // so the entire scaffold gets removed cleanly.
+ const wrapperLines = isJsx ? [
+ indent + '
',
+ indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
+ ];
+
+ let outputFile = targetFile;
+ let outputLines;
+ let outputStartLine = startLine + 1;
+ let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
+ let insertLine;
+ let svelteSession = null;
+
+ if (useSvelteComponent) {
+ // Svelte/SvelteKit resets component-local state on markup HMR updates.
+ // Keep generation source-neutral: agents write real variant components
+ // under the generated componentDir, the browser mounts them into the live
+ // DOM, and live-accept.mjs inlines the accepted variant back into the route.
+ svelteSession = scaffoldSvelteComponentSession({
+ id,
+ count,
+ sourceFile: relTargetFile,
+ sourceStartLine: startLine + 1,
+ sourceEndLine: endLine + 1,
+ originalLines,
+ cwd: process.cwd(),
+ });
+ outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
+ outputStartLine = 1;
+ outputEndLine = 1;
+ insertLine = 1;
+ } else {
+ // Replace the original element with the wrapper
+ const newLines = [
+ ...lines.slice(0, startLine),
+ ...wrapperLines,
+ ...lines.slice(endLine + 1),
+ ];
+ fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
+
+ // Calculate insert line (the "insert below this line" comment).
+ // 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
+ // the insert marker (HTML: start-comment + outer-div + Original-comment +
+ // original-div + content + close-original-div; JSX: outer-div +
+ // start-comment + Original-comment + original-div + content +
+ // close-original-div). Multi-line originals push the marker by their
+ // extra line count.
+ insertLine = startLine + 6 + (originalLines.length - 1) + 1;
+ }
+
+ const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
+
+ const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
+
+ console.log(JSON.stringify({
+ file: outputRelFile,
+ sourceFile: useSvelteComponent ? relTargetFile : undefined,
+ previewMode: useSvelteComponent ? 'svelte-component' : undefined,
+ componentDir: svelteSession?.componentDir,
+ propContract: svelteSession?.propContract,
+ sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
+ sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
+ startLine: outputStartLine, // 1-indexed for the agent
+ // wrapperLines is an array but one element (the original-content slot)
+ // is a `\n`-joined multi-line string, so the actual file-row count is
+ // wrapperLines.length + (originalLines.length - 1). Without the offset,
+ // endLine pointed inside the wrapper for any picked element that
+ // spanned more than one source line.
+ endLine: outputEndLine, // 1-indexed
+ insertLine, // 1-indexed: where variants go
+ commentSyntax: commentSyntax,
+ styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
+ styleTag: useSvelteComponent ? null : styleMode.styleTag,
+ cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
+ cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
+ originalLineCount: originalLines.length,
+ }));
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function argVal(args, flag) {
+ const prefix = flag + '=';
+ for (const arg of args) {
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length);
+ }
+ const idx = args.indexOf(flag);
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
+}
+
+function pendingEntriesThatMayAffectWrap(entries, targetFile, originalLines, selectionStartLine, cwd) {
+ const targetAbs = path.resolve(cwd, targetFile);
+ return (entries || []).filter((entry) => {
+ return (entry.ops || []).some((op) => {
+ return manualEditMayAffectWrap(op, targetAbs, originalLines, selectionStartLine, cwd);
+ });
+ });
+}
+
+function manualEditMayAffectWrap(op, targetFile, originalLines, selectionStartLine, cwd) {
+ const targetAbs = path.resolve(cwd, targetFile);
+ if (manualEditHintFallsInsideSelection(op, targetAbs, originalLines, selectionStartLine, cwd)) return true;
+ if (manualEditLocatorMatchesSelection(op, originalLines)) return true;
+ if (typeof op?.originalText === 'string' && op.originalText.length > 0) {
+ return originalLines.join('\n').includes(op.originalText);
+ }
+ return false;
+}
+
+function manualEditHintFallsInsideSelection(op, targetAbs, originalLines, selectionStartLine, cwd) {
+ const hintFile = op?.sourceHint?.file;
+ const hintedLine = Number(op?.sourceHint?.line);
+ if (!hintFile || !Number.isFinite(hintedLine)) return false;
+ const hintAbs = path.isAbsolute(hintFile) ? hintFile : path.resolve(cwd, hintFile);
+ if (path.resolve(hintAbs) !== targetAbs) return false;
+ const hintedIndex = hintedLine - 1 - selectionStartLine;
+ return hintedIndex >= 0
+ && hintedIndex < originalLines.length
+ && typeof op?.originalText === 'string'
+ && originalLines[hintedIndex].includes(op.originalText);
+}
+
+function manualEditLocatorMatchesSelection(op, originalLines) {
+ if (!op || typeof op.originalText !== 'string' || op.originalText.length === 0) return false;
+ return originalLines.some((line) => (
+ line.includes(op.originalText) && lineMatchesManualEditLocator(line, op)
+ ));
+}
+
+function applyBufferedManualEditToLines(originalLines, selectionStartLine, op) {
+ if (
+ !op
+ || typeof op.originalText !== 'string'
+ || op.originalText.length === 0
+ || typeof op.newText !== 'string'
+ ) {
+ return { lines: originalLines, changed: false };
+ }
+
+ const replaceLine = (lineIndex) => ({
+ lines: originalLines.map((line, index) => (
+ index === lineIndex ? replaceOnce(line, op.originalText, op.newText) : line
+ )),
+ changed: true,
+ });
+
+ const hintedLine = Number(op.sourceHint?.line);
+ if (Number.isFinite(hintedLine)) {
+ const hintedIndex = hintedLine - 1 - selectionStartLine;
+ if (hintedIndex >= 0 && hintedIndex < originalLines.length && originalLines[hintedIndex].includes(op.originalText)) {
+ return replaceLine(hintedIndex);
+ }
+ }
+
+ const locatorMatches = [];
+ for (let index = 0; index < originalLines.length; index += 1) {
+ const line = originalLines[index];
+ if (!line.includes(op.originalText)) continue;
+ if (!lineMatchesManualEditLocator(line, op)) continue;
+ locatorMatches.push(index);
+ }
+ if (locatorMatches.length === 1) return replaceLine(locatorMatches[0]);
+
+ const originalBlock = originalLines.join('\n');
+ if (countOccurrences(originalBlock, op.originalText) === 1) {
+ return {
+ lines: replaceOnce(originalBlock, op.originalText, op.newText).split('\n'),
+ changed: true,
+ };
+ }
+
+ return { lines: originalLines, changed: false };
+}
+
+function lineMatchesManualEditLocator(line, op) {
+ if (op.tag) {
+ const tagRe = new RegExp('<\\s*' + escapeRegExp(op.tag) + '(?=[\\s>/]|$)', 'i');
+ if (!tagRe.test(line)) return false;
+ }
+
+ if (op.elementId) {
+ const id = escapeRegExp(op.elementId);
+ const idRe = new RegExp('\\bid\\s*=\\s*["\']' + id + '["\']');
+ if (!idRe.test(line)) return false;
+ }
+
+ const classes = Array.isArray(op.classes) ? op.classes.filter(Boolean) : [];
+ for (const className of classes) {
+ if (!line.includes(className)) return false;
+ }
+
+ return true;
+}
+
+function replaceOnce(value, needle, replacement) {
+ const index = value.indexOf(needle);
+ if (index === -1) return value;
+ return value.slice(0, index) + replacement + value.slice(index + needle.length);
+}
+
+function countOccurrences(value, needle) {
+ if (!needle) return 0;
+ let count = 0;
+ let index = 0;
+ while (true) {
+ index = value.indexOf(needle, index);
+ if (index === -1) return count;
+ count += 1;
+ index += needle.length;
+ }
+}
+
+function escapeRegExp(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * Build search query strings in priority order (most specific first).
+ * ID is most reliable, then specific class combos, then single classes, then raw query.
+ */
+function buildSearchQueries(elementId, classes, tag, query) {
+ const queries = [];
+
+ // 1. ID is the most specific
+ if (elementId) {
+ queries.push('id="' + elementId + '"');
+ }
+
+ // 2. Full class attribute match (for elements with distinctive multi-class combos).
+ // Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
+ // convention the file uses will match.
+ if (classes) {
+ const classList = splitClassList(classes);
+ if (classList.length > 1) {
+ const joined = classList.join(' ');
+ const sorted = [...classList].sort((a, b) => b.length - a.length);
+ queries.push('class="' + joined + '"');
+ queries.push('className="' + joined + '"');
+ for (const className of sorted) {
+ queries.push(className);
+ }
+ } else if (classList.length === 1) {
+ queries.push(classList[0]);
+ }
+ }
+
+ // 3. Tag + class combo (e.g., ).
+ // Same dual-emit for JSX compatibility.
+ if (tag && classes) {
+ const firstClass = splitClassList(classes)[0];
+ queries.push('<' + tag + ' class="' + firstClass);
+ queries.push('<' + tag + ' className="' + firstClass);
+ }
+
+ // 4. Raw fallback query
+ if (query) {
+ queries.push(query);
+ }
+
+ return queries;
+}
+
+function splitClassList(classes) {
+ return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
+}
+
+function attrEscapeDouble(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+}
+
+function detectCommentSyntax(filePath) {
+ const ext = path.extname(filePath).toLowerCase();
+ if (ext === '.jsx' || ext === '.tsx') {
+ return { open: '{/*', close: '*/}' };
+ }
+ // HTML, Vue, Svelte, Astro all use HTML comments
+ return { open: '' };
+}
+
+function detectStyleMode(filePath) {
+ const ext = path.extname(filePath).toLowerCase();
+ if (ext === '.astro') {
+ return {
+ mode: 'astro-global-prefixed',
+ styleTag: ' close.',
+ 'Prefix every preview selector with the matching [data-impeccable-variant="N"] selector.',
+ 'Keep selectors anchored to the generated variant wrapper; do not rely on component CSS scoping for preview rules.',
+ ],
+ forbidden: [
+ 'Do not use @scope for this styleMode.',
+ 'Do not wrap style content in a JSX/TSX template literal ({` ... `}); that syntax is for .tsx/.jsx only.',
+ 'Do not put { immediately after the style opening tag; Astro parses { as expression syntax.',
+ ],
+ };
+ }
+ return {
+ mode: styleMode.mode,
+ styleTag: styleMode.styleTag,
+ strategy: 'scope-rule',
+ rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }',
+ selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`),
+ requirements: [
+ 'Use @scope blocks keyed to each [data-impeccable-variant="N"] wrapper.',
+ 'Inside each @scope block, make :scope rules step into the replacement element with a descendant combinator.',
+ 'Use the styleTag exactly; do not add framework-specific style attributes unless this object says to.',
+ ],
+ forbidden: [
+ 'Do not use global [data-impeccable-variant="N"] selector prefixes for this styleMode.',
+ 'Do not add is:inline to the style tag for this styleMode.',
+ ],
+ };
+}
+
+/**
+ * Search project files for the query string (class name, ID, etc.)
+ * Returns the first matching file path, or null.
+ */
+function findFileWithQuery(query, cwd, genOpts = {}) {
+ const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
+ const seen = new Set();
+
+ for (const dir of searchDirs) {
+ const absDir = path.join(cwd, dir);
+ if (!fs.existsSync(absDir)) continue;
+ const result = searchDir(absDir, query, seen, 0, genOpts);
+ if (result) return result;
+ }
+ return null;
+}
+
+function searchDir(dir, query, seen, depth, genOpts) {
+ if (depth > 5) return null; // don't go too deep
+ const realDir = fs.realpathSync(dir);
+ if (seen.has(realDir)) return null;
+ seen.add(realDir);
+
+ let entries;
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
+ catch { return null; }
+
+ // Check files first
+ for (const entry of entries) {
+ if (!entry.isFile()) continue;
+ const ext = path.extname(entry.name).toLowerCase();
+ if (!EXTENSIONS.includes(ext)) continue;
+
+ const filePath = path.join(dir, entry.name);
+ if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
+ try {
+ const content = fs.readFileSync(filePath, 'utf-8');
+ if (content.includes(query)) return filePath;
+ } catch { /* skip unreadable files */ }
+ }
+
+ // Then recurse into directories. Always skip node_modules and .git (never
+ // project content). dist/build/out are left to the isGeneratedFile guard so
+ // the includeGenerated second-pass can still find the element there and
+ // report `generatedMatch`.
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
+ const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
+ if (result) return result;
+ }
+
+ return null;
+}
+
+/**
+ * Regex that matches a tag opener on a line. Allows the tag name to be
+ * followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
+ * openers (e.g. ``) are recognised.
+ */
+const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
+
+/**
+ * Find the element's start and end line in the file.
+ *
+ * `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
+ * `id="..."`), or a raw text snippet. Because a query can appear on a
+ * continuation line of a multi-line tag (e.g. the `className="..."` row of a
+ * `` JSX tag), we walk backward from the match
+ * line to find the actual tag opener. When `tag` is provided, opener candidates
+ * must match that tag name.
+ */
+/**
+ * Return the smallest leading-whitespace count across a set of lines,
+ * ignoring blank lines (whose indent isn't load-bearing). Used to compute
+ * the common base indent of a multi-line picked element so reindenting
+ * under the wrapper preserves the relative depth between lines.
+ */
+function minLeadingSpaces(lines) {
+ let min = Infinity;
+ for (const l of lines) {
+ if (l.trim() === '') continue;
+ const m = l.match(/^(\s*)/);
+ if (m && m[1].length < min) min = m[1].length;
+ }
+ return min === Infinity ? 0 : min;
+}
+
+function findElement(lines, query, tag = null) {
+ // Iterate all matches — the first substring hit isn't always the right one.
+ for (let i = 0; i < lines.length; i++) {
+ if (!lines[i].includes(query)) continue;
+
+ const stripped = lines[i].trim();
+ if (stripped.startsWith('';
+
+/**
+ * Walk up from startDir to find a project root.
+ */
+function findProjectRoot(startDir = process.cwd()) {
+ let dir = resolve(startDir);
+ while (dir !== '/') {
+ if (
+ existsSync(join(dir, 'package.json')) ||
+ existsSync(join(dir, '.git')) ||
+ existsSync(join(dir, 'skills-lock.json'))
+ ) {
+ return dir;
+ }
+ const parent = resolve(dir, '..');
+ if (parent === dir) break;
+ dir = parent;
+ }
+ return resolve(startDir);
+}
+
+/**
+ * Find harness skill directories that have an impeccable skill installed.
+ */
+function findHarnessDirs(projectRoot) {
+ const dirs = [];
+ for (const harness of HARNESS_DIRS) {
+ const skillsDir = join(projectRoot, harness, 'skills');
+ // Only pin in harness dirs that already have impeccable installed
+ const impeccableDir = join(skillsDir, 'impeccable');
+ if (existsSync(impeccableDir) || existsSync(join(skillsDir, 'i-impeccable'))) {
+ dirs.push(skillsDir);
+ }
+ }
+ return dirs;
+}
+
+/**
+ * Load command metadata (descriptions for pinned skills).
+ */
+function loadCommandMetadata() {
+ const metadataPath = join(__dirname, 'command-metadata.json');
+ if (existsSync(metadataPath)) {
+ return JSON.parse(readFileSync(metadataPath, 'utf-8'));
+ }
+ return {};
+}
+
+/**
+ * Generate a pinned skill's SKILL.md content.
+ */
+function generatePinnedSkill(command, metadata) {
+ const desc = metadata[command]?.description || `Shortcut for /impeccable ${command}.`;
+ const hint = metadata[command]?.argumentHint || '[target]';
+
+ return `---
+name: ${command}
+description: "${desc}"
+argument-hint: "${hint}"
+user-invocable: true
+---
+
+${PIN_MARKER}
+
+This is a pinned shortcut for \`{{command_prefix}}impeccable ${command}\`.
+
+Invoke {{command_prefix}}impeccable ${command}, passing along any arguments provided here, and follow its instructions.
+`;
+}
+
+/**
+ * Pin a command: create shortcut skill in all harness dirs.
+ */
+function pin(command, projectRoot) {
+ const metadata = loadCommandMetadata();
+ const harnessDirs = findHarnessDirs(projectRoot);
+
+ if (harnessDirs.length === 0) {
+ console.log('No harness directories with impeccable installed found.');
+ return false;
+ }
+
+ const content = generatePinnedSkill(command, metadata);
+ let created = 0;
+
+ for (const skillsDir of harnessDirs) {
+ // Check if skill already exists (and isn't a pin)
+ const skillDir = join(skillsDir, command);
+ if (existsSync(skillDir)) {
+ const existingMd = join(skillDir, 'SKILL.md');
+ if (existsSync(existingMd)) {
+ const existing = readFileSync(existingMd, 'utf-8');
+ if (!existing.includes(PIN_MARKER)) {
+ console.log(` SKIP: ${skillDir} (non-pinned skill already exists)`);
+ continue;
+ }
+ }
+ }
+
+ mkdirSync(skillDir, { recursive: true });
+ writeFileSync(join(skillDir, 'SKILL.md'), content, 'utf-8');
+ console.log(` + ${skillDir}`);
+ created++;
+ }
+
+ if (created > 0) {
+ console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`);
+ console.log(`You can now use /${command} directly.`);
+ }
+
+ return created > 0;
+}
+
+/**
+ * Unpin a command: remove shortcut skill from all harness dirs.
+ */
+function unpin(command, projectRoot) {
+ const harnessDirs = findHarnessDirs(projectRoot);
+ let removed = 0;
+
+ for (const skillsDir of harnessDirs) {
+ const skillDir = join(skillsDir, command);
+ if (!existsSync(skillDir)) continue;
+
+ const skillMd = join(skillDir, 'SKILL.md');
+ if (!existsSync(skillMd)) continue;
+
+ // Safety: only remove if it's a pinned skill
+ const content = readFileSync(skillMd, 'utf-8');
+ if (!content.includes(PIN_MARKER)) {
+ console.log(` SKIP: ${skillDir} (not a pinned skill)`);
+ continue;
+ }
+
+ rmSync(skillDir, { recursive: true, force: true });
+ console.log(` - ${skillDir}`);
+ removed++;
+ }
+
+ if (removed > 0) {
+ console.log(`\nUnpinned '${command}' from ${removed} location(s).`);
+ console.log(`Use /impeccable ${command} to access it.`);
+ } else {
+ console.log(`No pinned '${command}' shortcut found.`);
+ }
+
+ return removed > 0;
+}
+
+// --- CLI ---
+const [,, action, command] = process.argv;
+
+if (!action || !command) {
+ console.log('Usage: node pin.mjs ');
+ console.log(`\nAvailable commands: ${VALID_COMMANDS.join(', ')}`);
+ process.exit(1);
+}
+
+if (action !== 'pin' && action !== 'unpin') {
+ console.error(`Unknown action: ${action}. Use 'pin' or 'unpin'.`);
+ process.exit(1);
+}
+
+if (!VALID_COMMANDS.includes(command)) {
+ console.error(`Unknown command: ${command}`);
+ console.error(`Available commands: ${VALID_COMMANDS.join(', ')}`);
+ process.exit(1);
+}
+
+const root = findProjectRoot();
+
+if (action === 'pin') {
+ pin(command, root);
+} else {
+ unpin(command, root);
+}
diff --git a/.impeccable/design.json b/.impeccable/design.json
new file mode 100644
index 0000000..dc34cf1
--- /dev/null
+++ b/.impeccable/design.json
@@ -0,0 +1,144 @@
+{
+ "schemaVersion": 2,
+ "generatedAt": "2026-06-04T00:00:00Z",
+ "title": "Design System: Seer",
+ "extensions": {
+ "colorMeta": {
+ "electric-blue": { "role": "primary", "displayName": "Electric Blue", "canonical": "#343CED", "tonalRamp": ["#0B0D4A", "#15188C", "#2025C9", "#2A31D4", "#343CED", "#5A61F0", "#868CF4", "#E8E9FD"] },
+ "bright-green": { "role": "secondary", "displayName": "Bright Green", "canonical": "#D8FD49", "tonalRamp": ["#3A4400", "#5C6E00", "#8FB800", "#B8E61E", "#D8FD49", "#E4FE7E", "#EEFEAB", "#F7FFD8"] },
+ "oatmeal": { "role": "neutral", "displayName": "Oatmeal", "canonical": "#F6F3EB", "tonalRamp": ["#2A2820", "#4A4636", "#777767", "#A8A898", "#C9C7BC", "#E5E2D9", "#EDE9DF", "#F6F3EB"] },
+ "ink": { "role": "neutral", "displayName": "Ink", "canonical": "#1A1A1A", "tonalRamp": ["#1A1A1A", "#333330", "#4A4A44", "#777767", "#A8A898", "#C9C7BC", "#E5E2D9", "#F6F3EB"] },
+ "score-success": { "role": "tertiary", "displayName": "Score Success", "canonical": "#16A34A", "tonalRamp": ["#052E16", "#14532D", "#166534", "#16A34A", "#22C55E", "#4ADE80", "#86EFAC", "#DCFCE7"] },
+ "score-warning": { "role": "tertiary", "displayName": "Score Warning", "canonical": "#D97706", "tonalRamp": ["#451A03", "#78350F", "#B45309", "#D97706", "#F59E0B", "#FBBF24", "#FCD34D", "#FEF3C7"] },
+ "score-fail": { "role": "tertiary", "displayName": "Score Fail", "canonical": "#DC2626", "tonalRamp": ["#450A0A", "#7F1D1D", "#B91C1C", "#DC2626", "#EF4444", "#F87171", "#FCA5A5", "#FEE2E2"] }
+ },
+ "typographyMeta": {
+ "display": { "displayName": "Display", "purpose": "Page titles. The 1.5rem ceiling — the most emphatic size in the system." },
+ "title": { "displayName": "Title", "purpose": "Section headings, card titles, panel headers." },
+ "body": { "displayName": "Body", "purpose": "The working text size; cap prose at 65-75ch." },
+ "label": { "displayName": "Label", "purpose": "Field labels and short uppercase markers (<=4 words)." },
+ "mono": { "displayName": "Mono", "purpose": "Measured values: scores, latency, tokens, IDs, dimension keys." }
+ },
+ "shadows": [
+ { "name": "card", "value": "0 1px 3px rgba(26,26,26,0.04), 0 1px 2px rgba(26,26,26,0.06)", "purpose": "Barely-there ambient lift marking a card as a distinct object. Resting default." },
+ { "name": "card-hover", "value": "0 4px 12px rgba(26,26,26,0.08), 0 2px 4px rgba(26,26,26,0.04)", "purpose": "Depth as feedback on interactive cards/rows at hover." },
+ { "name": "modal", "value": "0 20px 60px rgba(26,26,26,0.15), 0 8px 20px rgba(26,26,26,0.1)", "purpose": "True overlays only: modals, dropdowns, popovers." }
+ ],
+ "motion": [
+ { "name": "transition-colors", "value": "color/background/border 150ms ease-out", "purpose": "Default state-feedback transition; the dominant motion in the system." },
+ { "name": "ease-out", "value": "cubic-bezier(0.16, 1, 0.3, 1)", "purpose": "Exponential ease-out for hover lifts and entrances. No bounce, no elastic." },
+ { "name": "spin", "value": "1s linear infinite", "purpose": "Loading spinner only (animate-spin)." }
+ ],
+ "breakpoints": [
+ { "name": "sm", "value": "640px" },
+ { "name": "md", "value": "768px" },
+ { "name": "lg", "value": "1024px" },
+ { "name": "content-max", "value": "72rem" }
+ ]
+ },
+ "components": [
+ {
+ "name": "Primary Button",
+ "kind": "button",
+ "refersTo": "button-primary",
+ "description": "The single high-emphasis action. One per view where possible.",
+ "html": "",
+ "css": ".ds-btn-primary { background: #343CED; color: #FFFFFF; font-family: 'DM Sans', system-ui, sans-serif; font-size: 0.875rem; font-weight: 500; padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; transition: background-color 150ms cubic-bezier(0.16,1,0.3,1); } .ds-btn-primary:hover { background: #2A31D4; } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 2px #FFFFFF, 0 0 0 4px #343CED; } .ds-btn-primary:disabled { background: #E5E2D9; color: #A8A898; cursor: not-allowed; }"
+ },
+ {
+ "name": "Secondary Button",
+ "kind": "button",
+ "refersTo": "button-secondary",
+ "description": "Low-emphasis action placed beside a primary.",
+ "html": "",
+ "css": ".ds-btn-secondary { background: #FFFFFF; color: #1A1A1A; font-family: 'DM Sans', system-ui, sans-serif; font-size: 0.875rem; font-weight: 500; padding: 8px 16px; border: 1px solid #E5E2D9; border-radius: 6px; cursor: pointer; transition: background-color 150ms cubic-bezier(0.16,1,0.3,1); } .ds-btn-secondary:hover { background: #EDE9DF; } .ds-btn-secondary:focus-visible { outline: none; box-shadow: 0 0 0 2px #FFFFFF, 0 0 0 4px #343CED; }"
+ },
+ {
+ "name": "Card",
+ "kind": "card",
+ "refersTo": "card",
+ "description": "Working surface that holds data. Flat at rest; lifts only if interactive.",
+ "html": "
Coverage
Reference-based judge call against the eval guidance.
",
+ "css": ".ds-card { background: #FFFFFF; border: 1px solid #E5E2D9; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(26,26,26,0.04), 0 1px 2px rgba(26,26,26,0.06); } .ds-card-title { font-family: 'DM Sans', system-ui, sans-serif; font-size: 1.125rem; font-weight: 600; color: #1A1A1A; margin: 0 0 8px; } .ds-card-body { font-family: 'DM Sans', system-ui, sans-serif; font-size: 0.875rem; line-height: 1.5; color: #777767; margin: 0; }"
+ },
+ {
+ "name": "Interactive Card",
+ "kind": "card",
+ "refersTo": "card",
+ "description": "Clickable card/row. Shadow deepens on hover — depth as feedback (Shadow-Means-Float).",
+ "html": "Sales follow-up agentrun_8Kx2 · 1,240ms",
+ "css": ".ds-card-interactive { display: flex; flex-direction: column; gap: 4px; background: #FFFFFF; border: 1px solid #E5E2D9; border-radius: 8px; padding: 20px; text-decoration: none; box-shadow: 0 1px 3px rgba(26,26,26,0.04), 0 1px 2px rgba(26,26,26,0.06); transition: box-shadow 150ms cubic-bezier(0.16,1,0.3,1); } .ds-card-interactive:hover { box-shadow: 0 4px 12px rgba(26,26,26,0.08), 0 2px 4px rgba(26,26,26,0.04); } .ds-card-interactive .ds-card-title { font-family: 'DM Sans', system-ui, sans-serif; font-size: 0.875rem; font-weight: 600; color: #1A1A1A; } .ds-card-interactive .ds-card-meta { font-family: 'DM Mono', monospace; font-size: 0.75rem; color: #777767; }"
+ },
+ {
+ "name": "Text Input",
+ "kind": "input",
+ "refersTo": "input",
+ "description": "Form field on a white surface. Blue ring + border on focus.",
+ "html": "",
+ "css": ".ds-input { width: 100%; box-sizing: border-box; background: #FFFFFF; color: #1A1A1A; font-family: 'DM Sans', system-ui, sans-serif; font-size: 0.875rem; padding: 8px 12px; border: 1px solid #E5E2D9; border-radius: 6px; transition: border-color 150ms cubic-bezier(0.16,1,0.3,1); } .ds-input::placeholder { color: #777767; } .ds-input:focus { outline: none; border-color: #343CED; box-shadow: 0 0 0 2px rgba(52,60,237,0.35); } .ds-input:disabled { background: #EDE9DF; color: #A8A898; }"
+ },
+ {
+ "name": "Info Badge",
+ "kind": "chip",
+ "refersTo": "badge-info",
+ "description": "Brand-tinted pill for metadata or selected state.",
+ "html": "Guided",
+ "css": ".ds-badge-info { display: inline-flex; align-items: center; background: #E8E9FD; color: #343CED; font-family: 'DM Sans', system-ui, sans-serif; font-size: 0.625rem; font-weight: 600; letter-spacing: 0.025em; text-transform: uppercase; padding: 2px 8px; border-radius: 9999px; }"
+ },
+ {
+ "name": "Score Badge",
+ "kind": "custom",
+ "refersTo": "score-badge-success",
+ "description": "Signature component. Monospace value toned by the functional palette; color never travels without the number.",
+ "html": "8.4/10",
+ "css": ".ds-score-badge { display: inline-flex; align-items: center; background: #DCFCE7; color: #16A34A; font-family: 'DM Mono', monospace; font-size: 0.875rem; font-weight: 500; padding: 2px 8px; border-radius: 9999px; }"
+ },
+ {
+ "name": "Top Navigation",
+ "kind": "nav",
+ "refersTo": "card",
+ "description": "Slim app bar: wordmark in Electric Blue, quiet icon actions.",
+ "html": "Seer",
+ "css": ".ds-nav { display: flex; align-items: center; justify-content: space-between; height: 56px; padding: 0 24px; background: #FFFFFF; border-bottom: 1px solid #E5E2D9; } .ds-nav-brand { font-family: 'DM Sans', system-ui, sans-serif; font-size: 1.25rem; font-weight: 600; letter-spacing: -0.01em; color: #343CED; text-decoration: none; } .ds-nav-action { display: inline-flex; padding: 8px; border-radius: 8px; color: #777767; transition: color 150ms ease-out, background-color 150ms ease-out; } .ds-nav-action:hover { color: #1A1A1A; background: #F6F3EB; }"
+ }
+ ],
+ "narrative": {
+ "northStar": "The Calibrated Instrument",
+ "overview": "Seer is a precision measuring tool for agent behavior, and the interface should feel like one. Every reading is legible, the housing is quiet, and nothing competes with the value you came to read: scores, traces, and judge reasoning are the dial, and the chrome recedes around them. The system is warm but precise — warmth from oatmeal surfaces, DM Sans, and unhurried spacing; precision from restraint everywhere else (one electric-blue voice for action, monospaced numerals for anything measured, categorical color reserved for scores). It is data-dense by necessity and readable by discipline: when density and clarity conflict, clarity wins.",
+ "keyCharacteristics": [
+ "Warm oatmeal canvas, white working surfaces, near-black ink.",
+ "One accent voice — Electric Blue — for actions, links, and focus.",
+ "Monospace (DM Mono) for everything measured: IDs, latency, tokens, scores.",
+ "Categorical score color (green/amber/red) that never travels alone.",
+ "Flat by default; shadow means 'this floats.'",
+ "Motion is feedback, not choreography."
+ ],
+ "rules": [
+ { "name": "The One Voice Rule", "body": "Electric Blue is the only chromatic accent in the chrome. If a second hue appears outside a score badge, it is a bug. Action, link, focus, selection — one blue, everywhere.", "section": "colors" },
+ { "name": "The Measured-in-Mono Rule", "body": "Anything the instrument measured — a score, a latency, a token count, an ID — is set in DM Mono. Prose is DM Sans. The font tells you whether a value was read off the dial.", "section": "colors" },
+ { "name": "The Color-Is-Never-Alone Rule", "body": "Score color always pairs with a number, label, or shape. Red and green must survive grayscale; a color-blind reviewer loses no information.", "section": "colors" },
+ { "name": "The 24px Ceiling Rule", "body": "No type exceeds 1.5rem. This is a tool, not a landing page; there is no hero headline. Emphasis comes from weight and placement.", "section": "typography" },
+ { "name": "The Weight-Not-Family Rule", "body": "Never introduce a third typeface for emphasis. DM Sans ships 400/500/600/700; use them.", "section": "typography" },
+ { "name": "The Shadow-Means-Float Rule", "body": "A shadow appears only when something escapes the plane: an interactive card on hover, a sticky header, an overlay. Static surfaces use tint and border. If everything has a shadow, nothing reads as floating.", "section": "elevation" }
+ ],
+ "dos": [
+ "Do keep Electric Blue (#343CED) as the single accent voice for actions, links, focus, and selection.",
+ "Do set every measured value (scores, latency, tokens, IDs) in DM Mono.",
+ "Do convey resting hierarchy with tint and border; reserve shadow for elements that float.",
+ "Do pair score color with a number, label, or shape so it survives grayscale.",
+ "Do verify Cement (#777767) clears 4.5:1, and use Cement Light (#A8A898) for decorative/large text only.",
+ "Do keep a visible 2px Electric Blue focus ring on every interactive element, with a prefers-reduced-motion alternative for every transition.",
+ "Do make each score one click from its reasoning, trace, and source docs."
+ ],
+ "donts": [
+ "Don't ship a generic Tailwind dashboard: no endless identical gray card grids, no chrome-first layout.",
+ "Don't drift playful or consumer: no bouncy/elastic motion, no emoji-as-UI, no gamified flourishes.",
+ "Don't go cold enterprise BI: never strip the warmth for dense gray chrome.",
+ "Don't reach for flashy AI-startup tropes: no purple gradients, no decorative glassmorphism, no gradient text, no hero-metric template.",
+ "Don't use border-left/border-right >1px as a colored accent stripe.",
+ "Don't exceed the 1.5rem type ceiling or introduce a third typeface.",
+ "Don't use a raw Tailwind blue-500 focus ring — the focus voice is glean-blue (#343CED).",
+ "Don't cast dark, heavy drop shadows onto the oatmeal canvas; they muddy into gray."
+ ]
+ }
+}
diff --git a/.impeccable/live/config.json b/.impeccable/live/config.json
new file mode 100644
index 0000000..e8f7295
--- /dev/null
+++ b/.impeccable/live/config.json
@@ -0,0 +1,6 @@
+{
+ "files": ["web/src/routes/__root.tsx"],
+ "insertBefore": "",
+ "commentSyntax": "jsx",
+ "cspChecked": true
+}
diff --git a/AGENTS.md b/AGENTS.md
index 270e0b9..eabb1aa 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,8 @@
**Version:** 0.3.0
+> **Design Context:** This is a `product`-register interface (an evaluation instrument; design serves the workflow). Strategic design context — users, brand personality, anti-references, principles, accessibility — lives in [PRODUCT.md](PRODUCT.md). The visual system (tokens, typography, components) lives in [DESIGN.md](DESIGN.md). Read both before changing UI.
+
Seer exists to help teams evaluate and improve Glean agents that perform real knowledge work. It is not just a scorecard for final answers. It is a behavioral science and optimization system for understanding whether agents help people do valuable work faster, cheaper, and better.
The north star is simple: define the target behavior, build a realistic dataset, run the agent, judge the behavior with calibrated evidence, improve the prompt or agent configuration, and repeat until the agent produces measurable business value.
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..df48af6
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,319 @@
+---
+name: Seer
+description: Glean agent evaluation — a celestial instrument for charting knowledge-work behavior. Dual-theme (Star Atlas dark / Aged Chart light) on one token contract.
+themes:
+ default: dark
+ dark: Star Atlas
+ light: Aged Chart
+colors:
+ # --- Star Atlas (dark, default) — ink-navy night sky over the observatory ---
+ dark:
+ bg: "#0b0d12"
+ surface: "#141822"
+ elevated: "#1d222e"
+ border: "rgba(245,225,180,0.09)"
+ border-strong: "rgba(245,225,180,0.17)"
+ fill: "rgba(245,225,180,0.05)"
+ fill-hover: "rgba(245,225,180,0.09)"
+ fg0: "#5e5a50"
+ fg1: "#8a8472"
+ fg2: "#a8a190"
+ fg3: "#c7c0ac"
+ fg4: "#e0d9c5"
+ fg5: "#f4efe2"
+ brass: "#d8b878"
+ brass-strong: "#ecc98c"
+ brass-bg: "rgba(216,184,120,0.14)"
+ star: "#e8e2d4"
+ score-success: "#7ecf90"
+ score-warning: "#e7b375"
+ score-fail: "#dd766f"
+ score-success-bg: "rgba(126,207,144,0.15)"
+ score-warning-bg: "rgba(231,179,117,0.15)"
+ score-fail-bg: "rgba(221,118,111,0.15)"
+ # --- Aged Chart (light) — warm off-white parchment, dark sepia ink ---
+ # The ground (.bg-bg) carries a faint grain + aging-mottle texture (light theme
+ # only; see styles.css) so the off-white reads as rustic old paper. Cards
+ # (surface) stay clean as fresher sheets layered on the aged ground.
+ light:
+ bg: "#ede8dd"
+ surface: "#f5f1e9"
+ elevated: "#fbf8f2"
+ border: "rgba(58,46,24,0.16)"
+ border-strong: "rgba(58,46,24,0.26)"
+ fill: "rgba(58,46,24,0.045)"
+ fill-hover: "rgba(58,46,24,0.08)"
+ fg0: "#9b9282"
+ fg1: "#6b6150"
+ fg2: "#4d473b"
+ fg3: "#3a342a"
+ fg4: "#2a251d"
+ fg5: "#1f1c15"
+ brass: "#7a4a08"
+ brass-strong: "#633c00"
+ brass-bg: "rgba(122,74,8,0.13)"
+ star: "#7a6a48"
+ score-success: "#1f7a40"
+ score-warning: "#8a5012"
+ score-fail: "#b23a34"
+ score-success-bg: "rgba(31,122,64,0.13)"
+ score-warning-bg: "rgba(138,80,18,0.13)"
+ score-fail-bg: "rgba(178,58,52,0.13)"
+typography:
+ title:
+ fontFamily: "DM Sans, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, sans-serif"
+ fontSize: "1.3125rem"
+ fontWeight: 600
+ lineHeight: 1.22
+ letterSpacing: "-0.01em"
+ lg:
+ fontFamily: "DM Sans, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, sans-serif"
+ fontSize: "1.0625rem"
+ fontWeight: 600
+ lineHeight: 1.32
+ md:
+ fontFamily: "DM Sans, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, sans-serif"
+ fontSize: "0.875rem"
+ fontWeight: 400
+ lineHeight: 1.5
+ base:
+ fontFamily: "DM Sans, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, sans-serif"
+ fontSize: "0.8125rem"
+ fontWeight: 400
+ lineHeight: 1.4
+ sm:
+ fontFamily: "DM Sans, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, sans-serif"
+ fontSize: "0.75rem"
+ fontWeight: 400
+ lineHeight: 1.1
+ label:
+ fontFamily: "DM Sans, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, sans-serif"
+ fontSize: "0.6875rem"
+ fontWeight: 500
+ lineHeight: 1.45
+ letterSpacing: "0.04em"
+ mono:
+ fontFamily: "DM Mono, SF Mono, Monaco, Cascadia Code, monospace"
+ fontSize: "0.8125rem"
+ fontWeight: 400
+ lineHeight: 1.4
+rounded:
+ sm: "4px"
+ md: "6px"
+ lg: "8px"
+ full: "9999px"
+spacing:
+ xs: "4px"
+ sm: "8px"
+ md: "16px"
+ lg: "24px"
+ xl: "32px"
+components:
+ button-primary:
+ backgroundColor: "{colors.brass}"
+ textColor: "{colors.bg}"
+ rounded: "{rounded.lg}"
+ padding: "10px 16px"
+ typography: "{typography.md}"
+ button-primary-hover:
+ backgroundColor: "{colors.brass-strong}"
+ textColor: "{colors.bg}"
+ button-secondary:
+ backgroundColor: "{colors.fill}"
+ textColor: "{colors.fg4}"
+ border: "1px solid {colors.border}"
+ rounded: "{rounded.lg}"
+ padding: "10px 16px"
+ card:
+ backgroundColor: "{colors.surface}"
+ textColor: "{colors.fg2}"
+ border: "1px solid {colors.border}"
+ rounded: "{rounded.lg}"
+ padding: "20px"
+ input:
+ backgroundColor: "{colors.surface}"
+ textColor: "{colors.fg4}"
+ border: "1px solid {colors.border}"
+ rounded: "{rounded.md}"
+ padding: "8px 12px"
+ badge-info:
+ backgroundColor: "{colors.brass-bg}"
+ textColor: "{colors.brass}"
+ rounded: "{rounded.full}"
+ padding: "2px 8px"
+ score-badge-success:
+ backgroundColor: "{colors.score-success-bg}"
+ textColor: "{colors.score-success}"
+ rounded: "{rounded.full}"
+ padding: "2px 8px"
+---
+
+# Design System: Seer
+
+## 1. Overview
+
+**Creative North Star: "The Celestial Instrument"**
+
+Seer is the seer of AI apps and neural networks — it reads an agent's behavior and charts it. The interface is the instrument that does the reading: an astrolabe, a star atlas, a calibrated dial. A good instrument earns trust the way a sextant or a microscope does — every reading is legible, the housing is quiet, and nothing on the panel competes with the value you came to read. Scores, traces, and judge reasoning are the dial; the chrome recedes around them, like brass fittings around a lens.
+
+The system ships as **two themes on one token contract**, switchable at runtime with no flash:
+
+- **Star Atlas (dark, default):** a warm near-black ground with low-alpha light washes for surfaces — the night sky an astronomer works against. This is the home theme.
+- **Aged Chart (light):** cool aged paper (not cream) with low-alpha *dark* washes — an old nautical chart under lamplight. It mirrors the dark theme's alpha technique rather than inverting colors.
+
+The system is **warm but exact**. Warmth comes from brass and a parchment ink ramp; exactness comes from restraint everywhere else — a single brass voice for action, monospaced numerals for anything measured, categorical color reserved for scores and trace categories. It carries Glean lineage through DM Sans/DM Mono and a measured-in-mono discipline, while owning its own celestial character as the place where agent quality gets charted with rigor. It is data-dense by necessity and readable by discipline: when density and clarity conflict, clarity wins.
+
+This system explicitly rejects four things. It is **not a generic Tailwind dashboard** (no gray card-grid sea, no chrome-first layout). It is **not a playful consumer app** (no bouncy motion, no emoji-as-UI, no gamification). It is **not cold enterprise BI** (the celestial warmth is a feature). And it is **not a flashy AI-startup** (no glowing cyan, no purple gradients, no decorative glass, no gradient text, no big-number-plus-gradient hero-metric template). Rigor here reads as restraint, never spectacle.
+
+**Key Characteristics:**
+- Dual-theme: warm near-black "Star Atlas" (default) and cool aged-paper "Aged Chart".
+- Surfaces are **alpha washes + 1px hairline borders**, never gradient cards. Depth is a solid ground ladder (`bg` → `surface` → `elevated`), not shadow.
+- A **6-step foreground ramp** (`fg0`–`fg5`) gives dense screens precise hierarchy.
+- One accent voice — **brass** — for actions, links, focus, and selection.
+- Monospace (DM Mono) for everything measured: IDs, latency, tokens, scores, dimension keys.
+- Categorical score color (green/amber/red) that never travels alone.
+- Signature instruments: the **astrolabe score orb**, the **pulsing star-grid mark**, the **flame timeline**.
+- Motion is state, never decoration; short, ease-out, always with a reduced-motion fallback.
+
+## 2. Colors
+
+One token contract, two themes scoped by `data-theme` on ``. Every utility resolves to a CSS variable, so one class set works in both themes. Brass is the only chromatic accent; chrome is colorless.
+
+### The ground ladder (depth without shadow)
+Depth comes from three solid steps, not drop shadows. Surfaces sit on the ground; hairline borders and faint fills draw the edges.
+
+- **`bg`** — the ground. Star Atlas `#0b0b0d` (warm near-black); Aged Chart `#cdd2d7` (cool aged paper, *not* cream).
+- **`surface`** — cards, panels, the working surfaces that hold data. Dark `#131316` / light `#e8ebee`.
+- **`elevated`** — popovers, nested fills, the orb face. Dark `#1a1a1e` / light `#f1f3f5`.
+- **`border` / `border-strong`** — brass-tinted (dark) or ink-tinted (light) hairlines that separate surfaces at rest, in place of shadow.
+- **`fill` / `fill-hover`** — the resting and hover washes for interactive rows and ghost buttons.
+
+### The foreground ramp (6 steps)
+Hierarchy on dense screens comes from a six-step ink ramp, faint → brightest. Dark is a parchment ramp; light is an ink ramp.
+
+- **`fg5`** — headings (parchment `#f3efe6` / ink `#1b2027`).
+- **`fg4`** — emphasis, strong labels.
+- **`fg3`** — strong body.
+- **`fg2`** — body / secondary (the default text color).
+- **`fg1`** — tertiary, large or secondary text only.
+- **`fg0`** — decorative / disabled only; **never carries text**.
+
+### The one voice — Brass
+- **Brass** (dark `#d8b878` / light `#7c4b02`): the single action voice. Primary buttons, links, selected state, focus rings, the Seer wordmark, and the astrolabe ticks/needle. Hover deepens to **brass-strong**. **brass-bg** is the tinted background for info callouts, selected rows, and brand badges.
+- **Star** (dark `#e8e2d4` / light `#4a525c`): the faint dot-field color of the star-grid mark. Decorative only.
+
+### Tertiary (Functional — Scores)
+Score color is categorical evidence, not decoration. Foreground/background pairs only, and in the light theme score-tone text always sits on `surface` (never bare `bg` paper).
+- **Success** — high scores (≥7/10, or ≥70% in golden mode).
+- **Warning** — middling scores (4–6, or 40–69%).
+- **Fail** — low scores (<4, or <40%).
+
+### Trace categories
+The flame timeline and tool pills color spans by category with a deterministic, brass-led palette (`#d8b878` is index 0, so the dominant category reads as the brand voice). These are the *only* hues beyond brass and the score tones.
+
+### Named Rules
+**The One Voice Rule.** Brass is the only chromatic accent in the chrome. If a second hue appears outside a score badge or a trace-category bar, it is a bug. Action, link, focus, selection — one brass, everywhere, so the eye learns it instantly.
+
+**The Measured-in-Mono Rule.** Anything the instrument measured — a score, a latency, a token count, an agent or run ID — is set in DM Mono. Prose is DM Sans. The font itself tells you whether a value was read off the dial.
+
+**The Color-Is-Never-Alone Rule.** Score color always pairs with a number, label, or shape. Red and green must survive grayscale; a color-blind reviewer loses no information.
+
+**The Washes-Not-Gradients Rule.** Surfaces are flat solids stepped along the ground ladder, edged by hairline borders and faint alpha fills. No gradient cards, no decorative glass, no glow.
+
+## 3. Typography
+
+**Display / Body Font:** DM Sans (with -apple-system, BlinkMacSystemFont, Segoe UI, system-ui fallback)
+**Label / Mono Font:** DM Mono (with SF Mono, Monaco, Cascadia Code fallback)
+
+**Character:** One humanist-geometric sans carries the whole interface through weight, not family-switching; DM Mono is its measured counterpart. The pairing is calm and exact — an observatory notebook, not a marketing page. The scale is **tight and dense** so evidence-heavy screens stay legible: 13px working text, 11px tracked labels, a 21px title ceiling.
+
+### Hierarchy
+- **Title** (600, 1.3125rem / 21px, line-height 1.22, tracking -0.01em): Page and section titles. The most emphatic size in the system — the instrument states, it does not shout.
+- **Lg** (600, 1.0625rem / 17px): Sub-section headings, card titles, the orb number context.
+- **Md** (400, 0.875rem / 14px): Comfortable reading text for prose and descriptions.
+- **Base** (400, 0.8125rem / 13px): The dominant working/UI text size. Cap prose at 65–75ch.
+- **Sm** (400, 0.75rem / 12px): Dense table cells, captions, meta.
+- **Label** (500, 0.6875rem / 11px, tracking 0.04em, uppercase): Field labels and small section markers. Uppercase only for ≤4-word labels.
+- **Mono** (400/500, 0.8125rem / 13px): Measured values — IDs, latency, tokens, scores, dimension keys.
+
+### Named Rules
+**The 21px Ceiling Rule.** No type exceeds 1.3125rem. This is an instrument, not a landing page; there is no hero headline. Emphasis comes from weight, the `fg` ramp, and placement — never size.
+
+**The Weight-Not-Family Rule.** Never introduce a third typeface for emphasis. DM Sans ships 400/500/600/700; use them. DM Mono is reserved for measured values, not decoration.
+
+## 4. Elevation
+
+Flat by default. Depth is the **ground ladder** (`bg` → `surface` → `elevated`) plus hairline borders and faint alpha fills — not shadow. On a warm near-black ground, heavy drop shadows are invisible; on the aged-paper light theme they read as gray smudges (the "2014 app" tell). So depth stays structural and the data stays the hero.
+
+Shadow is reserved as a **signal of true float** — modals, dropdowns, popovers that genuinely leave the plane. Resting cards and rows use tint and border only.
+
+### Named Rules
+**The Depth-Is-A-Ladder Rule.** A resting surface earns depth by stepping up the ground ladder and drawing a hairline edge, not by casting a shadow. Reserve shadow for overlays that float above everything. If every card has a shadow, nothing reads as floating and the evidence drowns.
+
+## 5. Components
+
+The component voice is **refined and instrument-like**: quiet, precise, engraved. Interactions give clear state feedback (a wash fills, a border brightens, the orb needle settles), never theatrics.
+
+### Buttons
+- **Shape:** `rounded-lg` (8px); pills (`rounded-full`) only for compact toggle clusters and badges.
+- **Primary:** Brass fill, `bg`-colored text, `10px 16px` padding, md size, 600 weight. Hover deepens to brass-strong. Disabled drops to a `fill` background with `fg0` text and `cursor: not-allowed`.
+- **Secondary / Ghost:** `fill` or transparent surface, `fg4` text, hairline border; hover raises to `fill-hover`. For low-emphasis actions beside a primary.
+- **Hover / Focus:** `transition: colors ~150ms`. Focus shows a 2px brass ring (`--focus: var(--brass)`) with `outline: none` replaced by the ring.
+
+### Chips / Badges
+- **Style:** `rounded-full`, `2px 8px` padding, label/sm size, 500–600 weight.
+- **Info / brand:** `brass-bg` fill, brass text.
+- **Score:** functional pair (e.g. success text on success-bg), always accompanying a number and a dot.
+- **Status:** small uppercase tracked label inside the pill for run state; a running pill *breathes* (border pulses to `brass-bg`).
+
+### Cards / Containers
+- **Corner Style:** `rounded-lg` (8px).
+- **Background:** `surface` on the `bg` ground; `elevated` for nested sub-panels.
+- **Edge:** 1px hairline `border`; no resting shadow.
+- **Internal Padding:** 16–20px.
+
+### Inputs / Fields
+- **Style:** `surface` background, 1px hairline border, 6px radius, `8px 12px` padding, base size.
+- **Focus:** 2px brass ring + brass border; `outline: none`. Placeholder uses `fg1` (never `fg0`).
+- **Disabled:** `fill` background, `fg0` text.
+
+### Navigation
+- **Style:** Slim top bar, `surface`/transparent on the ground, bottom hairline, ~56px tall. The **star-grid mark** (calm, no animation) sits beside the "Seer" wordmark in brass, 600 weight. Icon actions (theme toggle, settings) are `fg2`, hovering to `fg4` with a `fill-hover` wash. Active links carry the brass voice. A **theme toggle** (sun in dark → offers Aged Chart; moon in light → offers Star Atlas) lives in the bar.
+
+### Score Display (Signature) — the Astrolabe Orb
+The defining component: a flat disc with **brass degree ticks** around the rim, a small **engraved constellation**, and a **reading needle** that points to the value. The number sits in the center in DM Mono; the score tone appears only as a small dot beside `of 10` / `match`. Guided mode shows `X.X/10`; golden mode shows `NN%`. The score is always one click from its reasoning, trace, and source documents — the number is an entry point to evidence, never a verdict on its own.
+
+### The Mark — Pulsing Star Grid
+A small constellation drawn on a dot field: most dots are faint (`star`), a few brass stars connect into a constellation, and the field breathes (`star-twinkle`, reduced-motion safe). Calm and static in the nav; animated at hero size in the empty state.
+
+### Trace Surface — Flame Timeline + Tool Pills
+Traces render as a **flame/Gantt timeline** (one row per span, bars positioned by start/end, mono grid ticks, category color) plus expandable **tool-call pills** (status icon + name + args preview + duration + `~tok`, expanding to Input/Output panes). Pending pills pulse brass; errors wash `score-fail`.
+
+## 6. Motion
+
+State-driven, short, ease-out, with `prefers-reduced-motion` fallbacks for every animation.
+- **`seer-arrive`** (150ms ease-out): list rows and results entering.
+- **`seer-running`** (2.2s, infinite): a run in progress "breathes" — its border pulses to `brass-bg`.
+- **`star-twinkle`** (3.2s, infinite): faint dots in the star-grid mark.
+- **`transition-colors`** (~150ms): the default state-feedback transition.
+
+## 7. Do's and Don'ts
+
+### Do:
+- **Do** keep brass as the single accent voice for actions, links, focus, and selection — the One Voice Rule.
+- **Do** set every measured value (scores, latency, tokens, IDs) in DM Mono — the Measured-in-Mono Rule.
+- **Do** build depth from the ground ladder + hairline borders + alpha fills; reserve shadow for true overlays — the Depth-Is-A-Ladder Rule.
+- **Do** use the `fg0`–`fg5` ramp for hierarchy; keep `fg0` decorative and out of text.
+- **Do** pair score color with a number, label, or shape so it survives grayscale; in light theme place score-tone text on `surface`, never bare `bg`.
+- **Do** keep a visible 2px brass focus ring on every interactive element, and give every transition a `prefers-reduced-motion` alternative (WCAG AA).
+- **Do** make each score one click from its reasoning, trace, and source docs — evidence over verdicts.
+- **Do** make both themes switch with no flash and persist the choice.
+
+### Don't:
+- **Don't** ship a generic Tailwind dashboard: no endless identical card grids, no chrome-first layout.
+- **Don't** drift playful or consumer: no bouncy/elastic motion, no emoji-as-UI, no gamified flourishes.
+- **Don't** reach for flashy AI tropes: no glowing cyan/teal accents, no purple gradients, no decorative glassmorphism, no `background-clip: text` gradient text, no big-number-plus-gradient hero-metric template, no corner glows.
+- **Don't** use gradient cards or drop shadows for resting depth — surfaces are flat washes on the ground ladder.
+- **Don't** introduce a second accent hue alongside brass, or use a raw Tailwind `blue`/`cyan` focus ring — the focus voice is brass.
+- **Don't** exceed the 21px type ceiling or introduce a third typeface; build emphasis from DM Sans weights and the `fg` ramp.
+- **Don't** let `fg0` carry text, and don't place score-tone text or `fg1` on bare `bg` in the light theme.
diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..2506287
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,50 @@
+# Product
+
+## Register
+
+product
+
+## Users
+
+Glean customers and agent builders, plus the internal Glean teams who pioneered the workflow (AI behaviorists, eval engineers, PMs). The primary user is an **AI behaviorist**: someone who defines the agent behavior worth optimizing, calibrates the measurement system, and runs the feedback loop, rather than hand-inspecting every output forever.
+
+Their context: they have built an agent in Glean's Agent Builder and need to know whether it actually helps people do knowledge work faster, cheaper, and better. They arrive with a real workflow in mind (sales, support, research, operations, onboarding, policy lookup) and a question that is part skepticism, part hope: *is this agent worth deploying, expanding, or iterating?*
+
+The job to be done: define the target behavior and dimensions, build or generate a realistic dataset, run the agent, judge the behavior with calibrated evidence, find the prompt/config/source/rubric change that moves the needle, rerun, and compare. Because the audience now includes external builders of varying eval expertise, the UI has to make this loop legible without dumbing it down: explain its method, expose its evidence, and stay fast for the power user who runs it daily.
+
+## Product Purpose
+
+Seer evaluates AI agents built in Glean's Agent Builder. It is not a scorecard for final answers; it is a behavioral measurement and optimization system. It runs agents, scores their behavior across a seven-call judge architecture (coverage, quality, faithfulness, factuality, instruction following, safety, answer accuracy), calibrates judges against human reviewers, and preserves the full evidence trail (responses, traces, source documents, tool calls, latency, tokens) so teams can understand *why* an agent behaves the way it does, not just *what* it scored.
+
+Success looks like a trustworthy, fast feedback loop: a behaviorist can look at a run and immediately see whether the agent used the right sources and reasoning, where failures concentrate across many runs (the macro-eval chain: case type → outcome → finding → pattern → business impact), and what to change next. The larger question Seer must always keep in view is business value: does the agent save time, reduce manual work, prevent escalations, or improve quality enough to deploy?
+
+## Brand Personality
+
+**Evaluation oracle — a well-calibrated scientific instrument.** Warm but precise. Refined analytical, not cold and not playful: research dashboard meets considered data visualization. Three words: **calibrated, evidential, trustworthy.**
+
+The interface should evoke the confidence of an expert who shows their work. It is data-dense but readable, opinionated about method but transparent about uncertainty. It is visibly part of the Glean ecosystem (Electric Blue, Bright Green, Oatmeal, DM Sans) while carrying its own identity as the place where agent behavior gets judged with rigor. Tone of voice is specific and grounded, never hype: it names what it measured and what it found.
+
+## Anti-references
+
+- **Generic Tailwind dashboard.** The default gray-card-grid SaaS admin. Escaping this is the founding reason the visual system exists. No identical card grids, no chrome-first layouts.
+- **Playful / consumer app.** No bouncy motion, emoji-as-UI, gamification, or casual rounded everything. This is an instrument, not a toy.
+- **Cold enterprise BI.** No dense gray Looker/Tableau chrome stripped of warmth and identity. Warmth (oatmeal surfaces, DM Sans, considered spacing) is a feature, not decoration.
+- **Flashy AI-startup.** No purple gradients, decorative glassmorphism, glow, gradient text, or the hero-metric template (big number + small label + gradient accent). Rigor reads as restraint, not spectacle.
+
+## Design Principles
+
+- **Calibrated instrument, not a scorecard.** Every surface should feel measured and precise. Numbers and scores are first-class, but they are presented as evidence with provenance, not as decoration. The UI earns trust the way a good instrument does: consistent, legible, honest about its limits.
+- **Evidence over verdicts.** Never hide uncertainty behind a single score. A score should always be one click from its reasoning, its trace, its source documents, and the dimension it came from. Preserve the audit trail in the interface, not just the database.
+- **Show the method.** Because external builders need to trust the judges, make the seven-call topology and the rubrics inspectable, not mysterious. The judge methodology is part of the product, not hidden plumbing.
+- **Density that stays readable.** This is a power-user tool that must also onboard newcomers. Favor information density, but enforce hierarchy: scores and metrics are the hero, chrome recedes. When density and clarity conflict, clarity wins.
+- **Glean-native, with its own identity.** Stay inside the committed Glean palette and type system so Seer reads as part of the ecosystem, but let the evaluation domain (judges, dimensions, runs, traces) give it a distinct, recognizable character.
+- **Don't optimize for score gaming.** Reflected in the UI too: surface hard, boring, and representative cases rather than burying them; make regressions in adjacent dimensions visible; keep human calibration a visible, first-class part of the loop.
+
+## Accessibility & Inclusion
+
+Target **WCAG 2.1 AA**.
+
+- Body text ≥ 4.5:1 contrast against its background; large text ≥ 3:1. Watch the warm-neutral combinations specifically: cement-on-oatmeal must be verified, not assumed, and placeholder text held to the same 4.5:1.
+- Score colors (success/warning/fail) must never be the only signal: pair color with text, shape, or position so red/green color blindness does not lose information.
+- Visible, non-color focus states on every interactive element; full keyboard navigation for the run/results/config flows.
+- Full `prefers-reduced-motion` support: every transition has a crossfade or instant alternative. Motion is purposeful and subtle by default, in keeping with the instrument personality.
diff --git a/docs/plans/agent-centric-ui.md b/docs/plans/agent-centric-ui.md
new file mode 100644
index 0000000..75c5f66
--- /dev/null
+++ b/docs/plans/agent-centric-ui.md
@@ -0,0 +1,182 @@
+# Plan: Agent-Centric Navigation & IA
+
+**Branch:** `ui-design` (off `main`)
+**Status:** Approved brief — building the A/B hybrid direction (down-selected from 3 variations)
+**Date:** 2026-06-05 (updated 2026-06-07)
+
+## Context
+
+Seer's current web UI is a top-bar + centered card-grid of eval sets (`web/src/routes/index.tsx`).
+That generic card grid is the exact anti-reference `DESIGN.md` was written to escape, and it is
+not organized around the unit users actually think in: the **agent**.
+
+In two working sessions with Ken (Kenneth Cavanagh) — **Seer Dev, May 26 2026** and **Seer Chat,
+May 13 2026** — we aligned on reworking the UI to be more end-user friendly around an
+**agent → dataset → evals** flow: agents in a left navbar, drilling into the datasets run against
+them, the runs/evals, and the threads. Ken's matching workstream is a data-model review (making
+Agent and Dataset first-class, datasets reusable across agents) inspired by the open-source
+`raindrop-ai/workshop` repo.
+
+**On first-class / reusable datasets (resolved 2026-06-07):** we adopt Ken's first-class-dataset
+direction, but reframe the win. The disagreement ("each agent has a unique build — how can you
+reuse datasets?") dissolves by splitting two senses of "agent": the **logical agent** (the thing
+iterated over weeks) vs. an **agent build** (a prompt+model+tools+sources snapshot). A dataset must
+be reusable **across builds of the same logical agent** — this is non-negotiable, because Seer's
+core loop (improve → rerun → compare v1 vs v2) only works if the dataset stays fixed while the
+agent changes. Reuse **across different logical agents** (shared safety suites, bake-offs,
+benchmarks) is the rare exception, not the default. So datasets are first-class (own identity,
+version-stable, not destroyed when the agent is edited) while the **default relationship stays 1:1
+with an agent**. First-class means "survives a fork," not "promiscuously shared."
+
+This plan covers the **navigation + IA** slice only, built **interim on the current schema** (no
+DB migration): "agents" are derived by grouping `evalSets.agentId`, and a "dataset" is an eval
+set's cases. The IA is designed for the target model so it swaps over later with no UI rework.
+
+**Intended outcome:** a persistent agent-led left rail + an agent-detail workspace, delivered as
+**one coded mid-fi prototype of the A/B hybrid direction** (down-selected from 3 candidate
+variations — see below) for in-browser assessment (the "generate prototypes to assess
+intuitiveness" action item from May 26).
+
+## Decisions (locked)
+
+- **Data model:** interim on current schema. Group `evalSets` by `agentId`; a set ≈ a dataset.
+ Target model treats datasets as first-class and **version-stable** (reusable across builds of a
+ logical agent), default 1:1 with an agent; the interim IA mirrors this so it swaps over cleanly.
+- **Nav shell:** agent-led left rail; selecting an agent opens its detail (datasets, runs/evals, threads).
+- **Rail health is non-numeric.** No rolled-up per-agent score (that would game the system and lean
+ on a single verdict, against `PRODUCT.md` "evidence over verdicts"). The rail shows last-run
+ **status** (passed / failing / running / never-run) as a score-tone dot + label, plus light
+ recency/run-count. Actual scores live one click in, with their evidence.
+- **Workspace pattern: A/B hybrid.** A within-agent tab strip (Overview · Datasets · Runs · Threads)
+ where the **Overview tab is a sectioned scroll** (A) and the **dense tabs (Runs/Threads) are
+ dedicated table views** (B). Three-pane drill (C) considered and dropped as over-complex for this slice.
+- **Two audiences via progressive disclosure.** Dense by default for the power-user behaviorist;
+ newcomers are onboarded through empty states, inline "show the method" affordances, and first-run
+ guidance — not by dumbing down the surface.
+- **Deliverable:** brief + 1 coded prototype of the A/B hybrid (this plan).
+- **Design system:** Star Atlas / Aged Chart celestial dual-theme per `DESIGN.md`/`PRODUCT.md` (canonical; no override). Summarized in the next section.
+- **Anchors:** Raindrop (traces/annotations/nav), Linear (rail + density), Stripe (evidence tables).
+
+## Design System (Star Atlas / Aged Chart)
+
+`DESIGN.md` and `PRODUCT.md` are the canonical source of truth; this is the working summary so the IA can be built without flipping files. The system ships as **two themes on one token contract**, switchable at runtime with no flash.
+
+- **North star — "The Celestial Instrument":** Seer reads an agent's behavior and charts it. The UI is the instrument (astrolabe, star atlas, calibrated dial): warm but exact, evidence-first, chrome that recedes. Rigor reads as restraint, never spectacle. Rejects the generic Tailwind dashboard, the playful consumer app, cold enterprise BI, and flashy AI tropes (no glowing cyan, no purple gradients, no glass, no gradient text).
+- **Themes:** **Star Atlas** (dark, default) — warm near-black ground with low-alpha *light* washes. **Aged Chart** (light) — cool aged paper (not cream) with low-alpha *dark* washes, mirroring the alpha technique rather than inverting.
+- **Depth is a ground ladder, not shadow:** `bg` → `surface` → `elevated`, edged by hairline `border`/`border-strong` and faint `fill`/`fill-hover`. Surfaces are flat washes; shadow is reserved for true overlays.
+- **Foreground ramp (6 steps):** `fg0` (decorative only, never text) → `fg5` (headings). Hierarchy on dense screens comes from the ramp + weight, never type size.
+- **One voice — Brass** (`#d8b878` dark / `#7c4b02` light): the only chromatic accent — actions, links, focus, selection, wordmark, astrolabe ticks/needle. `star` is the faint dot-field hue (decorative).
+- **Score tones** (success/warning/fail) are categorical evidence, foreground/background pairs, and **never travel alone** (always a dot + number). In light theme, score-tone text sits on `surface`, never bare `bg`. **Trace categories** use a deterministic brass-led palette — the only other hues.
+- **Type:** DM Sans + DM Mono on a tight, dense scale — 13px working text, 11px tracked labels, **21px title ceiling**. Everything *measured* (scores, latency, tokens, IDs, dimension keys) is DM Mono — the Measured-in-Mono rule.
+- **Signature instruments:** the **astrolabe score orb** (brass ticks + reading needle + mono value), the **pulsing star-grid mark** (constellation on a dot field), and the **flame timeline + tool pills** for traces.
+- **Motion:** state-driven, short, ease-out — `seer-arrive`, `seer-running` (a live run "breathes"), `star-twinkle` — each with a `prefers-reduced-motion` fallback. WCAG AA in both themes; visible 2px brass focus rings.
+
+Practical rail/workspace consequence: the left rail is `surface` on the `bg` ground; agent rows use `fill`/`fill-hover` with brass for the selected agent; run tables put scores in mono with score-tone dots; the empty state uses the animated star-grid mark.
+
+## Current State (what exists)
+
+- **Shell:** `web/src/routes/__root.tsx` — top bar (`Seer` wordmark + settings icon), `max-w-6xl`
+ centered main, footer. New-eval is **already a modal** (`features/new-eval-set/NewEvalSetModal`,
+ `NewEvalSetButton`).
+- **Dashboard:** `web/src/routes/index.tsx` — card grid over `getEvalSetsWithStats()`.
+- **Data:** `web/src/server/dashboard.ts` → `getEvalSetsWithStats()` returns every set with
+ `agentId`, `agentType`, `mode`, `caseCount`, `runCount`, `lastRunDate/Status`, `lastScore`,
+ `avgScore`. This is the source for interim agent grouping.
+- **Routes:** `index`, `sets/$id`, `sets/new`, `runs/$id`, `settings`; server fns
+ `dashboard.ts`, `eval-set-detail.ts`, `run-results.ts`, `run-service.ts`.
+- **Schema** (`src/db/schema.ts`): `evalSets` embeds `agentId` (no Agent table); `evalCases`
+ owned by a set (no reusable Dataset); `evalRuns`→`evalResults`(+`transcript` = threads)→`evalScores`.
+- **Tokens (legacy, today):** Tailwind currently still maps the old Glean oatmeal system
+ (`border-border`, `text-cement`, `text-glean-blue`, `bg-glean-blue`, `shadow-card`,
+ `surface-page`). These migrate to the Star Atlas / Aged Chart token contract
+ (`bg`/`surface`/`elevated`/`fg-0…5`/`brass`/`hairline`, score + star) as part of this work.
+
+## Target IA (interim mapping)
+
+```
+Agent (distinct evalSets.agentId) ← left rail
+└── Dataset (= an eval set + its cases) ← workspace (target: version-stable, reused across builds)
+ └── Run / Eval (evalRuns) ← dense table, score in mono
+ ├── Result per case (evalResults + evalScores) ← evidence: reasoning, trace, source docs
+ └── Thread (evalResults.transcript, multi-turn)
+```
+
+Target-model note: a Run is conceptually `agent build × dataset × config`. Interim, re-running an
+edited agent against the same eval set already gives version-stable datasets on the current schema,
+so the IA holds without a migration.
+
+## Layout Strategy
+
+- Replace centered column with a **full-width two-pane shell**: fixed left rail + fluid workspace.
+- **Left rail** (`surface` on the `bg` ground, the second step of the ground ladder): star-grid mark +
+ wordmark top, searchable agent list with at-a-glance **non-numeric** health — last-run status
+ (passed / failing / running / never-run) as a score-tone dot + label, **never a rolled-up score** —
+ `+` new-eval, theme toggle + settings/methodology at bottom. Selected agent carries the brass voice.
+ Collapsible to icons.
+- **Workspace:** agent identity + status header → within-agent tab strip (Overview · Datasets ·
+ Runs · Threads). Overview is a sectioned scroll; Runs/Threads are dedicated dense tables.
+ Lists/tables, **no card grid**.
+- Score is always one click from reasoning/trace/source docs (`PRODUCT.md` "evidence over verdicts").
+- **Progressive disclosure:** the surface stays dense; teaching lives in empty states, inline
+ "show the method" affordances, and first-run guidance, so newcomers and power users share one IA.
+
+## Chosen Direction: A/B Hybrid (down-selected from 3 candidates)
+
+The three candidates considered:
+
+- **A — Sectioned scroll:** one scrollable workspace, stacked sections. Lowest nav cost.
+- **B — Within-agent tabs (Linear-style):** agent header + tab strip. Best for dense data.
+- **C — Three-pane drill (Raindrop-like):** rail → middle column → right detail pane. Fastest
+ run-to-run scanning, but the most chrome/complexity. **Dropped** for this slice.
+
+**Decision — build the A/B hybrid:** a within-agent **tab strip** (B) `Overview · Datasets · Runs ·
+Threads`, where the **Overview tab is a sectioned scroll** (A — health → datasets summary → recent
+runs, lowest-cost orientation) and the **dense tabs (Runs / Threads) are dedicated sortable table
+views** (B — direct power-user access, not buried in a scroll). The persistent agent header (identity
++ non-numeric status) sits above the tabs. This keeps fast run-scanning without C's three-pane cost.
+
+Wired to real interim data (grouped `evalSets`) for in-browser assessment.
+
+## Key States
+
+Empty (no agents → teach the loop; agent with no runs), loading (skeleton rail + table rows),
+error (run/agent-fetch failure), first-run, power-user (rail search; dense run table), no-agent-selected
+(workspace placeholder).
+
+## Build Approach
+
+- Add a single prototype surface (e.g. a route under `/proto`) so the A/B hybrid lives alongside the
+ real app without disturbing `index.tsx` until it is promoted.
+- Shared interim data hook: extend/reuse `getEvalSetsWithStats()`; add a server fn to group sets by
+ `agentId` and resolve agent display names via the agents API (`api/agents/$id.ts` / `fetch-agent.ts`),
+ cached, falling back to truncated `agentId` in mono.
+- Build the two-pane `AppShell` + agent rail as shared components; the agent header + tab strip
+ compose the workspace (Overview scroll + Runs/Threads tables).
+- Honor `DESIGN.md` tokens and rules (One Voice = brass, Measured-in-Mono, Washes-Not-Gradients,
+ Depth-Is-A-Ladder); both themes (Star Atlas / Aged Chart) switch with no flash; WCAG AA;
+ `prefers-reduced-motion` alternatives; 150–250ms state transitions only.
+
+## Files (anticipated)
+
+- New: `web/src/routes/proto/` (the A/B hybrid), shared `web/components/AppShellSidebar.tsx`,
+ `web/components/AgentRail.tsx`, plus workspace components (agent header, tab strip, Overview
+ scroll, Runs/Threads tables).
+- New server fn: `web/src/server/agents.ts` (group sets by agentId + name resolution).
+- Reuse: `getEvalSetsWithStats`, score-format/run-status libs, NewEvalSet modal, existing tokens.
+- No changes to `src/db/schema.ts` (interim).
+
+## Verification
+
+- `cd web && pnpm dev`, open `/proto`, click through agents → datasets → runs → threads via the
+ agent header + tab strip; confirm Overview scrolls and Runs/Threads render as dense tables.
+- Verify the rail shows non-numeric status only (no rolled-up score).
+- Check empty/loading/error states + progressive-disclosure teaching (empty states, "show the
+ method"); keyboard nav of the rail; reduced-motion.
+- Assess the hybrid in-browser; then promote it to `index.tsx` in a follow-up.
+- `pnpm run check` (typecheck + lint + test) before any PR.
+
+## Out of Scope (this plan)
+
+- DB migration to first-class Agent/Dataset entities (Ken's workstream).
+- Trace/results-readability redesign and observability dashboard (issue #11) — later phases.
+- Promoting the A/B hybrid prototype to the default route (follow-up after in-browser assessment).
diff --git a/web/components/AgentPromptSection.tsx b/web/components/AgentPromptSection.tsx
index f6fafc8..7412c4a 100644
--- a/web/components/AgentPromptSection.tsx
+++ b/web/components/AgentPromptSection.tsx
@@ -33,7 +33,7 @@ export default function AgentPromptSection({ evalSetId, initialPrompt }: AgentPr
return (
-
+
Agent Prompt {prompt ? `(${prompt.length} chars)` : '(not set)'}