scene: ${primitives} primitive shapes, ~${Math.round(w)}x${Math.round(h)}px, ${fills.size} fill colors`,
+ });
+ }
+ return findings;
+}
+
+// Scoped scan corpora for the page-level pattern checks. CSS-property
+// regexes run over the whole source string fire on documentation ABOUT
+// css — `background-clip: text` prose, samples, HTML
+// comments — so the checks scan only the strings that actually style the
+// page:
+// styleText — ' : ''));
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ lines.push(
+ bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
+ );
+ }
+ lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
+ lines.push(bodyIndent + '');
+ lines.push(...bodyRestored);
+ lines.push(bodyIndent + '
');
+ };
+
+ if (isJsx) {
+ const wrapperStyle = 'style={{ display: "contents" }}';
+ lines.push(indent + '');
+ pushCarbonizeBody(indent + ' ');
+ lines.push(indent + '
');
+ } else {
+ pushCarbonizeBody(indent);
+ }
+
+ return lines;
+}
+
+function reindentContent(contentLines, fromIndent, toIndent) {
+ return contentLines.map((line) => {
+ if (line.trim() === '') return '';
+ if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
+ return toIndent + line.trimStart();
+ });
+}
+
+function handleAccept(id, variantNum, _lines, targetFile, paramValues) {
+ return withSourceLockSync(targetFile, 'accept:' + id, () => {
+ const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
+ return handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues);
+ }, { waitMs: ACCEPT_LOCK_WAIT_MS });
+}
+
+function handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues) {
+ const built = buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues);
+ if (built.handled === false) return built;
+ fs.writeFileSync(targetFile, built.content, 'utf-8');
+ return {
+ carbonize: built.carbonize,
+ acceptedOriginalText: built.acceptedOriginalText,
+ };
+}
+
+function buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues) {
+ const block = findMarkerBlock(id, lines);
+ if (!block) return { handled: false, error: 'Markers not found' };
+
+ const commentSyntax = detectCommentSyntax(targetFile);
+ const isJsx = commentSyntax.open === '{/*';
+ // Anchor indent on the line we're replacing FROM (the outer wrapper),
+ // not on `block.start` — for JSX that's the marker comment 2 spaces
+ // deeper than the original element. See handleDiscard for the full
+ // rationale.
+ const replaceRange = expandReplaceRange(block, lines, isJsx);
+ const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
+
+ // Extract the chosen variant's inner content
+ const variantContent = extractVariant(lines, block, variantNum);
+ if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
+ const originalContent = extractOriginal(lines, block);
+
+ // Extract CSS block if present
+ const cssContent = extractCss(lines, block, id);
+
+ // Check if carbonizing is needed:
+ // - CSS block exists, OR
+ // - variant HTML contains helper classes/attributes that need cleanup
+ const variantText = variantContent.join('\n');
+ const hasHelperAttrs = variantText.includes('data-impeccable-variant');
+ const needsCarbonize = !!(cssContent || hasHelperAttrs);
+
+ const restored = deindentContent(variantContent, indent);
+ const replacement = buildCarbonizeReplacement({
+ indent,
+ commentSyntax,
+ isJsx,
+ id,
+ variantNum,
+ cssContent,
+ paramValues,
+ restored,
+ });
+
+ const newLines = [
+ ...lines.slice(0, replaceRange.start),
+ ...replacement,
+ ...lines.slice(replaceRange.end + 1),
+ ];
+ return {
+ content: newLines.join('\n'),
+ carbonize: needsCarbonize,
+ acceptedOriginalText: originalContent.join('\n'),
+ };
+}
+
+
+function readSourceShadowPreviewMeta(content, id) {
+ const escaped = escapeRegExp(id);
+ const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
+ const match = String(content || '').match(wrapperRe);
+ if (!match) return null;
+ const tag = match[0];
+ if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
+ const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
+ const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
+ const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
+ if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
+ return { sourceFile, sourceStartLine, sourceEndLine };
+}
+
+function readHtmlAttr(tag, name) {
+ const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
+ if (!match) return null;
+ return decodeHtmlAttr(match[2]);
+}
+
+function decodeHtmlAttr(value) {
+ return String(value || '')
+ .replace(/"/g, '"')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/&/g, '&');
+}
+
+// ---------------------------------------------------------------------------
+// Parsing helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Find the start/end marker lines for a session.
+ * Returns { start, end } (0-indexed line numbers) or null.
+ */
+function findMarkerBlock(id, lines) {
+ let start = -1;
+ let end = -1;
+ const startPattern = 'impeccable-variants-start ' + id;
+ const endPattern = 'impeccable-variants-end ' + id;
+
+ for (let i = 0; i < lines.length; i++) {
+ if (start === -1 && lines[i].includes(startPattern)) start = i;
+ if (lines[i].includes(endPattern)) { end = i; break; }
+ }
+
+ return (start !== -1 && end !== -1) ? { start, end, id } : null;
+}
+
+/**
+ * Compute the line range to REPLACE (vs. just the marker range to extract
+ * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE
+ * the `` outer wrapper so the picked
+ * element's JSX slot keeps a single child — a Fragment `<>>` would have
+ * solved the multi-sibling case but failed inside `asChild` / cloneElement
+ * parents with "Invalid prop supplied to React.Fragment".
+ *
+ * That means the marker block is enclosed by the wrapper `
` opener
+ * (with `data-impeccable-variants="ID"`) and its matching `
`. We
+ * walk back to the opener and forward to the closer so accept/discard
+ * remove the entire scaffold, not just the inner markers.
+ *
+ * Marker lines themselves stay where they were so extractOriginal /
+ * extractVariant / extractCss continue to walk the same range.
+ */
+function expandReplaceRange(block, lines, isJsx) {
+ if (!isJsx) return { start: block.start, end: block.end };
+
+ let { start, end } = block;
+
+ // Walk back for the wrapper `
= 0; i--) {
+ if (isVariantEndMarkerLine(lines[i], block.id)) break;
+ if (hasVariantWrapperAttr(lines[i], block.id)) {
+ let opener = i;
+ while (opener > 0 && !/
` by div-depth tracking from the
+ // wrapper opener. Operate on JOINED text instead of per-line: a
+ // multi-line self-closing JSX `
` would
+ // fool per-line regex tracking (the `
` line never matches selfCloseRe since it needs `
` orphaned after accept/discard. Single regex with
+ // `[^>]*?` (which spans newlines in JS) handles either form correctly.
+ const joined = lines.slice(start).join('\n');
+ // Match either `
` (self-close, group 1 is `/`), `
`
+ // (open, group 1 is empty), or `
`.
+ const tagRe = /
]*?(\/?)>|<\/div\s*>/g;
+ let depth = 0;
+ let m;
+ while ((m = tagRe.exec(joined)) !== null) {
+ const isClose = m[0].startsWith('');
+ const isSelfClose = !isClose && m[1] === '/';
+ if (isClose) depth--;
+ else if (!isSelfClose) depth++;
+ if (depth <= 0) {
+ // m.index is offset within `joined`; convert back to a file line.
+ const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
+ const candidateEnd = start + linesBefore;
+ if (candidateEnd >= end) {
+ end = candidateEnd;
+ break;
+ }
+ }
+ }
+
+ return { start, end };
+}
+
+function escapeRegExp(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function isVariantEndMarkerLine(line, id) {
+ return new RegExp('impeccable-variants-end\\s+' + escapeRegExp(id) + '(?:\\s|--|\\*/|$)').test(line);
+}
+
+function hasVariantWrapperAttr(line, id) {
+ const escaped = escapeRegExp(id);
+ return new RegExp(`data-impeccable-variants\\s*=\\s*(?:"${escaped}"|'${escaped}'|\\{["']${escaped}["']\\})`).test(line);
+}
+
+/**
+ * Join wrapper lines into a single string with `` to close on)
+ * - Same-line `` blocks
+ * - Multi-line `` blocks
+ */
+function stripStyleAndJoin(lines, block) {
+ const out = [];
+ let inStyle = false;
+ for (let i = block.start; i <= block.end; i++) {
+ let line = lines[i];
+
+ if (!inStyle) {
+ // Strip any complete .
+ const closeIdx = line.search(/<\/style\s*>/);
+ if (closeIdx !== -1) {
+ inStyle = false;
+ out.push(line.slice(closeIdx).replace(/<\/style\s*>/, ''));
+ }
+ // else: skip line entirely
+ }
+ }
+ return out.join('\n');
+}
+
+/**
+ * Find the inner content of `
… ` inside `text`,
+ * handling nested same-tag elements via depth counting. `attrMatch` is a
+ * regex source fragment that must appear inside the opener tag.
+ * Returns the inner string (may be empty), or null if not found.
+ */
+function extractInnerByAttr(text, attrMatch) {
+ const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>');
+ const openMatch = text.match(openerRe);
+ if (!openMatch) return null;
+
+ const tagName = openMatch[1];
+ const innerStart = openMatch.index + openMatch[0].length;
+
+ // Match any opener or closer of this tag name after innerStart.
+ // (Does not match self-closing
, which doesn't contribute to depth.)
+ const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g');
+ tagRe.lastIndex = innerStart;
+
+ let depth = 1;
+ let m;
+ while ((m = tagRe.exec(text))) {
+ const isClose = m[0].startsWith('');
+ const isSelfClose = !isClose && /\/\s*>$/.test(m[0]);
+ if (isClose) {
+ depth--;
+ if (depth === 0) return text.slice(innerStart, m.index);
+ } else if (!isSelfClose) {
+ depth++;
+ }
+ }
+ return null;
+}
+
+/**
+ * Extract the original element content from within the variant wrapper.
+ * Returns an array of lines.
+ */
+function extractOriginal(lines, block) {
+ const text = stripStyleAndJoin(lines, block);
+ const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"');
+ if (inner === null) return [];
+ return inner.split('\n');
+}
+
+/**
+ * Extract a specific variant's inner content (stripping the wrapper div).
+ * Returns an array of lines, or null if not found.
+ */
+function extractVariant(lines, block, variantNum) {
+ const text = stripStyleAndJoin(lines, block);
+ const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"');
+ if (inner === null) return null;
+ const result = inner.split('\n');
+ // Collapse a lone empty leading/trailing line (common after string splice).
+ while (result.length > 1 && result[0].trim() === '') result.shift();
+ while (result.length > 1 && result[result.length - 1].trim() === '') result.pop();
+ return result.length > 0 ? result : null;
+}
+
+/**
+ * Extract the colocated ` — return the inner content.
+ * 3. Multi-line: `` on a later line — return
+ * the lines between them.
+ */
+function extractCss(lines, block, id) {
+ const styleAttr = 'data-impeccable-css="' + id + '"';
+ let inStyle = false;
+ const content = [];
+
+ for (let i = block.start; i <= block.end; i++) {
+ const line = lines[i];
+
+ if (!inStyle && line.includes(styleAttr)) {
+ // Self-closing: nothing to carbonize.
+ if (/ anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
+ content.push(line);
+ }
+ }
+
+ if (content.length === 0) return null;
+ return stripJsxTemplateLines(content);
+}
+
+/**
+ * Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a
+ * `',
+ )
+ .replace(/\bclassName\s*=\s*\{\s*`([^`]*?)`\s*\}/g, (_match, value) => {
+ const literalClasses = value.replace(/\$\{[^}]*\}/g, ' ').replace(/\s+/g, ' ').trim();
+ return literalClasses ? 'class="' + escapeHtml(literalClasses) + '"' : '';
+ })
+ .replace(/\bclassName\s*=/g, 'class=')
+ .replace(/\sstyle=\{\{([\s\S]*?)\}\}/g, (_match, body) => {
+ const css = jsxStyleObjectToCss(body);
+ return css ? ' style="' + escapeHtml(css) + '"' : '';
+ });
+ }
+
+ function jsxStyleObjectToCss(body) {
+ const declarations = [];
+ const re = /(["'][^"']+["']|[A-Za-z_$][\w$-]*)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?\d+(?:\.\d+)?))/g;
+ let match;
+ while ((match = re.exec(String(body || '')))) {
+ const prop = jsxStylePropToCss(match[1]);
+ const value = match[2] ?? match[3] ?? match[4] ?? '';
+ if (!prop || value === '') continue;
+ declarations.push(prop + ': ' + value);
+ }
+ return declarations.join('; ');
+ }
+
+ function jsxStylePropToCss(prop) {
+ let out = String(prop || '').trim().replace(/^["']|["']$/g, '');
+ if (!out) return '';
+ if (out.startsWith('--')) return out;
+ return out.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase()).replace(/^-ms-/, '-ms-');
+ }
+
+ function buildSvelteExpressionTextMap(sourceOriginal, liveOriginal) {
+ const map = new Map();
+ if (!sourceOriginal || !liveOriginal) return map;
+
+ const sourceNodes = collectTextNodes(sourceOriginal)
+ .filter((node) => /\{[^{}]+\}/.test(node.nodeValue || ''));
+ const liveTexts = collectTextNodes(liveOriginal)
+ .map((node) => normalizePreviewText(node.nodeValue || ''))
+ .filter(Boolean);
+ let liveIndex = 0;
+
+ for (const sourceNode of sourceNodes) {
+ const sourceText = sourceNode.nodeValue || '';
+ const tokens = sourceText.match(/\{[^{}]+\}/g) || [];
+ if (tokens.length === 0) continue;
+
+ const liveText = liveTexts[liveIndex++] || '';
+ if (!liveText) continue;
+
+ if (tokens.length === 1) {
+ const token = tokens[0];
+ const normalizedSource = normalizePreviewText(sourceText);
+ if (normalizedSource === token) {
+ map.set(token, liveText);
+ continue;
+ }
+
+ const match = liveText.match(expressionTextMatcher(sourceText, [token]));
+ if (match && match[1]) map.set(token, match[1].trim());
+ continue;
+ }
+
+ if (normalizePreviewText(sourceText) === tokens.join(' ')) {
+ for (const token of tokens) {
+ const tokenLiveText = liveTexts[liveIndex - 1] || '';
+ if (tokenLiveText) map.set(token, tokenLiveText);
+ }
+ }
+ }
+
+ return map;
+ }
+
+ function expressionTextMatcher(sourceText, tokens) {
+ let pattern = '^';
+ let cursor = 0;
+ for (const token of tokens) {
+ const index = sourceText.indexOf(token, cursor);
+ if (index === -1) continue;
+ pattern += escapeRegExp(sourceText.slice(cursor, index)).replace(/\s+/g, '\\s*');
+ pattern += '(.*?)';
+ cursor = index + token.length;
+ }
+ pattern += escapeRegExp(sourceText.slice(cursor)).replace(/\s+/g, '\\s*') + '$';
+ return new RegExp(pattern);
+ }
+
+ function collectTextNodes(root) {
+ if (!root) return [];
+ const nodes = [];
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ let node = walker.nextNode();
+ while (node) {
+ nodes.push(node);
+ node = walker.nextNode();
+ }
+ return nodes;
+ }
+
+ function normalizePreviewText(value) {
+ return String(value || '').replace(/\s+/g, ' ').trim();
+ }
+
+ function escapeRegExp(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ }
+
+ async function selectVariant(next, checkpointReason) {
+ if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
+ if (variantSelectionInFlight) return;
+ if (next < 1 || next > arrivedVariants) return;
+ if (next === visibleVariant) return;
+
+ const previous = visibleVariant;
+ variantSelectionInFlight = true;
+ const selectionPromise = (async () => {
+ visibleVariant = next;
+ showOrUpdateCyclingBar();
+ saveSession();
+ const shown = await showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
+ if (!shown) {
+ visibleVariant = previous;
+ await showVariantInDOM(currentSessionId, previous);
+ showOrUpdateCyclingBar();
+ saveSession();
+ return;
+ }
+ updateSelectedElement();
+ showOrUpdateCyclingBar();
+ positionBar();
+ saveSession();
+ if (checkpointReason) queueCheckpoint(checkpointReason);
+ })();
+ variantSelectionPromise = selectionPromise;
+ try {
+ await selectionPromise;
+ } finally {
+ if (variantSelectionPromise === selectionPromise) variantSelectionPromise = null;
+ variantSelectionInFlight = false;
+ }
+ }
+
+ function cycleVariant(dir) {
+ selectVariant(visibleVariant + dir, 'variant_changed');
+ }
+
+ function updateSelectedElement() {
+ if (!currentSessionId) return;
+ if (svelteComponentSession?.sessionId === currentSessionId) {
+ const anchor = resolveSvelteComponentAnchor();
+ if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
+ return;
+ }
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
+ if (!wrapper) return;
+ const visEl = pickVariantContent(wrapper, visibleVariant);
+ if (visEl) selectedElement = visEl;
+ }
+
+ function readVisibleVariantFromDOM(sessionId) {
+ if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
+ return svelteComponentSession.mountedVariant;
+ }
+ const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
+ if (!wrapper) return 0;
+ const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
+ for (const variant of variants) {
+ if (!isVariantShown(variant)) continue;
+ const idx = parseInt(variant.dataset.impeccableVariant || '0', 10);
+ if (idx > 0) return idx;
+ }
+ return 0;
+ }
+
+ // Resolve the element that represents the variant's visible content.
+ // Contract: each variant div should contain exactly one top-level element
+ // (the full replacement). In practice a model may ship loose siblings or
+ // lead with 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.
+ *
+ * Only `node_modules`, `.git`, and `.impeccable` are skipped outright.
+ * dist/build/out are left to the isGeneratedFile guard so the
+ * `includeGenerated` second pass can still find the element there and report
+ * `generatedMatch`.
+ */
+function findFileWithQuery(query, cwd, genOpts = {}) {
+ return findSourceFile({
+ query,
+ cwd,
+ extensions: resolveLiveTemplateExtensions(cwd),
+ fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts),
+ });
+}
+
+/**
+ * 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(''; }
+
+/**
+ * `scriptAttrs` is a pre-rendered attribute string (trailing space included)
+ * that the registry supplies for the target file. Astro is the only framework
+ * that uses it today: Astro processes `\n' +
+ open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
+ );
+}
+
+function detectLineEnding(content) {
+ if (content.includes('\r\n')) return '\r\n';
+ if (content.includes('\r')) return '\r';
+ return '\n';
+}
+
+function normalizeLineEndings(content, lineEnding) {
+ return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
+}
+
+function readLineEndingAt(content, index) {
+ if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
+ if (content[index] === '\n') return '\n';
+ if (content[index] === '\r') return '\r';
+ return '';
+}
+
+export function insertTag(content, config, port, token, scriptAttrs = '') {
+ const lineEnding = detectLineEnding(content);
+ const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, token, scriptAttrs), lineEnding);
+ // insertBefore: match the LAST occurrence. Anchors like `` 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
+ // `` 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 an existing trailing newline if the anchor already has one.
+ // Slice the remainder from the original anchor offset, not prefix.length:
+ // in the no-newline case prefix is one char longer than the anchor (the
+ // appended '\n'), so slicing by prefix.length would drop the first real
+ // character after the anchor (#227).
+ const existingNewline = readLineEndingAt(content, after);
+ const prefix = content.slice(0, after) + (existingNewline || lineEnding);
+ const rest = content.slice(after + existingNewline.length);
+ return prefix + block + rest;
+}
+
+/**
+ * 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.
+ * ``), 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.
+ */
+export function removeTag(content, _syntax) {
+ const patterns = [
+ /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/,
+ /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
+ ];
+ for (const pat of patterns) {
+ let changed = false;
+ let next = content;
+ do {
+ content = next;
+ next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
+ if (/[\r\n]/.test(trailing)) 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;
+}
+
+/** The journal's undo for a tag-strategy patch: drop the block, restore CSP. */
+export function unpatchTagFile(content) {
+ return revertCspMeta(removeTag(content));
+}
diff --git a/.claude/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs b/.claude/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs
new file mode 100644
index 00000000..9bfb3db4
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs
@@ -0,0 +1,70 @@
+/**
+ * TanStack Start registry entry.
+ *
+ * Detection and the apply/remove pair are the existing adapter's
+ * (`../tanstack-adapter.mjs`); this file only declares them to the registry
+ * and names the artifacts the journal has to be able to heal.
+ */
+
+import {
+ TANSTACK_MARKER_OPEN,
+ applyTanStackLiveAdapter,
+ detectTanStackStartProject,
+ removeTanStackLiveAdapter,
+ unpatchTanStackRoot,
+} from '../tanstack-adapter.mjs';
+
+export const tanstackStart = {
+ name: 'tanstack-start',
+
+ detect(cwd) {
+ return detectTanStackStartProject(cwd);
+ },
+
+ inject: {
+ kind: 'adapter',
+
+ apply({ cwd, port, token, project }) {
+ return applyTanStackLiveAdapter({ cwd, port, token, project });
+ },
+
+ remove({ cwd, project }) {
+ return removeTanStackLiveAdapter({ cwd, project });
+ },
+
+ // The mount component's extension follows the root route's, so the path
+ // cannot live in the static ignore list.
+ ignorePatterns(project) {
+ return project?.componentFile ? [project.componentFile] : [];
+ },
+
+ artifacts({ project }) {
+ if (!project) return [];
+ return [
+ {
+ kind: 'created',
+ path: project.componentFile,
+ marker: 'impeccable-live-tanstack',
+ pruneTo: 'src',
+ },
+ {
+ kind: 'patched',
+ path: project.rootRoute,
+ patch: 'tanstack-root',
+ markers: [TANSTACK_MARKER_OPEN],
+ },
+ ];
+ },
+
+ unpatch: {
+ 'tanstack-root': unpatchTanStackRoot,
+ },
+ },
+
+ source: {
+ extensions: ['.tsx', '.jsx'],
+ preview: 'source',
+ styleMode: 'scoped',
+ commentSyntax: 'jsx',
+ },
+};
diff --git a/.claude/skills/impeccable/scripts/live/frameworks/vite-generic.mjs b/.claude/skills/impeccable/scripts/live/frameworks/vite-generic.mjs
new file mode 100644
index 00000000..4713670f
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live/frameworks/vite-generic.mjs
@@ -0,0 +1,42 @@
+/**
+ * Generic Vite registry entry: a bundled app with a real `index.html` entry
+ * and no framework-specific document ownership. React, Vue, Solid, Preact and
+ * a plain TanStack Router SPA all land here — the marker-wrapped script block
+ * goes straight into the HTML entry.
+ *
+ * This is the entry that catches everything with a bundler config; only
+ * static-html sits below it.
+ */
+
+import { fileExists, findConfigFile, hasAnyDependency } from './detect-utils.mjs';
+
+const VITE_CONFIG_RE = /^vite\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
+
+export function detectViteProject(cwd = process.cwd()) {
+ const configFile = findConfigFile(cwd, VITE_CONFIG_RE);
+ if (configFile) return { configFile, via: 'config' };
+ if (hasAnyDependency(cwd, ['vite'])) return { configFile: null, via: 'package' };
+ // A zero-config Vite app is index.html + package.json, the same pair
+ // roots.mjs treats as an app root.
+ if (fileExists(cwd, 'index.html') && fileExists(cwd, 'package.json')) {
+ return { configFile: null, via: 'zero-config' };
+ }
+ return null;
+}
+
+export const viteGeneric = {
+ name: 'vite-generic',
+
+ detect(cwd) {
+ return detectViteProject(cwd);
+ },
+
+ inject: { kind: 'tag' },
+
+ source: {
+ extensions: ['.tsx', '.jsx'],
+ preview: 'source',
+ styleMode: 'scoped',
+ commentSyntax: 'jsx',
+ },
+};
diff --git a/.claude/skills/impeccable/scripts/live/generation-preflight.mjs b/.claude/skills/impeccable/scripts/live/generation-preflight.mjs
new file mode 100644
index 00000000..bfe81b32
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live/generation-preflight.mjs
@@ -0,0 +1,149 @@
+import { execFile } from 'node:child_process';
+import path from 'node:path';
+import { promisify } from 'node:util';
+
+const execFileAsync = promisify(execFile);
+const PREFLIGHT_TIMEOUT_MS = 15_000;
+
+// Per-target cache of the resolved source file. The wrap search walks the whole
+// project tree and was measured at ~7.6s on a large repo; it re-ran on every
+// generate for the same picked element (re-rolls, param passes). Keyed by the
+// target signature (locator + route), so it invalidates automatically when the
+// element or route changes; a failed resolution evicts its entry (see below).
+const sourceResolutionCache = new Map();
+
+/** Test/lifecycle hook: drop all cached source resolutions. */
+export function clearSourceResolutionCache() {
+ sourceResolutionCache.clear();
+}
+
+function targetSignature(event) {
+ const isInsert = event.mode === 'insert';
+ const target = isInsert ? insertTarget(event) : replaceTarget(event);
+ return JSON.stringify({
+ mode: isInsert ? 'insert' : 'replace',
+ position: isInsert ? target.position : null,
+ elementId: target.elementId || null,
+ classes: target.classes || null,
+ tag: target.tag || null,
+ pageUrl: event.pageUrl || null,
+ });
+}
+
+export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
+ if (!event || event.type !== 'generate' || !event.id) return null;
+
+ const isInsert = event.mode === 'insert';
+ const target = isInsert ? insertTarget(event) : replaceTarget(event);
+ if (!target.elementId && !target.classes) return null;
+
+ const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
+ const args = [script, '--id', event.id, '--count', String(event.count || 3)];
+ // Compute the scaffold but do not write it into source for source-preview
+ // targets. The agent writes wrapper + variants atomically; a premature
+ // server-side write reloads the framework and strands the browser at 0/N.
+ // No-op on the svelte-component path, which never writes the route source.
+ args.push('--defer-source-write');
+ if (isInsert) args.push('--position', target.position);
+ if (target.elementId) args.push('--element-id', target.elementId);
+ if (target.classes) args.push('--classes', target.classes);
+ if (target.tag) args.push('--tag', target.tag);
+ if (target.text) args.push('--text', target.text);
+ if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
+ const signature = targetSignature(event);
+ // A cached resolution points the helper straight at the file, skipping the
+ // tree search. The helper still reads current content, so line ranges stay
+ // fresh; only discovery is cached.
+ const cachedFile = cache ? cache.get(signature) : null;
+ if (cachedFile) args.push('--file', cachedFile);
+ return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
+}
+
+/**
+ * Scaffold the source for a generate event before handing it to an agent.
+ *
+ * Async on purpose. This spawns `live-wrap.mjs`, which walks the project's
+ * source tree and can take seconds (measured at ~7.6s on a large repo when the
+ * element is not found, with a 15s ceiling). The live server is single-threaded
+ * and calls this while leasing a poll, so a synchronous spawn froze the whole
+ * server for that entire window: Accept and Discard POSTs, SSE progress
+ * broadcasts, and every other poll stalled behind it.
+ */
+export async function runGenerationPreflight(event, {
+ cwd = process.cwd(),
+ scriptsDir,
+ execFileImpl = execFileAsync,
+ timeoutMs = PREFLIGHT_TIMEOUT_MS,
+ cache = sourceResolutionCache,
+} = {}) {
+ const command = buildGenerationPreflight(event, scriptsDir, { cache });
+ if (!command) {
+ return { ok: false, skipped: true, reason: 'insufficient_locator' };
+ }
+
+ const startedAt = performance.now();
+ try {
+ const { stdout } = await execFileImpl(process.execPath, command.args, {
+ cwd,
+ encoding: 'utf-8',
+ timeout: timeoutMs,
+ });
+ const line = String(stdout).trim().split('\n').filter(Boolean).pop();
+ if (!line) throw new Error('preflight returned no scaffold metadata');
+ const scaffold = JSON.parse(line);
+ // Cache the resolved SOURCE file (route source, not the svelte manifest) so
+ // the next generate on this target skips the tree search.
+ const resolvedSource = scaffold.sourceFile || scaffold.file;
+ if (cache && command.signature && typeof resolvedSource === 'string') {
+ cache.set(command.signature, resolvedSource);
+ }
+ return {
+ ok: true,
+ mode: command.mode,
+ durationMs: performance.now() - startedAt,
+ scaffold,
+ };
+ } catch (error) {
+ // Evict a stale/failed resolution so the next attempt does a full search
+ // (the element may have moved out of the previously cached file).
+ if (cache && command.signature) cache.delete(command.signature);
+ return {
+ ok: false,
+ mode: command.mode,
+ durationMs: performance.now() - startedAt,
+ error: compactError(error),
+ };
+ }
+}
+
+function replaceTarget(event) {
+ return normalizeTarget(event.element || {});
+}
+
+function insertTarget(event) {
+ return {
+ ...normalizeTarget(event.insert?.anchor || {}),
+ position: event.insert?.position === 'before' ? 'before' : 'after',
+ };
+}
+
+function normalizeTarget(target) {
+ const classes = Array.isArray(target.classes)
+ ? target.classes.join(' ')
+ : String(target.classes || '').trim();
+ const text = typeof target.textContent === 'string'
+ ? target.textContent.trim().slice(0, 80)
+ : '';
+ return {
+ elementId: target.id || target.elementId || undefined,
+ classes: classes || undefined,
+ tag: target.tagName || target.tag || undefined,
+ text: text || undefined,
+ };
+}
+
+function compactError(error) {
+ const stderr = error?.stderr ? String(error.stderr).trim() : '';
+ const message = stderr.split('\n').filter(Boolean).pop() || error?.message || 'preflight failed';
+ return String(message).slice(0, 500);
+}
diff --git a/.claude/skills/impeccable/scripts/live/insert-ui.mjs b/.claude/skills/impeccable/scripts/live/insert-ui.mjs
new file mode 100644
index 00000000..ae54f6f9
--- /dev/null
+++ b/.claude/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/.claude/skills/impeccable/scripts/live/instructions.mjs b/.claude/skills/impeccable/scripts/live/instructions.mjs
new file mode 100644
index 00000000..19f6a1ae
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live/instructions.mjs
@@ -0,0 +1,142 @@
+/**
+ * Just-in-time agent instructions for live mode.
+ *
+ * The live scripts, not the reference doc, own situational plumbing: every
+ * event printed by live-poll carries an `_instructions` string describing
+ * exactly what to do NEXT, with real ids, paths, and line numbers already
+ * substituted and only the active path's rules included (a svelte-component
+ * session never sees JSX guidance, and vice versa). live.md stays lean: the
+ * session contract, harness policy, and design-quality guidance that is not
+ * situational (identity lock, variation axes, parameter budgets).
+ *
+ * Keep these strings imperative, concrete, and short. They are read by an
+ * agent mid-session; every sentence must earn its tokens. Instructions are
+ * versioned with the scripts, so they cannot drift from behavior the way a
+ * hand-maintained doc can.
+ */
+
+const PLAN_POINTER = 'Plan per live.md section 4: extract the identity lock, pick default vs departure mode, commit each variant to a DIFFERENT primary axis, squint-test the trio. Size parameter knobs per section 7 budgets.';
+
+function pollCmd(scriptsPath) {
+ return `node ${scriptsPath}/live-poll.mjs`;
+}
+
+function replyCmd(scriptsPath, id, rest) {
+ return `${pollCmd(scriptsPath)} --reply ${id} ${rest}`;
+}
+
+export function instructionsForEvent(event, { scriptsPath = '{{scripts_path}}' } = {}) {
+ if (!event || typeof event !== 'object') return undefined;
+ switch (event.type) {
+ case 'generate':
+ return generateInstructions(event, scriptsPath);
+ case 'steer':
+ return `Do what the message asks (page edits, navigation help, or a short answer). Then reply exactly once: ${replyCmd(scriptsPath, event.id, 'steer_done ["optional short toast"]')} (on failure: --reply ${event.id} error "Short reason"). No pickup ack; poll again immediately after.`;
+ case 'prefetch':
+ return `Speculative pre-read, no reply owed: resolve ${JSON.stringify(event.pageUrl || '/')} to its source file (root "/" is usually the boot's pageFile; multi-page sites map /foo to public/foo/index.html; SPAs map all routes to one entry), read it into context, then poll again. Skip if you cannot resolve it confidently.`;
+ case 'variant_mount_failed':
+ return `The browser could NOT render variant ${event.variant}${event.url ? ` (module: ${event.url})` : ''}${event.error ? `: ${String(event.error).slice(0, 200)}` : ''}. The user sees a persistent error card, not variants. Fix the variant source files, then reply ${replyCmd(scriptsPath, event.id, 'done --file ')}; the browser retries on its own. Poll again after the reply.`;
+ case 'accept':
+ return acceptInstructions(event, scriptsPath);
+ case 'discard':
+ return event?._completionAck?.ok === true
+ ? 'Original restored and durable completion acknowledged; nothing to do. Poll again.'
+ : `Completion was not acknowledged: run node ${scriptsPath}/live-complete.mjs --id ${event.id} --discarded, then poll again.`;
+ case 'manual_edit_apply':
+ return `The user already clicked Apply; never ask, discard, or redirect. Delegate the source edits to the impeccable_manual_edit_applier subagent when available (pass cwd, scripts path, event id, page URL, chunk/deadline, batch, evidencePath); it must not poll or reply. ${event.repair ? 'A `repair` payload is present: the previous Apply changed source but validation failed; fix the CURRENT source, never roll back yourself. ' : ''}Reply exactly once: ${replyCmd(scriptsPath, event.id, `done --data '{"status":"done","appliedEntryIds":[...],"failed":[],"files":[...],"notes":[]}'`)} (status "partial"/"error" with failed[] when not every entry applied). Then poll again.`;
+ case 'timeout':
+ return 'No event arrived; poll again immediately.';
+ case 'exit':
+ return `Session over: kill any background poll, then node ${scriptsPath}/live-server.mjs stop (removes the injected script tag). Sweep leftover impeccable-variants-start / impeccable-carbonize-start markers from source.`;
+ default:
+ return undefined;
+ }
+}
+
+function generateInstructions(event, scriptsPath) {
+ const id = event.id;
+ const scaffold = event.scaffold;
+ const steps = [];
+
+ if (event.screenshotPath) {
+ steps.push(`Read the annotated screenshot first: ${event.screenshotPath}. Comment {x,y} positions bind text to the child under that point; strokes read by shape (loop = emphasis on this thing, arrow = direction, cross = delete).`);
+ } else {
+ steps.push('No screenshot was sent (the user did not annotate); do not ask for one and do not screenshot the page. Work from element.outerHTML, the computed styles, and the prompt.');
+ }
+
+ if (event.mode === 'insert') {
+ steps.push(insertScaffoldInstructions(event, scriptsPath));
+ } else if (scaffold?.previewMode === 'svelte-component') {
+ steps.push(svelteComponentInstructions(event, scaffold, scriptsPath));
+ } else if (scaffold && scaffold.sourceWritten === false) {
+ steps.push(deferredWrapperInstructions(event, scaffold, scriptsPath));
+ } else if (scaffold) {
+ steps.push(`The wrapper is already written into ${scaffold.file}. Splice preview CSS plus all ${event.count} variants at line ${scaffold.insertLine} in ONE edit, following the returned cssAuthoring contract (styleTag, selector strategy, forbidden patterns). Each variant div holds exactly ONE top-level element (same tag as the original); first visible, others display: none.`);
+ } else {
+ steps.push(`Preflight could not scaffold${event.scaffoldError ? ` (${event.scaffoldError})` : ''}. Run node ${scriptsPath}/live-wrap.mjs --id ${id} --count ${event.count} --element-id "${event.element?.id || ''}" --classes "${(event.element?.classes || []).join(',')}" --tag "${event.element?.tagName || ''}" --text "". Keep the flags separate; --text disambiguates repeated siblings. On a fallback error, follow live.md's Handle fallback.`);
+ }
+
+ steps.push(event.action && event.action !== 'impeccable'
+ ? `Action is "${event.action}": read reference/${event.action}.md before planning; its MUST params are non-negotiable. ${PLAN_POINTER}`
+ : `Freeform action: work from SKILL.md rules plus craft-floor.md; no sub-command file. ${PLAN_POINTER}`);
+
+ steps.push(`When all ${event.count} variants are delivered: ${replyCmd(scriptsPath, id, 'done --file ')}. Then poll again. If generation fails after the browser flipped to GENERATING, reply --reply ${id} error "Short reason" so the bar resets (never live-accept --discard for this).`);
+
+ return steps.map((s, i) => `${i + 1}. ${s}`).join('\n');
+}
+
+function svelteComponentInstructions(event, scaffold, scriptsPath) {
+ const dir = scaffold.componentDir;
+ const count = event.count;
+ return `Svelte component preview. EDIT the existing stubs ${dir}/v1.svelte ... v${count}.svelte in place; never delete or recreate them; do not read them back (the prop-substituted markup is in scaffold.componentStubMarkup). Keep the stub's control flow ({#each}, {#if}) and propContract prop names exactly; never flatten a loop into literal items. The stub \n`;
+}
+
+function buildInsertVariantStub(variantNum) {
+ return `${buildPropsScript([])}Insert variant ${variantNum}
\n\n\n`;
+}
+
+/**
+ * Scaffold a component-preview session. The scaffold is AST-based: the app's
+ * own svelte compiler parses the selected markup, control-flow blocks are
+ * preserved (an each collection crosses the prop contract as ONE structured
+ * prop, its loop body verbatim), and constructs a detached preview cannot
+ * support return `{ fallback: 'source-preview', reason }` so the caller keeps
+ * the markup inside the route file instead of shipping a wrong preview.
+ */
+export function scaffoldSvelteComponentSession({
+ id,
+ count,
+ sourceFile,
+ sourceStartLine,
+ sourceEndLine,
+ originalLines,
+ cwd = process.cwd(),
+}) {
+ const originalMarkup = originalLines.join('\n');
+
+ const compiler = loadSvelteCompiler(cwd);
+ if (!compiler) {
+ return { fallback: 'source-preview', reason: 'svelte 5 compiler not resolvable from the app root' };
+ }
+ const analysis = analyzeSvelteMarkup(originalMarkup, compiler.parse);
+ if (!analysis.ok) {
+ return { fallback: 'source-preview', reason: analysis.reason };
+ }
+
+ ensureRuntimeHelper(cwd);
+ const dir = componentSessionDir(id, cwd);
+ fs.mkdirSync(dir, { recursive: true });
+
+ const contract = analysis.contract;
+ const seeded = extractMatchingSourceCss(
+ safeReadSource(path.resolve(cwd, sourceFile)),
+ originalMarkup,
+ );
+ const seededCss = seeded.css;
+ // The preview compiles in isolation, so NONE of these source rules applied
+ // to what the user approved. Accept enforces that preview truth: any of
+ // them the variant does not re-declare is superseded and removed, instead
+ // of re-attaching to the accepted markup through kept class names (the
+ // ".decisions grid grabs the new board" failure). Only the CLASS-matched
+ // selectors are candidates; tag rules style shared route elements.
+ const seededSelectors = [...seeded.supersedable];
+
+ const manifest = {
+ id,
+ previewMode: 'svelte-component',
+ contractVersion: 2,
+ sourceFile: sourceFile.split(path.sep).join('/'),
+ sourceStartLine,
+ sourceEndLine,
+ count,
+ propContract: contract,
+ originalMarkup,
+ seededSelectors,
+ componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
+ // Absolute paths let the browser fall back to /@fs/ imports when the dev
+ // server's base or root makes root-relative URLs miss, and probe whether
+ // the preview tree is reachable at all before blaming a variant.
+ componentDirAbs: dir.split(path.sep).join('/'),
+ runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
+ runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'),
+ probeModule: `/${SVELTE_PROBE_FILE}`,
+ probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'),
+ };
+
+ 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, buildVariantStubV2(n, analysis.markupWithProps, contract, seededCss), 'utf-8');
+ }
+ }
+
+ return {
+ manifest,
+ manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
+ componentDir: manifest.componentDir,
+ propContract: contract,
+ // Inlined so the generate event's scaffold payload carries the stub
+ // shape; the agent edits vN.svelte in place instead of spending reads on
+ // the manifest and stub files (or deleting and recreating them).
+ stubMarkup: analysis.markupWithProps,
+ seededCss,
+ };
+}
+
+function safeReadSource(filePath) {
+ try { return fs.readFileSync(filePath, 'utf-8'); } catch { return ''; }
+}
+
+function escapeSelectorToken(token) {
+ return String(token).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * Seed variant stubs with the source component's rules that already style the
+ * selected markup, so variants start from the real cascade (a detached
+ * preview inherits none of the route's compile-scoped CSS) instead of
+ * reimplementing it blind.
+ *
+ * Returns { css, supersedable }. `css` is every matching rule (class OR tag
+ * matched). `supersedable` holds only the CLASS-matched selectors: those are
+ * the accept-time removal candidates. Tag selectors (h1, a, p) style shared
+ * elements across the whole route, so they seed the preview but are never
+ * candidates for removal.
+ */
+export function extractMatchingSourceCss(routeSource, originalMarkup) {
+ const empty = { css: '', supersedable: new Set() };
+ const styleMatch = String(routeSource || '').match(/\n`
+ : `\n\n`;
+ return `${buildPropsScriptV2(contract)}${propsComment}${markupWithProps.trim()}\n${css}`;
+}
+
+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('/'),
+ componentDirAbs: dir.split(path.sep).join('/'),
+ runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
+ runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'),
+ probeModule: `/${SVELTE_PROBE_FILE}`,
+ probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'),
+ };
+
+ 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);
+ }
+ // Legacy location: a session scaffolded by an older version can still be
+ // accepted after an upgrade.
+ const legacyDirect = path.join(cwd, LEGACY_SVELTE_COMPONENT_ROOT, id, 'manifest.json');
+ if (fs.existsSync(legacyDirect)) {
+ return readManifest(legacyDirect);
+ }
+ for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) {
+ const root = path.join(cwd, rootRel);
+ if (!fs.existsSync(root)) continue;
+ 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 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 compiler = loadSvelteCompiler(cwd);
+ const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
+
+ // Restore props back to route expressions. Contract v2 restores through the
+ // AST so a prop used without braces (each headers, attribute positions)
+ // still maps back to its original expression; v1 falls back to the textual
+ // placeholder swap.
+ let restoredText;
+ if (Number(manifest.contractVersion) === 2 && compiler) {
+ const restored = restoreSvelteMarkup(mergedMarkup, contract, compiler.parse);
+ if (!restored.ok) {
+ return { handled: false, error: 'Accepted variant does not parse: ' + restored.reason, ...resultBase };
+ }
+ restoredText = restored.markup;
+ } else {
+ restoredText = substitutePropsWithExprs(mergedMarkup, contract);
+ }
+ const restoredMarkup = restoredText.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 = reindentPreservingStructure(restoredMarkup, indent);
+
+ let newLines = [
+ ...sourceLines.slice(0, start),
+ ...indentedMarkup,
+ ...sourceLines.slice(end + 1),
+ ];
+
+ // Selectors that were already unused before this accept are the user's
+ // pre-existing code; the pruning pass must not touch them.
+ const preUnused = compiler ? collectUnusedSelectors(sourceContent, compiler.compile) : new Set();
+
+ // Bake params (declared kinds from params.json drive branch pruning), then
+ // MERGE into the component's existing style block: matching selectors are
+ // replaced, new ones appended. Appending alone is how superseded rules used
+ // to survive their own replacement.
+ const declaredParams = readDeclaredParams(manifest, variantNum, cwd);
+ let variantCss = cssLines.join('\n');
+ if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) {
+ // Defensive: strip preview-wrapper selectors that authoring rules forbid
+ // on this path but an off-spec agent may still emit.
+ variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n');
+ }
+ const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {});
+ const cssStats = { replaced: 0, appended: 0, pruned: [], superseded: [] };
+ if (bakedCss.trim()) {
+ const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss);
+ newLines = merged.text.split('\n');
+ cssStats.replaced = merged.replaced;
+ cssStats.appended = merged.appended;
+ }
+
+ let finalText = newLines.join('\n');
+
+ // Preview truth: the detached preview never applied the source rules that
+ // styled the replaced selection, so the user approved a design without
+ // them. Any seeded selector the variant did not re-declare is superseded;
+ // left in place it re-attaches through kept class names (the accepted root
+ // keeps its original classes) and re-layouts markup it no longer owns.
+ //
+ // Removal is bounded by ownership: a selector whose classes are still used
+ // by route markup OUTSIDE the replaced region does not belong to the pick
+ // alone, and removing it would strip styling from markup this accept never
+ // touched. Keeping it risks a visible re-attachment quirk on the accepted
+ // region; deleting it breaks the rest of the route. Keep it.
+ const outsideMarkup = [...sourceLines.slice(0, start), ...sourceLines.slice(end + 1)]
+ .join('\n')
+ .replace(/`;
+ return {
+ text: text.slice(0, lastMatch.index) + rebuilt + text.slice(lastMatch.index + lastMatch[0].length),
+ removed,
+ };
+}
+
+export function findLostSelectors(beforeSource, afterSource, prunedSelectors = []) {
+ const before = collectAllSelectors(styleBlockText(beforeSource));
+ const after = collectAllSelectors(styleBlockText(afterSource));
+ const pruned = new Set((prunedSelectors || []).map((s) => normalizeSelector(s)));
+ const lost = [];
+ for (const selector of before) {
+ if (!after.has(selector) && !pruned.has(selector)) lost.push(selector);
+ }
+ return lost;
+}
+
+function readDeclaredParams(manifest, variantNum, cwd) {
+ try {
+ const raw = JSON.parse(fs.readFileSync(path.join(cwd, manifest.componentDir, 'params.json'), 'utf-8'));
+ const list = raw?.[String(variantNum)];
+ return Array.isArray(list) ? list : [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Merge CSS into a svelte component's top-level style block (created when
+ * absent), replacing rules whose selectors match and appending the rest.
+ */
+export function mergeCssIntoSvelteSource(sourceText, incomingCss) {
+ const text = String(sourceText || '');
+ const styleRe = /\n`,
+ replaced,
+ appended,
+ };
+ }
+
+ const inner = lastMatch[1];
+ const { css, replaced, appended } = reconcileCss(inner, incomingCss);
+ const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1);
+ const replacedBlock = `${openTag}\n${indentCssBlock(css)}\n`;
+ return {
+ text: text.slice(0, lastMatch.index) + replacedBlock + text.slice(lastMatch.index + lastMatch[0].length),
+ replaced,
+ appended,
+ };
+}
+
+function indentCssBlock(css) {
+ return String(css || '')
+ .split('\n')
+ .map((line) => (line.trim() === '' ? '' : ' ' + line))
+ .join('\n');
+}
+
+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 = reindentPreservingStructure(restoredMarkup, indent);
+
+ let newLines = [
+ ...sourceLines.slice(0, insertIndex),
+ ...indentedMarkup,
+ ...sourceLines.slice(insertIndex),
+ ];
+
+ let variantCss = cssLines.join('\n');
+ if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) {
+ variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n');
+ }
+ const declaredParams = readDeclaredParams(manifest, variantNum, cwd);
+ const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {});
+ if (bakedCss.trim()) {
+ const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss);
+ newLines = merged.text.split('\n');
+ }
+
+ 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);
+
+ const verify = verifyAcceptedSource(newLines.join('\n'));
+ return {
+ handled: true,
+ verify,
+ ...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(SVELTE_ROOT_IMPORT_LINE_RE, '');
+ 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/.claude/skills/impeccable/scripts/live/tanstack-adapter.mjs b/.claude/skills/impeccable/scripts/live/tanstack-adapter.mjs
new file mode 100644
index 00000000..4a1c81a9
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live/tanstack-adapter.mjs
@@ -0,0 +1,280 @@
+/**
+ * TanStack Start live-mode adapter.
+ *
+ * TanStack Start is SSR: there is no static index.html to patch. The document
+ * shell is a React component (`shellComponent`/`component`) defined in the root
+ * route file, `src/routes/__root.tsx`, which renders `…{children}
+ * `.
+ *
+ * A raw ``;
+}
+
+const server = http.createServer((req, res) => {
+ if (req.method === 'GET' && req.url === '/') {
+ const pending = nextFile();
+ if (pending && fs.existsSync(pending)) {
+ try { loadRound(fs.readFileSync(pending, 'utf8')); fs.rmSync(pending); } catch { /* keep current round */ }
+ }
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
+ res.end(page());
+ return;
+ }
+ if (req.method === 'POST' && req.url === '/heartbeat') {
+ res.writeHead(204); res.end();
+ if (detachedKey) {
+ const now = Date.now();
+ if (!server.lastBeatWrite || now - server.lastBeatWrite > 4000) {
+ server.lastBeatWrite = now;
+ try {
+ const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8'));
+ state.lastBeat = now;
+ fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state));
+ } catch { /* state file recreated on next beat */ }
+ }
+ }
+ return;
+ }
+ if (req.method === 'GET' && req.url === '/next-status') {
+ const pending = nextFile();
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
+ return;
+ }
+ const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ if (imageMatch) {
+ const abs = localImages[Number(imageMatch[1])];
+ if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
+ const type = abs.endsWith('.webp') ? 'image/webp'
+ : abs.endsWith('.png') ? 'image/png'
+ : abs.endsWith('.svg') ? 'image/svg+xml'
+ : abs.endsWith('.gif') ? 'image/gif'
+ : 'image/jpeg';
+ res.writeHead(200, { 'content-type': type });
+ fs.createReadStream(abs).pipe(res);
+ return;
+ }
+ if (req.method === 'POST' && req.url === '/answer') {
+ let body = '';
+ req.on('data', (chunk) => { body += chunk; });
+ req.on('end', () => {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end('{"ok":true}');
+ let parsed = {};
+ try { parsed = JSON.parse(body); } catch { /* empty steer */ }
+ const chosen = options.find((o) => o.id === parsed.optionId);
+ const answer = JSON.stringify({
+ optionId: parsed.optionId ?? null,
+ steer: parsed.steer ?? '',
+ ...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
+ ...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
+ });
+ const isReroll = parsed.optionId === 'reroll';
+ if (detachedKey) {
+ fs.mkdirSync(QUESTION_DIR, { recursive: true });
+ fs.writeFileSync(answerFile(detachedKey), answer + '\n');
+ } else {
+ printAnswer(answer);
+ }
+ // A re-roll in detached mode keeps the table open: the client shows a
+ // loading hand and reloads when --update delivers the next round.
+ if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150);
+ });
+ return;
+ }
+ res.writeHead(404); res.end();
+});
+
+server.listen(portArg, '127.0.0.1', () => {
+ const { port } = server.address();
+ const url = `http://127.0.0.1:${port}/`;
+ if (hasFlag('detached-serve')) {
+ fs.mkdirSync(QUESTION_DIR, { recursive: true });
+ fs.writeFileSync(stateFile(arg('key')), JSON.stringify({ pid: process.pid, port, url }));
+ } else {
+ console.log(`QUESTION URL: ${url}`);
+ console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
+ }
+ if (!hasFlag('no-open')) {
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
+ try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
+ }
+ if (timeoutSec > 0) {
+ setTimeout(() => {
+ console.log('serve-question: timed out with no answer');
+ process.exit(2);
+ }, timeoutSec * 1000).unref?.();
+ }
+});
diff --git a/.claude/skills/impeccable/scripts/surface-brief.mjs b/.claude/skills/impeccable/scripts/surface-brief.mjs
new file mode 100644
index 00000000..723f7c1b
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/surface-brief.mjs
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { resolveProjectRoot } from './context.mjs';
+import {
+ listSurfaceBriefs,
+ resolveSurfaceBrief,
+ surfaceBriefPathForTarget,
+ writeSurfaceBrief,
+} from './lib/surface-briefs.mjs';
+
+function summary(brief, projectRoot) {
+ return {
+ slug: brief.slug,
+ path: path.relative(projectRoot, brief.path).split(path.sep).join('/'),
+ primaryTarget: brief.primaryTarget,
+ relatedTargets: brief.relatedTargets,
+ };
+}
+
+function main(argv) {
+ const [command, target, bodyFile, ...relatedTargets] = argv;
+ const projectRoot = resolveProjectRoot(process.cwd(), target ? { targetPath: target } : {});
+ if (command === 'path') {
+ const filePath = surfaceBriefPathForTarget(target, { projectRoot });
+ if (!filePath) throw new Error('surface brief path requires a concrete target');
+ process.stdout.write(`${path.relative(process.cwd(), filePath) || filePath}\n`);
+ return;
+ }
+ if (command === 'list') {
+ process.stdout.write(`${JSON.stringify(listSurfaceBriefs(projectRoot).map((brief) => summary(brief, projectRoot)), null, 2)}\n`);
+ return;
+ }
+ if (command === 'read') {
+ const result = resolveSurfaceBrief(projectRoot, target || null);
+ if (result.brief) {
+ process.stdout.write(result.brief.text);
+ return;
+ }
+ if (result.candidates.length) process.stderr.write(`${JSON.stringify(result.candidates.map((brief) => summary(brief, projectRoot)), null, 2)}\n`);
+ process.exit(2);
+ }
+ if (command === 'write') {
+ if (!target || !bodyFile) throw new Error('usage: surface-brief.mjs write ');
+ const filePath = writeSurfaceBrief({
+ projectRoot,
+ primaryTarget: target,
+ relatedTargets,
+ body: fs.readFileSync(bodyFile, 'utf-8'),
+ });
+ process.stdout.write(`${path.relative(process.cwd(), filePath) || filePath}\n`);
+ return;
+ }
+ throw new Error('usage: surface-brief.mjs [target] [body-file] [related-target ...]');
+}
+
+function isMainModule() {
+ if (!process.argv[1]) return false;
+ try {
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]);
+ } catch {
+ return import.meta.url === pathToFileURL(process.argv[1]).href;
+ }
+}
+
+if (isMainModule()) {
+ try {
+ main(process.argv.slice(2));
+ } catch (error) {
+ process.stderr.write(`${error?.message || error}\n`);
+ process.exit(1);
+ }
+}
diff --git a/.impeccable/critique/2026-08-04T23-39-08Z__landing-page-hosted.md b/.impeccable/critique/2026-08-04T23-39-08Z__landing-page-hosted.md
new file mode 100644
index 00000000..0d327514
--- /dev/null
+++ b/.impeccable/critique/2026-08-04T23-39-08Z__landing-page-hosted.md
@@ -0,0 +1,68 @@
+---
+target: landing page (hosted)
+total_score: 20
+max_score: 36
+na_heuristics: 9
+p0_count: 2
+p1_count: 5
+timestamp: 2026-08-04T23-39-08Z
+slug: landing-page-hosted
+---
+Method: dual-agent (Assessment A design review and Assessment B detector/browser evidence run as isolated parallel sub-agents), plus a two-agent technical audit (a11y+responsive, perf+theming+integrity). Browser evidence via Playwright + system Chrome against the production build; the claude-in-chrome extension was not connected. No user-visible overlay was produced — the in-page detector ran headless.
+
+## Design Health Score — 20/36 applicable
+
+| # | Heuristic | Score | Key issue |
+|---|-----------|-------|-----------|
+| 1 | Visibility of System Status | 2 | CopyCode aria-label frozen at "Copy "; zero aria-live regions; nav has no active-section state |
+| 2 | Match System / Real World | 2 | Four nouns (desk/workspace/room/document), "workspace" overloaded, "desk" used 4x defined 0x |
+| 3 | User Control and Freedom | 3 | Anchors and theme persistence work; global smooth scroll; all nav links display:none <=680px |
+| 4 | Consistency and Standards | 2 | Landing has forked four DESIGN.md named rules; five terracotta elements on the fold |
+| 5 | Error Prevention | 3 | Little to get wrong; clipboard failure swallowed by an empty catch |
+| 6 | Recognition Rather Than Recall | 2 | The one artifact showing what a review IS is cropped through its own text; unglossed protocol vocabulary |
+| 7 | Flexibility and Efficiency | 1 | Page branches on readDeskCount() for returning users, then serves them the identical 6,100px scroll |
+| 8 | Aesthetic and Minimalist Design | 3 | Genuinely restrained; 18 non-code mono strings; three identical /app#new CTAs in 1.5 screens |
+| 9 | Error Recovery | n/a | Zero forms, zero inputs, zero user-visible async operations — no error surface exists |
+| 10 | Help and Documentation | 2 | A no-account E2EE tool with no docs, no FAQ, no threat model, no security page |
+
+Total 20/36 (56%) — Acceptable band. Visual craft sits well above that number; information architecture and the demonstration of behaviour drag it down.
+
+## Design Specificity Verdict
+
+Well-made and under-authored. Roughly 70% could ship for any local-first dev tool with the nouns swapped, and the 30% that is attn's is spent on the wrong argument.
+
+The damning fact, verified: "agent" and "AI" appear ZERO times on the page. PRODUCT.md positions attn as "the reviewer for agent-authored docs… human comments and AI suggestions in a single end-to-end-encrypted thread". The page argues "private local markdown editor with sharing" — a category with a dozen occupants. Even the hero screenshot shows only human reviewer cards.
+
+Interchangeable structures: the eyebrow->oversized-headline->lede->button-pair->micro-proof hero with a rotated window screenshot; the three-up entry triptych; numbered 01/02/03 how-it-works; the two-up comparison with one panel inverted to near-black; the brew-install section; accent-mono chapter indices.
+
+Genuinely authored: the paper ground and grain, the two-tier h1 (line two at 0.78em), the 6rem serif masthead, the Surfaces ground-colour inversion. All surface. The brand is "warm surface, sharp behavior" and the page ships only the surface — two interactions in 6,100px of scroll, zero @keyframes.
+
+Deterministic scan: CLI detector returns 0 findings on web/src/hosted/landing, but that zero is a scope artifact — the landing's styling lives in chrome.css/tokens.css outside the scanned paths, and the rules that matter are render-time. Injected into the live page the detector found 4: hero-eyebrow-chip (genuine), all-caps-body x2 (false positives — 31/32-char kickers, which is what the rule says uppercase is for), cream-palette (false positive — DESIGN.md specifies oklch(0.905 0.010 78) with chroma deliberately held at 0.010 to read as paper, not cream). All three eyebrow findings originate from one CSS rule at chrome.css:98-103.
+
+## Audit Health Score — 14/20
+
+| # | Dimension | Score | Key finding |
+|---|-----------|-------|-------------|
+| 1 | Accessibility | 3 | 37 text styles measured, one contrast failure (decorative window dots); five AA-tier defects |
+| 2 | Performance | 3 | LCP 1932ms / CLS 0.0027 / TBT 5ms on Fast 3G+4x CPU; hero sizes over-fetches 1.5x; 7.5MB dead PNGs |
+| 3 | Responsive Design | 3 | Zero overflow at eight widths; 184px h-scroll at 200% text; 500px breakpoint gap with a real collision |
+| 4 | Theming | 2 | Zero hard-coded colours, but three named rules broken systemically plus the INK bootstrap flash |
+| 5 | Implementation Integrity | 3 | Detector clean and verified real; hero crop; duplicated window chrome; a public alternate homepage |
+
+## Priority Issues
+
+- [P0] The positioning is absent from the positioning surface (zero mentions of agent/AI).
+- [P0] The hero screenshot is cropped through its own text on both edges — object-fit:cover on a 1.333 source in a 1.025 box.
+- [P1] E2EE asserted four times, demonstrated zero times; no threat model, no security page.
+- [P1] Mobile navigation disappears entirely below 680px with no replacement.
+- [P1] 200% text produces 184px of horizontal scroll and an unreachable CTA at 390px (four grids use 1fr instead of minmax(0,1fr)).
+- [P1] Dark-mode visitors see a full paper-white paint before INK applies; no prefers-color-scheme fallback exists.
+- [P1] Eight controls miss 44x44; four miss the WCAG 2.2 24px floor. Only .button carries a size rule.
+
+## Persona Red Flags
+
+Jordan: clicks "Open your desk" — a word used four times and defined zero times — and the nav surfaces the empty desk most prominently to the person least equipped to read it. Riley: tests the E2EE claim first and finds nothing testable; presses Copy with permission denied and gets silence. Casey: no navigation at all, 6,100px of scroll, the only reachable controls top-right and undersized. James (PRODUCT.md's primary user): the returning path is one swapped nav label; no recent files, no keyboard entry, no Cmd-K, on the homepage of a keyboard-first product.
+
+## What's Working
+
+Token discipline is real — zero hard-coded colours across landing.css and all ten landing components; dark mode is a second design rather than an inversion; AA holds at all 24-37 sampled roles in both themes with the AA-retune documented in-comment. Focus indication is complete: all 17 tabbable controls draw a 2px ring at 5.30-8.18:1. The route-bundle boundary is real and gated pre-deploy at 25.9KB brotli. The side-stripe antipattern was anticipated and avoided on purpose, with the reasoning written into the CSS.
diff --git a/.impeccable/critique/2026-08-04T23-40-27Z__desk-page-hosted-app.md b/.impeccable/critique/2026-08-04T23-40-27Z__desk-page-hosted-app.md
new file mode 100644
index 00000000..2a29d344
--- /dev/null
+++ b/.impeccable/critique/2026-08-04T23-40-27Z__desk-page-hosted-app.md
@@ -0,0 +1,60 @@
+---
+target: desk page (hosted /app)
+total_score: 20
+max_score: 40
+na_heuristics:
+p0_count: 3
+p1_count: 4
+timestamp: 2026-08-04T23-40-27Z
+slug: desk-page-hosted-app
+---
+Method: dual-agent (Assessment A design review and Assessment B detector/browser evidence as isolated sub-agents) plus a technical audit agent. Browser evidence via Playwright + system Chrome against the production build across empty / populated / invite-open / delete-confirm states in both themes; claude-in-chrome was not connected.
+
+Register: OPERATE. Judged as a tool opened fifty times a week.
+
+## Design Health Score — 20/40 (all ten applicable)
+
+| # | Heuristic | Score | Key issue |
+|---|-----------|-------|-----------|
+| 1 | Visibility of System Status | 3 | best-effort returns warn:false, so "Backup recommended" paints the same green as "On this device" |
+| 2 | Match System / Real World | 3 | U+21A5 glyph reads as mojibake; Local only / Backed up / Shared have no legend |
+| 3 | User Control and Freedom | 2 | Escape does not close the join panel; closeJoin() never restores focus |
+| 4 | Consistency and Standards | 1 | Join panel is a foreign system (10px radius, raw px) inside a rem/0-radius desk; serif buttons |
+| 5 | Error Prevention | 2 | role="alertdialog" with no focus move and no Escape; rename commits on blur |
+| 6 | Recognition Rather Than Recall | 2 | Mobile deletes file count and last-edited from a list titled "Recently on this device" |
+| 7 | Flexibility and Efficiency | 0 | Zero keyboard affordances on the whole desk; no palette, filter, sort or search |
+| 8 | Aesthetic and Minimalist Design | 2 | 558px of chrome before the first workspace name; 813px on iPhone — payload below the fold |
+| 9 | Error Recovery | 3 | Join error copy is genuinely good and role="alert"-ed; import error is an inline-styled p |
+| 10 | Help and Documentation | 2 | Nothing explains what "Backup recommended" wants you to do, or that Storage fixes it |
+
+Total 20/40 — Acceptable band, and the 0 on heuristic 7 is the headline: this is a keyboard-first product's most-opened surface with no keyboard model.
+
+## Design Specificity Verdict
+
+Authored from the fold up; a generic "recent projects" list below it. The masthead is unmistakably attn — "Your desk" at 72px Source Serif over a hairline rule, mono storage line on the baseline, rust eyebrow. Then the payload arrives and the authorship stops: .workspace-row is a four-column div-table with no header, no hover (the hover CSS targets a.workspace-row and the element is a div — dead rule), no keyboard model, no search, and Rename/Delete stamped on every line. Strip the serif and it is any project list.
+
+Three tells: the accent is spent on a static eyebrow and withheld from the primary action (One Pencil inverted); buttons are set in 400-weight serif (Read/Do violated); four chrome roles are set in mono while the system's label token is used zero times.
+
+The deeper gap: the desk shows no review state at all, and WorkspaceSummary (types.ts:36-47) carries no review facts to show. A desk that lists files instead of reviews in flight is a file manager wearing attn's typeface.
+
+## Audit evidence
+
+Detector: 1 finding on web/src/hosted/app, verified false positive (blockquote rule using the neutral --rule token, not on the desk). Zero true positives.
+Contrast: 31 text styles measured across four state/theme combinations — zero failures, lowest 5.23:1. The perceived washed-out metadata is small uppercase mono with wide tracking, not a WCAG problem.
+Overflow: clean at 320/375/390/768/1024/1280, zero offending elements.
+Console: zero errors, warnings or failed requests across all eight state/theme combinations.
+Touch targets at 390: Rename 57.2x27.6, Delete 47.6x27.6 (0.6rem apart, destructive, irreversible), row-open 26px tall, join-go 60x37.
+
+## Priority Issues
+
+- [P0] Join panel has 64px above it and 0px below — .folio-label owns no margin and borrows it from .quick-actions via sibling collapse.
+- [P0] No keyboard model at all on the surface a keyboard-first power user opens most.
+- [P0] Rename/Delete announce no workspace name — a screen-reader user cannot tell which workspace is about to be irreversibly deleted.
+- [P1] "Backup recommended" is painted in the safe-state green, and there is no backup affordance on the desk.
+- [P1] The workspace row: dead hover CSS, a 200x28px open target in an 80,000px² row, resident admin controls heavier than the content.
+- [P1] Mobile puts the first workspace at y=813 in an 844px viewport.
+- [P1] The desk exposes no review state; the data model has nowhere to put one.
+
+## What's Working
+
+Colour token discipline is excellent and could not be broken — every text pair clears AA in both themes, most clear AAA, and the "never lighter than oklch(0.32 0.012 65)" floor is held exactly. Focus-visible is complete: 16 Tab stops, every one drawing a 2px ring, correctly recoloured to steel in INK. The privacy copy earns its place without a badge wall — "Shared · relay sees only ciphertext" and "The part after # is the room key — it never reaches the relay" are the best-written text in the product. The empty-desk composition (tilted sheet, "What deserves your attention?") is a genuinely authored moment.
diff --git a/.impeccable/design.json b/.impeccable/design.json
index cb61773f..449911a1 100644
--- a/.impeccable/design.json
+++ b/.impeccable/design.json
@@ -1,6 +1,6 @@
{
"schemaVersion": 2,
- "generatedAt": "2026-07-22T00:20:42.662708+00:00",
+ "generatedAt": "2026-08-07T02:06:32.640618+00:00",
"title": "Design System: attn",
"extensions": {
"colorMeta": {
@@ -150,31 +150,105 @@
"displayName": "Participant Berry",
"canonical": "oklch(0.58 0.14 358)",
"tonalRamp": []
+ },
+ "panel-surface": {
+ "role": "neutral",
+ "displayName": "Panel Surface",
+ "canonical": "oklch(0.855 0.012 75)",
+ "tonalRamp": [
+ "oklch(0.150 0.012 75)",
+ "oklch(0.264 0.012 75)",
+ "oklch(0.379 0.012 75)",
+ "oklch(0.493 0.012 75)",
+ "oklch(0.607 0.012 75)",
+ "oklch(0.721 0.012 75)",
+ "oklch(0.836 0.012 75)",
+ "oklch(0.950 0.012 75)"
+ ]
+ },
+ "panel-border": {
+ "role": "neutral",
+ "displayName": "Panel Border",
+ "canonical": "oklch(0.14 0.008 55 / 16%)",
+ "tonalRamp": [
+ "oklch(0.150 0.008 55)",
+ "oklch(0.264 0.008 55)",
+ "oklch(0.379 0.008 55)",
+ "oklch(0.493 0.008 55)",
+ "oklch(0.607 0.008 55)",
+ "oklch(0.721 0.008 55)",
+ "oklch(0.836 0.008 55)",
+ "oklch(0.950 0.008 55)"
+ ]
+ },
+ "rail-chip-surface": {
+ "role": "neutral",
+ "displayName": "Rail Chip Surface",
+ "canonical": "oklch(0.88 0.012 75)",
+ "tonalRamp": [
+ "oklch(0.150 0.012 75)",
+ "oklch(0.264 0.012 75)",
+ "oklch(0.379 0.012 75)",
+ "oklch(0.493 0.012 75)",
+ "oklch(0.607 0.012 75)",
+ "oklch(0.721 0.012 75)",
+ "oklch(0.836 0.012 75)",
+ "oklch(0.950 0.012 75)"
+ ]
}
},
"typographyMeta": {
"display": {
"displayName": "Display",
- "purpose": "Document title at the top of the reading column. Serif, fixed rem."
+ "purpose": "Document title, top of the reading column.",
+ "fontSize": "2rem"
},
"body": {
"displayName": "Body",
- "purpose": "The reading surface. Serif, 1.72 line-height for restful long-form markdown."
+ "purpose": "The reading surface; serif, generous leading.",
+ "fontSize": "1rem"
},
"label": {
"displayName": "Label",
- "purpose": "Table headers, meta chips, sidebar markers. Sans, uppercase, tracked."
+ "purpose": "Table headers, meta chips, sidebar section markers. Sans, uppercase.",
+ "fontSize": "0.7rem"
},
"mono": {
"displayName": "Mono",
- "purpose": "Code blocks and inline code. Source Code Pro."
+ "purpose": "Code blocks and inline code.",
+ "fontSize": "0.85rem"
+ },
+ "headline": {
+ "displayName": "Headline",
+ "purpose": "Major section.",
+ "fontSize": "1.5rem"
+ },
+ "title": {
+ "displayName": "Title",
+ "purpose": "Subsection.",
+ "fontSize": "1.25rem"
+ },
+ "meta": {
+ "displayName": "Meta",
+ "purpose": "App-shell chrome: secondary/meta text. Sans.",
+ "fontSize": "0.78rem"
+ },
+ "control": {
+ "displayName": "Control",
+ "purpose": "App-shell chrome: default control and button text. Sans.",
+ "fontSize": "0.95rem"
+ },
+ "control-lg": {
+ "displayName": "Control Lg",
+ "purpose": "App-shell chrome: larger controls and emphasis. Sans.",
+ "fontSize": "1.15rem"
}
},
"shadows": [
{
"name": "review-card-lift",
"value": "0 16px 42px oklch(0.20 0.02 55 / 16%), 0 1px 0 oklch(1 0 0 / 45%) inset",
- "purpose": "Margin review cards \u2014 ambient drop plus top inset highlight so the card reads as physical paper."
+ "purpose": "Margin review cards — ambient drop plus top inset highlight so the card reads as physical paper."
},
{
"name": "panel-soft",
@@ -189,7 +263,7 @@
{
"name": "pressed-inset",
"value": "inset 0 1px 3px oklch(0 0 0 / 4%)",
- "purpose": "Code blocks and inputs \u2014 pressed into the paper, not raised."
+ "purpose": "Code blocks and inputs — pressed into the paper, not raised."
}
],
"motion": [
@@ -216,7 +290,7 @@
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
- "description": "Solid terracotta action button \u2014 primary CTA and confirm actions.",
+ "description": "Solid terracotta action button — primary CTA and confirm actions.",
"html": "Share for review ",
"css": ".ds-btn-primary { display: inline-flex; align-items: center; justify-content: center; gap: 0.55rem; min-height: 46px; padding: 0.7rem 1.05rem; border: 1px solid var(--primary, oklch(0.48 0.14 28)); border-radius: 8px; background: var(--primary, oklch(0.48 0.14 28)); color: var(--primary-foreground, oklch(0.98 0.005 78)); font-family: 'Source Sans 3 Variable', system-ui, sans-serif; font-weight: 700; font-size: 0.91rem; cursor: pointer; transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease; } .ds-btn-primary:hover { transform: translateY(-2px); background: oklch(0.42 0.15 28); border-color: oklch(0.42 0.15 28); } .ds-btn-primary:focus-visible { outline: 2px solid oklch(0.48 0.14 28); outline-offset: 2px; }"
},
@@ -224,7 +298,7 @@
"name": "Secondary Button",
"kind": "button",
"refersTo": "button-secondary",
- "description": "Translucent sheet-fill button with hairline border \u2014 non-primary actions.",
+ "description": "Translucent sheet-fill button with hairline border — non-primary actions.",
"html": "Cancel ",
"css": ".ds-btn-secondary { display: inline-flex; align-items: center; justify-content: center; gap: 0.55rem; min-height: 46px; padding: 0.7rem 1.05rem; border: 1px solid oklch(0.14 0.008 55 / 18%); border-radius: 8px; background: oklch(0.89 0.012 76 / 45%); color: oklch(0.14 0.008 55); font-family: 'Source Sans 3 Variable', system-ui, sans-serif; font-weight: 700; font-size: 0.91rem; cursor: pointer; transition: transform 0.18s ease, border-color 0.18s ease; } .ds-btn-secondary:hover { transform: translateY(-2px); border-color: oklch(0.14 0.008 55); } .ds-btn-secondary:focus-visible { outline: 2px solid oklch(0.48 0.14 28); outline-offset: 2px; }"
},
@@ -232,7 +306,7 @@
"name": "Search Input",
"kind": "input",
"refersTo": "input-field",
- "description": "Sidebar search / text field \u2014 pressed-paper fill with inset highlight.",
+ "description": "Sidebar search / text field — pressed-paper fill with inset highlight.",
"html": " ",
"css": ".ds-input { width: 100%; height: 32px; padding: 6px 12px; border-radius: 10px; border: 1px solid oklch(0.14 0.008 55 / 12%); background: oklch(0.905 0.010 78 / 84%); color: oklch(0.14 0.008 55); font-family: 'Source Sans 3 Variable', system-ui, sans-serif; font-size: 0.82rem; font-weight: 600; box-shadow: inset 0 1px 0 oklch(1 0 0 / 35%); transition: border-color 0.12s ease, box-shadow 0.12s ease; } .ds-input::placeholder { color: oklch(0.32 0.012 65); } .ds-input:focus-visible { outline: none; border-color: oklch(0.14 0.008 55 / 24%); box-shadow: inset 0 1px 0 oklch(1 0 0 / 45%); }"
},
@@ -248,8 +322,8 @@
"name": "Review Margin Card",
"kind": "card",
"refersTo": "review-card",
- "description": "The signature container \u2014 a slip of raised paper carrying a comment or suggestion, with a small accent identifying which.",
- "html": "R Tighten this sentence \u2014 the clause runs long.
",
+ "description": "The signature container — a slip of raised paper carrying a comment or suggestion, with a small accent identifying which.",
+ "html": "R Tighten this sentence — the clause runs long.
",
"css": ".ds-review-card { position: relative; display: flex; gap: 10px; padding: 16px; border-radius: 10px; border: 1px solid oklch(0.14 0.008 55 / 22%); background: oklch(0.94 0.010 76 / 96%); color: oklch(0.14 0.008 55); box-shadow: 0 16px 42px oklch(0.20 0.02 55 / 16%), 0 1px 0 oklch(1 0 0 / 45%) inset; font-family: 'Source Serif 4 Variable', Georgia, serif; font-size: 0.95rem; line-height: 1.5; } .ds-review-card__accent { flex: 0 0 auto; width: 4px; align-self: stretch; border-radius: 999px; background: oklch(0.58 0.15 150); } .ds-review-card__body { display: flex; align-items: baseline; gap: 8px; }"
},
{
@@ -263,67 +337,96 @@
],
"narrative": {
"northStar": "The Lit Reading Room",
- "overview": "attn is a private study where documents are read closely and marked by hand \u2014 warm paper under a desk lamp, ink that dried a century ago, a single red pencil for the one mark that matters. The surface is unmistakably editorial, but the behavior under it is a precision instrument that responds like Linear or Raycast: instant, exact, keyboard-first. Warm surface, sharp behavior. The system runs two themes from one identity \u2014 PAPER (warm parchment, light) and INK (cool blue-black, dark) \u2014 the same room at two times of day, the accent shifting warm terracotta to steel blue. It rejects three neighbors: cloud-SaaS review tools, IDEs, and rounded-pastel productivity apps.",
+ "overview": "attn is a private study where documents are read closely and marked by hand — warm paper under a desk lamp, ink that dried a century ago, a single red pencil for the one mark that matters. The surface is unmistakably editorial, but the behavior under it is a precision instrument that responds like Linear or Raycast: instant, exact, keyboard-first. Warm surface, sharp behavior. The system runs two themes from one identity — PAPER (warm parchment, light) and INK (cool blue-black, dark) — the same room at two times of day, the accent shifting warm terracotta to steel blue. It rejects three neighbors: cloud-SaaS review tools, IDEs, and rounded-pastel productivity apps.",
"keyCharacteristics": [
- "Serif for reading, sans for chrome, mono for code \u2014 a strict three-role split.",
- "One accent (terracotta / steel), spent only on action, selection, and state \u2014 never decoration.",
+ "Serif for reading, sans for chrome, mono for code — a strict three-role split.",
+ "One accent (terracotta / steel), spent only on action, selection, and state — never decoration.",
"A real paper grain overlay unifies every surface, including the browser build.",
- "Fixed rem type scale (product register): headings don't fluidly resize in a pane.",
- "A dedicated review vocabulary: inline tracked-change marks, margin cards, role-colored peer avatars."
+ "Fixed rem type scale (product register): headings don't fluidly resize in a sidebar.",
+ "A dedicated review vocabulary: inline tracked-change marks, margin cards, role-colored peer avatars.",
+ "Five typeset presets change the reading column only — app chrome never moves when you switch one.",
+ "Three planes: chrome rails (sidebar + comments rail) recede equally, the document is the lit sheet between them, and review cards float above the rail."
],
"rules": [
{
"name": "The One Pencil Rule",
- "body": "The primary accent is a red pencil, not a highlighter. It appears on primary action, current selection, and focus \u2014 nowhere else. If two things on a screen are terracotta, one is wrong.",
+ "body": "The primary accent is a red pencil, not a highlighter. It appears on primary action, current selection, and focus — nowhere else. If two things on a screen are terracotta, one of them is wrong.",
"section": "colors"
},
{
"name": "The Quarantine Rule",
- "body": "Green, amber, and the peer hues belong to the collaboration layer only. They never appear as decoration on base chrome.",
+ "body": "Green, amber, and the peer hues belong to the collaboration layer only. They never appear as decoration on base chrome; their meaning (suggestion / comment / who) is the entire reason they exist.",
"section": "colors"
},
{
"name": "The Warm-Paper, Not-Cream Rule",
- "body": "The ground holds chroma \u2264 0.012. Warmth is carried by the accent and the serif, not by the background.",
+ "body": "The ground holds chroma ≤ 0.012. The moment it drifts warmer it becomes the saturated AI cream default. Warmth is carried by the accent and the serif, not by the background.",
"section": "colors"
},
+ {
+ "name": "The Chrome-Invariance Rule",
+ "body": "A preset may set only *document-scoped* tokens: `--doc-font`, `--attn-doc-scale`, `--doc-leading`, `--doc-tracking`, and `--content-measure`. It may never touch `--attn-base-font-size` (the rem baseline for all app chrome) or the global `--serif`/`--sans`/`--mono` families. Presets originally did both, which meant choosing a typeset silently rescaled every header, dialog and control in the app — the rem baseline drives `html { font-size }`. Chrome now holds still and only the reading column reflows. Document type sizes are therefore `em` (relative to the doc's own scale) while margins stay `rem` (anchored to the app baseline), so a preset's margin override means the same thing at every scale.",
+ "section": "typography"
+ },
{
"name": "The Read/Do Rule",
- "body": "If the user is reading it, it's serif. If the user is operating it, it's sans. A button never uses the serif; a document heading never uses the sans.",
+ "body": "If the user is reading it, it's serif. If the user is operating it, it's sans. There is no third case; a button never uses the serif, a heading in the document never uses the sans. (A preset may change *which* face reads as the serif — Modern makes it a sans — but never which role gets the reading face.)",
+ "section": "typography"
+ },
+ {
+ "name": "The Scoped-Document Rule",
+ "body": "Document typography is scoped to `.attn-doc` — the class the editor mount and the viewer article carry. Bare `p` / `h1` / `ul` / `li` selectors are never global: chrome rendered in the same tree used to inherit 2rem heading gaps and absolutely-positioned list bullets that escaped their card, and each leak got patched individually until an opt-out class existed purely to undo the defaults. Type the document, not the page.",
"section": "typography"
},
{
"name": "The Fixed-Scale Rule",
- "body": "Product register: headings are fixed rem, not clamp(). A fluid h1 that shrinks in a sidebar looks worse, not better.",
+ "body": "Product register: headings are fixed rem, not `clamp()`. Users view at consistent DPI inside panes and windows; a fluid h1 that shrinks in a sidebar looks worse, not better.",
+ "section": "typography"
+ },
+ {
+ "name": "The Wide-Sheet Rule",
+ "body": "The reading surface is full-width and left-set, never a centered narrow column: all content — running prose *and* wide blocks (mermaid diagrams, tables, code) — shares one column capped at the `--content-measure` token (**960px**); oversized tables/code scroll inside it. (Revised 2026-07-13 from the original split layout — 72ch prose beside full-pane blocks — which read as ragged whenever a wide block was on screen. Retuned 1100px → 960px, and corrected here 2026-08-06 where the doc still said 1100.) The `micro` (2px) radius is the mark family for inline review marks, focus rings, and accent bars.",
+ "section": "typography"
+ },
+ {
+ "name": "The Measure-Is-Opt-Out Rule",
+ "body": "The shared column is currently applied by a hand-maintained *allowlist* of element selectors, so anything not named in it silently escapes the measure. Two blocks were found escaping in one sweep (the frontmatter card and the math container), and on the viewer side the list can never be complete, because comrak passes raw HTML through — an author writing `` or `
` in markdown lands an arbitrary element outside the column. The durable shape is `article.attn-doc > *` with explicit opt-outs for the wrappers that need a `min()` clamp. Recorded as the intent; not yet implemented.",
"section": "typography"
},
{
"name": "The Flat-Until-Lifted Rule",
- "body": "Surfaces are flat and tonal at rest. A shadow appears only when something is genuinely floating or genuinely pressed. Shadow states physical position, never decorative depth.",
- "section": "elevation"
+ "body": "Surfaces are flat and tonal at rest. A shadow appears only when something is genuinely floating above the page (a card, a dialog, a menu) or genuinely pressed into it (an input, a code block). Shadow is a statement about physical position, never a decorative gradient of depth.",
+ "section": "elevation-depth"
},
- "The Truth Rule: pixels always equal state \u2014 no visible fact may depend on an animation completing or a debounce flushing; closed overlays are display:none in plain CSS; theme flips are atomic.",
- "The Topmost-Escape Rule: Escape closes exactly one layer (palette \u2192 composer \u2192 dialog \u2192 popover \u2192 drawer) and never destroys a draft; overlays store focus on open and restore it on close.",
- "The Wide-Sheet Rule: the reading surface is full-width and left-set; document prose and wide content share one column capped by the 1100px content-measure token, and oversized blocks scroll inside it."
+ {
+ "name": "The Truth Rule",
+ "body": "Pixels always equal state: no user-visible fact — a modal open, a comment arrived, a file saved — may depend on an animation completing or a debounce flushing. Closed overlays are `display: none` in plain CSS (`[data-state=\"closed\"]`); theme flips are atomic (transitions suppressed for the flip frame); animation is enhancement, never the carrier of state. Occluded windows freeze the animation clock, so anything less soft-locks the app.",
+ "section": "elevation-depth"
+ },
+ {
+ "name": "The Topmost-Escape Rule",
+ "body": "Escape closes exactly one layer — the topmost (palette → composer → dialog → popover → drawer) — and never destroys a draft. Every overlay stores focus on open and restores it on close.",
+ "section": "elevation-depth"
+ }
],
"dos": [
- "Do keep the terracotta/steel accent to action, selection, and focus only \u2014 the One Pencil Rule.",
- "Do use serif for everything read and sans for everything operated \u2014 no exceptions.",
- "Do hold the paper ground at chroma \u2264 0.012; carry warmth through the accent and the serif.",
- "Do keep the review hues quarantined to the collaboration layer, distinguished by meaning and attribution.",
- "Do specify default / hover / focus-visible / active / disabled for every interactive component.",
- "Do honor prefers-reduced-motion; keep state transitions 120-250ms and let motion convey state, not choreography.",
- "Do carry white-on-role-hue chips at their AA-tuned lightness."
+ "Do keep the terracotta/steel accent to action, selection, and focus only — the One Pencil Rule. Everything else is ink, paper, and the second neutral layer.",
+ "Do use serif for everything read and sans for everything operated — no exceptions (the Read/Do Rule).",
+ "Do hold the paper ground at chroma ≤ 0.012; carry warmth through the accent and the serif.",
+ "Do keep the review hues (green / amber / peer colors) quarantined to the collaboration layer, distinguished by meaning and attribution — never decoration.",
+ "Do specify default / hover / focus-visible / active / disabled for every interactive component; keyboard reachability is a requirement, not a nicety.",
+ "Do honor `prefers-reduced-motion`; keep state transitions in the 120–250ms range and let motion convey state, not choreography.",
+ "Do carry white-on-role-hue chips at their AA-tuned lightness; if you add a peer hue, tune it to clear 4.5:1 for its monogram."
],
"donts": [
- "Don't let attn read like a cloud-SaaS review tool (Google Docs): no account-wall chrome, no toolbar-dense header.",
- "Don't let it read like an IDE (VS Code): no activity bars, no panels-in-panels. The reading column is the hero.",
- "Don't let it drift toward Notion rounded-pastel: no candy-colored blocks, no emoji-forward headers.",
- "Don't borrow the Linear-clone saturated-purple glassy gradient-glow dark theme; INK mode is a cool blue-black study.",
- "Don't use a border-left/border-right colored stripe > 1px on cards or callouts.",
- "Don't use gradient text, glassmorphism as a default, or a warm-cream background.",
- "Don't let muted body text go lighter than oklch(0.32 0.012 65) on paper.",
- "Don't fluidly clamp() UI headings; the product type scale is fixed rem."
+ "Don't let attn read like a cloud-SaaS review tool (Google Docs): no account-wall chrome, no toolbar-dense header, no \"your doc lives in our cloud\" framing.",
+ "Don't let it read like an IDE (VS Code): no activity bars, no panels-in-panels, no everything-is-a-toolbar. The reading column is the hero.",
+ "Don't let it drift toward Notion rounded-pastel: no candy-colored blocks, no emoji-forward headers, no soft-everything. Warmth is paper and type.",
+ "Don't borrow the Linear-clone saturated-purple glassy gradient-glow dark theme; INK mode is a cool blue-black study, not neon.",
+ "Don't use a colored side-stripe on a card or callout as *decoration* — the AI-UI tell is a thick tinted border that means nothing. The one sanctioned exception is the review margin card's `3px` accent strip, which is load-bearing: it encodes the comment's author and its kind/state, and removing it deletes an information channel. (Amended 2026-08-06: this previously read as a flat \">1px\" prohibition, which the shipped card had never satisfied — the rule described an intent the product had already outgrown. If a new stripe cannot say what it *means*, it is decoration and the prohibition stands.)",
+ "Don't use gradient text, glassmorphism as a default, or the drift toward a warm-cream background — all are prohibited.",
+ "Don't let muted body text go lighter than ~`oklch(0.32 0.012 65)` on paper; light-gray-for-elegance is the fastest way to fail the 4.5:1 floor.",
+ "Don't fluidly `clamp()` UI headings; the product type scale is fixed rem."
]
}
}
\ No newline at end of file
diff --git a/DESIGN.md b/DESIGN.md
index c62ef9d3..996fce69 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -9,7 +9,10 @@ colors:
muted-ink: "oklch(0.32 0.012 65)"
card: "oklch(0.89 0.012 76)"
sidebar: "oklch(0.855 0.012 75)"
- code-block: "oklch(0.885 0.012 73)"
+ panel-surface: "oklch(0.855 0.012 75)"
+ panel-border: "oklch(0.14 0.008 55 / 16%)"
+ rail-chip-surface: "oklch(0.88 0.012 75)"
+ code-block: "oklch(0.972 0.008 78)"
border: "oklch(0.14 0.008 55 / 18%)"
link: "oklch(0.38 0.04 55)"
destructive: "oklch(0.55 0.20 27)"
@@ -58,6 +61,24 @@ typography:
fontWeight: 600
lineHeight: 1.2
letterSpacing: "0.06em"
+ meta:
+ fontFamily: "Source Sans 3 Variable, Source Sans 3, -apple-system, system-ui, sans-serif"
+ fontSize: "0.78rem"
+ fontWeight: 500
+ lineHeight: 1.4
+ letterSpacing: "normal"
+ control:
+ fontFamily: "Source Sans 3 Variable, Source Sans 3, -apple-system, system-ui, sans-serif"
+ fontSize: "0.95rem"
+ fontWeight: 500
+ lineHeight: 1.4
+ letterSpacing: "normal"
+ control-lg:
+ fontFamily: "Source Sans 3 Variable, Source Sans 3, -apple-system, system-ui, sans-serif"
+ fontSize: "1.15rem"
+ fontWeight: 600
+ lineHeight: 1.35
+ letterSpacing: "normal"
mono:
fontFamily: "Source Code Pro Variable, Source Code Pro, SF Mono, Consolas, monospace"
fontSize: "0.85rem"
@@ -103,13 +124,13 @@ components:
review-card:
backgroundColor: "oklch(0.94 0.010 76 / 96%)"
textColor: "{colors.ink}"
- rounded: "{rounded.lg}"
- padding: "16px"
+ rounded: "{rounded.sm}"
+ padding: "10px 12px 10px 13px"
---
# Design System: attn
-## 1. Overview
+## Overview
**Creative North Star: "The Lit Reading Room"**
@@ -125,11 +146,15 @@ This system explicitly rejects three neighbors. It is **not a cloud-SaaS review
- A real paper grain overlay unifies every surface, including the browser build.
- Fixed rem type scale (product register): headings don't fluidly resize in a sidebar.
- A dedicated review vocabulary: inline tracked-change marks, margin cards, role-colored peer avatars.
+- Five typeset presets change the reading column only — app chrome never moves when you switch one.
+- Three planes: chrome rails (sidebar + comments rail) recede equally, the document is the lit sheet between them, and review cards float above the rail.
-## 2. Colors
+## Colors
A warm parchment field carrying near-black ink and a single terracotta accent; cool review hues (green, amber, blue, violet) are quarantined to the collaboration layer so they never dilute the editorial ground. All values are canonical **OKLCH** — attn is OKLCH-native and the frontmatter carries OKLCH directly.
+**Paper / Ink / System.** Appearance is a three-state preference (Settings → Appearance), defaulting to **System** — the app follows the OS appearance and tracks changes to it live. The preference is durable (`prefs.json`, next to the project registry) and is stamped into the page before the bundle loads, so launching never shows a frame of the wrong theme. `light`/`dark` are explicit overrides that ignore the OS.
+
### Primary
- **Terracotta Pencil** (`oklch(0.48 0.14 28)`, INK theme `oklch(0.72 0.10 220)` steel blue): the one accent. Primary buttons, current selection, checked checkboxes, focus rings, the "shared for review" marker. Warm red-clay on paper; it becomes a cool steel blue in dark mode because a saturated red-clay glows unpleasantly against a near-black ground.
@@ -153,7 +178,9 @@ Role is no longer a color channel for humans — shape carries it (round = human
- **Ink** (`oklch(0.14 0.008 55)`): body text, strong rules, the native side panel.
- **Muted Ink** (`oklch(0.32 0.012 65)`): secondary text, labels, table headers. Sits at ~4.5:1 on paper — the floor for body, never lighter.
- **Card / Sidebar** (`oklch(0.89 0.012 76)` / `oklch(0.855 0.012 75)`): the second neutral layer for chrome, a hair darker than the content surface so panels recede.
-- **Code Block** (`oklch(0.885 0.012 73)`): inline code and `pre` ground.
+- **Panel Surface** (`oklch(0.855 0.012 75)`, INK `oklch(0.172 0.014 257)`): the chrome plane — the comments rail and the app header sit on it, deliberately the *same* value as the sidebar. Both edges of the workspace recede equally so the document reads as a lit sheet between two rails. In INK the move inverts (the rails lift off a darker ground rather than sinking into it).
+- **Rail Chip Surface** (`oklch(0.88 0.012 75)`, INK `oklch(0.205 0.013 257)`): fills for chips sitting *on* the panel plane. It exists because `--muted` lands 0.003 from `--panel-surface` in INK, so a `muted` chip on the rail is invisible there — a trap that has now been hit twice.
+- **Code Block** (`oklch(0.972 0.008 78)`, INK `oklch(0.19 0.014 256)`): the raised surface shared by `pre`, inline code, **and tables** — a table and a code block are the same class of object and must not read as different materials.
- **Border** (`oklch(0.14 0.008 55 / 18%)`): hairline dividers — ink at low alpha, never a solid gray line.
### Named Rules
@@ -163,7 +190,7 @@ Role is no longer a color channel for humans — shape carries it (round = human
**The Warm-Paper, Not-Cream Rule.** The ground holds chroma ≤ 0.012. The moment it drifts warmer it becomes the saturated AI cream default. Warmth is carried by the accent and the serif, not by the background.
-## 3. Typography
+## Typography
**Reading Font:** Source Serif 4 Variable (with Georgia, serif)
**Chrome Font:** Source Sans 3 Variable (with system-ui)
@@ -179,14 +206,45 @@ Role is no longer a color channel for humans — shape carries it (round = human
- **Label** (600, `0.7rem`, `0.06em`, UPPERCASE): table headers, meta chips, sidebar section markers. Sans.
- **Mono** (400, `0.85rem`, 1.55): code blocks and inline code.
+### Typeset presets
+The three cuts above are the **Editorial** preset — the default, and the shape every rule in this section describes. Settings offers four alternates (shadcn's typeset model: a preset is a complete reading system, never a pile of independent font knobs):
+
+- **Editorial** — the default described above. Its values are the canonical tokens restated verbatim.
+- **Modern** — sans for reading as well as chrome, with display sizes pulled in and tracking tightened (serif display scale reads oversized in sans). For readers who want a code-review tool rather than a manuscript.
+- **Compact** — Editorial's fonts at a denser scale and leading, on a narrower measure. For dense ops docs.
+- **Manuscript** (added 2026-08-06) — large serif on a short column (`660px`), 1.9 leading. The opposite pole from Compact: for reading a spec end to end rather than scanning it. It deliberately trades technical width for reading comfort, since wide blocks share the same narrow edge under the Wide-Sheet Rule.
+- **Terminal** (added 2026-08-06) — monospace throughout, for diffs and config where column alignment *is* the content. Display sizes compress (a 2em mono h1 reads as shouting) and headings keep natural tracking, because negative letter-spacing fights a monospaced face.
+
+Presets live in `web/styles/typeset.css`, keyed off `data-typeset` on ``. They are orthogonal to light/dark (which owns color) and to the ⌘+/⌘- font scale — all three compose.
+
+**The Chrome-Invariance Rule** (added 2026-08-06). A preset may set only *document-scoped* tokens: `--doc-font`, `--attn-doc-scale`, `--doc-leading`, `--doc-tracking`, and `--content-measure`. It may never touch `--attn-base-font-size` (the rem baseline for all app chrome) or the global `--serif`/`--sans`/`--mono` families. Presets originally did both, which meant choosing a typeset silently rescaled every header, dialog and control in the app — the rem baseline drives `html { font-size }`. Chrome now holds still and only the reading column reflows. Document type sizes are therefore `em` (relative to the doc's own scale) while margins stay `rem` (anchored to the app baseline), so a preset's margin override means the same thing at every scale.
+
+Because every preset states its full hand — including Editorial — `[data-typeset]` is authoritative wherever it appears, including on a nested specimen in Settings. Declaring nothing was how the default preset's own specimen ended up rendering in whichever face happened to be active.
+
### Named Rules
-**The Read/Do Rule.** If the user is reading it, it's serif. If the user is operating it, it's sans. There is no third case; a button never uses the serif, a heading in the document never uses the sans.
+**The Read/Do Rule.** If the user is reading it, it's serif. If the user is operating it, it's sans. There is no third case; a button never uses the serif, a heading in the document never uses the sans. (A preset may change *which* face reads as the serif — Modern makes it a sans — but never which role gets the reading face.)
+
+*Product-chrome steps* (added 2026-08-05, attn-n01r.8). The six steps above describe the **document**. The app shell needs four more between `label` and `title`, and pretending otherwise is why `app-shell.css` had drifted to 33 distinct sizes with no rhythm — every new component invented a value because no existing one fit. The full chrome ramp is:
+
+`0.7` label · `0.78` meta · `0.85` mono/caption · `0.95` control · `1` body · `1.15` control-lg · `1.25` title · `1.5` headline · `2` display
+
+Nine steps, and nothing between them. This is an *extension* of the ramp, not an exemption from it: a size outside this list is still a defect, and the hosted app shell now uses exactly these nine.
+
+(Corrected 2026-08-06: `meta`, `control` and `control-lg` existed only in this prose for two weeks, while the frontmatter carried the six document roles. Tokens are the normative layer — prose only contextualises them — so every chrome-ramp size read as off-ramp to any tool consuming this file, and `0.95rem` alone accounted for most of the drift reported against `app-shell.css`. All nine steps are now declared as frontmatter typography roles. **A ramp step that is not in the frontmatter does not exist.**)
+
+**The Scoped-Document Rule** (2026-08-04). Document typography is scoped to `.attn-doc` — the class the editor mount and the viewer article carry. Bare `p` / `h1` / `ul` / `li` selectors are never global: chrome rendered in the same tree used to inherit 2rem heading gaps and absolutely-positioned list bullets that escaped their card, and each leak got patched individually until an opt-out class existed purely to undo the defaults. Type the document, not the page.
**The Fixed-Scale Rule.** Product register: headings are fixed rem, not `clamp()`. Users view at consistent DPI inside panes and windows; a fluid h1 that shrinks in a sidebar looks worse, not better.
-**The Wide-Sheet Rule** (decided 2026-07-12). The reading surface is full-width and left-set, never a centered narrow column: all content — running prose *and* wide blocks (mermaid diagrams, tables, code) — shares one column capped at the `--content-measure` token (1100px); oversized tables/code scroll inside it. (Revised 2026-07-13 from the original split layout — 72ch prose beside full-pane blocks — which read as ragged whenever a wide block was on screen.) The `micro` (2px) radius is the mark family for inline review marks, focus rings, and accent bars.
+*Marketing carve-out* (added 2026-08-05, attn-n01r.18). The rule's rationale is panes and sidebars, which the hosted **landing** does not have — it is a full-bleed Persuade surface viewed at whatever width the visitor brings. Display headings there may `clamp()`, in two tiers only: the hero `h1` at `clamp(3.2rem, 5.2vw, 6rem)` and every section head at `clamp(2.6rem, 4.4vw, 4.6rem)`. Two tiers, not per-section values — a third coefficient is how the `h1` ended up rendering *smaller* than two `h2`s at 1440px. Everything else, including the desk and the app shell, stays on the fixed ramp. The landing had already forked this by 3x with nothing written down; this records the fork rather than pretending it isn't there.
+
+**The Wide-Sheet Rule** (decided 2026-07-12). The reading surface is full-width and left-set, never a centered narrow column: all content — running prose *and* wide blocks (mermaid diagrams, tables, code) — shares one column capped at the `--content-measure` token (**960px**); oversized tables/code scroll inside it. (Revised 2026-07-13 from the original split layout — 72ch prose beside full-pane blocks — which read as ragged whenever a wide block was on screen. Retuned 1100px → 960px, and corrected here 2026-08-06 where the doc still said 1100.) The `micro` (2px) radius is the mark family for inline review marks, focus rings, and accent bars.
+
+*Measure is a preset's to move, but only when the column IS the preset's identity* (2026-08-06). Changing `--content-measure` re-wraps every line and moves the document's right edge — the most disruptive thing a preset can do — and 960px is a reviewed decision, not a neutral default. So Manuscript sets it (660px; a short measure is the entire point of a book column) and Compact keeps its long-standing 880px. Everything else inherits 960px. Modern and Terminal briefly carried 920/900px: arbitrary nudges that re-litigated a settled decision, and Terminal's was backwards, since monospace fits *fewer* characters per pixel and a narrower column shortens the line twice over.
+
+**The Measure-Is-Opt-Out Rule** (open, 2026-08-06). The shared column is currently applied by a hand-maintained *allowlist* of element selectors, so anything not named in it silently escapes the measure. Two blocks were found escaping in one sweep (the frontmatter card and the math container), and on the viewer side the list can never be complete, because comrak passes raw HTML through — an author writing `` or `
` in markdown lands an arbitrary element outside the column. The durable shape is `article.attn-doc > *` with explicit opt-outs for the wrappers that need a `min()` clamp. Recorded as the intent; not yet implemented.
-## 4. Elevation
+## Elevation & Depth
A hybrid: mostly flat tonal layering (chrome recedes by being a step darker than content, not by floating), with a small, restrained shadow vocabulary reserved for genuinely-lifted surfaces — review cards, dialogs, dropdowns — and soft *inset* shadows that make code blocks and inputs read as pressed into the paper. The paper-grain overlay (a fixed fractal-noise SVG at `--grain-opacity`) sits above everything as the unifying texture; it is not elevation but it is why nothing looks like flat plastic.
@@ -202,7 +260,7 @@ A hybrid: mostly flat tonal layering (chrome recedes by being a step darker than
**The Topmost-Escape Rule.** Escape closes exactly one layer — the topmost (palette → composer → dialog → popover → drawer) — and never destroys a draft. Every overlay stores focus on open and restores it on close.
-## 5. Components
+## Components
### Buttons
- **Shape:** gently rounded (`8px`, `{rounded.md}`), `min-height: 46px`, sans-serif 700.
@@ -217,8 +275,10 @@ A hybrid: mostly flat tonal layering (chrome recedes by being a step darker than
- **Moved badge:** a muted neutral pill (`--moved-badge-bg`) marking a re-anchored suggestion.
### Cards / Containers
-- **Review margin card** (signature): the primary container. Near-opaque raised paper (`oklch(0.94 0.010 76 / 96%)`), `10px` radius, `16px` padding, the review-card lift shadow, and a top hairline border. A left color accent identifies comment (amber) vs. suggestion (green) — carried as a small accent element, **not** a thick side-stripe border.
+- **Review margin card** (signature): the primary container. Near-opaque raised paper (`oklch(0.94 0.010 76 / 96%)`), `6px` radius, `10px 12px 10px 13px` padding (the asymmetric left leaves room for the accent), the review-card lift shadow, and a top hairline border.
+- **The accent strip** (corrected 2026-08-06): a `3px` full-height strip on the card's left edge, **square at both ends** even though the card's corners are round. It carries `--rmc-accent` — the comment author's personal color, with kind (comment amber / suggestion green) and state (stale / low-confidence) overrides layered after. Implemented as an absolutely-positioned `::before` at `border-radius: 0`, with `isolation: isolate` on the card so its negative `z-index` cannot escape. It was previously an `inset` box-shadow, which the card's radius necessarily clipped into a tapered curve at both ends; the strip is information (who, and what kind), so it must not read as a decorative flourish. The card must never gain `overflow: hidden` — that would re-clip the strip and bring the curve back.
- **General panels:** flat, one tonal step off the content surface, hairline `18%`-ink borders. No nested cards.
+- **Tables** are code blocks: same `--code-block` fill, 1px border, `6px` radius and inset lip. Achieved with `border-collapse: separate` + `border-spacing: 0` (a collapsed table merges cell borders into the table box and squares off the radius) and **no cell backgrounds** — a filled header row would re-square the top corners and cover the inset lip, so header distinction is carried by ink weight instead.
### Inputs / Fields
- **Style:** `84%` paper fill, `10px` radius, hairline `12%`-ink border, `32px` high, sans-serif `0.82–0.95rem`, a top inset highlight.
@@ -236,7 +296,7 @@ The editorial heart of the product. Reviewer edits render as attributed inline m
- **Comment anchor:** amber highlight tint behind the running text, `box-decoration-break: clone` so it wraps cleanly across lines.
- **Confidence ramp & stale:** anchored suggestions carry a descending-presence background (high → low) in the accent hue; a stale anchor desaturates and switches to a dotted underline.
-## 6. Do's and Don'ts
+## Do's and Don'ts
### Do:
- **Do** keep the terracotta/steel accent to action, selection, and focus only — the One Pencil Rule. Everything else is ink, paper, and the second neutral layer.
@@ -252,7 +312,7 @@ The editorial heart of the product. Reviewer edits render as attributed inline m
- **Don't** let it read like an **IDE** (VS Code): no activity bars, no panels-in-panels, no everything-is-a-toolbar. The reading column is the hero.
- **Don't** let it drift toward **Notion rounded-pastel**: no candy-colored blocks, no emoji-forward headers, no soft-everything. Warmth is paper and type.
- **Don't** borrow the Linear-clone **saturated-purple glassy gradient-glow** dark theme; INK mode is a cool blue-black study, not neon.
-- **Don't** use a `border-left`/`border-right` colored stripe > 1px on cards or callouts (the review card identifies comment vs. suggestion with a small accent element, not a side-stripe).
+- **Don't** use a colored side-stripe on a card or callout as *decoration* — the AI-UI tell is a thick tinted border that means nothing. The **one** sanctioned exception is the review margin card's `3px` accent strip, which is load-bearing: it encodes the comment's author and its kind/state, and removing it deletes an information channel. (Amended 2026-08-06: this previously read as a flat ">1px" prohibition, which the shipped card had never satisfied — the rule described an intent the product had already outgrown. If a new stripe cannot say what it *means*, it is decoration and the prohibition stands.)
- **Don't** use gradient text, glassmorphism as a default, or the drift toward a warm-cream background — all are prohibited.
- **Don't** let muted body text go lighter than ~`oklch(0.32 0.012 65)` on paper; light-gray-for-elegance is the fastest way to fail the 4.5:1 floor.
- **Don't** fluidly `clamp()` UI headings; the product type scale is fixed rem.
diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
new file mode 100644
index 00000000..9e7bd921
--- /dev/null
+++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
@@ -0,0 +1 @@
+{"version":"4.1.10","results":[[":web/src/lib/embedded-svg-roundtrip.test.ts",{"duration":0,"failed":true}]]}
\ No newline at end of file
diff --git a/planning/embedded-svg-threat-model.md b/planning/embedded-svg-threat-model.md
new file mode 100644
index 00000000..950c4e1f
--- /dev/null
+++ b/planning/embedded-svg-threat-model.md
@@ -0,0 +1,687 @@
+# Embedded SVG: Threat Model and Sanitisation Decision Record
+
+**Issue:** attn-vlmz.4.1 (decision) → attn-vlmz.4.2 (implementation)
+**Date:** 2026-08-06
+**Status:** Accepted and implemented. Re-argued against six named bypass classes
+after review — see §6.
+
+| File | |
+| --- | --- |
+| `web/src/lib/svg-sanitizer.ts` | allowlist sanitiser (DOM-free, unit-testable) |
+| `web/src/lib/embedded-svg-view.ts` | DOM builder, post-build audit, sizing, injected CSS |
+| `web/src/lib/schema.ts` | `embedded_svg` node, `attn_svg_block` markdown-it rule, serializer |
+| `web/src/lib/svg-sanitizer.test.ts` | 74 cases, adversarial |
+| `web/src/lib/embedded-svg-roundtrip.test.ts` | 22 cases, byte-exact round trip |
+
+## The report
+
+A document containing raw SVG rendered as literal escaped text in a paragraph —
+the reporter saw `'` — no `unsafe-inline`, so injected inline handlers and inline `"#,
init_payload_json = init_payload_json,
);
- // Inject into the template
+ // Inject into the template. The theme written here is the stored
+ // PREFERENCE (which may be `system`); the template's inline resolver
+ // script turns it into an effective light/dark before first paint.
APP_HTML
.replace("", &init_script)
.replace("data-theme=\"system\"", &format!("data-theme=\"{theme}\""))
.replace("data-theme=\"light\"", &format!("data-theme=\"{theme}\""))
+ // `replacen(.., 1)`, not `replace`: since typeset.css gained an
+ // explicit `[data-typeset='editorial']` rule, this needle is no longer
+ // unique to the tag in principle. It is in practice only
+ // because the CSS minifier emits selectors unquoted
+ // (`[data-typeset=editorial]`) — one minifier-config change away from
+ // this rewriting the default preset's own selector and stripping its
+ // tokens. The tag is the first occurrence in the document, so
+ // bounding the replacement removes the dependency on that accident.
+ .replacen(
+ "data-typeset=\"editorial\"",
+ &format!("data-typeset=\"{typeset}\""),
+ 1,
+ )
}
#[cfg(test)]
diff --git a/src/prefs.rs b/src/prefs.rs
new file mode 100644
index 00000000..9a1d29b2
--- /dev/null
+++ b/src/prefs.rs
@@ -0,0 +1,277 @@
+//! Durable UI preferences (appearance, typeset, review-rail width).
+//!
+//! Kept next to the project registry in the daemon's runtime namespace so a
+//! preference survives daemon restarts AND can be read before the window
+//! exists. That ordering is the point: the theme is stamped into the page HTML
+//! at build time, so the first frame already carries the right appearance and
+//! the user never sees a flash of the wrong theme.
+
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+/// `system` defers to the OS appearance, resolved in the webview (which
+/// tracks macOS light/dark live via `prefers-color-scheme`).
+pub const THEME_LIGHT: &str = "light";
+pub const THEME_DARK: &str = "dark";
+pub const THEME_SYSTEM: &str = "system";
+
+pub const TYPESET_DEFAULT: &str = "editorial";
+/// Mirrors `TypesetName` in web/src/lib/types.ts and the presets in
+/// web/styles/typeset.css. An id missing here is silently downgraded to the
+/// default, so all three lists have to move together.
+const TYPESETS: [&str; 5] = [
+ TYPESET_DEFAULT,
+ "modern",
+ "compact",
+ "manuscript",
+ "terminal",
+];
+
+/// Expanded review-rail width, in CSS px (attn-11g4.2).
+///
+/// Mirrors `RAIL_WIDTH_PX.expanded`, `RAIL_WIDTH_MIN_PX` and
+/// `RAIL_WIDTH_MAX_PX` in `web/src/lib/review/rail-mode.ts`, where the bounds
+/// are justified against card legibility and prose measure. The webview clamps
+/// before it sends; this is the gate that decides what is allowed to survive a
+/// restart, so the two sets of numbers have to move together.
+/// `web/src/lib/review/rail-width.test.ts` reads this file and fails on drift.
+pub const RAIL_WIDTH_DEFAULT: u32 = 320;
+pub const RAIL_WIDTH_MIN: u32 = 260;
+pub const RAIL_WIDTH_MAX: u32 = 640;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Preferences {
+ /// `light` | `dark` | `system`
+ #[serde(default = "default_theme")]
+ pub theme: String,
+ /// Typeset preset id — see `web/styles/typeset.css`.
+ #[serde(default = "default_typeset")]
+ pub typeset: String,
+ /// Width of the expanded review rail in CSS px. `#[serde(default)]` keeps
+ /// prefs.json files written before attn-11g4.2 loading cleanly.
+ #[serde(default = "default_rail_width")]
+ pub rail_width: u32,
+}
+
+fn default_theme() -> String {
+ THEME_SYSTEM.to_string()
+}
+
+fn default_typeset() -> String {
+ TYPESET_DEFAULT.to_string()
+}
+
+fn default_rail_width() -> u32 {
+ RAIL_WIDTH_DEFAULT
+}
+
+impl Default for Preferences {
+ fn default() -> Self {
+ Self {
+ theme: default_theme(),
+ typeset: default_typeset(),
+ rail_width: default_rail_width(),
+ }
+ }
+}
+
+/// Normalize an untrusted theme string; anything unrecognized falls back to
+/// `system` rather than stamping a bogus attribute into the page.
+pub fn normalize_theme(value: &str) -> String {
+ match value.trim() {
+ THEME_LIGHT => THEME_LIGHT.to_string(),
+ THEME_DARK => THEME_DARK.to_string(),
+ _ => THEME_SYSTEM.to_string(),
+ }
+}
+
+pub fn normalize_typeset(value: &str) -> String {
+ let trimmed = value.trim();
+ if TYPESETS.contains(&trimmed) {
+ trimmed.to_string()
+ } else {
+ TYPESET_DEFAULT.to_string()
+ }
+}
+
+/// Normalize an untrusted rail width.
+///
+/// Deliberately the same discipline as `normalize_typeset`: out-of-range means
+/// *reject*, not snap to the nearest bound. The webview already clamps into
+/// `[RAIL_WIDTH_MIN, RAIL_WIDTH_MAX]` before it sends, so a value outside the
+/// range did not come from a drag — it came from a hand-edited prefs.json or a
+/// caller we should not be interpolating for. Falling back to the default
+/// gives the user a rail they can see and re-drag instead of one silently
+/// pinned to an edge they never chose.
+pub fn normalize_rail_width(value: u32) -> u32 {
+ if (RAIL_WIDTH_MIN..=RAIL_WIDTH_MAX).contains(&value) {
+ value
+ } else {
+ RAIL_WIDTH_DEFAULT
+ }
+}
+
+pub fn load() -> Preferences {
+ let path = prefs_path();
+ let Ok(raw) = std::fs::read_to_string(&path) else {
+ return Preferences::default();
+ };
+ match serde_json::from_str::(&raw) {
+ Ok(prefs) => Preferences {
+ theme: normalize_theme(&prefs.theme),
+ typeset: normalize_typeset(&prefs.typeset),
+ rail_width: normalize_rail_width(prefs.rail_width),
+ },
+ Err(e) => {
+ tracing::warn!("could not parse preferences {}: {}", path.display(), e);
+ Preferences::default()
+ }
+ }
+}
+
+pub fn set_theme(theme: &str) -> Result {
+ let mut prefs = load();
+ prefs.theme = normalize_theme(theme);
+ save(&prefs)?;
+ Ok(prefs)
+}
+
+pub fn set_typeset(typeset: &str) -> Result {
+ let mut prefs = load();
+ prefs.typeset = normalize_typeset(typeset);
+ save(&prefs)?;
+ Ok(prefs)
+}
+
+pub fn set_rail_width(width: u32) -> Result {
+ let mut prefs = load();
+ prefs.rail_width = normalize_rail_width(width);
+ save(&prefs)?;
+ Ok(prefs)
+}
+
+fn save(prefs: &Preferences) -> Result<()> {
+ let dir = crate::projects::storage_dir();
+ std::fs::create_dir_all(&dir).with_context(|| format!("could not create {}", dir.display()))?;
+ let path = prefs_path();
+ let payload = serde_json::to_string_pretty(prefs).context("could not serialize preferences")?;
+ std::fs::write(&path, payload).with_context(|| format!("could not write {}", path.display()))
+}
+
+fn prefs_path() -> PathBuf {
+ crate::projects::storage_dir().join("prefs.json")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn unknown_theme_falls_back_to_system() {
+ assert_eq!(normalize_theme("dark"), THEME_DARK);
+ assert_eq!(normalize_theme("light"), THEME_LIGHT);
+ assert_eq!(normalize_theme("system"), THEME_SYSTEM);
+ assert_eq!(normalize_theme("chartreuse"), THEME_SYSTEM);
+ assert_eq!(normalize_theme(""), THEME_SYSTEM);
+ }
+
+ #[test]
+ fn unknown_typeset_falls_back_to_default() {
+ assert_eq!(normalize_typeset("modern"), "modern");
+ assert_eq!(normalize_typeset("compact"), "compact");
+ assert_eq!(normalize_typeset("manuscript"), "manuscript");
+ assert_eq!(normalize_typeset("terminal"), "terminal");
+ assert_eq!(normalize_typeset("wingdings"), TYPESET_DEFAULT);
+ }
+
+ /// The allowlist is the last gate before an id is stamped into the page as
+ /// `data-typeset`, so every id the UI can select has to survive it.
+ #[test]
+ fn every_allowlisted_typeset_round_trips() {
+ for id in TYPESETS {
+ assert_eq!(normalize_typeset(id), id);
+ }
+ }
+
+ #[test]
+ fn preferences_round_trip_through_json() {
+ let prefs = Preferences {
+ theme: THEME_DARK.to_string(),
+ typeset: "compact".to_string(),
+ rail_width: 420,
+ };
+ let raw = serde_json::to_string(&prefs).expect("serialize");
+ let parsed: Preferences = serde_json::from_str(&raw).expect("deserialize");
+ assert_eq!(parsed.theme, THEME_DARK);
+ assert_eq!(parsed.typeset, "compact");
+ assert_eq!(parsed.rail_width, 420);
+ }
+
+ #[test]
+ fn missing_fields_use_defaults() {
+ let parsed: Preferences = serde_json::from_str("{}").expect("deserialize empty");
+ assert_eq!(parsed.theme, THEME_SYSTEM);
+ assert_eq!(parsed.typeset, TYPESET_DEFAULT);
+ assert_eq!(parsed.rail_width, RAIL_WIDTH_DEFAULT);
+ }
+
+ /// Every prefs.json written before attn-11g4.2 lacks `rail_width`. Those
+ /// files must keep loading — a user who set a theme two releases ago does
+ /// not get reset to `system` because a rail learned to resize.
+ #[test]
+ fn prefs_written_before_rail_width_still_load() {
+ let parsed: Preferences =
+ serde_json::from_str(r#"{"theme":"dark","typeset":"compact"}"#).expect("deserialize");
+ assert_eq!(parsed.theme, THEME_DARK);
+ assert_eq!(parsed.typeset, "compact");
+ assert_eq!(parsed.rail_width, RAIL_WIDTH_DEFAULT);
+ }
+
+ #[test]
+ fn in_range_rail_widths_round_trip() {
+ for width in [
+ RAIL_WIDTH_MIN,
+ RAIL_WIDTH_DEFAULT,
+ RAIL_WIDTH_MAX,
+ 261,
+ 400,
+ 639,
+ ] {
+ assert_eq!(normalize_rail_width(width), width, "width {width}");
+ }
+ }
+
+ /// Out-of-range reverts to the default rather than snapping to a bound —
+ /// see `normalize_rail_width` for why. Covers both directions plus the
+ /// degenerate values a corrupt file can produce.
+ #[test]
+ fn out_of_range_rail_width_falls_back_to_default() {
+ for width in [0, 1, RAIL_WIDTH_MIN - 1, RAIL_WIDTH_MAX + 1, 4096, u32::MAX] {
+ assert_eq!(normalize_rail_width(width), RAIL_WIDTH_DEFAULT, "width {width}");
+ }
+ }
+
+ /// The default has to be a width the gate accepts, or `set_rail_width`
+ /// would bounce the reset the double-click handler sends.
+ #[test]
+ fn rail_width_default_survives_its_own_gate() {
+ assert!(RAIL_WIDTH_MIN < RAIL_WIDTH_MAX);
+ assert!((RAIL_WIDTH_MIN..=RAIL_WIDTH_MAX).contains(&RAIL_WIDTH_DEFAULT));
+ assert_eq!(normalize_rail_width(RAIL_WIDTH_DEFAULT), RAIL_WIDTH_DEFAULT);
+ }
+
+ /// A stored width outside the range must not reach the frontend even
+ /// though it parsed fine — `load()` is the only reader, so this is where
+ /// a hand-edited prefs.json gets caught.
+ #[test]
+ fn load_normalizes_an_out_of_range_stored_width() {
+ let parsed: Preferences =
+ serde_json::from_str(r#"{"theme":"dark","typeset":"modern","rail_width":9999}"#)
+ .expect("deserialize");
+ // Raw parse keeps the bogus value...
+ assert_eq!(parsed.rail_width, 9999);
+ // ...and the normalization `load()` applies is what rejects it.
+ assert_eq!(normalize_rail_width(parsed.rail_width), RAIL_WIDTH_DEFAULT);
+ }
+}
diff --git a/src/projects.rs b/src/projects.rs
index 0e9cd83e..3ab714fe 100644
--- a/src/projects.rs
+++ b/src/projects.rs
@@ -69,7 +69,9 @@ fn registry_path() -> PathBuf {
storage_dir().join("projects.json")
}
-fn storage_dir() -> PathBuf {
+/// The daemon's runtime namespace — shared by the project registry and
+/// durable UI preferences (`src/prefs.rs`).
+pub fn storage_dir() -> PathBuf {
// ATTN_HOME wins over XDG_STATE_HOME so the project registry shares the
// daemon's runtime namespace (see src/daemon.rs::runtime_dir).
if let Ok(value) = std::env::var("ATTN_HOME") {
diff --git a/src/review/apply.rs b/src/review/apply.rs
index 830ed6b0..9e7cbb4a 100644
--- a/src/review/apply.rs
+++ b/src/review/apply.rs
@@ -24,8 +24,8 @@ use crate::review::model::{
ReviewEventBody, RevisionSource, SuggestionOperation,
};
use crate::review::store::ReviewStore;
-use serde::{Deserialize, Serialize};
use crate::review::working_copy::{SaveRequest, SaveSource, WorkingCopyError, WorkingCopyService};
+use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
// ---------------------------------------------------------------------------
@@ -518,9 +518,7 @@ pub fn revert_accepted_suggestion(
let start = splice.start;
let inserted = splice.inserted.as_bytes();
let end = start.saturating_add(inserted.len());
- if end > current_markdown_bytes.len()
- || ¤t_markdown_bytes[start..end] != inserted
- {
+ if end > current_markdown_bytes.len() || ¤t_markdown_bytes[start..end] != inserted {
return Err(ApplyError::RevertUnavailable {
reason: "recorded splice no longer matches the file content".to_string(),
});
@@ -1759,7 +1757,6 @@ mod tests {
use crate::review::ids::RoomId;
use crate::review::store::ReviewStore;
-use serde::{Deserialize, Serialize};
use crate::review::working_copy::WorkingCopyService;
use std::sync::Arc;
use tempfile::TempDir;
@@ -2009,9 +2006,14 @@ use serde::{Deserialize, Serialize};
let outcome = apply_ready_verdict(&verdict, &ctx, initial).expect("apply succeeds");
// The accept revision carries the recorded splice.
- let splice: AcceptSplice =
- serde_json::from_str(outcome.revision.patch_text.as_deref().expect("splice recorded"))
- .expect("splice parses");
+ let splice: AcceptSplice = serde_json::from_str(
+ outcome
+ .revision
+ .patch_text
+ .as_deref()
+ .expect("splice recorded"),
+ )
+ .expect("splice parses");
assert_eq!(splice.removed, "brown");
assert_eq!(splice.inserted, "auburn");
assert_eq!(splice.start, 10);
diff --git a/src/review/bootstrap.rs b/src/review/bootstrap.rs
index e406c9c7..f7e60cc8 100644
--- a/src/review/bootstrap.rs
+++ b/src/review/bootstrap.rs
@@ -358,10 +358,7 @@ pub fn is_valid_identity_color(raw: &str) -> bool {
if let Some(hex) = raw.strip_prefix('#') {
return hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit());
}
- let Some(inner) = raw
- .strip_prefix("oklch(")
- .and_then(|r| r.strip_suffix(')'))
- else {
+ let Some(inner) = raw.strip_prefix("oklch(").and_then(|r| r.strip_suffix(')')) else {
return false;
};
let parts: Vec<&str> = inner.split_whitespace().collect();
@@ -3222,6 +3219,11 @@ impl Bootstrapper {
Ok(published)
}
+ // The identity of a published snapshot IS this argument list — room, file,
+ // snapshot id, owner-facing path, base hash, payload, and mint time are all
+ // independent inputs the caller must state explicitly. Bundling them into a
+ // struct would only move the same list one level away from the call site.
+ #[allow(clippy::too_many_arguments)]
async fn publish_snapshot_plaintext(
&self,
room_id: &RoomId,
diff --git a/src/review/compression.rs b/src/review/compression.rs
index e2425ece..3cd05c7e 100644
--- a/src/review/compression.rs
+++ b/src/review/compression.rs
@@ -37,11 +37,8 @@ pub fn compress_if_smaller(plaintext: &[u8]) -> Cow<'_, [u8]> {
if plaintext.len() < 64 {
return Cow::Borrowed(plaintext); // header overhead always loses
}
- let mut encoder =
- flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
- let compressed = encoder
- .write_all(plaintext)
- .and_then(|()| encoder.finish());
+ let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
+ let compressed = encoder.write_all(plaintext).and_then(|()| encoder.finish());
match compressed {
Ok(bytes) if bytes.len() < plaintext.len() => Cow::Owned(bytes),
_ => Cow::Borrowed(plaintext),
@@ -100,8 +97,8 @@ mod tests {
}
let wire = compress_if_smaller(&bytes);
assert!(matches!(wire, Cow::Borrowed(_)), "random bytes stay raw");
- let restored = decompress_if_needed(&bytes, MAX_DECOMPRESSED_SNAPSHOT_BYTES)
- .expect("passthrough");
+ let restored =
+ decompress_if_needed(&bytes, MAX_DECOMPRESSED_SNAPSHOT_BYTES).expect("passthrough");
assert!(matches!(restored, Cow::Borrowed(_)));
}
@@ -128,7 +125,10 @@ mod tests {
fn zip_bomb_hits_the_ceiling() {
let zeros = vec![0u8; 1024 * 1024];
let wire = compress_if_smaller(&zeros);
- assert!(is_gzip(&wire) && wire.len() < 8192, "zeros must compress hard");
+ assert!(
+ is_gzip(&wire) && wire.len() < 8192,
+ "zeros must compress hard"
+ );
let err = decompress_if_needed(&wire, 64 * 1024).expect_err("must hit ceiling");
assert!(err.contains("decompression ceiling"), "{err}");
let ok = decompress_if_needed(&wire, MAX_DECOMPRESSED_SNAPSHOT_BYTES)
@@ -161,12 +161,10 @@ mod tests {
// (browser DEFLATE implementations differ from miniz_oxide). Encoded
// here with flate2 but validated shape-wise: magic + round-trip.
let plaintext = b"{\"v\":1,\"content\":\"interop across native and browser clients\"}";
- let mut encoder =
- flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
+ let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
encoder.write_all(plaintext).expect("write");
let wire = encoder.finish().expect("finish");
- let restored =
- decompress_if_needed(&wire, MAX_DECOMPRESSED_SNAPSHOT_BYTES).expect("open");
+ let restored = decompress_if_needed(&wire, MAX_DECOMPRESSED_SNAPSHOT_BYTES).expect("open");
assert_eq!(restored.as_ref(), plaintext.as_slice());
}
}
diff --git a/src/watcher.rs b/src/watcher.rs
index a407700d..5ace9afc 100644
--- a/src/watcher.rs
+++ b/src/watcher.rs
@@ -69,6 +69,8 @@ pub enum UserEvent {
OpenDevtools,
/// The user started dragging a custom title bar region.
DragWindow,
+ /// The user double-clicked a title bar region — toggle native zoom.
+ ZoomWindow,
/// Explicitly toggle the macOS resident daemon LaunchAgent.
ResidentLaunchAtLogin { enabled: bool },
/// Show and focus the main window.
diff --git a/web/e2e/dialog-scroll.spec.ts b/web/e2e/dialog-scroll.spec.ts
new file mode 100644
index 00000000..9441d191
--- /dev/null
+++ b/web/e2e/dialog-scroll.spec.ts
@@ -0,0 +1,189 @@
+// Dialog scrolling — real layout, real components, real compiled CSS.
+//
+// attn-11g4.1.1: the share modal could not be scrolled. The ScrollArea viewport
+// sized itself with `size-full` (`height: 100%`), which only resolves against a
+// containing block with a DEFINITE height. `DialogContent` is `max-h-[85vh]`
+// with `height: auto`, so the ScrollArea root's height comes out of flex layout
+// of an indefinite-height container and is not definite for percentage
+// resolution. `height: 100%` fell back to `auto`, the viewport grew to its full
+// content height (`scrollHeight === clientHeight` — nothing to scroll) and the
+// overflow was silently clipped by the dialog's `overflow-hidden`.
+//
+// Measured against this very spec on a pre-fix bundle, at a 420px window:
+// dialog 357px, viewport clientHeight 529px, last section's bottom edge 150px
+// BELOW the dialog's bottom edge and unreachable. After the fix: viewport
+// 355px, scrollHeight 529px, scrolls to the last line.
+//
+// Why the Settings dialog and not the share modal: this spec drives the NATIVE
+// bundle, where `dialog-content.svelte` is shared by every dialog. Settings is
+// the one that opens in a single click with no fixture state, and it is a real
+// product surface. It was broken by the same bug — it simply needs a short
+// window rather than long content, which is why nobody had noticed. The share
+// modal needs an open document, and the hosted build's share sheet is a bespoke
+// component that does not use `Dialog.Content` at all.
+//
+// Companion: `src/lib/components/ui/scroll-area/scroll-area-sizing.test.ts`
+// pins the class contract under the (layout-free) Node unit harness. THIS file
+// is the one that proves a scrollable box actually results.
+//
+// Requires the native bundle: `cd web && npm run build`. No server needed — the
+// bundle is single-file and boots its own mock IPC when no wry bridge is found.
+// Run with: `npx playwright test e2e/dialog-scroll.spec.ts`
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { expect, test, type Page } from '@playwright/test';
+
+const BUNDLE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../dist/index.html');
+const BUNDLE_URL = `file://${BUNDLE}`;
+
+/** The 85vh ceiling `dialog-content.svelte` puts on every dialog. */
+const CAP_RATIO = 0.85;
+
+test.beforeAll(() => {
+ test.skip(
+ !fs.existsSync(BUNDLE),
+ `native bundle missing at ${BUNDLE} — run \`npm run build\` in web/ first`,
+ );
+});
+
+/**
+ * Open the Settings dialog and return a handle to it.
+ *
+ * `SettingsDialog` passes its own `data-slot`, which REPLACES
+ * `dialog-content`'s — hence the role selector rather than
+ * `[data-slot="dialog-content"]`.
+ */
+async function openDialog(page: Page) {
+ await page.goto(BUNDLE_URL);
+ const settings = page.getByRole('button', { name: 'Settings' });
+ await expect(settings).toBeVisible();
+ await settings.click();
+ const dialog = page.locator('[role="dialog"][data-state="open"]');
+ await expect(dialog).toBeVisible();
+ await expect(dialog.locator('[data-slot="dialog-content-body"]')).toBeVisible();
+ return dialog;
+}
+
+/**
+ * Scroll the dialog's body to the bottom and measure whether its last element
+ * ended up inside the dialog's own box.
+ *
+ * Deliberately NOT `toBeInViewport()`: the dialog clips with `overflow-hidden`,
+ * so a clipped element can still intersect the browser viewport and pass. At a
+ * 560px window the pre-fix build put the last section at y=549 — inside the
+ * window, invisible in the dialog. The containment test has to be against the
+ * DIALOG's rect, not the window's.
+ */
+async function scrollToEnd(page: Page) {
+ return page.evaluate(() => {
+ const dialog = document.querySelector('[role="dialog"][data-state="open"]');
+ if (!dialog) throw new Error('no open dialog');
+ const viewport = dialog.querySelector('[data-slot="scroll-area-viewport"]');
+ const body = dialog.querySelector('[data-slot="dialog-content-body"]');
+ if (!viewport || !body) throw new Error('dialog is missing its scrolling body');
+ const last = body.lastElementChild;
+ if (!last) throw new Error('dialog body is empty');
+
+ viewport.scrollTop = viewport.scrollHeight;
+ const lastRect = last.getBoundingClientRect();
+ const dialogRect = dialog.getBoundingClientRect();
+ return {
+ windowHeight: window.innerHeight,
+ dialogHeight: dialogRect.height,
+ viewportClientHeight: viewport.clientHeight,
+ viewportScrollHeight: viewport.scrollHeight,
+ overflows: viewport.scrollHeight > viewport.clientHeight,
+ // 1px of slack throughout: subpixel layout, not a real gap.
+ lastInsideDialog: lastRect.bottom <= dialogRect.bottom + 1 && lastRect.top >= dialogRect.top - 1,
+ lastOverhang: lastRect.bottom - dialogRect.bottom,
+ };
+ });
+}
+
+test('a dialog taller than the window scrolls to its last element', async ({ page }) => {
+ // 420px window → 357px cap against ~529px of Settings content.
+ await page.setViewportSize({ width: 1200, height: 420 });
+ await openDialog(page);
+
+ const result = await scrollToEnd(page);
+
+ // The bug, stated directly: the box must actually be scrollable. Pre-fix this
+ // was false — clientHeight had grown to equal scrollHeight.
+ expect(result.overflows).toBe(true);
+ expect(result.viewportScrollHeight).toBeGreaterThan(result.viewportClientHeight);
+ // And the last element must be reachable, not clipped away. Pre-fix it
+ // overhung the dialog's bottom edge by ~150px.
+ expect(result.lastInsideDialog).toBe(true);
+ expect(result.lastOverhang).toBeLessThanOrEqual(1);
+});
+
+test('the dialog honours its 85vh ceiling instead of growing past the window', async ({ page }) => {
+ // Guards the other way out of the bug: capping content or dropping the
+ // ceiling would also make "nothing is clipped" true, and would be wrong.
+ await page.setViewportSize({ width: 1200, height: 420 });
+ await openDialog(page);
+ const { dialogHeight, windowHeight } = await scrollToEnd(page);
+ expect(dialogHeight).toBeLessThanOrEqual(CAP_RATIO * windowHeight + 1);
+});
+
+for (const height of [420, 560, 700, 900, 1400]) {
+ test(`the last element is reachable at a ${height}px window`, async ({ page }) => {
+ await page.setViewportSize({ width: 1200, height });
+ await openDialog(page);
+ const result = await scrollToEnd(page);
+
+ // Holds whether or not the content overflows: short windows scroll, tall
+ // windows simply fit. Pre-fix, 420 and 560 both failed here.
+ expect(result.lastInsideDialog).toBe(true);
+ // Whenever the dialog IS at its ceiling, it must be scrollable rather than
+ // clipped — the two must never both be true.
+ if (result.dialogHeight >= CAP_RATIO * result.windowHeight - 1) {
+ expect(result.overflows).toBe(true);
+ }
+ });
+}
+
+test('the close button stays pinned to the frame while the body scrolls', async ({ page }) => {
+ await page.setViewportSize({ width: 1200, height: 420 });
+ const dialog = await openDialog(page);
+ const close = dialog.getByRole('button', { name: 'Close' });
+ await expect(close).toBeVisible();
+
+ const before = await close.boundingBox();
+ await scrollToEnd(page);
+ const after = await close.boundingBox();
+
+ // The close affordance lives outside the scrolling body on purpose. If it
+ // ever moves with the content, it has been pulled inside the scroller.
+ expect(before).not.toBeNull();
+ expect(after).not.toBeNull();
+ expect(Math.abs((after?.y ?? 0) - (before?.y ?? 0))).toBeLessThanOrEqual(1);
+ await expect(close).toBeInViewport();
+});
+
+test('no ScrollArea viewport outgrows its own root', async ({ page }) => {
+ // Blast-radius guard. The fix changed the SHARED ScrollArea, so assert the
+ // invariant generically across every ScrollArea the app has mounted, not just
+ // the one in the dialog. A viewport taller than its root IS the bug's
+ // signature — that is how content escapes and gets clipped.
+ await page.setViewportSize({ width: 1200, height: 420 });
+ await openDialog(page);
+
+ const offenders = await page.evaluate(() =>
+ [...document.querySelectorAll('[data-slot="scroll-area"]')]
+ .map((root) => {
+ const viewport = root.querySelector('[data-slot="scroll-area-viewport"]');
+ if (!viewport) return null;
+ const rootHeight = root.getBoundingClientRect().height;
+ const viewportHeight = viewport.getBoundingClientRect().height;
+ return viewportHeight > rootHeight + 1
+ ? { rootHeight, viewportHeight, classes: root.className }
+ : null;
+ })
+ .filter((entry) => entry !== null),
+ );
+
+ expect(offenders).toEqual([]);
+});
diff --git a/web/e2e/hosted-authoring.spec.ts b/web/e2e/hosted-authoring.spec.ts
index d218da02..e655ce8a 100644
--- a/web/e2e/hosted-authoring.spec.ts
+++ b/web/e2e/hosted-authoring.spec.ts
@@ -249,7 +249,7 @@ test('desk rename and delete are real and confirmed in-app', async ({ page }) =>
await page.goto('/app');
await expect(page.locator('.workspace-row')).toHaveCount(1);
- await page.getByRole('button', { name: 'Rename', exact: true }).click();
+ await page.locator('.workspace-row').getByRole('button', { name: /^Rename /u }).click();
const input = page.getByRole('textbox', { name: 'Workspace name' });
await input.fill('Product direction');
await input.press('Enter');
@@ -259,7 +259,7 @@ test('desk rename and delete are real and confirmed in-app', async ({ page }) =>
await page.reload();
await expect(page.locator('.workspace-row').first()).toContainText('Product direction');
- await page.getByRole('button', { name: 'Delete', exact: true }).click();
+ await page.locator('.workspace-row').getByRole('button', { name: /^Delete /u }).click();
const confirm = page.getByRole('alertdialog');
await expect(confirm).toContainText('Delete “Product direction” from this device?');
await confirm.getByRole('button', { name: 'Delete workspace' }).click();
diff --git a/web/e2e/hosted-routes.spec.ts b/web/e2e/hosted-routes.spec.ts
index 5dd0e956..085a8e29 100644
--- a/web/e2e/hosted-routes.spec.ts
+++ b/web/e2e/hosted-routes.spec.ts
@@ -6,6 +6,35 @@ import { expect, test, type Page } from '@playwright/test';
const FORBIDDEN_ON_LANDING = /prosemirror|mermaid|katex|noble|BrowserReviewApp|\/assets\/(?:review|app)-/iu;
+/* The desk lists workspace names. It must not fetch the editor, the markdown
+ parser, or the crypto suite to do it (attn-n01r.41).
+ Chunk names are content-hashed but the vendor stems are stable, which is what
+ these match on. */
+const FORBIDDEN_ON_DESK = /prosemirror|mermaid|katex|schema-|BrowserReviewApp/iu;
+
+/* Runtime script-byte budgets per route, in KB.
+ check-route-bundles.mjs walks the Vite manifest's static `imports` and reports
+ green while an awaited dynamic import pulls the same graph over the wire —
+ that is how ~600 KB shipped to the desk under a passing gate. A static-manifest
+ gate structurally cannot see this; only measuring what the browser actually
+ fetches can. Headroom over the measured values is deliberate but small: these
+ should fail on a regression, not absorb one. */
+const SCRIPT_BUDGET_KB: Record = {
+ '/': 110, // measured ~72 KB
+ '/app': 500, // measured ~414 KB
+};
+
+async function measureScriptKb(page: Page, path: string): Promise {
+ let bytes = 0;
+ page.on('response', (response) => {
+ if (response.request().resourceType() !== 'script') return;
+ const length = Number(response.headers()['content-length'] ?? 0);
+ if (Number.isFinite(length)) bytes += length;
+ });
+ await page.goto(path, { waitUntil: 'networkidle' });
+ return bytes / 1024;
+}
+
function captureAssetRequests(page: Page): string[] {
const urls: string[] = [];
page.on('request', (request) => {
@@ -81,7 +110,11 @@ test('landing serves at / without editor, crypto, or other-entry chunks', async
expect(response?.status()).toBe(200);
expect(response?.headers()['content-security-policy']).toContain("script-src 'self'");
await expect(page.locator('body[data-route="landing"]')).toBeVisible();
- await expect(page.locator('h1')).toHaveText('A private desk for working documents.');
+ await expect(page.locator('h1')).toHaveText('Review it together. Even when they aren\u2019t human.');
+ // The page must actually argue the product's positioning (attn-n01r.10):
+ // PRODUCT.md calls attn "the reviewer for agent-authored docs", and the
+ // landing previously said "agent" and "AI" zero times.
+ await expect(page.locator('body')).toContainText(/agent/iu);
await expect(page.locator('body')).toHaveAttribute('data-hydrated', 'true');
expect(requests.some((url) => /\/assets\/landing-/u.test(url))).toBe(true);
const forbidden = requests.filter((url) => FORBIDDEN_ON_LANDING.test(new URL(url).pathname));
@@ -114,7 +147,7 @@ test('landing theme toggle flips palette, swaps captures, and persists', async (
),
).toBe(true);
expect(await heroShot.evaluate((image) => (image as HTMLImageElement).currentSrc)).toMatch(/\.avif$/u);
- await page.getByRole('button', { name: 'Toggle theme' }).click();
+ await page.getByRole('button', { name: /^Switch to (dark|light) theme$/u }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(heroShot).toHaveAttribute('src', /collab-dark/u);
await waitForLandingCaptureImages(page, 'dark');
@@ -139,12 +172,16 @@ test('capture landing screenshots for design review', async ({ page }) => {
await expect(page.locator('body')).toHaveAttribute('data-hydrated', 'true');
await waitForLandingCaptureImages(page, 'light');
await page.screenshot({ path: 'test-results/landing-desktop-light.png', fullPage: true });
- await page.getByRole('button', { name: 'Toggle theme' }).click();
+ await page.getByRole('button', { name: /^Switch to (dark|light) theme$/u }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await waitForLandingCaptureImages(page, 'dark');
await page.screenshot({ path: 'test-results/landing-desktop-dark.png', fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
- await page.getByRole('button', { name: 'Toggle theme' }).click();
+ // The nav collapses to a hamburger below the mid tier, so the theme control
+ // lives inside the disclosure. This test asserted it was clickable at 390
+ // without opening the menu and had been failing on main for that reason.
+ await page.getByRole('button', { name: 'Open menu' }).click();
+ await page.getByRole('button', { name: /^Switch to (dark|light) theme$/u }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await waitForLandingCaptureImages(page, 'light');
await page.screenshot({ path: 'test-results/landing-iphone-light.png', fullPage: true });
@@ -189,3 +226,20 @@ test('review entry serves durable share paths without redirecting', async ({ pag
await expect(page).toHaveTitle('Attn review');
expect(new URL(page.url()).pathname).toBe('/s/AAAAAAAAAAAAAAAAAAAAAA');
});
+
+test('the desk never fetches the editor or crypto graph', async ({ page }) => {
+ const requests = captureAssetRequests(page);
+ await page.goto('/app', { waitUntil: 'networkidle' });
+ const forbidden = requests.filter((url) => FORBIDDEN_ON_DESK.test(new URL(url).pathname));
+ expect(forbidden, `desk fetched forbidden chunks: ${forbidden.join(', ')}`).toEqual([]);
+});
+
+for (const [route, budgetKb] of Object.entries(SCRIPT_BUDGET_KB)) {
+ test(`${route} stays inside its script budget (${budgetKb} KB)`, async ({ page }) => {
+ const actual = await measureScriptKb(page, route);
+ expect(
+ actual,
+ `${route} shipped ${actual.toFixed(1)} KB of script against a ${budgetKb} KB budget`,
+ ).toBeLessThan(budgetKb);
+ });
+}
diff --git a/web/hosted/app/index.html b/web/hosted/app/index.html
index dd152ddc..1a1a7f17 100644
--- a/web/hosted/app/index.html
+++ b/web/hosted/app/index.html
@@ -5,6 +5,8 @@
+
+
diff --git a/web/hosted/index.html b/web/hosted/index.html
index cf6dcbcb..37f31480 100644
--- a/web/hosted/index.html
+++ b/web/hosted/index.html
@@ -18,7 +18,8 @@
content="Write in the browser or open local Markdown in native attn. Share a link when it needs another pair of eyes. No account, and no server can read the words."
/>
-
+
+
diff --git a/web/hosted/review/index.html b/web/hosted/review/index.html
index c50788c2..1e888b3e 100644
--- a/web/hosted/review/index.html
+++ b/web/hosted/review/index.html
@@ -4,6 +4,11 @@
+
+
+
+
+
Attn review
diff --git a/web/index.html b/web/index.html
index 0642b073..3441f9e8 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1,9 +1,49 @@
-
+
attn
+
+
+
diff --git a/web/scripts/check-route-bundles.mjs b/web/scripts/check-route-bundles.mjs
index 1cb1ce0e..59b97f22 100644
--- a/web/scripts/check-route-bundles.mjs
+++ b/web/scripts/check-route-bundles.mjs
@@ -84,7 +84,16 @@ if (failures > 0) {
console.error(`route bundle boundaries violated (${failures} finding${failures === 1 ? '' : 's'})`);
process.exit(1);
}
-console.log('route bundle boundaries hold: landing/app never preload editor or crypto chunks');
+/* Say only what was actually checked (attn-n01r.41). This previously claimed
+ 'route bundle boundaries hold', which read as a guarantee about what ships.
+ It is not: this walks chunk.imports, and Vite records a dynamic import's graph
+ under chunk.dynamicImports. An awaited import() in an entry pulls that graph
+ over the wire during bootstrap while this gate stays green — which is exactly
+ how ~600 KB of ProseMirror and crypto reached the desk under a passing build.
+ The wire is verified by the per-route script budgets in
+ e2e/hosted-routes.spec.ts; this checks the static graph only. */
+console.log('static route graphs clean: no editor or crypto chunks statically reachable');
+console.log(' note: dynamic-import graphs are NOT checked here — see the script budgets in e2e/hosted-routes.spec.ts');
/**
* Collect files reachable from a manifest key through static imports only.
diff --git a/web/scripts/theme-preflight-hash.mjs b/web/scripts/theme-preflight-hash.mjs
new file mode 100644
index 00000000..549a5cd9
--- /dev/null
+++ b/web/scripts/theme-preflight-hash.mjs
@@ -0,0 +1,13 @@
+// Recompute the CSP source hash for the inline theme-preflight script.
+// Run after editing THEME_PREFLIGHT_SCRIPT, then paste the result into
+// THEME_PREFLIGHT_SHA256 in src/lib/hosted/theme-preflight.ts.
+import { createHash } from 'node:crypto';
+import { readFileSync } from 'node:fs';
+
+const source = readFileSync(new URL('../src/lib/hosted/theme-preflight.ts', import.meta.url), 'utf8');
+const match = source.match(/export const THEME_PREFLIGHT_SCRIPT =\s*([\s\S]*?);\n/u);
+if (!match) throw new Error('THEME_PREFLIGHT_SCRIPT not found');
+// eslint-disable-next-line no-eval
+const script = eval(match[1]);
+const hash = createHash('sha256').update(script, 'utf8').digest('base64');
+console.log(`sha256-${hash}`);
diff --git a/web/src/App.svelte b/web/src/App.svelte
index 02ac464e..2c80d1e3 100644
--- a/web/src/App.svelte
+++ b/web/src/App.svelte
@@ -1,5 +1,5 @@
diff --git a/web/src/hosted/app/AppShell.svelte b/web/src/hosted/app/AppShell.svelte
index cce55f2b..4e8be8da 100644
--- a/web/src/hosted/app/AppShell.svelte
+++ b/web/src/hosted/app/AppShell.svelte
@@ -1,7 +1,6 @@
+
+
{#snippet actions()}
@@ -128,34 +243,62 @@
No account · {health.quotaLabel === 'unavailable' ? 'storage unavailable' : `${health.quotaLabel} available`}
+ {#if storageUnavailable}
+
+
+ This browser profile cannot store workspaces, so creating and importing are unavailable.
+ Check private-browsing or site-data settings, then reload.
+
+ {/if}
{
+ if (storageUnavailable) return;
+ onCreate();
+ }}
>
- One click · starts with untitled.md
- + New workspace
+ New workspace
+ One click · starts with untitled.md
fileInput?.click()}
+ aria-disabled={storageUnavailable}
+ aria-describedby={storageUnavailable ? 'storage-blocked-reason' : undefined}
+ onclick={() => {
+ if (storageUnavailable) return;
+ fileInput?.click();
+ }}
>
- Markdown, images, folders, or zip
- ↥ Import workspace
+ Import workspace
+ Markdown, images, folders, or zip
-
- Browser or native link
- ↗ Join a review
+
+ Join a review
+ Browser or native link
{#if joinOpen}
Join
Cancel
{#if joinError}
- {joinError}
+ {joinError}
{:else}
-
+
The part after # is the room key — it never reaches the relay.
{/if}
@@ -196,15 +341,44 @@
onchange={onFilesPicked}
/>
{#if importError}
-
+
Import failed: {importError}
{/if}
{#if workspaces.length > 0}
- Recently on this device
- {#each workspaces as workspace (workspace.id)}
-
+
+
+
Recently on this device
+
+ Filter workspaces
+
+ /
+
+
+
+ {filterQuery ? `${visibleWorkspaces.length} of ${workspaces.length} workspaces match` : ''}
+
+
+ {#each visibleWorkspaces as workspace, index (workspace.id)}
+
+
{#if renamingId === workspace.id}
{workspace.lastEditedLabel}
+
+
{workspace.sizeLabel}
+
+ {#if workspace.review && (workspace.review.pendingSuggestions > 0 || workspace.review.openComments > 0)}
+
+ {#if workspace.review.pendingSuggestions > 0}
+
+ {workspace.review.pendingSuggestions}
+ {workspace.review.pendingSuggestions === 1 ? 'suggestion' : 'suggestions'}
+
+ {/if}
+ {#if workspace.review.openComments > 0}
+
+ {/if}
+
+ {/if}
{#if workspace.sharing === 'shared'}
Shared · relay sees only ciphertext
{:else}
{sharingLabel(workspace.sharing)}
{/if}
- startRename(workspace)}>
- Rename
+
+ startRename(workspace)}
+ >
+
+
+
+
(confirmingDeleteId = workspace.id)}
+ title="Delete"
+ aria-label={`Delete ${workspace.name}`}
+ onclick={(event) => openDeleteConfirm(workspace.id, event.currentTarget)}
>
- Delete
+
+
+
+
+
+
{#if confirmingDeleteId === workspace.id}
-
+
+
Delete “{workspace.name}” from this device?
This cannot be undone. Export it first if you need a copy.
- (confirmingDeleteId = null)}>
+
Cancel
{
await onDelete(workspace.id);
confirmingDeleteId = null;
+ deleteTrigger = undefined;
}}
>
Delete workspace
@@ -269,17 +504,32 @@
{/if}
+
{/each}
+
{:else if !storageUnavailable}
-
Your first sheet
-
- UNTITLED.MD · NOT CREATED YET
- What deserves your attention?
-
+
Your first sheet
+
+
+ UNTITLED.MD · NOT CREATED YET
+ What deserves your attention?
+
Start with one blank Markdown file. It stays on this device — no account, no upload,
no naming step.
-
-
+
+
{/if}
diff --git a/web/src/hosted/app/EditorShell.svelte b/web/src/hosted/app/EditorShell.svelte
index c933a41a..5698c9a0 100644
--- a/web/src/hosted/app/EditorShell.svelte
+++ b/web/src/hosted/app/EditorShell.svelte
@@ -415,6 +415,40 @@
let lightboxClose = $state();
+ /* Publish the dock's REAL height as --dock-h (attn-n01r.3).
+ It was a hard-coded `calc(64px + env(safe-area-inset-bottom))` while the
+ rendered dock measured 61px at inset 0 — and the edit bar positions off the
+ token, not off the dock, so the two disagree by whatever the constant is
+ wrong by. On a device with a non-zero safe-area inset that error is
+ compounded, which is the reported gap between the formatting bar and the
+ dock.
+
+ Measuring removes the assumption rather than correcting the guess: it is
+ right at inset 0 and at inset 34, with the keyboard open or closed, and it
+ stays right if the dock's contents ever change height. */
+ function measureDock(node: HTMLElement): { destroy(): void } {
+ const publish = (): void => {
+ const height = node.getBoundingClientRect().height;
+ if (height > 0) {
+ document.documentElement.style.setProperty('--dock-h', `${height}px`);
+ }
+ };
+ publish();
+ const observer = new ResizeObserver(publish);
+ observer.observe(node);
+ // The inset itself can change (rotation, split view), and that resizes the
+ // dock, so the observer covers it — but orientation changes can land a frame
+ // early on iOS.
+ window.addEventListener('orientationchange', publish);
+ return {
+ destroy(): void {
+ observer.disconnect();
+ window.removeEventListener('orientationchange', publish);
+ document.documentElement.style.removeProperty('--dock-h');
+ },
+ };
+ }
+
// ————— iOS editing (attn-7xl.3.5) —————
// The formatting bar rides directly above the visual keyboard using
// visualViewport, never a guessed keyboard height.
@@ -2875,15 +2909,31 @@
onblur={() => void commitTitleRename()}
/>
{:else if editing}
+
+
+ {workspace.name}
+
{
titleValue = workspace.name;
renamingTitle = true;
}}
- >{workspace.name}
+ >
+
+
+
+
+
{:else}
+
editorRef?.toggleBulletList()}>••
editorRef?.undoStep()}>↺
editorRef?.redoStep()}>↻
- {saveState}
+
+
+ {#if saveState === 'Saving…'}
+
+
+
+ {:else if saveState === 'Saved on this device'}
+
+
+
+ {:else}
+
+
+
+
+ {/if}
+
{/if}
diff --git a/web/src/hosted/app/HostedDesktopWorkspaceFrame.svelte b/web/src/hosted/app/HostedDesktopWorkspaceFrame.svelte
index f0f08107..3f34feb9 100644
--- a/web/src/hosted/app/HostedDesktopWorkspaceFrame.svelte
+++ b/web/src/hosted/app/HostedDesktopWorkspaceFrame.svelte
@@ -1,6 +1,7 @@
{code}
-
- {copied ? 'Copied' : 'Copy'}
+
+
+ {#if copied}
+
+
+
+ {:else}
+
+
+
+
+ {/if}
+
+
+ {copied ? `Copied ${code}` : failed ? 'Copy failed. Select the command to copy it manually.' : ''}
+
diff --git a/web/src/hosted/landing/EntryStrip.svelte b/web/src/hosted/landing/EntryStrip.svelte
index c8cc7a68..4817b1a4 100644
--- a/web/src/hosted/landing/EntryStrip.svelte
+++ b/web/src/hosted/landing/EntryStrip.svelte
@@ -2,20 +2,33 @@
// Markup-only component; script block keeps svelte-check module typing.
+
diff --git a/web/src/hosted/landing/Footer.svelte b/web/src/hosted/landing/Footer.svelte
index 3276f91a..9fa06eaa 100644
--- a/web/src/hosted/landing/Footer.svelte
+++ b/web/src/hosted/landing/Footer.svelte
@@ -1,7 +1,42 @@
+
+
+ Start with one file.
+
+ No account, no upload, no naming step. It stays on this device until you decide otherwise.
+
+
+
+