import { PLAN_CONTENT_VERSION, createPlanBlockId, exceedsPlanBlockDepth, migratePlanContent, planContentSchema, type PlanArtboard, type PlanAnnotation, type PlanBlock, type PlanContent, type PlanContentInput, type PlanConnector, type PlanDiagramBlock, type PlanImageBlock, type PlanLegacyWireframeBlock, type PlanPrototype, type PlanPrototypeScreen, type PlanWireframeBlock, type PlanWireframeNode, type PlanWireframeSurface, type PlanWireframeRegion, type PlanVisualQuestion, } from "../shared/plan-content.js"; import type { PlanSection } from "../shared/types.js"; type SectionLike = Pick; /** Region-based wireframe data — the renderer's legacy fallback shape. */ type LegacyWireframeData = PlanLegacyWireframeBlock["data"]; export function parsePlanContent(value: unknown): PlanContent | null { if (!value) return null; // Drizzle returns a Buffer for any `content` row stored with BLOB affinity // (e.g. a raw SQL insert via readfile()/a Buffer instead of a JSON string). // Decode it to text so the JSON path below runs — otherwise the Buffer falls // through as an "object", migrate reads undefined version/blocks, and the // plan silently parses to an empty body with no warning. const source = value instanceof Uint8Array ? new TextDecoder().decode(value) : value; const parsedValue = typeof source === "string" ? (() => { try { return JSON.parse(source); } catch { return null; } })() : source; if (!parsedValue) return null; // Upgrade old/raw shapes (region wireframes -> legacy-wireframe, sketch-* -> // diagram, version backfill) before validating. Never lossily migrate. try { const migrated = migratePlanContent(parsedValue); const result = planContentSchema.safeParse( preSanitizePlanContentInput(migrated), ); if (result.success) return result.data; // Full-document parse failed. Attempt per-block salvage so one unknown or // malformed block does not blank the entire document. Validate each block // individually; replace failing blocks with a typed `unknown-block` // placeholder that carries the original type + error summary. The reader // renders these as "Unsupported block" cards so the rest of the document // remains visible. console.warn( "[plan-content] full parse failed; attempting per-block salvage:", result.error.issues.slice(0, 4), ); return parsePlanContentWithSalvage(migrated); } catch (error) { // Defense-in-depth: pathological input (e.g. deeply nested tabs) can overflow // the recursive schema/migration and throw a RangeError that safeParse does // NOT catch. Fail closed so the reading route shows a graceful fallback // instead of crashing the entire plan page. console.warn("[plan-content] errored while parsing stored content:", error); return null; } } /** * Per-block salvage fallback. When the full planContentSchema parse fails, * validate each block individually and substitute an `unknown-block` placeholder * (stored as a `callout` with a special marker in `data`) for any that fail. * This keeps N-1 good blocks visible instead of blanking the whole document. */ function parsePlanContentWithSalvage(migrated: unknown): PlanContent | null { if (!migrated || typeof migrated !== "object") return null; const raw = migrated as Record; // Check if the block tree is pathologically deep BEFORE attempting per-block // salvage. exceedsPlanBlockDepth walks the full container-block tree using // the same visit-budget as the schema's preflight preprocessor. When the // depth limit is exceeded the schema would replace ALL blocks with a single // sentinel placeholder; per-block salvage would turn that into a single // "Unsupported block" card, which is misleading. Bail closed instead. if (exceedsPlanBlockDepth(raw)) { console.warn("[plan-content] per-block salvage bailed: depth-exceeded"); return null; } // Validate the document envelope (version, title, brief, canvas, prototype) // independently of the blocks array so we keep the metadata even when blocks // fail. const envelopeResult = planContentSchema.safeParse( preSanitizePlanContentInput({ ...raw, blocks: [] }), ); const envelope = envelopeResult.success ? envelopeResult.data : ({ version: typeof raw.version === "number" ? raw.version : 1, title: typeof raw.title === "string" ? raw.title.slice(0, 240) : undefined, brief: typeof raw.brief === "string" ? raw.brief.slice(0, 4000) : undefined, blocks: [], } as PlanContent); // If the blocks field is not an array, the document structure itself is // unsalvageable — bail closed so we don't return an empty document. if (!Array.isArray(raw.blocks)) { console.warn( "[plan-content] per-block salvage bailed: blocks is not an array", ); return null; } // Validate each block individually; replace bad ones with a callout placeholder. const rawBlocks = raw.blocks; const salvaged: PlanBlock[] = rawBlocks .slice(0, 200) .map((rawBlock: unknown) => { const singleResult = planContentSchema.safeParse( preSanitizePlanContentInput({ ...raw, blocks: [rawBlock] }), ); if (singleResult.success && singleResult.data.blocks[0]) { return singleResult.data.blocks[0]; } // Replace with an `unknown-block` placeholder stored as a special callout. const rb = (rawBlock as Record | null) ?? {}; const originalType = typeof rb.type === "string" ? rb.type : "unknown"; const blockId = typeof rb.id === "string" && rb.id.length > 0 ? rb.id : `unknown-block-${Math.random().toString(36).slice(2, 9)}`; const errorSummary = singleResult.success ? "Block data was missing" : summarizePlanBlockValidationIssues( singleResult.error?.issues, originalType, ); return { id: blockId, type: "callout", title: typeof rb.title === "string" ? rb.title : undefined, // Embed a machine-readable marker so the client can render a better // "Unsupported block" card rather than a generic callout. data: { tone: "warning" as const, body: `​__unknown_block__:${originalType}\n${errorSummary}`, }, } satisfies PlanBlock; }); return sanitizePlanContent({ ...envelope, blocks: salvaged }); } function summarizePlanBlockValidationIssues( issues: unknown, originalType: string, ): string { if (!Array.isArray(issues) || issues.length === 0) return "Parse error"; const lines = issues .slice(0, 4) .map((issue) => { if (!issue || typeof issue !== "object") return null; const record = issue as { message?: unknown; path?: unknown }; const message = typeof record.message === "string" && record.message.trim() ? record.message.trim() : "Invalid input"; const path = formatValidationPath(record.path); return path ? `${path}: ${message}` : message; }) .filter(Boolean) as string[]; const summary = lines.join("\n").slice(0, 800) || "Parse error"; const hint = validationHintForIssues(issues, originalType); return hint ? `${summary}\n\n${hint}` : summary; } function formatValidationPath(path: unknown): string { if (!Array.isArray(path) || path.length === 0) return ""; const parts = path .filter((part) => typeof part === "string" || typeof part === "number") .map(String); if (parts.length >= 2 && parts[0] === "blocks" && parts[1] === "0") { parts.splice(0, 2); } return parts.length > 0 ? parts.join(".") : "block"; } function validationHintForIssues(issues: unknown[], originalType: string) { if (originalType !== "tabs") return ""; const serialized = JSON.stringify(issues); if ( serialized.includes('"line"') || serialized.includes('"lines"') || serialized.includes("annotations") ) { return 'Hint: nested diff and annotated-code annotations use `lines`, e.g. { lines: "3" }, not `line`.'; } return "Hint: each tab needs an id, label, and recursively valid child blocks."; } export function serializePlanContent(content: PlanContentInput): string { return JSON.stringify( sanitizePlanContent( planContentSchema.parse(preSanitizePlanContentInput(content)), ), ); } export function normalizePlanContent( content: PlanContentInput | undefined, options: { salvageInvalidBlocks?: boolean } = {}, ): PlanContent | null { if (!content) return null; const migrated = migratePlanContent(content); // Recaps degrade gracefully: rather than failing the whole import when one // block the agent authored is invalid, salvage per-block — keep the valid // blocks and substitute an "Unsupported block" placeholder for the bad ones // (same battle-tested path the read flow uses). A few imperfect blocks must // never sink an entire recap, which is informational. Plans stay strict. if (options.salvageInvalidBlocks) { const result = planContentSchema.safeParse( preSanitizePlanContentInput(migrated), ); if (result.success) return sanitizePlanContent(result.data); console.warn( "[plan-content] recap import: full parse failed; salvaging per-block so the rest publishes:", result.error.issues.slice(0, 4), ); return parsePlanContentWithSalvage(migrated); } return sanitizePlanContent(planContentSchema.parse(migrated)); } export function normalizePlanDesignContent( content: PlanContentInput | undefined, input: PlanDesignContentInput, ): PlanContent | null { const normalized = normalizePlanContent(content); if (!normalized) return null; const next = cloneJson(normalized); if (next.prototype) { next.prototype = sanitizePrototype({ ...next.prototype, screens: next.prototype.screens.map((screen) => ({ ...screen, renderMode: "design", })), }); } if (!next.prototype && next.canvas?.frames.length) { const prototype = createPrototypeFromPlanContent(next, { title: input.title, brief: input.brief, }); if (prototype) { next.prototype = sanitizePrototype({ ...prototype, screens: prototype.screens.map((screen) => ({ ...screen, renderMode: "design", })), }); } } if (!next.canvas && next.prototype) { next.canvas = prototypeToCanvas(next.prototype); } if (!next.canvas) { const fallback = createPlanDesignContent({ ...input, screens: [] }); next.canvas = fallback.canvas; next.prototype = fallback.prototype; } if (next.canvas) { next.canvas = { ...next.canvas, mode: "design", title: next.canvas.title ?? "Design Direction", design: mergeDesignMetadata(next.canvas.design, input), frames: next.canvas.frames.map((frame) => ({ ...frame, wireframe: frame.wireframe?.html ? { ...frame.wireframe, renderMode: "design" } : frame.wireframe, })), }; } return sanitizePlanContent( planContentSchema.parse({ ...next, blocks: next.blocks.map(forceDesignWireframeBlocks), }), ); } /* -------------------------------------------------------------------------- */ /* custom-html sanitization (defense in depth at the action boundary) */ /* -------------------------------------------------------------------------- */ /** * Tags that may NEVER survive in a stored custom-html fragment. The zod schema * already rejects these at validation time; this is a second, allowlist-style * pass so the value we persist (and later export) is structurally clean even if * validation is ever bypassed or relaxed. The in-app React path renders these * fragments in a sandboxed iframe; the export path shows escaped source. */ /** * Content-bearing dangerous elements: the whole element (open tag, body, close * tag) must go, not just the tags — otherwise script/style bodies leak through. */ const FORBIDDEN_ELEMENT = /<(script|style|iframe|object|embed|noscript|svg|math|applet|portal|frameset|marquee)\b[^>]*>[\s\S]*?<\/\s*\1\s*>/gi; /** Standalone / self-closing forbidden tags (e.g. , , dangling). */ const FORBIDDEN_TAG = /<\/?\s*(?:script|style|iframe|object|embed|link|meta|base|form|svg|math|noscript|frame|frameset|applet|portal|marquee)\b[^>]*>/gi; const DIAGRAM_FORBIDDEN_ELEMENT = /<(script|style|iframe|object|embed|noscript|math|foreignObject|applet|portal|frameset|marquee)\b[^>]*>[\s\S]*?<\/\s*\1\s*>/gi; const DIAGRAM_FORBIDDEN_TAG = /<\/?\s*(?:script|style|iframe|object|embed|link|meta|base|form|math|foreignObject|noscript|frame|frameset|applet|portal|marquee)\b[^>]*>/gi; /** Inline event handlers and javascript:/data: URLs in attributes. */ const FORBIDDEN_ATTR = /\son[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi; const FORBIDDEN_BOUND_ATTR = /\s(?::on[a-z][\w:-]*|x-bind:on[a-z][\w:-]*|:style|x-bind:style)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi; const DIAGRAM_FORBIDDEN_BOUND_ATTR = /\s(?:@[\w:.-]+|x-on:[\w:.-]+|:on[\w:.-]+|x-bind:on[\w:.-]+|:style|x-bind:style)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi; const URL_ATTR = /\s(?:href|src|xlink:href|srcdoc|action|formaction|data|background|poster|ping)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi; const STYLE_ATTR = /\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi; function decodeSafetyEntities(value: string): string { return value .replace(/&#(x[0-9a-f]+|\d+);?/gi, (_, code: string) => { const point = code.toLowerCase().startsWith("x") ? Number.parseInt(code.slice(1), 16) : Number.parseInt(code, 10); return Number.isFinite(point) ? String.fromCodePoint(point) : ""; }) .replace(/&(colon|tab|newline);/gi, (_, name: string) => { if (name.toLowerCase() === "colon") return ":"; if (name.toLowerCase() === "tab") return "\t"; return "\n"; }); } function decodeCssSafetyEscapes(value: string): string { return value.replace(/\\([0-9a-fA-F]{1,6}\s?|.)/g, (_match, escaped) => { const hex = String(escaped).match(/^[0-9a-fA-F]{1,6}/)?.[0]; if (hex) { const point = Number.parseInt(hex, 16); return Number.isFinite(point) ? String.fromCodePoint(point) : ""; } return String(escaped)[0] ?? ""; }); } const decodedSafetyText = (value: string) => decodeCssSafetyEscapes(decodeSafetyEntities(value)); const compactSafetyText = (value: string) => decodedSafetyText(value) .toLowerCase() .replace(/[\u0000-\u0020]+/g, ""); const unsafeViewportCssPattern = /(?:^|[;{\s])position\s*:\s*(?:fixed|sticky)\b|(?:^|[;{\s])z-index\s*:\s*[1-9]\d{4,}\b/i; function hasUnsafeUrl(value: string): boolean { const compact = compactSafetyText(value); return ( compact.startsWith("javascript:") || compact.startsWith("vbscript:") || compact.startsWith("data:text/html") || compact.startsWith("data:image/svg+xml") ); } function hasUnsafeStyle(value: string): boolean { const decoded = decodedSafetyText(value); const compact = compactSafetyText(value); return ( unsafeViewportCssPattern.test(decoded) || compact.includes("expression(") || compact.includes("javascript:") || compact.includes("vbscript:") || compact.includes("url(data:text/html") || compact.includes("url(data:image/svg+xml") ); } function cloneJson(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function sanitizeMaybeWireframeData(data: unknown) { if (!data || typeof data !== "object") return; const record = data as Record; if (typeof record.html === "string") { record.html = sanitizeCustomHtml(record.html); } if (typeof record.css === "string") { record.css = sanitizeCustomHtml(record.css); } } function sanitizeMaybeDiagramData(data: unknown) { if (!data || typeof data !== "object") return; const record = data as Record; if (typeof record.html === "string") { record.html = sanitizeDiagramHtml(record.html); } if (typeof record.css === "string") { record.css = sanitizeCustomHtml(record.css); } } function sanitizeMaybeQuestionPreviews(data: unknown) { if (!data || typeof data !== "object") return; const questions = (data as Record).questions; if (!Array.isArray(questions)) return; for (const question of questions) { if (!question || typeof question !== "object") continue; const options = (question as Record).options; if (!Array.isArray(options)) continue; for (const option of options) { if (!option || typeof option !== "object") continue; sanitizeMaybeWireframeData((option as Record).wireframe); sanitizeMaybeDiagramData((option as Record).diagram); } } } function sanitizeMaybeBlocks(blocks: unknown) { if (!Array.isArray(blocks)) return; for (const block of blocks) { if (!block || typeof block !== "object") continue; const record = block as Record; const data = record.data as Record | undefined; if (record.type === "custom-html" || record.type === "wireframe") { sanitizeMaybeWireframeData(data); } if (record.type === "diagram") { sanitizeMaybeDiagramData(data); } if (record.type === "question-form" || record.type === "visual-questions") { sanitizeMaybeQuestionPreviews(data); } if (record.type === "tabs" && data && Array.isArray(data.tabs)) { for (const tab of data.tabs) { if (!tab || typeof tab !== "object") continue; sanitizeMaybeBlocks((tab as Record).blocks); } } // `columns` is the recommended before/after recap primitive and nests child // blocks (commonly wireframes). It must recurse like `tabs` does — otherwise // a nested wireframe authored as a full HTML document never gets its // scaffold stripped and the whole columns block degrades to an "Unsupported // block" card at validation time. if (record.type === "columns" && data && Array.isArray(data.columns)) { for (const column of data.columns) { if (!column || typeof column !== "object") continue; sanitizeMaybeBlocks((column as Record).blocks); } } } } function preSanitizePlanContentInput(input: unknown): unknown { if (!input || typeof input !== "object") return input; const content = cloneJson(input) as Record; const prototype = content.prototype as Record | undefined; if (prototype && Array.isArray(prototype.screens)) { for (const screen of prototype.screens) { if (!screen || typeof screen !== "object") continue; const record = screen as Record; if (typeof record.html === "string") { record.html = sanitizeCustomHtml(record.html); } if (typeof record.css === "string") { record.css = sanitizeCustomHtml(record.css); } } } const canvas = content.canvas as Record | undefined; if (canvas && Array.isArray(canvas.frames)) { for (const frame of canvas.frames) { if (!frame || typeof frame !== "object") continue; sanitizeMaybeWireframeData((frame as Record).wireframe); } } sanitizeMaybeBlocks(content.blocks); return content; } /** * Coerce a full HTML document into a bounded fragment. Wireframe, custom-html, * and diagram blocks must be bounded fragments (the renderer owns the * surrounding document, theme, and styling), so the schema rejects any value * carrying document scaffolding. Agents frequently author one of these blocks * as a standalone page anyway; rather than degrade the whole block to an * "Unsupported block" card, drop the scaffold and keep the body content. The * `` is removed wholesale because its ` ${prototype} ${canvas}

${escapeHtml(planLabel)}

${escapeHtml(input.content.title || input.title)}

${escapeHtml(input.content.brief || input.brief)}

${blocks}
`; } function blockFromSection(section: SectionLike, index: number): PlanBlock { if (section.html?.trim()) { return { id: section.id || createPlanBlockId(section.title), type: "custom-html", title: section.title, data: { html: section.html, caption: section.body, }, }; } if (section.type === "implementation") { return { id: section.id || createPlanBlockId(section.title), type: "implementation-map", title: section.title, data: { files: [ { path: "repo/path.tsx", title: "Implementation detail", note: section.body || "Add concrete file and symbol notes here.", language: "tsx", }, ], }, }; } if (section.type === "wireframe" || section.type === "mockup") { return { id: section.id || createPlanBlockId(section.title), type: "wireframe", title: section.title, summary: section.body, data: createUiWireframeData({ title: section.title, description: section.body, viewport: index === 0 ? "desktop" : "phone", }), }; } if (section.type === "diagram") { return { id: section.id || createPlanBlockId(section.title), type: "diagram", title: section.title, data: createBasicDiagram(section.title, section.body), }; } if (section.type === "questions") { const questions = markdownLines(section.body); return { id: section.id || createPlanBlockId(section.title), type: "question-form", title: section.title, data: { submitLabel: "Send to agent", questions: (questions.length ? questions : [section.title]).map( (question, questionIndex) => ({ id: `question-${questionIndex + 1}`, title: question, mode: "freeform" as const, placeholder: "Answer to revise the plan...", }), ), }, }; } if (section.type === "decisions") { // Legacy "decisions" section → a decision-tone `callout` (the `decision` // block was retired). The section title is the question; each body line is an // option. const optionLines = markdownLines(section.body).map((line) => `- ${line}`); const body = [`**${section.title}**`, optionLines.join("\n")] .filter(Boolean) .join("\n\n") || section.title || "Decision"; return { id: section.id || createPlanBlockId(section.title), type: "callout", data: { tone: "decision", body }, }; } return { id: section.id || createPlanBlockId(section.title), type: "rich-text", title: section.title, editable: true, data: { markdown: section.body, }, }; } function findCanvas(blocks: PlanBlock[]): PlanContent["canvas"] | undefined { const frames = blocks .filter((block): block is PlanWireframeBlock | PlanLegacyWireframeBlock => { return block.type === "wireframe" || block.type === "legacy-wireframe"; }) .slice(0, 6) .map((block, index) => ({ id: `frame-${block.id}`, label: block.title || `Frame ${index + 1}`, blockId: block.id, ...(block.type === "wireframe" ? { surface: block.data.surface, wireframe: block.data } : { legacyWireframe: block.data }), })); if (frames.length === 0) return undefined; return { title: "Wireframes", frames, flow: frames.slice(0, -1).map((frame, index) => ({ from: frame.id, to: frames[index + 1]?.id ?? frame.id, label: `Step ${index + 1}`, })), }; } function createComponentContextWireframe(input: { title: string; brief: string; }): LegacyWireframeData { return { viewport: "desktop", template: "context-xray-app", caption: `Show ${input.title} in the surrounding app before reviewing focused component states.`, regions: [], }; } function createWireframeData(input: { title: string; description?: string; viewport?: "desktop" | "tablet" | "phone"; component?: boolean; }): LegacyWireframeData { const viewport = input.viewport ?? "desktop"; const title = compactLabel(input.title, 24); const description = compactLabel(input.description ?? "", 78); if (input.component) { const template = inferComponentWireframeTemplate(input); return { viewport, ...(template ? { template } : {}), caption: input.description, regions: template ? [] : createComponentWireframeRegions(input), }; } if (viewport === "phone") { return { viewport, caption: input.description, regions: [ { id: "phone-back", kind: "button", label: "Back", x: 9, y: 7, width: 18, height: 7, emphasis: true, }, { id: "phone-title", kind: "header", label: title, x: 32, y: 7, width: 34, height: 7, }, { id: "phone-menu", kind: "toolbar", label: "...", x: 76, y: 7, width: 12, height: 7, }, { id: "phone-filter-all", kind: "button", label: "All", x: 9, y: 20, width: 17, height: 8, }, { id: "phone-filter-active", kind: "button", label: "Active", x: 29, y: 20, width: 24, height: 8, }, { id: "phone-filter-done", kind: "button", label: "Done", x: 56, y: 20, width: 21, height: 8, }, { id: "phone-row-1", kind: "list", x: 9, y: 35, width: 80, height: 10, }, { id: "phone-row-2", kind: "list", x: 9, y: 49, width: 80, height: 10, }, { id: "phone-row-3", kind: "list", x: 9, y: 63, width: 80, height: 10, }, { id: "action", kind: "button", label: "+", x: 70, y: 82, width: 16, height: 9, emphasis: true, }, ], }; } return { viewport, caption: input.description, regions: [ { id: "chrome", kind: "header", label: title, x: 3, y: 4, width: 94, height: 8, }, { id: "nav", kind: "nav", label: "Workspace", x: 3, y: 12, width: 22, height: 78, }, { id: "nav-active", kind: "button", x: 6, y: 25, width: 16, height: 7, emphasis: true, }, { id: "nav-item-1", kind: "toolbar", x: 6, y: 37, width: 16, height: 6 }, { id: "nav-item-2", kind: "toolbar", x: 6, y: 48, width: 16, height: 6 }, { id: "nav-item-3", kind: "toolbar", x: 6, y: 59, width: 16, height: 6 }, { id: "title", kind: "header", label: title, x: 30, y: 18, width: 36, height: 8, }, { id: "summary", kind: "content", label: description, x: 30, y: 29, width: 50, height: 11, }, { id: "filter-all", kind: "button", label: "All", x: 30, y: 45, width: 9, height: 7, }, { id: "filter-active", kind: "button", label: "Active", x: 42, y: 45, width: 14, height: 7, }, { id: "filter-done", kind: "button", label: "Done", x: 59, y: 45, width: 13, height: 7, }, { id: "row-1", kind: "list", x: 30, y: 58, width: 62, height: 10 }, { id: "row-2", kind: "list", x: 30, y: 71, width: 62, height: 10 }, { id: "row-3", kind: "list", x: 30, y: 84, width: 62, height: 8 }, { id: "primary", kind: "button", label: "Primary", x: 82, y: 20, width: 12, height: 8, emphasis: true, }, ], }; } function compactLabel(value: string, maxLength: number) { const normalized = value.replace(/\s+/g, " ").trim(); if (normalized.length <= maxLength) return normalized; return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}...`; } function inferComponentWireframeTemplate(input: { title: string; description?: string; }): LegacyWireframeData["template"] | undefined { const text = `${input.title} ${input.description ?? ""}`.toLowerCase(); if (/\b(map view|treemap|token map|token distribution)\b/.test(text)) { return "context-xray-map"; } if ( /\b(expanded|segment detail|detail|pin\s*\/\s*evict|evict|tool result|user message)\b/.test( text, ) ) { return "context-xray-expanded"; } if ( /\b(chat cleanup|chat messages|composer|thinking|step chrome)\b/.test(text) ) { return "context-xray-chat-cleanup"; } if ( /\b(context\s*x-?ray|x-?ray|popover|usage|meter|list\/?map|conversation group)\b/.test( text, ) ) { return "context-xray-default"; } return undefined; } function createComponentWireframeRegions(input: { title: string; description?: string; }): PlanWireframeRegion[] { const text = `${input.title} ${input.description ?? ""}`.toLowerCase(); if (/\b(chat|message|composer|thinking)\b/.test(text)) { return [ componentShell(), { id: "messages", kind: "list", label: "Chat messages", x: 16, y: 16, width: 68, height: 40, emphasis: true, }, { id: "thinking-status", kind: "toolbar", label: "Thinking status", x: 16, y: 62, width: 38, height: 8, }, { id: "composer", kind: "input", label: "Composer", x: 16, y: 76, width: 68, height: 10, }, ]; } const looksLikeContextXRay = /\b(context\s*x-?ray|x-?ray|popover|usage|meter|list\/?map)\b/.test(text); if ( !looksLikeContextXRay && /\b(map|treemap|token distribution)\b/.test(text) ) { return [ componentShell(), { id: "map-title", kind: "header", label: "Map", x: 16, y: 13, width: 34, height: 9, emphasis: true, }, { id: "token-map", kind: "content", label: "Token map", x: 16, y: 29, width: 68, height: 36, emphasis: true, }, { id: "legend", kind: "toolbar", label: "Legend", x: 16, y: 72, width: 32, height: 8, }, { id: "selected-summary", kind: "content", label: "Selected 2.0k", x: 54, y: 72, width: 30, height: 8, }, ]; } if (/\b(expanded|segment|detail|pin|evict|protected)\b/.test(text)) { return [ componentShell(), { id: "segment-title", kind: "header", label: "Conversation", x: 16, y: 13, width: 40, height: 9, emphasis: true, }, { id: "segment-usage", kind: "toolbar", label: "2.0k protected", x: 58, y: 13, width: 26, height: 9, }, { id: "user-row", kind: "list", label: "User message", x: 16, y: 31, width: 68, height: 13, }, { id: "tool-row", kind: "list", label: "Tool result", x: 16, y: 50, width: 68, height: 13, }, { id: "pin-evict", kind: "button", label: "Pin / evict", x: 60, y: 72, width: 26, height: 9, emphasis: true, }, ]; } if (looksLikeContextXRay) { return [ componentShell(), { id: "xray-title", kind: "header", label: "Context X-Ray", x: 16, y: 13, width: 42, height: 9, emphasis: true, }, { id: "usage-meter", kind: "content", label: "2.0k used", x: 16, y: 30, width: 68, height: 18, }, { id: "view-toggle", kind: "toolbar", label: "List / Map", x: 16, y: 54, width: 36, height: 8, }, { id: "conversation-group", kind: "list", label: "Conversation", x: 16, y: 68, width: 68, height: 18, emphasis: true, }, { id: "row-action", kind: "button", label: "Pin", x: 68, y: 76, width: 14, height: 7, }, ]; } return [ componentShell(), { id: "title", kind: "header", label: input.title, x: 14, y: 12, width: 42, height: 9, emphasis: true, }, { id: "summary", kind: "content", x: 14, y: 28, width: 72, height: 18 }, { id: "controls", kind: "toolbar", x: 14, y: 52, width: 36, height: 8 }, { id: "content", kind: "list", x: 14, y: 66, width: 72, height: 20, emphasis: true, }, ]; } function componentShell(): PlanWireframeRegion { return { id: "shell", kind: "content", x: 9, y: 7, width: 82, height: 86 }; } function createBasicDiagram( title: string, body: string, ): PlanDiagramBlock["data"] { const labels = markdownLines(body).slice(0, 5); const nodes = (labels.length ? labels : [title, "Review", "Build", "Verify"]) .slice(0, 6) .map((label, index) => ({ id: `node-${index + 1}`, label, })); return { nodes, edges: nodes.slice(0, -1).map((node, index) => ({ from: node.id, to: nodes[index + 1]?.id ?? node.id, })), }; } function renderBlockHtml(block: PlanBlock): string { const title = block.title ? `

${escapeHtml(block.title)}

` : ""; if (block.type === "rich-text") { return `
${title}
${markdownToHtml(block.data.markdown)}
`; } if (block.type === "callout") { return ``; } if (block.type === "checklist") { return `
${title}
    ${block.data.items.map((item) => `
  • ${item.checked ? "[x]" : "[ ]"} ${escapeHtml(item.label)}
  • `).join("")}
`; } if (block.type === "table") { return `
${title}${block.data.columns.map((column) => ``).join("")}${block.data.rows.map((row) => `${row.map((cell) => ``).join("")}`).join("")}
${escapeHtml(column)}
${escapeHtml(cell)}
`; } if (block.type === "code-tabs") { return `
${title}
${block.data.tabs.map((tab) => `

${escapeHtml(tab.label)}

${escapeHtml(tab.code)}
`).join("")}
`; } if (block.type === "implementation-map") { return `
${title}
${block.data.files.map((file) => `

${escapeHtml(file.title || file.path)}

${escapeHtml(file.path)}

${escapeHtml(file.note)}

${file.snippet ? `
${escapeHtml(file.snippet)}
` : ""}
`).join("")}
`; } if (block.type === "legacy-wireframe") { return `
${title}${renderWireframeHtml(block.data)}
`; } if (block.type === "wireframe") { return `
${title}${renderKitWireframeHtml(block.data)}
`; } if (block.type === "diagram") { return `
${title}${renderDiagramHtml(block.data)}
`; } if (block.type === "image") { return renderImageHtml(block); } if (block.type === "tabs") { return `
${title}
${block.data.tabs.map((tab) => `

${escapeHtml(tab.label)}

${tab.blocks.map(renderBlockHtml).join("")}
`).join("")}
`; } if (block.type === "custom-html") { const source = [ block.data.css ? `` : "", block.data.html, ] .filter(Boolean) .join("\n"); return `
${title}

Custom HTML fragment. Plan renders this safely in a sandboxed iframe; standalone exports show the source instead of executing it.

${escapeHtml(source)}
${block.data.caption ? `

${escapeHtml(block.data.caption)}

` : ""}
`; } if (block.type === "question-form" || block.type === "visual-questions") { return `
${title}${block.data.questions.map((question, index) => `

${index + 1}. ${escapeHtml(question.title)}

${question.subtitle ? `

${escapeHtml(question.subtitle)}

` : ""}
${question.options?.map((option) => `${escapeHtml(option.label)}`).join("") ?? ""}
`).join("")}
`; } return ""; } function frameLegacyData(frame: PlanArtboard): LegacyWireframeData | undefined { return frame.legacyWireframe; } function renderPrototypeHtml(prototype: PlanPrototype): string { const initial = prototype.screens.find( (screen) => screen.id === prototype.initialScreenId, ) ?? prototype.screens[0]; if (!initial) return ""; const transitions = prototype.transitions ?? []; return `

Prototype

${escapeHtml(prototype.title ?? "Clickable prototype")}

${prototype.brief ? `

${escapeHtml(prototype.brief)}

` : ""}
${escapeHtml(initial.title ?? initial.id)}

Prototype HTML is shown as static source in standalone exports. Open the live plan to click through screens and add anchored comments.

${escapeHtml(initial.html)}
${ transitions.length > 0 ? `
    ${transitions .map( (transition) => `
  1. ${escapeHtml(transition.from)} to ${escapeHtml(transition.to)}${transition.label ? ` — ${escapeHtml(transition.label)}` : ""}${transition.trigger ? ` ${escapeHtml(transition.trigger)}` : ""}
  2. `, ) .join("")}
` : "" }
`; } function renderCanvasHtml(canvas: NonNullable): string { const layoutFrames = layoutCanvasFrames(canvas.frames); const frames = layoutFrames .map((frame) => { const legacy = frameLegacyData(frame); const inner = frame.wireframe ? renderKitWireframeHtml(frame.wireframe) : legacy ? renderWireframeHtml(legacy) : ""; return `

${escapeHtml(frame.label ?? "")}

${inner}
`; }) .join(""); const legacyNotes = (canvas.notes ?? []).map( (note) => ``, ); const annotations = (canvas.annotations ?? []).map( (annotation) => ``, ); const notes = [...legacyNotes, ...annotations].join(""); return `
${frames}${notes}
`; } function isPhoneFrame(frame: PlanArtboard): boolean { return ( frame.surface === "mobile" || frameLegacyData(frame)?.viewport === "phone" ); } function layoutCanvasFrames(frames: PlanArtboard[]): PlanArtboard[] { return frames.map((frame, index) => { const explicitSize = frame.width !== undefined || frame.height !== undefined; const isPhone = isPhoneFrame(frame); const width = frame.width ?? (isPhone ? 300 : index === 0 ? 640 : 560); const height = frame.height ?? (isPhone ? 520 : 420); if (frame.x !== undefined || frame.y !== undefined || explicitSize) { return { ...frame, width, height, x: frame.x ?? 80, y: frame.y ?? 80, }; } const desktopCountBefore = frames .slice(0, index) .filter((candidate) => !isPhoneFrame(candidate)).length; const phoneCountBefore = frames .slice(0, index) .filter((candidate) => isPhoneFrame(candidate)).length; return { ...frame, width, height, x: isPhone ? 760 + phoneCountBefore * 380 : 80 + desktopCountBefore * 700, y: isPhone ? 80 : 80 + Math.floor(desktopCountBefore / 2) * 520, }; }); } function renderWireframeHtml(data: LegacyWireframeData) { if (data.template) { return `
${renderWireframeTemplateHtml(data.template)}
`; } return `
${data.regions .map( (region) => `${region.label ? escapeHtml(region.label) : ""}`, ) .join("")}
`; } function renderWireframeTemplateHtml( template: NonNullable, ) { if (template === "context-xray-app") { return `
App shell
Chat thread
Agent sidebar
Context X-Ray popover${renderXRayMeterHtml(true)}
ListMap
Conversation
X-Ray
Thinking status
Composer
`; } if (template === "context-xray-expanded") { return `
Conversation
2.0k protected
User message
Tool result
Protected
Pin / evict
`; } if (template === "context-xray-map") { return `
Context X-Ray
Map
Token map2.0k selected
Legend
Selected 2.0k
`; } if (template === "context-xray-chat-cleanup") { return `
Chat messages
Thinking status
Composer
`; } return `
Context X-Ray
Pinned 0 · Evicted 0
${renderXRayMeterHtml(false)}
ListMap
Conversation2.0k
ConversationPin
`; } function renderXRayMeterHtml(compact: boolean) { return `
2.0k used${compact ? "" : "1% used · 198k free"}
`; } function renderDiagramHtml(data: PlanDiagramBlock["data"]) { if (data.html?.trim()) { return `
${data.css ? `` : ""} ${data.html} ${data.caption ? `

${escapeHtml(data.caption)}

` : ""}
`; } const nodes = data.nodes ?? []; const edges = data.edges ?? []; const positioned = nodes.map((node, index) => ({ ...node, x: node.x ?? 12 + index * (76 / Math.max(nodes.length - 1, 1)), y: node.y ?? 50, })); return ` ${edges .map((edge) => { const from = positioned.find((node) => node.id === edge.from); const to = positioned.find((node) => node.id === edge.to); if (!from || !to) return ""; return ``; }) .join("")} ${positioned .map( (node) => `${escapeHtml(node.label)}`, ) .join("")} `; } /* -------------------------------------------------------------------------- */ /* Kit-tree wireframe export (semantic flex tree -> inert HTML) */ /* -------------------------------------------------------------------------- */ function renderKitWireframeHtml(data: PlanWireframeBlock["data"]): string { const surface = escapeHtml(data.surface || "desktop"); const screen = data.screen.map(renderKitNodeHtml).join(""); const caption = data.caption ? `

${escapeHtml(data.caption)}

` : ""; return `
${screen}
${caption}`; } function renderKitNodeHtml(node: PlanWireframeNode): string { const el = escapeHtml(node.el); const classes = ["kit-node", `kit-${el}`]; if (node.tone) classes.push(`tone-${escapeHtml(node.tone)}`); if (node.active) classes.push("is-active"); if (node.emphasis) classes.push("is-emphasis"); if (node.done) classes.push("is-done"); const label = node.text ?? node.label ?? node.title ?? node.value ?? ""; const text = label ? escapeHtml(label) : ""; if (node.el === "lines") { const count = node.n ?? 2; const lines = Array.from({ length: Math.min(count, 8) }) .map(() => ``) .join(""); return `${lines}`; } if (node.el === "chips" && node.items?.length) { const chips = node.items .map( (item) => `${escapeHtml(item.label)}${item.count !== undefined ? ` ${item.count}` : ""}`, ) .join(""); return `
${chips}
`; } if (node.el === "kv" && node.rows?.length) { const rows = node.rows .map( (row) => `
${escapeHtml(row.k)}${escapeHtml(row.v)}
`, ) .join(""); return `
${rows}
`; } const children = node.children?.length ? node.children.map(renderKitNodeHtml).join("") : ""; return `
${text ? `${text}` : ""}${children}
`; } function renderImageHtml(block: PlanImageBlock): string { const title = block.title ? `

${escapeHtml(block.title)}

` : ""; const src = block.data.url; const alt = escapeHtml(block.data.alt); const caption = block.data.caption ? `

${escapeHtml(block.data.caption)}

` : ""; // Only inline a same-origin-safe src when an explicit url is present; asset // ids resolve in-app, so the standalone export shows a placeholder instead. const body = src ? `${alt}` : ``; return `
${title}${body}${caption}
`; } function previewToWireframe( preview: VisualQuestionPreview, label: string, ): PlanWireframeBlock["data"] | undefined { if (preview === "desktop" || preview === "mobile" || preview === "split") { // Visual-question previews use the lean kit tree so they validate against // the new wireframe model (region data is rejected there by design). return { surface: preview === "mobile" ? "mobile" : "desktop", screen: [ { el: "screen", children: [ { el: "title", script: true, text: label }, { el: "text", value: "Preview direction" }, { el: "row", children: [ { el: "btn", label: "Primary", solid: true }, { el: "btn", label: "Secondary" }, ], }, ], }, ], }; } return undefined; } function previewToDiagram(preview: VisualQuestionPreview, label: string) { if (preview === "flow" || preview === "diagram") { return createBasicDiagram(label, "Start\nChoose\nBuild"); } return undefined; } function defaultVisualQuestions(brief: string): VisualQuestionBuilderInput[] { return [ { id: "form-factor", type: "single", title: "What form factor should lead?", subtitle: "Where should the first design direction feel native?", options: [ { label: "Desktop web app", preview: "desktop" }, { label: "Mobile app", preview: "mobile" }, { label: "Both / responsive", recommended: true, preview: "split" }, { label: "Decide for me" }, ], }, { id: "aesthetic", type: "multi", title: "What aesthetic direction appeals?", subtitle: "Pick any signals worth exploring.", options: [ { label: "Calm and minimal" }, { label: "Dense and productive" }, { label: "Playful and colorful" }, { label: "Editorial / typographic" }, { label: "Sleek dark mode" }, ], }, { id: "scope", type: "freeform", title: "Anything the plan must include?", subtitle: brief, }, { id: "flow-complexity", type: "visual", title: "How complex should the flow be?", subtitle: "Choose how much canvas vs document detail the plan needs.", options: [ { label: "One polished path", description: "Fastest to approve with fewer branches.", preview: "flow", recommended: true, }, { label: "A few variations", description: "Useful when direction is fuzzy and tradeoffs matter.", preview: "diagram", }, ], }, ]; } function markdownLines(value: string) { return value .split(/\r?\n/) .map((line) => line .replace(/^[-*]\s+/, "") .replace(/^#+\s+/, "") .trim(), ) .filter(Boolean); } function markdownToHtml(value: string) { const lines = value.split(/\r?\n/); const html: string[] = []; let list: string[] = []; const flushList = () => { if (list.length === 0) return; html.push( `
    ${list.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
`, ); list = []; }; for (const rawLine of lines) { const line = rawLine.trim(); if (!line) { flushList(); continue; } const heading = /^(#{1,3})\s+(.+)$/.exec(line); if (heading) { flushList(); const level = Math.min(heading[1]?.length ?? 2, 3); html.push(`${escapeHtml(heading[2])}`); continue; } const listItem = /^[-*]\s+(.+)$/.exec(line); if (listItem?.[1]) { list.push(listItem[1]); continue; } flushList(); html.push(`

${escapeHtml(line)}

`); } flushList(); return html.join("\n"); } function slug(value: string) { return value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48); } function escapeHtml(value: unknown) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } const CONTENT_EXPORT_CSS = ` :root { color-scheme: light dark; --bg: #fbfaf8; --canvas: #f2f1ee; --paper: #ffffff; --line: #dedbd5; --text: #191918; --muted: #68645f; --accent: #3f7cff; --code-bg: #f4f4f2; --code-text: #242321; } @media (prefers-color-scheme: dark) { :root { --bg: #1f1e1d; --canvas: #1c1b1a; --paper: #22211f; --line: #393735; --text: #f3f2ef; --muted: #aaa6a0; --code-bg: #171615; --code-text: #f0efeb; } } * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.55; } main { width: min(1120px, calc(100vw - 48px)); margin: 0 auto; padding: 72px 0 96px; } .prototype-export { padding: 42px clamp(24px, 4vw, 64px); border-bottom: 1px solid var(--line); background: var(--paper); } .prototype-export-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; max-width: 1120px; margin: 0 auto 24px; } .prototype-export-header h2 { margin: 0 0 8px; } .prototype-export-header > span { border: 1px solid var(--line); border-radius: 999px; padding: 6px 12px; color: var(--muted); font-size: 12px; font-weight: 700; white-space: nowrap; } .prototype-export-screen { max-width: 1120px; margin: 0 auto; } .prototype-export-flow { max-width: 1120px; margin: 22px auto 0; color: var(--muted); } .prototype-export-flow span { color: var(--muted); } .canvas-export { height: 65vh; overflow: hidden; background-color: var(--canvas); background-image: linear-gradient(var(--line) 1px, transparent 1px), linear-gradient(90deg, var(--line) 1px, transparent 1px); background-size: 28px 28px; border-bottom: 1px solid var(--line); } .canvas-inner { position: relative; width: 2400px; height: 1400px; } .canvas-frame, .canvas-note { position: absolute; } .canvas-frame h3 { margin: 0 0 8px; font-size: 14px; } .canvas-note { width: 280px; color: var(--muted); } .hero { padding-bottom: 34px; border-bottom: 1px solid var(--line); } .kicker { color: var(--muted); font-size: 12px; font-weight: 760; letter-spacing: .12em; text-transform: uppercase; } h1 { margin: 0; max-width: 880px; font-size: clamp(42px, 5vw, 74px); line-height: .98; letter-spacing: -.03em; } .lede { max-width: 880px; color: var(--muted); font-size: 22px; } .plan-block, .callout { margin-top: 60px; padding-top: 34px; border-top: 1px solid var(--line); } h2 { margin: 0 0 18px; font-size: clamp(28px, 4vw, 44px); letter-spacing: -.025em; } h3 { margin: 0 0 10px; } .copy { max-width: 840px; color: var(--muted); font-size: 18px; } .sketch-wireframe { position: relative; height: 360px; border: 2px solid currentColor; border-radius: 18px; color: #eceae5; background: var(--paper); } .sketch-wireframe.phone { width: 260px; height: 480px; border-radius: 38px; } .sketch-wireframe.template { display: flex; color: var(--text); padding: 18px; font-family: "Excalifont", "Comic Sans MS", "Bradley Hand", cursive; } .wf-template { display: grid; width: 100%; height: 100%; min-height: 0; gap: 14px; } .wf-template.popover { grid-template-rows: auto auto auto 1fr; gap: 16px; padding: 10px; } .wf-template.popover.map { grid-template-rows: auto 1fr auto; } .wf-template.chat-cleanup { grid-template-rows: 1fr auto auto; padding: 10px; } .wf-box { min-width: 0; min-height: 0; border: 1.5px solid currentColor; border-radius: 12px; background: transparent; padding: 12px 14px; } .wf-box.emphasis { color: var(--accent); } .wf-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; } .wf-head > span { color: var(--muted); font: 700 11px Inter, sans-serif; } .wf-lines { display: flex; width: min(100%, 230px); flex-direction: column; gap: 4px; } .wf-lines i { display: block; width: 70%; height: 4px; border-radius: 999px; background: var(--line); opacity: .72; } .wf-lines i:nth-child(2) { width: 54%; } .wf-lines i:nth-child(3) { width: 34%; } .wf-meter { display: grid; gap: 9px; } .wf-meter > div { display: flex; justify-content: space-between; gap: 10px; } .wf-meter span { color: var(--muted); font: 700 11px Inter, sans-serif; } .wf-progress { height: 5px; overflow: hidden; border-radius: 999px; background: var(--line); opacity: .72; } .wf-progress i { display: block; width: 18%; height: 100%; background: var(--accent); } .wf-toggle { display: inline-flex; gap: 8px; } .wf-toggle b, .wf-pill { display: inline-flex; align-items: center; justify-content: center; min-height: 24px; border: 1.4px solid currentColor; border-radius: 999px; padding: 3px 10px 4px; font-size: 12px; white-space: nowrap; } .wf-toggle b:first-child { color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); } .wf-rowhead { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } .wf-rowhead span { color: var(--muted); font: 700 11px Inter, sans-serif; } .wf-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; } .wf-button { display: inline-flex; align-items: center; justify-content: center; border: 1.4px solid currentColor; border-radius: 10px; padding: 7px 12px; font-weight: 700; white-space: nowrap; } .wf-button.solid { color: #fff; border-color: var(--accent); background: var(--accent); } .wf-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; } .wf-map-area { display: grid; gap: 12px; } .wf-treemap { display: grid; min-height: 130px; grid-template-columns: 1.4fr .9fr .7fr; grid-template-rows: 1fr .8fr; gap: 8px; } .wf-treemap i { border-radius: 8px; background: color-mix(in srgb, var(--accent) 18%, transparent); outline: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); } .wf-treemap i:first-child { grid-row: span 2; background: color-mix(in srgb, var(--accent) 30%, transparent); } .wf-foot { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .wf-chat-thread { display: grid; align-content: center; gap: 28px; padding: 18px 20px; } .wf-bubble { display: grid; gap: 8px; } .wf-bubble.reply { width: 84%; margin-left: auto; } .wf-status { width: 48%; } .wf-composer { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 10px; } .wf-template.app .app-shell { display: grid; height: 100%; grid-template-rows: auto 1fr; gap: 12px; padding: 18px; } .wf-topbar { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 12px; } .wf-topbar i, .wf-composer i { display: block; height: 4px; border-radius: 999px; background: var(--line); } .wf-app-grid { display: grid; min-height: 0; grid-template-columns: minmax(0, 1fr) minmax(188px, 34%); grid-template-rows: minmax(0, 1fr) auto auto; gap: 14px 18px; } .wf-chat { display: grid; align-content: center; gap: 24px; padding: 22px 26px; } .wf-sidebar { display: grid; grid-row: 1 / span 3; grid-template-rows: auto 1fr auto; gap: 12px; } .wf-side-title { width: 78%; justify-self: end; } .wf-popover { display: grid; gap: 10px; background: var(--canvas); } .wf-sidebar > .wf-button { width: 84px; justify-self: end; } .sketch-region { position: absolute; border: 1.5px solid currentColor; border-radius: 10px; color: inherit; } .sketch-region.emphasis { border-color: var(--accent); } .sketch-diagram { width: 100%; max-width: 900px; min-height: 260px; color: #eceae5; } .sketch-diagram line { stroke: var(--accent); stroke-width: 1.7; stroke-linecap: round; } .sketch-diagram rect { fill: var(--paper); stroke: currentColor; stroke-width: 1.3; } .sketch-diagram text { fill: currentColor; font: 4px ui-sans-serif, system-ui; text-anchor: middle; dominant-baseline: middle; } .kit-wireframe { display: flex; flex-direction: column; gap: var(--kit-gap, 11px); min-height: 320px; padding: 16px; border: 1.4px solid var(--line); border-radius: 16px; background: var(--paper); color: var(--text); font-family: "Gaegu", "Excalifont", "Comic Sans MS", "Bradley Hand", cursive; } .kit-wireframe.surface-mobile { width: 300px; min-height: 560px; border-radius: 30px; margin: 0 auto; } .kit-wireframe .kit-node { display: flex; flex-direction: column; gap: 8px; min-width: 0; } .kit-wireframe .kit-row { flex-direction: row; align-items: center; gap: 10px; } .kit-wireframe .kit-sidebar { width: 30%; gap: 7px; } .kit-wireframe .kit-main { flex: 1; } .kit-wireframe .kit-title { font-family: "Caveat", "Gaegu", cursive; font-size: 24px; font-weight: 700; } .kit-wireframe .kit-text { display: block; } .kit-wireframe .kit-btn, .kit-wireframe .kit-pill, .kit-wireframe .kit-chip { display: inline-flex; align-items: center; gap: 6px; border: 1.3px solid currentColor; border-radius: 999px; padding: 3px 12px; font-size: 13px; width: fit-content; } .kit-wireframe .kit-btn.tone-accent, .kit-wireframe .kit-chip.is-active { color: var(--accent); border-color: var(--accent); } .kit-wireframe .kit-chips { flex-direction: row; flex-wrap: wrap; gap: 8px; } .kit-wireframe .kit-card, .kit-wireframe .kit-box, .kit-wireframe .kit-taskRow, .kit-wireframe .kit-field, .kit-wireframe .kit-searchBar { border: 1.3px solid var(--line); border-radius: 10px; padding: 10px 12px; } .kit-wireframe .kit-lines { gap: 5px; } .kit-wireframe .kit-lines i { display: block; height: 5px; border-radius: 999px; background: var(--line); opacity: .8; } .kit-wireframe .kit-lines i:nth-child(2) { width: 78%; } .kit-wireframe .kit-lines i:nth-child(3) { width: 56%; } .kit-wireframe .kit-divider { height: 1px; background: var(--line); margin: 4px 0; } .kit-wireframe .kit-kv-row { display: flex; justify-content: space-between; gap: 12px; } .kit-wireframe .tone-warn { color: var(--warn, #b5503a); } .kit-wireframe .tone-ok { color: var(--ok, #5b8c6e); } .kit-wireframe .tone-muted { color: var(--muted); } .plan-image { max-width: 100%; border: 1px solid var(--line); border-radius: 14px; } .plan-image.placeholder { display: grid; place-items: center; min-height: 220px; color: var(--muted); background: var(--code-bg); font-size: 14px; } .chips { display: flex; flex-wrap: wrap; gap: 8px; } .chips span { border: 1px solid var(--line); border-radius: 999px; padding: 6px 12px; color: var(--muted); } pre { overflow: auto; border: 1px solid var(--line); border-radius: 12px; background: var(--code-bg); padding: 16px; color: var(--code-text); } code { font-family: "SFMono-Regular", Consolas, monospace; } table { width: 100%; border-collapse: collapse; } th, td { border-bottom: 1px solid var(--line); padding: 10px; text-align: left; } `;