Add initial project structure with essential files

- Created .gitignore to exclude build artifacts and dependencies.
- Added index.html as the main entry point for the application.
- Included LICENSE file with Apache 2.0 terms.
- Initialized package.json and package-lock.json for project dependencies.
- Added pnpm-lock.yaml for package management.
- Created QUICKSTART.md for setup instructions.
- Added README.md and README.zh-CN.md for project documentation in English and Chinese.
This commit is contained in:
pftom
2026-04-28 12:25:59 +08:00
commit a98096a042
258 changed files with 67862 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
import { delimiter } from 'node:path';
import path from 'node:path';
const execFileP = promisify(execFile);
// Each entry defines how to invoke the agent in non-interactive "one-shot" mode.
// `buildArgs(prompt, imagePaths)` returns argv for the child process.
// `streamFormat` hints to the daemon how to interpret stdout:
// - 'claude-stream-json' : line-delimited JSON emitted by Claude Code's
// `--output-format stream-json`. Daemon parses it into typed events
// (text / thinking / tool_use / tool_result / status) for the UI.
// - 'plain' (default) : raw text, forwarded chunk-by-chunk.
export const AGENT_DEFS = [
{
id: 'claude',
name: 'Claude Code',
bin: 'claude',
versionArgs: ['--version'],
buildArgs: (prompt) => [
'-p',
prompt,
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
],
streamFormat: 'claude-stream-json',
},
{
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
versionArgs: ['--version'],
buildArgs: (prompt) => ['exec', prompt],
streamFormat: 'plain',
},
{
id: 'gemini',
name: 'Gemini CLI',
bin: 'gemini',
versionArgs: ['--version'],
buildArgs: (prompt) => ['-p', prompt],
streamFormat: 'plain',
},
{
id: 'opencode',
name: 'OpenCode',
bin: 'opencode',
versionArgs: ['--version'],
buildArgs: (prompt) => ['run', prompt],
streamFormat: 'plain',
},
{
id: 'cursor-agent',
name: 'Cursor Agent',
bin: 'cursor-agent',
versionArgs: ['--version'],
buildArgs: (prompt) => ['-p', prompt],
streamFormat: 'plain',
},
{
id: 'qwen',
name: 'Qwen Code',
bin: 'qwen',
versionArgs: ['--version'],
buildArgs: (prompt) => ['-p', prompt],
streamFormat: 'plain',
},
];
function resolveOnPath(bin) {
const exts =
process.platform === 'win32'
? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';')
: [''];
const dirs = (process.env.PATH || '').split(delimiter);
for (const dir of dirs) {
for (const ext of exts) {
const full = path.join(dir, bin + ext);
if (full && existsSync(full)) return full;
}
}
return null;
}
async function probe(def) {
const resolved = resolveOnPath(def.bin);
if (!resolved) return { ...stripFns(def), available: false };
let version = null;
try {
const { stdout } = await execFileP(resolved, def.versionArgs, { timeout: 3000 });
version = stdout.trim().split('\n')[0];
} catch {
// binary exists but --version failed; still mark available
}
return { ...stripFns(def), available: true, path: resolved, version };
}
function stripFns(def) {
const { buildArgs, ...rest } = def;
return rest;
}
export async function detectAgents() {
return Promise.all(AGENT_DEFS.map(probe));
}
export function getAgentDef(id) {
return AGENT_DEFS.find((a) => a.id === id) || null;
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Parses Claude Code's `--output-format stream-json --verbose
* --include-partial-messages` JSONL stream into a small set of UI-friendly
* events. The Claude stream is rich (message_start, content_block_start/stop,
* deltas, tool_use, tool_result, status, result) but the UI only needs to
* know five things:
*
* - status : high-level lifecycle ("initializing", "requesting",
* "thinking")
* - text_delta : assistant text chunk (gets fed to the artifact parser)
* - thinking_delta: extended-thinking chunk (shown in a collapsed block)
* - tool_use : { id, name, input } (fires when input is complete)
* - tool_result : { tool_use_id, content, is_error }
* - usage : aggregated input/output/cache tokens + cost
*
* Callers give us `onEvent({ type, ...payload })`. We track per-content-block
* state to accumulate partial tool_use input JSON and emit a single
* `tool_use` event when that block stops.
*/
export function createClaudeStreamHandler(onEvent) {
let buffer = '';
// Per-content-block scratch, keyed by `${messageId}:${blockIndex}`.
const blocks = new Map();
// Most recent assistant message id so content_block_* events without an id
// can be attributed correctly.
let currentMessageId = null;
function blockKey(index) {
return `${currentMessageId ?? 'anon'}:${index}`;
}
function feed(chunk) {
buffer += chunk;
let nl;
while ((nl = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
let obj;
try {
obj = JSON.parse(line);
} catch {
onEvent({ type: 'raw', line });
continue;
}
handleObject(obj);
}
}
function flush() {
const rem = buffer.trim();
buffer = '';
if (!rem) return;
try {
handleObject(JSON.parse(rem));
} catch {
onEvent({ type: 'raw', line: rem });
}
}
function handleObject(obj) {
if (!obj || typeof obj !== 'object') return;
if (obj.type === 'system' && obj.subtype === 'init') {
onEvent({
type: 'status',
label: 'initializing',
model: obj.model ?? null,
sessionId: obj.session_id ?? null,
});
return;
}
if (obj.type === 'system' && obj.subtype === 'status') {
onEvent({ type: 'status', label: obj.status ?? 'working' });
return;
}
if (obj.type === 'stream_event' && obj.event) {
handleStreamEvent(obj.event);
return;
}
// `assistant` messages are the "block finished" signal for the current
// content block. For tool_use blocks whose input finished assembling,
// emit tool_use now with the final parsed input.
if (obj.type === 'assistant' && obj.message?.content) {
currentMessageId = obj.message.id ?? currentMessageId;
for (const block of obj.message.content) {
if (block.type === 'tool_use') {
onEvent({
type: 'tool_use',
id: block.id,
name: block.name,
input: block.input ?? null,
});
}
}
return;
}
// `user` messages in a stream-json transcript are usually tool_result
// wrappers from prior turns.
if (obj.type === 'user' && obj.message?.content) {
for (const block of obj.message.content) {
if (block.type === 'tool_result') {
onEvent({
type: 'tool_result',
toolUseId: block.tool_use_id,
content: stringifyToolResult(block.content),
isError: Boolean(block.is_error),
});
}
}
return;
}
if (obj.type === 'result') {
onEvent({
type: 'usage',
usage: obj.usage ?? null,
costUsd: obj.total_cost_usd ?? null,
durationMs: obj.duration_ms ?? null,
stopReason: obj.stop_reason ?? null,
});
return;
}
}
function handleStreamEvent(ev) {
if (ev.type === 'message_start') {
currentMessageId = ev.message?.id ?? null;
if (typeof ev.ttft_ms === 'number') {
onEvent({ type: 'status', label: 'streaming', ttftMs: ev.ttft_ms });
}
return;
}
if (ev.type === 'content_block_start' && ev.content_block) {
const key = blockKey(ev.index);
const block = ev.content_block;
blocks.set(key, { type: block.type, name: block.name, id: block.id, input: '' });
if (block.type === 'thinking') {
onEvent({ type: 'thinking_start' });
}
return;
}
if (ev.type === 'content_block_delta' && ev.delta) {
const state = blocks.get(blockKey(ev.index));
const delta = ev.delta;
if (delta.type === 'text_delta' && typeof delta.text === 'string') {
onEvent({ type: 'text_delta', delta: delta.text });
return;
}
if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') {
onEvent({ type: 'thinking_delta', delta: delta.thinking });
return;
}
if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
if (state && state.type === 'tool_use') {
state.input += delta.partial_json;
}
return;
}
}
if (ev.type === 'content_block_stop') {
blocks.delete(blockKey(ev.index));
return;
}
}
return { feed, flush };
}
function stringifyToolResult(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((c) => (c?.type === 'text' ? c.text : JSON.stringify(c)))
.join('\n');
}
return JSON.stringify(content);
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
import { startServer } from './server.js';
const args = process.argv.slice(2);
let port = Number(process.env.OCD_PORT) || 7456;
let open = true;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '-p' || a === '--port') {
port = Number(args[++i]);
} else if (a === '--no-open') {
open = false;
} else if (a === '-h' || a === '--help') {
console.log(`Usage: ocd [--port <n>] [--no-open]
Starts a local daemon that:
* scans PATH for installed code-agent CLIs (claude, codex, gemini, opencode, cursor-agent, ...)
* serves a tiny web chat UI at http://localhost:<port>
* proxies messages (text + images) to the selected agent via child-process spawn
`);
process.exit(0);
}
}
startServer({ port }).then(url => {
console.log(`[ocd] listening on ${url}`);
if (open) {
const opener = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'start'
: 'xdg-open';
import('node:child_process').then(({ spawn }) => {
spawn(opener, [url], { detached: true, stdio: 'ignore' }).unref();
});
}
});
+465
View File
@@ -0,0 +1,465 @@
// SQLite-backed persistence for projects, conversations, messages, and the
// per-project set of open file tabs. The on-disk project folder under
// .ocd/projects/<id>/ is still the single owner of the user's actual files
// (HTML artifacts, sketches, uploads); this database tracks the metadata
// that used to live in localStorage.
import Database from 'better-sqlite3';
import path from 'node:path';
import fs from 'node:fs';
let dbInstance = null;
export function openDatabase(projectRoot) {
if (dbInstance) return dbInstance;
const dir = path.join(projectRoot, '.ocd');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'app.sqlite');
const db = new Database(file);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
migrate(db);
dbInstance = db;
return db;
}
function migrate(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
skill_id TEXT,
design_system_id TEXT,
pending_prompt TEXT,
metadata_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
source_project_id TEXT,
files_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
title TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_conv_project
ON conversations(project_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
events_json TEXT,
attachments_json TEXT,
produced_files_json TEXT,
started_at INTEGER,
ended_at INTEGER,
position INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_conv
ON messages(conversation_id, position);
CREATE TABLE IF NOT EXISTS tabs (
project_id TEXT NOT NULL,
name TEXT NOT NULL,
position INTEGER NOT NULL,
is_active INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(project_id, name),
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tabs_project
ON tabs(project_id, position);
`);
// Forward-compatible column add for databases created before metadata_json.
// SQLite has no IF NOT EXISTS for ALTER, so we check pragma_table_info.
const cols = db.prepare(`PRAGMA table_info(projects)`).all();
if (!cols.some((c) => c.name === 'metadata_json')) {
db.exec(`ALTER TABLE projects ADD COLUMN metadata_json TEXT`);
}
}
// ---------- projects ----------
const PROJECT_COLS = `id, name, skill_id AS skillId,
design_system_id AS designSystemId,
pending_prompt AS pendingPrompt,
metadata_json AS metadataJson,
created_at AS createdAt,
updated_at AS updatedAt`;
export function listProjects(db) {
const rows = db
.prepare(
`SELECT ${PROJECT_COLS}
FROM projects
ORDER BY updated_at DESC`,
)
.all();
return rows.map(normalizeProject);
}
export function getProject(db, id) {
const row = db
.prepare(`SELECT ${PROJECT_COLS} FROM projects WHERE id = ?`)
.get(id);
return row ? normalizeProject(row) : null;
}
export function insertProject(db, p) {
db.prepare(
`INSERT INTO projects
(id, name, skill_id, design_system_id, pending_prompt,
metadata_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
p.id,
p.name,
p.skillId ?? null,
p.designSystemId ?? null,
p.pendingPrompt ?? null,
p.metadata ? JSON.stringify(p.metadata) : null,
p.createdAt,
p.updatedAt,
);
return getProject(db, p.id);
}
export function updateProject(db, id, patch) {
const existing = getProject(db, id);
if (!existing) return null;
const merged = {
...existing,
...patch,
updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(),
};
db.prepare(
`UPDATE projects
SET name = ?,
skill_id = ?,
design_system_id = ?,
pending_prompt = ?,
metadata_json = ?,
updated_at = ?
WHERE id = ?`,
).run(
merged.name,
merged.skillId ?? null,
merged.designSystemId ?? null,
merged.pendingPrompt ?? null,
merged.metadata ? JSON.stringify(merged.metadata) : null,
merged.updatedAt,
id,
);
return getProject(db, id);
}
export function deleteProject(db, id) {
db.prepare(`DELETE FROM projects WHERE id = ?`).run(id);
}
function normalizeProject(row) {
let metadata;
if (row.metadataJson) {
try {
metadata = JSON.parse(row.metadataJson);
} catch {
metadata = undefined;
}
}
return {
id: row.id,
name: row.name,
skillId: row.skillId,
designSystemId: row.designSystemId,
pendingPrompt: row.pendingPrompt ?? undefined,
metadata,
createdAt: Number(row.createdAt),
updatedAt: Number(row.updatedAt),
};
}
// ---------- templates ----------
export function listTemplates(db) {
return db
.prepare(
`SELECT id, name, description, source_project_id AS sourceProjectId,
files_json AS filesJson, created_at AS createdAt
FROM templates
ORDER BY created_at DESC`,
)
.all()
.map(normalizeTemplate);
}
export function getTemplate(db, id) {
const row = db
.prepare(
`SELECT id, name, description, source_project_id AS sourceProjectId,
files_json AS filesJson, created_at AS createdAt
FROM templates WHERE id = ?`,
)
.get(id);
return row ? normalizeTemplate(row) : null;
}
export function insertTemplate(db, t) {
db.prepare(
`INSERT INTO templates (id, name, description, source_project_id, files_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
t.id,
t.name,
t.description ?? null,
t.sourceProjectId ?? null,
JSON.stringify(t.files ?? []),
t.createdAt,
);
return getTemplate(db, t.id);
}
export function deleteTemplate(db, id) {
db.prepare(`DELETE FROM templates WHERE id = ?`).run(id);
}
function normalizeTemplate(row) {
let files = [];
try {
files = JSON.parse(row.filesJson || '[]');
} catch {
files = [];
}
return {
id: row.id,
name: row.name,
description: row.description ?? undefined,
sourceProjectId: row.sourceProjectId ?? undefined,
files,
createdAt: Number(row.createdAt),
};
}
// ---------- conversations ----------
export function listConversations(db, projectId) {
return db
.prepare(
`SELECT id, project_id AS projectId, title,
created_at AS createdAt, updated_at AS updatedAt
FROM conversations
WHERE project_id = ?
ORDER BY updated_at DESC`,
)
.all(projectId)
.map((r) => ({
id: r.id,
projectId: r.projectId,
title: r.title ?? null,
createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt),
}));
}
export function getConversation(db, id) {
const r = db
.prepare(
`SELECT id, project_id AS projectId, title,
created_at AS createdAt, updated_at AS updatedAt
FROM conversations WHERE id = ?`,
)
.get(id);
if (!r) return null;
return {
id: r.id,
projectId: r.projectId,
title: r.title ?? null,
createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt),
};
}
export function insertConversation(db, c) {
db.prepare(
`INSERT INTO conversations
(id, project_id, title, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`,
).run(c.id, c.projectId, c.title ?? null, c.createdAt, c.updatedAt);
return getConversation(db, c.id);
}
export function updateConversation(db, id, patch) {
const existing = getConversation(db, id);
if (!existing) return null;
const merged = {
...existing,
...patch,
updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(),
};
db.prepare(
`UPDATE conversations
SET title = ?, updated_at = ? WHERE id = ?`,
).run(merged.title ?? null, merged.updatedAt, id);
return getConversation(db, id);
}
export function deleteConversation(db, id) {
db.prepare(`DELETE FROM conversations WHERE id = ?`).run(id);
}
// ---------- messages ----------
export function listMessages(db, conversationId) {
return db
.prepare(
`SELECT id, role, content, events_json AS eventsJson,
attachments_json AS attachmentsJson,
produced_files_json AS producedFilesJson,
started_at AS startedAt, ended_at AS endedAt,
position
FROM messages
WHERE conversation_id = ?
ORDER BY position ASC`,
)
.all(conversationId)
.map(normalizeMessage);
}
export function upsertMessage(db, conversationId, m) {
const existing = db
.prepare(`SELECT position FROM messages WHERE id = ?`)
.get(m.id);
const now = Date.now();
if (existing) {
db.prepare(
`UPDATE messages
SET role = ?, content = ?, events_json = ?, attachments_json = ?,
produced_files_json = ?, started_at = ?, ended_at = ?
WHERE id = ?`,
).run(
m.role,
m.content,
m.events ? JSON.stringify(m.events) : null,
m.attachments ? JSON.stringify(m.attachments) : null,
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
m.startedAt ?? null,
m.endedAt ?? null,
m.id,
);
} else {
const max = db
.prepare(
`SELECT COALESCE(MAX(position), -1) AS m FROM messages WHERE conversation_id = ?`,
)
.get(conversationId);
const position = (max?.m ?? -1) + 1;
db.prepare(
`INSERT INTO messages
(id, conversation_id, role, content, events_json,
attachments_json, produced_files_json,
started_at, ended_at, position, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
m.id,
conversationId,
m.role,
m.content,
m.events ? JSON.stringify(m.events) : null,
m.attachments ? JSON.stringify(m.attachments) : null,
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
m.startedAt ?? null,
m.endedAt ?? null,
position,
now,
);
}
// Bump conversation activity so the sidebar's recency sort works.
db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`).run(
now,
conversationId,
);
const row = db
.prepare(
`SELECT id, role, content, events_json AS eventsJson,
attachments_json AS attachmentsJson,
produced_files_json AS producedFilesJson,
started_at AS startedAt, ended_at AS endedAt,
position
FROM messages WHERE id = ?`,
)
.get(m.id);
return row ? normalizeMessage(row) : null;
}
export function deleteMessage(db, id) {
db.prepare(`DELETE FROM messages WHERE id = ?`).run(id);
}
function normalizeMessage(row) {
return {
id: row.id,
role: row.role,
content: row.content,
events: parseJsonOrUndef(row.eventsJson),
attachments: parseJsonOrUndef(row.attachmentsJson),
producedFiles: parseJsonOrUndef(row.producedFilesJson),
startedAt: row.startedAt ?? undefined,
endedAt: row.endedAt ?? undefined,
};
}
function parseJsonOrUndef(s) {
if (!s) return undefined;
try {
return JSON.parse(s);
} catch {
return undefined;
}
}
// ---------- tabs ----------
export function listTabs(db, projectId) {
const rows = db
.prepare(
`SELECT name, position, is_active AS isActive
FROM tabs WHERE project_id = ? ORDER BY position ASC`,
)
.all(projectId);
const active = rows.find((r) => r.isActive) ?? null;
return {
tabs: rows.map((r) => r.name),
active: active ? active.name : null,
};
}
export function setTabs(db, projectId, names, activeName) {
const tx = db.transaction(() => {
db.prepare(`DELETE FROM tabs WHERE project_id = ?`).run(projectId);
const ins = db.prepare(
`INSERT INTO tabs (project_id, name, position, is_active)
VALUES (?, ?, ?, ?)`,
);
names.forEach((name, i) => {
ins.run(projectId, name, i, name === activeName ? 1 : 0);
});
});
tx();
return listTabs(db, projectId);
}
+619
View File
@@ -0,0 +1,619 @@
/**
* Build a showcase HTML page from a DESIGN.md so the user can see what each
* design system looks like *before* generating anything. We don't try to
* render a unique product mockup — we extract the palette, typography, and
* a couple of component conventions, then drop them into one fixed
* template. The full DESIGN.md is rendered below as prose for reference.
*
* Parsing is deliberately permissive: imported systems vary in section
* naming and bullet style, so we use loose regexes and fall back to sane
* defaults when a token isn't found.
*/
export function renderDesignSystemPreview(id, raw) {
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
const title = cleanTitle(titleMatch?.[1] ?? id);
const subtitle = extractSubtitle(raw);
const colors = extractColors(raw);
const fonts = extractFonts(raw);
const bg =
pickColor(colors, ['page background', 'background', 'canvas', 'paper', 'bg ', 'page bg'])
?? pickColor(colors, ['white'])
?? '#ffffff';
const fg =
pickColor(colors, ['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite'])
?? '#111111';
// Accent: brand/primary names first, then fall back to the first color
// that doesn't look like a neutral white/black/grey so we always show
// something punchy in the showcase header.
const accent =
pickColor(colors, ['primary brand', 'brand primary', 'primary', 'brand', 'accent'])
?? firstNonNeutral(colors)
?? '#2f6feb';
const muted = pickColor(colors, ['muted', 'secondary', 'neutral', 'subtle', 'caption']) ?? '#777777';
const border = pickColor(colors, ['border', 'divider', 'rule', 'stroke']) ?? '#e5e5e5';
const surface =
pickColor(colors, ['surface', 'card', 'background-secondary', 'panel', 'elevated'])
?? '#ffffff';
const display = fonts.display
?? fonts.heading
?? "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
const body = fonts.body ?? display;
const mono = fonts.mono ?? "ui-monospace, 'JetBrains Mono', monospace";
const renderedMarkdown = renderMarkdownLite(raw);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)} — design system preview</title>
<style>
:root {
--bg: ${bg};
--fg: ${fg};
--accent: ${accent};
--muted: ${muted};
--border: ${border};
--surface: ${surface};
--display: ${display};
--body: ${body};
--mono: ${mono};
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--body);
line-height: 1.55;
font-size: 16px;
}
.wrap { max-width: 960px; margin: 0 auto; padding: 56px 32px 96px; }
.badge {
display: inline-block;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 4px 10px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
margin-bottom: 24px;
}
h1 {
font-family: var(--display);
font-size: clamp(40px, 6vw, 72px);
line-height: 1.05;
letter-spacing: -0.02em;
margin: 0 0 16px;
}
.lede {
max-width: 60ch;
font-size: 18px;
color: var(--muted);
margin: 0 0 56px;
}
section { margin-bottom: 72px; }
.section-title {
font-family: var(--display);
font-size: 22px;
font-weight: 600;
margin: 0 0 16px;
letter-spacing: -0.01em;
}
.palette {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
}
.swatch {
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
background: var(--surface);
}
.swatch .chip {
height: 96px;
}
.swatch .meta {
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.swatch .name { font-size: 13px; font-weight: 500; }
.swatch .hex { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.typo-row {
display: grid;
grid-template-columns: 88px 1fr;
gap: 24px;
padding: 18px 0;
border-top: 1px solid var(--border);
}
.typo-row:first-child { border-top: none; padding-top: 0; }
.typo-row .label {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
padding-top: 4px;
}
.typo-display { font-family: var(--display); font-size: 40px; line-height: 1.1; letter-spacing: -0.02em; }
.typo-body { font-family: var(--body); font-size: 16px; }
.typo-mono { font-family: var(--mono); font-size: 14px; color: var(--muted); }
.components {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 640px) { .components { grid-template-columns: 1fr; } }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
}
.card .eyebrow {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
margin-bottom: 8px;
}
.card h3 {
font-family: var(--display);
font-size: 20px;
margin: 0 0 8px;
letter-spacing: -0.01em;
}
.card p { margin: 0; color: var(--muted); }
.btn-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
button {
font: inherit;
cursor: pointer;
border-radius: 8px;
padding: 10px 18px;
}
.btn-primary {
background: var(--accent);
color: ${pickReadableForeground(accent)};
border: 1px solid var(--accent);
}
.btn-secondary {
background: transparent;
color: var(--fg);
border: 1px solid var(--border);
}
.btn-link {
background: transparent;
border: none;
color: var(--accent);
padding: 10px 0;
font-weight: 500;
}
.prose {
border-top: 1px solid var(--border);
padding-top: 32px;
color: var(--fg);
}
.prose h1, .prose h2, .prose h3 { font-family: var(--display); letter-spacing: -0.01em; }
.prose h1 { font-size: 28px; margin-top: 0; }
.prose h2 { font-size: 20px; margin-top: 32px; }
.prose h3 { font-size: 16px; margin-top: 24px; }
.prose p, .prose ul, .prose ol { margin: 12px 0; }
.prose code { font-family: var(--mono); background: var(--surface); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; font-size: 0.92em; }
.prose blockquote { margin: 16px 0; padding: 8px 16px; border-left: 3px solid var(--accent); color: var(--muted); }
.prose ul, .prose ol { padding-left: 22px; }
.prose pre { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.55; }
.prose pre code { background: transparent; border: none; padding: 0; font-size: inherit; }
.prose hr { border: none; border-top: 1px solid var(--border); margin: 28px 0; }
.prose a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; }
.prose a:hover { border-bottom-color: var(--accent); }
.prose img { max-width: 100%; height: auto; border-radius: 6px; }
.prose .table-wrap { overflow-x: auto; margin: 18px 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
.prose table { width: 100%; border-collapse: collapse; font-size: 13.5px; line-height: 1.5; }
.prose th, .prose td { padding: 9px 14px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border); }
.prose th { background: var(--bg); font-weight: 600; font-size: 12px; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); }
.prose tr:last-child td { border-bottom: none; }
.prose td code, .prose th code { white-space: nowrap; }
.prose td[align="right"], .prose th[align="right"] { text-align: right; }
.prose td[align="center"], .prose th[align="center"] { text-align: center; }
</style>
</head>
<body>
<main class="wrap">
<span class="badge">Design system preview · ${escapeHtml(id)}</span>
<h1>${escapeHtml(title)}</h1>
${subtitle ? `<p class="lede">${escapeHtml(subtitle)}</p>` : ''}
<section>
<h2 class="section-title">Palette</h2>
<div class="palette">
${colors
.slice(0, 12)
.map(
(c) => `<div class="swatch">
<div class="chip" style="background:${c.value};"></div>
<div class="meta">
<span class="name">${escapeHtml(c.name)}</span>
<span class="hex">${escapeHtml(c.value)}</span>
</div>
</div>`,
)
.join('')}
</div>
</section>
<section>
<h2 class="section-title">Typography</h2>
<div class="typo-row">
<span class="label">Display</span>
<div class="typo-display">The grid carries weight; the line carries pace.</div>
</div>
<div class="typo-row">
<span class="label">Body</span>
<div class="typo-body">Body copy reads at sixteen pixels with a 1.55 leading. Restraint and rhythm matter more than novelty — pick a stack that earns the page.</div>
</div>
<div class="typo-row">
<span class="label">Mono</span>
<div class="typo-mono">/* monospace · ${escapeHtml(mono.split(',')[0]?.replace(/['"]/g, '').trim() ?? 'mono')} */</div>
</div>
</section>
<section>
<h2 class="section-title">Components</h2>
<div class="components">
<div class="card">
<div class="eyebrow">Card</div>
<h3>Production-quality artifact</h3>
<p>Sample card showing how surfaces, borders, and accent text behave in this system.</p>
</div>
<div class="card">
<div class="eyebrow">Buttons</div>
<h3>Three weights, one accent</h3>
<div class="btn-row" style="margin-top: 12px;">
<button class="btn-primary">Primary</button>
<button class="btn-secondary">Secondary</button>
<button class="btn-link">Link →</button>
</div>
</div>
</div>
</section>
<section class="prose">
${renderedMarkdown}
</section>
</main>
</body>
</html>`;
}
function extractSubtitle(raw) {
const lines = raw.split(/\r?\n/);
const h1 = lines.findIndex((l) => /^#\s+/.test(l));
if (h1 === -1) return '';
const after = lines.slice(h1 + 1);
const nextHeading = after.findIndex((l) => /^#{1,6}\s+/.test(l));
const window = (nextHeading === -1 ? after : after.slice(0, nextHeading))
.join('\n')
.replace(/^>\s*Category:.*$/gim, '')
.replace(/^>\s*/gm, '')
.trim();
return window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
}
function extractColors(raw) {
const colors = [];
const seen = new Set();
function push(name, value) {
const cleanName = name.replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim();
if (!cleanName || cleanName.length > 60) return;
const v = normalizeHex(value);
const key = `${cleanName.toLowerCase()}|${v}`;
if (seen.has(key)) return;
seen.add(key);
colors.push({ name: cleanName, value: v });
}
// Form A: "- **Background:** `#FAFAFA`" / "- Background: #FAFAFA"
const reA = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\s*\**\s*[:]\s*`?(#[0-9a-fA-F]{3,8})/gm;
let m;
while ((m = reA.exec(raw)) !== null) push(m[1], m[2]);
// Form B: "**Stripe Purple** (`#533afd`)" — common in awesome-design-md.
// Token name is whatever's bolded; the hex follows in parens/backticks.
const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g;
while ((m = reB.exec(raw)) !== null) push(m[1], m[2]);
return colors;
}
function extractFonts(raw) {
const out = {};
// "- **Display / headings:** `'GT Sectra', ...`"
// We want the backticked stack OR the rest of the line.
const re = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z /]{1,30}?)\s*\**\s*[:]\s*`?([^`\n]+?)`?$/gm;
let m;
while ((m = re.exec(raw)) !== null) {
const label = m[1].toLowerCase();
const value = m[2].trim().replace(/[*_`]+$/g, '').trim();
if (!/[a-zA-Z]/.test(value)) continue;
if (value.startsWith('#')) continue;
if (/display|heading|h1|title/.test(label) && !out.display) out.display = value;
else if (/body|text|paragraph|copy/.test(label) && !out.body) out.body = value;
else if (/mono|code/.test(label) && !out.mono) out.mono = value;
}
return out;
}
function pickColor(colors, hints) {
for (const hint of hints) {
const needle = hint.toLowerCase();
const found = colors.find((c) => c.name.toLowerCase().includes(needle));
if (found) return found.value;
}
return null;
}
function firstNonNeutral(colors) {
for (const c of colors) {
const v = c.value.replace('#', '').toLowerCase();
if (v.length !== 6) continue;
const r = parseInt(v.slice(0, 2), 16);
const g = parseInt(v.slice(2, 4), 16);
const b = parseInt(v.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const sat = max === 0 ? 0 : (max - min) / max;
if (sat > 0.25) return c.value;
}
return null;
}
function pickReadableForeground(hex) {
const n = normalizeHex(hex);
if (n.length !== 7) return '#ffffff';
const r = parseInt(n.slice(1, 3), 16);
const g = parseInt(n.slice(3, 5), 16);
const b = parseInt(n.slice(5, 7), 16);
// Standard luminance check.
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.6 ? '#0a0a0a' : '#ffffff';
}
function normalizeHex(hex) {
let h = hex.toLowerCase();
if (h.length === 4) {
h = '#' + h.slice(1).split('').map((c) => c + c).join('');
}
return h;
}
function cleanTitle(raw) {
return String(raw).replace(/^Design System (Inspired by|for)\s+/i, '').trim();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;',
);
}
// Tiny markdown renderer — enough for our DESIGN.md prose: H1H4, paragraphs,
// bullet/ordered lists, blockquotes, fenced code, GFM pipe tables, horizontal
// rules, inline `code` / **bold** / *italic* / [link](url). Not a full markdown
// implementation but covers everything the DESIGN.md files actually use.
function renderMarkdownLite(src) {
const lines = src.split(/\r?\n/);
const out = [];
let inList = null;
let inBlockquote = false;
let inCode = false;
let i = 0;
function closeList() {
if (inList) {
out.push(`</${inList}>`);
inList = null;
}
}
function closeBlockquote() {
if (inBlockquote) {
out.push('</blockquote>');
inBlockquote = false;
}
}
while (i < lines.length) {
const raw = lines[i] ?? '';
const line = raw.trimEnd();
if (line.startsWith('```')) {
closeList();
closeBlockquote();
if (!inCode) {
out.push('<pre><code>');
inCode = true;
} else {
out.push('</code></pre>');
inCode = false;
}
i++;
continue;
}
if (inCode) {
out.push(escapeHtml(raw));
i++;
continue;
}
if (!line.trim()) {
closeList();
closeBlockquote();
i++;
continue;
}
// GFM pipe table — at least a header row, a separator row of dashes,
// and one body row. Look ahead from `i` so we can consume the whole
// block in one step.
if (looksLikeTableHeader(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1] ?? '')) {
closeList();
closeBlockquote();
const headerCells = splitTableRow(line);
const aligns = parseAlignments(lines[i + 1] ?? '', headerCells.length);
const bodyRows = [];
let j = i + 2;
while (j < lines.length) {
const next = (lines[j] ?? '').trimEnd();
if (!next.trim() || !next.includes('|')) break;
bodyRows.push(splitTableRow(next));
j++;
}
out.push(renderTable(headerCells, bodyRows, aligns));
i = j;
continue;
}
// ATX headings #..####
const h = /^(#{1,4})\s+(.+)$/.exec(line);
if (h) {
closeList();
closeBlockquote();
const level = h[1].length;
out.push(`<h${level}>${inline(h[2])}</h${level}>`);
i++;
continue;
}
// Horizontal rule.
if (/^([-*_])\1{2,}\s*$/.test(line)) {
closeList();
closeBlockquote();
out.push('<hr />');
i++;
continue;
}
const bq = /^>\s?(.*)$/.exec(line);
if (bq) {
closeList();
if (!inBlockquote) {
out.push('<blockquote>');
inBlockquote = true;
}
out.push(`<p>${inline(bq[1] || '')}</p>`);
i++;
continue;
}
closeBlockquote();
const li = /^([-*])\s+(.+)$/.exec(line);
if (li) {
if (inList !== 'ul') {
closeList();
out.push('<ul>');
inList = 'ul';
}
out.push(`<li>${inline(li[2])}</li>`);
i++;
continue;
}
const oli = /^\d+\.\s+(.+)$/.exec(line);
if (oli) {
if (inList !== 'ol') {
closeList();
out.push('<ol>');
inList = 'ol';
}
out.push(`<li>${inline(oli[1])}</li>`);
i++;
continue;
}
closeList();
out.push(`<p>${inline(line)}</p>`);
i++;
}
closeList();
closeBlockquote();
if (inCode) out.push('</code></pre>');
return out.join('\n');
}
function looksLikeTableHeader(line) {
const trimmed = line.trim();
if (!trimmed.includes('|')) return false;
// At least one pipe between non-pipe content.
return /\|/.test(trimmed.replace(/^\||\|$/g, ''));
}
function isTableSeparator(line) {
const trimmed = line.trim();
if (!trimmed.includes('|')) return false;
// Each cell must be only dashes / colons / whitespace.
return splitTableRow(trimmed).every((cell) => /^:?-{1,}:?$/.test(cell.trim()));
}
function splitTableRow(line) {
let s = line.trim();
if (s.startsWith('|')) s = s.slice(1);
if (s.endsWith('|')) s = s.slice(0, -1);
return s.split('|').map((c) => c.trim());
}
function parseAlignments(separatorLine, count) {
const cells = splitTableRow(separatorLine);
const aligns = [];
for (let k = 0; k < count; k++) {
const cell = (cells[k] ?? '').trim();
const left = cell.startsWith(':');
const right = cell.endsWith(':');
if (left && right) aligns.push('center');
else if (right) aligns.push('right');
else aligns.push(null);
}
return aligns;
}
function renderTable(header, rows, aligns) {
const th = header
.map((cell, k) => {
const align = aligns[k];
const attr = align ? ` align="${align}"` : '';
return `<th${attr}>${inline(cell)}</th>`;
})
.join('');
const body = rows
.map((row) => {
const tds = row
.map((cell, k) => {
const align = aligns[k];
const attr = align ? ` align="${align}"` : '';
return `<td${attr}>${inline(cell)}</td>`;
})
.join('');
return `<tr>${tds}</tr>`;
})
.join('');
return `<div class="table-wrap"><table><thead><tr>${th}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function inline(s) {
// Process inline tokens. Order matters: code spans first so their content
// isn't further parsed; then bold/italic; then links; finally bare URLs.
const escaped = escapeHtml(s);
return escaped
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
.replace(/(^|[\s(])_([^_\n]+)_(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>')
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>');
}
+722
View File
@@ -0,0 +1,722 @@
/**
* Build a fully-formed product webpage that demonstrates a design system in
* action — not just a list of tokens, but a real-feeling marketing /
* product page (nav, hero, social proof, feature grid, dashboard preview,
* pricing, testimonials, FAQ, CTA, footer) styled entirely from the
* tokens we extract from the system's DESIGN.md.
*
* Same parsing utilities as design-system-preview.js — kept inline rather
* than imported so the two views can evolve independently.
*/
export function renderDesignSystemShowcase(id, raw) {
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
const rawTitle = titleMatch?.[1] ?? id;
const title = cleanTitle(rawTitle);
const subtitle = extractSubtitle(raw) || 'A design system rendered as a real product surface.';
const colors = extractColors(raw);
const fonts = extractFonts(raw);
const bg =
pickColor(colors, ['page background', 'background', 'canvas', 'paper', 'bg ', 'page bg'])
?? '#ffffff';
const fg =
pickColor(colors, ['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite'])
?? '#0a0a0a';
const accent =
pickColor(colors, ['primary brand', 'brand primary', 'primary', 'brand', 'accent'])
?? firstNonNeutral(colors)
?? '#2f6feb';
const accent2 =
pickColor(colors, ['secondary', 'tertiary', 'highlight', 'support'])
?? secondNonNeutral(colors, accent)
?? accent;
const muted = pickColor(colors, ['muted', 'subtle', 'caption', 'meta', 'neutral']) ?? '#666666';
const border = pickColor(colors, ['border', 'divider', 'rule', 'stroke']) ?? '#e6e6e6';
const surface =
pickColor(colors, ['surface', 'card', 'background-secondary', 'panel', 'elevated'])
?? mixSurface(bg);
const display = fonts.display ?? fonts.heading ?? "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
const body = fonts.body ?? display;
const mono = fonts.mono ?? "ui-monospace, 'JetBrains Mono', monospace";
const accentFg = pickReadableForeground(accent);
const accent2Fg = pickReadableForeground(accent2);
const productName = title;
const tagline = oneLine(subtitle).slice(0, 120);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(productName)} — showcase</title>
<style>
:root {
--bg: ${bg};
--fg: ${fg};
--accent: ${accent};
--accent-fg: ${accentFg};
--accent-2: ${accent2};
--accent-2-fg: ${accent2Fg};
--muted: ${muted};
--border: ${border};
--surface: ${surface};
--display: ${display};
--body: ${body};
--mono: ${mono};
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--body);
line-height: 1.6;
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }
.container { max-width: 1180px; margin: 0 auto; padding: 0 28px; }
/* Nav */
.nav {
position: sticky; top: 0; z-index: 30;
background: rgba(255,255,255,0.7);
backdrop-filter: saturate(180%) blur(14px);
border-bottom: 1px solid var(--border);
}
.nav-row {
display: flex; align-items: center; gap: 32px;
height: 64px;
}
.brand { display: flex; align-items: center; gap: 10px; font-family: var(--display); font-weight: 700; font-size: 17px; letter-spacing: -0.01em; }
.brand-mark {
width: 26px; height: 26px; border-radius: 7px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
}
.nav-links { display: flex; gap: 22px; font-size: 14px; color: var(--muted); }
.nav-links a:hover { color: var(--fg); }
.nav-spacer { flex: 1; }
.nav-cta {
display: inline-flex; align-items: center; gap: 6px;
background: var(--fg); color: var(--bg);
padding: 8px 14px; border-radius: 8px; font-size: 13px; font-weight: 500;
}
.nav-link-cta { color: var(--fg); font-weight: 500; font-size: 14px; }
/* Hero */
.hero { padding: 96px 0 72px; }
.hero-eyebrow {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--mono); font-size: 12px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.08em;
padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px;
background: var(--surface);
margin-bottom: 24px;
}
.hero-eyebrow .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.hero h1 {
font-family: var(--display);
font-size: clamp(44px, 6.6vw, 84px);
line-height: 1.02;
letter-spacing: -0.025em;
margin: 0 0 22px;
max-width: 18ch;
font-weight: 700;
}
.hero h1 em { font-style: normal; background: linear-gradient(120deg, var(--accent), var(--accent-2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
.hero p.lede {
font-size: 19px; color: var(--muted);
max-width: 56ch; margin: 0 0 36px;
}
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.btn {
font: inherit; cursor: pointer; border-radius: 10px;
padding: 13px 22px; font-size: 14.5px; font-weight: 500;
border: 1px solid transparent; display: inline-flex; align-items: center; gap: 8px;
}
.btn-primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.btn-primary:hover { filter: brightness(1.06); }
.btn-ghost { background: transparent; color: var(--fg); border-color: var(--border); }
.btn-ghost:hover { background: var(--surface); }
.hero-meta { display: flex; gap: 24px; margin-top: 44px; color: var(--muted); font-size: 13px; }
.hero-meta span strong { color: var(--fg); font-weight: 600; }
/* Logo strip */
.logos { padding: 36px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.logos-label { font-size: 12px; color: var(--muted); text-align: center; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 18px; }
.logos-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 44px; align-items: center; opacity: 0.85; }
.logo-pill { font-family: var(--display); font-weight: 700; font-size: 17px; letter-spacing: -0.01em; color: var(--muted); }
/* Features grid */
.section { padding: 96px 0; }
.section-eyebrow { font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.1em; font-size: 12px; color: var(--accent); margin-bottom: 12px; }
.section-title { font-family: var(--display); font-size: clamp(32px, 4.2vw, 48px); letter-spacing: -0.02em; line-height: 1.1; margin: 0 0 18px; max-width: 22ch; font-weight: 700; }
.section-lede { color: var(--muted); font-size: 17px; max-width: 56ch; margin: 0 0 48px; }
.features {
display: grid; gap: 18px;
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 920px) { .features { grid-template-columns: 1fr 1fr; } }
@media (max-width: 600px) { .features { grid-template-columns: 1fr; } }
.feature {
background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
padding: 26px; display: flex; flex-direction: column; gap: 12px;
}
.feature-icon {
width: 36px; height: 36px; border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: var(--accent-fg);
display: inline-flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 700;
}
.feature h3 { font-family: var(--display); font-size: 18px; margin: 0; letter-spacing: -0.01em; }
.feature p { color: var(--muted); margin: 0; font-size: 14.5px; line-height: 1.55; }
/* Product preview / dashboard mock */
.preview-wrap { padding-top: 24px; padding-bottom: 96px; }
.preview-frame {
background: var(--surface); border: 1px solid var(--border); border-radius: 18px;
padding: 14px;
box-shadow: 0 30px 80px rgba(0,0,0,0.06), 0 12px 30px rgba(0,0,0,0.04);
}
.preview-titlebar { display: flex; gap: 6px; padding: 4px 8px 12px; }
.preview-titlebar span { width: 10px; height: 10px; border-radius: 50%; background: var(--border); }
.preview-app {
background: var(--bg); border: 1px solid var(--border); border-radius: 12px;
display: grid; grid-template-columns: 220px 1fr; min-height: 440px; overflow: hidden;
}
.preview-side { background: var(--surface); border-right: 1px solid var(--border); padding: 18px 14px; display: flex; flex-direction: column; gap: 4px; }
.side-link { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; font-size: 13.5px; color: var(--muted); }
.side-link.active { background: var(--bg); color: var(--fg); font-weight: 500; box-shadow: inset 0 0 0 1px var(--border); }
.side-link .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.side-section { font-family: var(--mono); text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--muted); padding: 14px 10px 6px; }
.preview-main { padding: 22px 24px; display: flex; flex-direction: column; gap: 22px; }
.preview-head { display: flex; align-items: center; justify-content: space-between; }
.preview-head h4 { font-family: var(--display); font-size: 22px; margin: 0; letter-spacing: -0.01em; }
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
.kpi .label { font-size: 11.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
.kpi .value { font-family: var(--display); font-size: 24px; font-weight: 700; margin-top: 4px; letter-spacing: -0.01em; }
.kpi .delta { font-family: var(--mono); font-size: 11.5px; margin-top: 2px; color: var(--accent); }
.chart-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 18px; }
.chart-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
.chart-head .title { font-weight: 600; font-size: 14px; }
.chart-head .meta { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.chart svg { width: 100%; height: 160px; display: block; }
.preview-row-2 { display: grid; grid-template-columns: 1.6fr 1fr; gap: 14px; }
.list-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
.list-row { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--border); align-items: center; }
.list-row:first-of-type { border-top: none; }
.list-row .name { font-weight: 500; font-size: 13.5px; }
.list-row .meta { font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
.badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 500; background: var(--bg); border: 1px solid var(--border); color: var(--muted); }
.badge.up { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
.list-card .head { display: flex; justify-content: space-between; align-items: baseline; padding: 14px 16px; border-bottom: 1px solid var(--border); }
.list-card .head h5 { margin: 0; font-size: 14px; }
/* Pricing */
.pricing { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
@media (max-width: 920px) { .pricing { grid-template-columns: 1fr; } }
.price-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
padding: 28px; display: flex; flex-direction: column; gap: 18px;
}
.price-card.featured {
background: var(--fg); color: var(--bg); border-color: var(--fg);
}
.price-card.featured .muted, .price-card.featured h3, .price-card.featured .price { color: var(--bg); }
.price-card .tier-name { font-family: var(--display); font-size: 14px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
.price-card .price { font-family: var(--display); font-size: 44px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
.price-card .price small { font-size: 14px; color: var(--muted); font-weight: 400; }
.price-card ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; font-size: 14.5px; }
.price-card li::before { content: "✓"; color: var(--accent); margin-right: 8px; font-weight: 700; }
.price-card.featured li::before { color: var(--accent-2); }
/* Testimonials */
.quotes { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
@media (max-width: 760px) { .quotes { grid-template-columns: 1fr; } }
.quote { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 26px; display: flex; flex-direction: column; gap: 18px; }
.quote p { font-size: 17px; line-height: 1.55; margin: 0; font-family: var(--display); letter-spacing: -0.01em; }
.quote-author { display: flex; align-items: center; gap: 12px; }
.quote-author .avatar { width: 36px; height: 36px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-2)); }
.quote-author .name { font-weight: 600; font-size: 13.5px; }
.quote-author .role { font-size: 12.5px; color: var(--muted); }
/* FAQ */
.faq { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 32px; }
@media (max-width: 760px) { .faq { grid-template-columns: 1fr; } }
.faq-item { padding: 18px 0; border-top: 1px solid var(--border); }
.faq-item h4 { margin: 0 0 6px; font-family: var(--display); font-size: 17px; letter-spacing: -0.01em; }
.faq-item p { margin: 0; color: var(--muted); font-size: 14.5px; }
/* CTA */
.cta {
margin: 48px 0 96px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: var(--accent-fg);
border-radius: 24px;
padding: 64px 56px;
display: grid;
grid-template-columns: 1.4fr auto;
gap: 32px;
align-items: center;
}
@media (max-width: 760px) { .cta { grid-template-columns: 1fr; padding: 36px; } }
.cta h2 { font-family: var(--display); font-size: clamp(28px, 4vw, 40px); letter-spacing: -0.02em; margin: 0 0 10px; line-height: 1.1; max-width: 22ch; }
.cta p { margin: 0; opacity: 0.92; font-size: 16px; max-width: 50ch; }
.cta .btn { background: var(--accent-fg); color: var(--accent); border: none; }
.cta .btn-secondary { background: transparent; color: var(--accent-fg); border: 1px solid color-mix(in srgb, var(--accent-fg) 35%, transparent); }
/* Footer */
footer { border-top: 1px solid var(--border); padding: 36px 0 56px; color: var(--muted); font-size: 13.5px; }
.footer-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 32px; margin-bottom: 32px; }
@media (max-width: 760px) { .footer-row { grid-template-columns: 1fr 1fr; } }
.footer-col h6 { color: var(--fg); font-family: var(--display); font-size: 13.5px; margin: 0 0 12px; font-weight: 600; }
.footer-col a { display: block; padding: 4px 0; }
.footer-col a:hover { color: var(--fg); }
.footer-bottom { display: flex; justify-content: space-between; padding-top: 24px; border-top: 1px solid var(--border); }
</style>
</head>
<body>
<header class="nav">
<div class="container nav-row">
<a class="brand" href="#"><span class="brand-mark"></span>${escapeHtml(productName)}</a>
<nav class="nav-links">
<a href="#features">Product</a>
<a href="#preview">Workspace</a>
<a href="#pricing">Pricing</a>
<a href="#faq">Docs</a>
<a href="#faq">Customers</a>
</nav>
<div class="nav-spacer"></div>
<a class="nav-link-cta" href="#">Sign in</a>
<a class="nav-cta" href="#">Get started →</a>
</div>
</header>
<main>
<section class="hero">
<div class="container">
<div class="hero-eyebrow"><span class="dot"></span>${escapeHtml(productName)} · live preview</div>
<h1>The system that makes <em>${escapeHtml(productName)}</em> feel like ${escapeHtml(productName)}.</h1>
<p class="lede">${escapeHtml(tagline)}</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#">Start a free trial →</a>
<a class="btn btn-ghost" href="#preview">See it in action</a>
</div>
<div class="hero-meta">
<span><strong>4.9</strong> · App Store rating</span>
<span><strong>SOC 2</strong> · Type II compliant</span>
<span><strong>120k+</strong> active teams</span>
</div>
</div>
</section>
<section class="logos">
<div class="container">
<div class="logos-label">Trusted by teams shipping serious work</div>
<div class="logos-row">
<span class="logo-pill">Northwind</span>
<span class="logo-pill">Pioneer</span>
<span class="logo-pill">Lattice</span>
<span class="logo-pill">Atlas Co.</span>
<span class="logo-pill">Voltage</span>
<span class="logo-pill">Foundry</span>
</div>
</div>
</section>
<section class="section" id="features">
<div class="container">
<div class="section-eyebrow">What it does</div>
<h2 class="section-title">Every primitive a fast team needs.</h2>
<p class="section-lede">A system styled entirely from the tokens of ${escapeHtml(productName)} — palette, typography, surfaces, and motion. Drop it into any product and it stays in character.</p>
<div class="features">
${featureCard('★', 'Tokens that compose', 'Color, type, spacing, and elevation defined once and reused across every surface — from a marketing hero to a row in a table.')}
${featureCard('◐', 'Light & dark in lockstep', 'Every component ships with both modes. The accent reads as confident in either context, and contrast meets WCAG AA out of the box.')}
${featureCard('⌘', 'Desktop-first, but mobile-honest', 'Layouts collapse from a 12-column desktop grid to a focused single column without losing density or rhythm.')}
${featureCard('▣', 'Production-grade primitives', '40+ components — from the obvious (button, input) to the load-bearing (data table, command bar, empty states).')}
${featureCard('↗', 'Designed for handoff', 'Every spec carries a Figma frame, a code snippet, and a "do/dont" pair so engineers dont have to guess.')}
${featureCard('∞', 'Built to evolve', 'Tokens version semver-style. A palette refresh ships through one file — no component code touches.')}
</div>
</div>
</section>
<section class="preview-wrap" id="preview">
<div class="container">
<div class="section-eyebrow">In production</div>
<h2 class="section-title">A workspace, fully styled.</h2>
<p class="section-lede">This is the same component library you'd use in your app — rendered with ${escapeHtml(productName)} tokens.</p>
<div class="preview-frame">
<div class="preview-titlebar"><span></span><span></span><span></span></div>
<div class="preview-app">
<aside class="preview-side">
<div class="brand" style="margin-bottom: 14px;"><span class="brand-mark"></span>${escapeHtml(productName)}</div>
<a class="side-link active"><span class="dot"></span>Overview</a>
<a class="side-link">Customers</a>
<a class="side-link">Pipeline</a>
<a class="side-link">Reports</a>
<a class="side-link">Automations</a>
<div class="side-section">Workspaces</div>
<a class="side-link">Growth</a>
<a class="side-link">Lifecycle</a>
<a class="side-link">Finance</a>
</aside>
<div class="preview-main">
<div class="preview-head">
<h4>Overview</h4>
<span class="badge up">↑ 12.4% this week</span>
</div>
<div class="kpi-row">
${kpi('MRR', '$184,210', '+8.2%')}
${kpi('Active orgs', '2,914', '+121')}
${kpi('Conversion', '4.6%', '+0.4 pp')}
${kpi('Net retention', '113%', '+2 pp')}
</div>
<div class="chart-card">
<div class="chart-head">
<span class="title">Revenue · last 12 weeks</span>
<span class="meta">USD · weekly</span>
</div>
<div class="chart">
${inlineLineChart()}
</div>
</div>
<div class="preview-row-2">
<div class="list-card">
<div class="head">
<h5>Top accounts</h5>
<span class="badge">View all</span>
</div>
${listRow('Northwind Trading', 'Annual · NA', '$48,200', 'up')}
${listRow('Pioneer Robotics', 'Quarterly · EMEA', '$31,890', 'up')}
${listRow('Atlas Cooperative', 'Annual · APAC', '$22,400', '')}
${listRow('Foundry Group', 'Monthly · NA', '$14,750', 'up')}
</div>
<div class="list-card">
<div class="head">
<h5>Activity</h5>
<span class="badge">Live</span>
</div>
${activityRow('Renewal closed', 'Lattice · 11m ago')}
${activityRow('Trial started', 'Voltage · 22m ago')}
${activityRow('Plan upgraded', 'Pioneer · 1h ago')}
${activityRow('Invoice paid', 'Atlas · 2h ago')}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="section" id="pricing" style="padding-top: 24px;">
<div class="container">
<div class="section-eyebrow">Pricing</div>
<h2 class="section-title">Built for teams of one to one thousand.</h2>
<p class="section-lede">Pick the plan that matches the way your team ships. Every tier ships the full token system.</p>
<div class="pricing">
${priceCard('Starter', '$0', 'Free forever', ['Single user', 'All core tokens', 'Up to 3 projects', 'Community support'])}
${priceCard('Team', '$24', 'per seat / month', ['Unlimited projects', 'Real-time co-edit', 'Brand themes', 'Priority email support'], true)}
${priceCard('Enterprise', 'Custom', 'volume pricing', ['SSO + SCIM', 'Audit logs', 'Custom token schemas', 'Dedicated success manager'])}
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="section-eyebrow">Customers</div>
<h2 class="section-title">Loved by teams who care about craft.</h2>
<div class="quotes">
${quote('"Our marketing site, our app, and our internal dashboards finally feel like the same product. The token system is doing all the work."', 'Mira Okafor', 'Head of Design · Pioneer')}
${quote('"We swapped our entire design language in an afternoon. Nothing broke. Thats the line, and we crossed it."', 'Caleb Renner', 'Engineering Lead · Northwind')}
</div>
</div>
</section>
<section class="section" id="faq" style="padding-top: 24px;">
<div class="container">
<div class="section-eyebrow">FAQ</div>
<h2 class="section-title">Questions, answered.</h2>
<div class="faq">
${faq('Is this a Figma library, a code library, or both?', 'Both. Tokens flow from one source of truth into Figma styles and into the codegen pipeline at the same time.')}
${faq('Can we ship our own brand theme?', 'Yes — fork the token file, change the palette and type stack, and every component reskins automatically.')}
${faq('What about accessibility?', 'Color contrast meets WCAG AA on every surface. Components ship with focus rings, ARIA roles, and keyboard handling.')}
${faq('How do you handle dark mode?', 'Every token has a paired dark value. The system flips at the document level — no per-component overrides needed.')}
</div>
</div>
</section>
<section>
<div class="container">
<div class="cta">
<div>
<h2>Ship a product that finally feels finished.</h2>
<p>Drop the system into your app today. The first project is on us.</p>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<a class="btn btn-primary" href="#">Start free trial</a>
<a class="btn btn-secondary" href="#">Talk to sales</a>
</div>
</div>
</div>
</section>
</main>
<footer>
<div class="container">
<div class="footer-row">
<div class="footer-col">
<div class="brand" style="margin-bottom: 12px;"><span class="brand-mark"></span>${escapeHtml(productName)}</div>
<p style="margin: 0; max-width: 38ch;">${escapeHtml(tagline)}</p>
</div>
<div class="footer-col"><h6>Product</h6><a href="#">Features</a><a href="#">Pricing</a><a href="#">Changelog</a><a href="#">Roadmap</a></div>
<div class="footer-col"><h6>Company</h6><a href="#">About</a><a href="#">Customers</a><a href="#">Careers</a><a href="#">Press</a></div>
<div class="footer-col"><h6>Resources</h6><a href="#">Docs</a><a href="#">Status</a><a href="#">Brand</a><a href="#">Contact</a></div>
</div>
<div class="footer-bottom">
<span>© ${new Date().getFullYear()} ${escapeHtml(productName)}. All rights reserved.</span>
<span>Showcase rendered from <code style="font-family: var(--mono);">design-systems/${escapeHtml(id)}/DESIGN.md</code></span>
</div>
</div>
</footer>
</body>
</html>`;
}
function featureCard(icon, title, body) {
return `<div class="feature">
<div class="feature-icon">${escapeHtml(icon)}</div>
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(body)}</p>
</div>`;
}
function kpi(label, value, delta) {
return `<div class="kpi">
<div class="label">${escapeHtml(label)}</div>
<div class="value">${escapeHtml(value)}</div>
<div class="delta">${escapeHtml(delta)}</div>
</div>`;
}
function listRow(name, meta, value, status) {
const badge = status === 'up' ? '<span class="badge up">↑</span>' : '<span class="badge">·</span>';
return `<div class="list-row">
<div>
<div class="name">${escapeHtml(name)}</div>
<div class="meta">${escapeHtml(meta)}</div>
</div>
<div class="meta">${escapeHtml(value)}</div>
${badge}
</div>`;
}
function activityRow(name, meta) {
return `<div class="list-row">
<div>
<div class="name">${escapeHtml(name)}</div>
<div class="meta">${escapeHtml(meta)}</div>
</div>
<div></div>
<span class="badge">●</span>
</div>`;
}
function priceCard(name, price, sub, features, featured) {
return `<div class="price-card${featured ? ' featured' : ''}">
<div class="tier-name">${escapeHtml(name)}</div>
<div class="price">${escapeHtml(price)} <small>${escapeHtml(sub)}</small></div>
<ul>${features.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
<a class="btn ${featured ? 'btn-primary' : 'btn-ghost'}" href="#" style="${featured ? 'background: var(--accent); color: var(--accent-fg); border-color: var(--accent);' : ''}">Choose ${escapeHtml(name)}</a>
</div>`;
}
function quote(text, name, role) {
return `<div class="quote">
<p>${escapeHtml(text)}</p>
<div class="quote-author">
<div class="avatar"></div>
<div>
<div class="name">${escapeHtml(name)}</div>
<div class="role">${escapeHtml(role)}</div>
</div>
</div>
</div>`;
}
function faq(q, a) {
return `<div class="faq-item">
<h4>${escapeHtml(q)}</h4>
<p>${escapeHtml(a)}</p>
</div>`;
}
function inlineLineChart() {
// Deterministic numbers so the chart looks specific (12 weekly data points).
const data = [38, 44, 41, 52, 49, 61, 58, 67, 71, 76, 82, 88];
const max = Math.max(...data);
const min = Math.min(...data);
const w = 720;
const h = 160;
const padX = 8;
const padY = 14;
const stepX = (w - padX * 2) / (data.length - 1);
const norm = (v) => padY + (h - padY * 2) * (1 - (v - min) / (max - min));
const points = data.map((v, i) => `${padX + i * stepX},${norm(v).toFixed(1)}`).join(' ');
const area = `${padX},${h} ${points} ${w - padX},${h}`;
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
<defs>
<linearGradient id="lg" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="var(--accent)" stop-opacity="0.32"/>
<stop offset="100%" stop-color="var(--accent)" stop-opacity="0"/>
</linearGradient>
</defs>
<polygon points="${area}" fill="url(#lg)"/>
<polyline points="${points}" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
${data.map((v, i) => `<circle cx="${padX + i * stepX}" cy="${norm(v).toFixed(1)}" r="${i === data.length - 1 ? 4 : 0}" fill="var(--accent)"/>`).join('')}
</svg>`;
}
function extractSubtitle(raw) {
const lines = raw.split(/\r?\n/);
const h1 = lines.findIndex((l) => /^#\s+/.test(l));
if (h1 === -1) return '';
const after = lines.slice(h1 + 1);
const nextHeading = after.findIndex((l) => /^#{1,6}\s+/.test(l));
const window = (nextHeading === -1 ? after : after.slice(0, nextHeading))
.join('\n')
.replace(/^>\s*Category:.*$/gim, '')
.replace(/^>\s*/gm, '')
.trim();
return window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
}
function extractColors(raw) {
const colors = [];
const seen = new Set();
function push(name, value) {
const cleanName = name.replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim();
if (!cleanName || cleanName.length > 60) return;
const v = normalizeHex(value);
const key = `${cleanName.toLowerCase()}|${v}`;
if (seen.has(key)) return;
seen.add(key);
colors.push({ name: cleanName, value: v });
}
const reA = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\s*\**\s*[:]\s*`?(#[0-9a-fA-F]{3,8})/gm;
let m;
while ((m = reA.exec(raw)) !== null) push(m[1], m[2]);
const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g;
while ((m = reB.exec(raw)) !== null) push(m[1], m[2]);
return colors;
}
function extractFonts(raw) {
const out = {};
const re = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z /]{1,30}?)\s*\**\s*[:]\s*`?([^`\n]+?)`?$/gm;
let m;
while ((m = re.exec(raw)) !== null) {
const label = m[1].toLowerCase();
const value = m[2].trim().replace(/[*_`]+$/g, '').trim();
if (!/[a-zA-Z]/.test(value)) continue;
if (value.startsWith('#')) continue;
if (/display|heading|h1|title/.test(label) && !out.display) out.display = value;
else if (/body|text|paragraph|copy/.test(label) && !out.body) out.body = value;
else if (/mono|code/.test(label) && !out.mono) out.mono = value;
}
return out;
}
function pickColor(colors, hints) {
for (const hint of hints) {
const needle = hint.toLowerCase();
const found = colors.find((c) => c.name.toLowerCase().includes(needle));
if (found) return found.value;
}
return null;
}
function firstNonNeutral(colors) {
for (const c of colors) {
const v = c.value.replace('#', '').toLowerCase();
if (v.length !== 6) continue;
const r = parseInt(v.slice(0, 2), 16);
const g = parseInt(v.slice(2, 4), 16);
const b = parseInt(v.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const sat = max === 0 ? 0 : (max - min) / max;
if (sat > 0.25) return c.value;
}
return null;
}
function secondNonNeutral(colors, exclude) {
let seen = false;
for (const c of colors) {
const v = c.value.replace('#', '').toLowerCase();
if (v.length !== 6) continue;
const r = parseInt(v.slice(0, 2), 16);
const g = parseInt(v.slice(2, 4), 16);
const b = parseInt(v.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const sat = max === 0 ? 0 : (max - min) / max;
if (sat > 0.25) {
if (c.value === exclude || (!seen)) { seen = true; continue; }
return c.value;
}
}
return null;
}
function pickReadableForeground(hex) {
const n = normalizeHex(hex);
if (n.length !== 7) return '#ffffff';
const r = parseInt(n.slice(1, 3), 16);
const g = parseInt(n.slice(3, 5), 16);
const b = parseInt(n.slice(5, 7), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.6 ? '#0a0a0a' : '#ffffff';
}
function mixSurface(bg) {
const n = normalizeHex(bg);
if (n.length !== 7) return '#fafafa';
const r = parseInt(n.slice(1, 3), 16);
const g = parseInt(n.slice(3, 5), 16);
const b = parseInt(n.slice(5, 7), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Lift dark backgrounds; tint light backgrounds slightly cooler.
const adjust = lum < 0.4 ? 16 : -8;
const fix = (v) => Math.max(0, Math.min(255, v + adjust)).toString(16).padStart(2, '0');
return `#${fix(r)}${fix(g)}${fix(b)}`;
}
function normalizeHex(hex) {
let h = hex.toLowerCase();
if (h.length === 4) {
h = '#' + h.slice(1).split('').map((c) => c + c).join('');
}
return h;
}
function cleanTitle(raw) {
return String(raw).replace(/^Design System (Inspired by|for)\s+/i, '').trim();
}
function oneLine(s) {
return String(s).replace(/\s+/g, ' ').trim();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;',
);
}
+157
View File
@@ -0,0 +1,157 @@
// Design-system registry. Scans <projectRoot>/design-systems/* for DESIGN.md
// files. Title comes from the first H1. Category comes from a
// `> Category: <name>` blockquote line beneath the H1. Summary is the first
// paragraph between the H1 and the next heading (Category line stripped).
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
export async function listDesignSystems(root) {
const out = [];
let entries = [];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const designPath = path.join(root, entry.name, 'DESIGN.md');
try {
const stats = await stat(designPath);
if (!stats.isFile()) continue;
const raw = await readFile(designPath, 'utf8');
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
const title = cleanTitle(titleMatch?.[1] ?? entry.name);
out.push({
id: entry.name,
title,
category: extractCategory(raw) ?? 'Uncategorized',
summary: summarize(raw),
swatches: extractSwatches(raw),
body: raw,
});
} catch {
// Skip.
}
}
return out;
}
export async function readDesignSystem(root, id) {
const file = path.join(root, id, 'DESIGN.md');
try {
return await readFile(file, 'utf8');
} catch {
return null;
}
}
function summarize(raw) {
const lines = raw.split(/\r?\n/);
const firstH1 = lines.findIndex((l) => /^#\s+/.test(l));
if (firstH1 === -1) return '';
const afterH1 = lines.slice(firstH1 + 1);
const nextHeading = afterH1.findIndex((l) => /^#{1,6}\s+/.test(l));
const window = (nextHeading === -1 ? afterH1 : afterH1.slice(0, nextHeading))
.join('\n')
// Drop the Category metadata line — it's surfaced separately.
.replace(/^>\s*Category:.*$/gim, '')
.replace(/^>\s*/gm, '')
.trim();
return window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
}
function extractCategory(raw) {
const m = /^>\s*Category:\s*(.+?)\s*$/im.exec(raw);
return m?.[1];
}
// Strip boilerplate like "Design System Inspired by Cohere" → "Cohere" so
// the picker dropdown reads cleanly. Hand-authored titles that don't match
// the pattern (e.g. "Neutral Modern") pass through unchanged.
function cleanTitle(raw) {
return raw
.replace(/^Design System (Inspired by|for)\s+/i, '')
.trim();
}
/**
* Pull 4 representative colors from a DESIGN.md so the picker can render
* a tiny swatch row next to each system. Order: [bg, support, fg, accent].
*
* The shape is deliberately compact — one accent + one background + one
* fg + one supporting tone — so the row reads like a brand mark even at
* thumbnail scale. Picked greedily by token-name hints (matches the
* heuristics in design-system-preview.js so the strip and the showcase
* agree on which colors the system "is").
*
* @param {string} raw Markdown body of DESIGN.md
* @returns {string[]} Up to 4 hex strings; [] if extraction fails.
*/
function extractSwatches(raw) {
const colors = [];
const seen = new Set();
function push(name, value) {
const cleanName = name.replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
const v = normalizeHex(value);
if (!v || cleanName.length > 60) return;
const key = `${cleanName}|${v}`;
if (seen.has(key)) return;
seen.add(key);
colors.push({ name: cleanName, value: v });
}
// Form A: "- **Background:** `#FAFAFA`"
const reA = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\s*\**\s*[:]\s*`?(#[0-9a-fA-F]{3,8})/gm;
let m;
while ((m = reA.exec(raw)) !== null) push(m[1], m[2]);
// Form B: "**Stripe Purple** (`#533afd`)"
const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g;
while ((m = reB.exec(raw)) !== null) push(m[1], m[2]);
if (colors.length === 0) return [];
function pick(hints) {
for (const h of hints) {
const found = colors.find((c) => c.name.includes(h));
if (found) return found.value;
}
return null;
}
function isNeutral(hex) {
if (!/^#[0-9a-f]{6}$/.test(hex)) return false;
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return Math.max(r, g, b) - Math.min(r, g, b) < 10;
}
const bg =
pick(['page background', 'background', 'canvas', 'paper', 'surface'])
?? '#ffffff';
const fg =
pick(['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite'])
?? '#111111';
const accent =
pick(['primary brand', 'brand primary', 'accent', 'brand', 'primary'])
?? colors.find((c) => !isNeutral(c.value))?.value
?? colors[0]?.value
?? '#888888';
const support =
pick(['border', 'divider', 'rule', 'muted', 'secondary', 'subtle'])
?? colors.find(
(c) => isNeutral(c.value) && c.value !== bg && c.value !== fg,
)?.value
?? '#cccccc';
return [bg, support, fg, accent];
}
function normalizeHex(raw) {
if (typeof raw !== 'string') return null;
const m = /^#([0-9a-fA-F]{3,8})$/.exec(raw.trim());
if (!m) return null;
let hex = m[1];
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
if (hex.length === 4) hex = hex.split('').map((c) => c + c).join('').slice(0, 8);
return '#' + hex.toLowerCase();
}
+136
View File
@@ -0,0 +1,136 @@
// Minimal YAML front-matter parser. Handles the subset used by SKILL.md in
// our examples: scalar strings/numbers/booleans, block-literal (|) strings,
// and flat arrays ("- foo"). Keeps the daemon dep-free. If you need real
// YAML (nested objects, flow-style, anchors), swap for `yaml` or `js-yaml`.
export function parseFrontmatter(src) {
const text = src.replace(/^/, '');
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
if (!match) return { data: {}, body: text };
const [, yaml, body] = match;
return { data: parseYamlSubset(yaml), body };
}
function parseYamlSubset(src) {
const lines = src.split(/\r?\n/);
const root = {};
const stack = [{ indent: -1, container: root, key: null }];
let i = 0;
while (i < lines.length) {
const raw = lines[i];
if (/^\s*(#.*)?$/.test(raw)) {
i++;
continue;
}
const indent = raw.match(/^\s*/)[0].length;
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
stack.pop();
}
const top = stack[stack.length - 1];
const line = raw.slice(indent);
// Array item
if (line.startsWith('- ')) {
const value = line.slice(2).trim();
let container = top.container;
if (!Array.isArray(container)) {
// Convert the pending key's value to an array on first `-`.
const parent = stack[stack.length - 2];
if (parent && top.key) {
parent.container[top.key] = [];
container = parent.container[top.key];
top.container = container;
} else {
i++;
continue;
}
}
if (value.includes(':')) {
const obj = {};
const colonIdx = value.indexOf(':');
const key = value.slice(0, colonIdx).trim();
const valRaw = value.slice(colonIdx + 1).trim();
if (valRaw) obj[key] = coerce(valRaw);
container.push(obj);
stack.push({ indent, container: obj, key: null });
} else {
container.push(coerce(value));
}
i++;
continue;
}
// key: value or key: |
const kv = /^([^:]+):\s*(.*)$/.exec(line);
if (!kv) {
i++;
continue;
}
const key = kv[1].trim();
const val = kv[2];
if (val === '' || val === undefined) {
top.container[key] = {};
stack.push({ indent, container: top.container[key], key });
i++;
continue;
}
if (val === '|' || val === '|-' || val === '>' || val === '>-') {
const collected = [];
const childIndent = indent + 2;
i++;
while (i < lines.length) {
const next = lines[i];
if (/^\s*$/.test(next)) {
collected.push('');
i++;
continue;
}
const nIndent = next.match(/^\s*/)[0].length;
if (nIndent < childIndent) break;
collected.push(next.slice(childIndent));
i++;
}
top.container[key] = collected.join('\n').trimEnd();
continue;
}
if (val === '[]') {
top.container[key] = [];
i++;
continue;
}
if (val.startsWith('[') && val.endsWith(']')) {
top.container[key] = val
.slice(1, -1)
.split(',')
.map((s) => coerce(s.trim()))
.filter((v) => v !== '');
i++;
continue;
}
top.container[key] = coerce(val);
i++;
}
return root;
}
function coerce(raw) {
if (raw === undefined) return '';
let v = raw.trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
return v.slice(1, -1);
}
if (v === 'true') return true;
if (v === 'false') return false;
if (v === 'null' || v === '~') return null;
if (/^-?\d+$/.test(v)) return Number(v);
if (/^-?\d*\.\d+$/.test(v)) return Number(v);
return v;
}
+367
View File
@@ -0,0 +1,367 @@
/**
* Anti-slop linter for generated HTML artifacts.
*
* Runs grep-style checks against an artifact body and returns a list of
* structured findings. P0 findings indicate the artifact is regressing
* to AI-slop tropes (purple gradients, emoji feature icons, sans-serif
* display, invented metrics, lorem-style filler) and are surfaced back
* to the agent as a system message so it can self-correct on the next
* turn. P1/P2 findings are advisories.
*
* The linter is deliberately greppy: cheap, deterministic, and trivial
* to extend. It does NOT parse HTML — false positives are tolerable
* because each finding includes a snippet so the agent can verify.
*
* Wired into the artifact save flow (POST /api/artifacts/save) and
* exposed standalone at POST /api/artifacts/lint for the chat UI to
* surface badges next to each saved artifact.
*/
/**
* @typedef {Object} LintFinding
* @property {'P0'|'P1'|'P2'} severity
* @property {string} id short stable id (e.g. 'purple-gradient')
* @property {string} message one-line explanation
* @property {string} fix one-line corrective suggestion (for the agent)
* @property {string} [snippet] matched text (≤ 200 chars), if any
*/
const PURPLE_HEXES = [
'#a855f7', '#9333ea', '#7c3aed', '#6d28d9', '#581c87',
'#8b5cf6', '#a78bfa', '#c4b5fd', '#ddd6fe', '#ede9fe',
];
const SLOP_EMOJI = [
'✨', '🚀', '🎯', '⚡', '🔥', '💡', '📈', '🎨', '🛡️', '🌟',
'💪', '🎉', '👋', '🙌', '✅', '⭐', '🏆',
];
// Simple sentinel words for invented-metric copy. Catching every claim is
// hopeless; we look for the canonical AI-startup phrasings.
const INVENTED_METRIC_PATTERNS = [
/\b10×\s+(faster|better|easier)\b/i,
/\b100×\s+(faster|better)\b/i,
/\b99\.\d+%\s+uptime\b/i,
/\bzero[- ]downtime\b/i,
/\b3×\s+more\s+(productive|efficient)\b/i,
];
const FILLER_PATTERNS = [
/\bfeature\s+(one|two|three|1|2|3)\b/i,
/\blorem\s+ipsum\b/i,
/\bdolor\s+sit\s+amet\b/i,
/\bplaceholder\s+text\b/i,
/\bsample\s+content\b/i,
];
// Display-face check: an h1 / h2 / h3 element whose `font-family` lands on
// Inter / Roboto / Arial / -apple-system without an actual serif before it.
// We check the `<style>` block specifically; inline styles are checked too.
const DISPLAY_SANS_RE =
/(?:h1|h2|h3|\.h-?(?:hero|xl|lg|md))[^{}]*\{[^}]*font-family\s*:\s*["']?(?:Inter|Roboto|Arial|-apple-system|system-ui|SF\s+Pro)/i;
/**
* Run all checks against an HTML artifact body. Returns an array of
* findings. The checks are intentionally independent so adding a new
* one only means appending to this function.
*
* @param {string} html
* @returns {LintFinding[]}
*/
export function lintArtifact(rawHtml) {
/** @type {LintFinding[]} */
const out = [];
if (typeof rawHtml !== 'string' || rawHtml.length === 0) return out;
// Strip HTML comments before any pattern matching — comments often contain
// pedagogical examples ("paste a `<section class="slide">` here") that
// would otherwise fire false positives for the section / slide checks.
const html = rawHtml.replace(/<!--[\s\S]*?-->/g, '');
const lower = html.toLowerCase();
// ── P0-1: purple gradient backgrounds ─────────────────────────────
for (const hex of PURPLE_HEXES) {
const re = new RegExp(
`linear-gradient\\([^)]*${escapeRe(hex)}[^)]*\\)`,
'i',
);
const m = re.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'purple-gradient',
message: `Found a violet/purple gradient using ${hex} — anti-slop list says no.`,
fix: 'Replace the gradient with a flat surface (var(--bg) or var(--surface)) or use the active accent at a single intensity, not in a gradient.',
snippet: clip(m[0]),
});
break;
}
}
// Also catch the literal "purple"/"violet" keyword in a linear-gradient.
if (out.find((f) => f.id === 'purple-gradient') === undefined) {
const m = /linear-gradient\([^)]*\b(purple|violet)\b[^)]*\)/i.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'purple-gradient',
message: `Found a "${m[1]}" keyword inside a gradient — anti-slop.`,
fix: 'Remove the gradient or swap to a single solid color from the active design tokens.',
snippet: clip(m[0]),
});
}
}
// ── P0-2: emoji used as feature/UI icons ──────────────────────────
for (const e of SLOP_EMOJI) {
if (html.includes(e)) {
// Only flag if it appears in a structural context — heading,
// button, list item — not in body prose.
const re = new RegExp(
`<(?:h[1-6]|button|li|span class="[^"]*icon[^"]*")[^>]*>[^<]*${escapeRe(e)}`,
'i',
);
const m = re.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'emoji-icon',
message: `Emoji "${e}" used as a UI icon — anti-slop list says SVG monoline only.`,
fix: 'Replace with a small inline SVG icon (1.61.8px stroke, currentColor) or remove the icon entirely.',
snippet: clip(m[0]),
});
break;
}
}
}
// ── P0-3: rounded card with left-border accent ────────────────────
const leftAccentRe =
/\.[a-z-]+\s*\{[^}]*border-left\s*:\s*\d+px\s+solid\s+[^;]+;[^}]*border-radius\s*:\s*[1-9]/i;
const lam = leftAccentRe.exec(html);
if (lam) {
out.push({
severity: 'P0',
id: 'left-accent-card',
message: 'Rounded card with a coloured left border — the canonical AI-slop card pattern.',
fix: 'Drop either the border-radius (set 0px) or the border-left. Cards in the OCD seed use hairline borders all-round, no left accent.',
snippet: clip(lam[0]),
});
}
// ── P0-4: sans-serif display face ─────────────────────────────────
// Skill seeds bind --font-display to a serif. Catch the case where a
// generated artifact reverts this on h1/h2/h3 to system-sans.
const dm = DISPLAY_SANS_RE.exec(html);
if (dm) {
out.push({
severity: 'P0',
id: 'sans-display',
message: 'A heading rule uses Inter / Roboto / system-sans as the display face — not the serif the seed binds.',
fix: 'Use `font-family: var(--font-display)` on h1/h2/h3 and let the active design system pick the serif. Override only if the active direction is "tech / utility" or "modern minimal".',
snippet: clip(dm[0]),
});
}
// ── P0-5: invented metric phrasing ────────────────────────────────
for (const re of INVENTED_METRIC_PATTERNS) {
const m = re.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'invented-metric',
message: `Suspected invented metric: "${m[0]}". Anti-slop list says: no numbers without a real source.`,
fix: 'Either remove the claim or replace with a placeholder (— or a labelled stub) until the user supplies a real number.',
snippet: clip(m[0]),
});
break;
}
}
// ── P0-6: filler / lorem text ─────────────────────────────────────
for (const re of FILLER_PATTERNS) {
const m = re.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'filler-copy',
message: `Filler copy detected: "${m[0]}". Pages should ship with real, brief-derived copy.`,
fix: 'Replace with copy specific to the brief or delete the section entirely. An empty section is a design problem to solve with composition, not by inventing words.',
snippet: clip(m[0]),
});
break;
}
}
// ── P0-7: scrollIntoView (breaks iframe preview) ──────────────────
if (/\.scrollIntoView\s*\(/.test(html)) {
out.push({
severity: 'P0',
id: 'scroll-into-view',
message: 'Element.scrollIntoView() detected — yanks the host page when an iframe boundary is crossed.',
fix: 'Use `scrollTo({ left, top, behavior: "smooth" })` on the actual scroller (see simple-deck seed for the proven pattern).',
});
}
// ── P1-1: external image URLs (CDN / unsplash / placehold.co) ─────
// Allow data: urls and same-origin paths.
const extImg =
/<img[^>]+src=["']https?:\/\/(?:images\.unsplash\.com|placehold\.co|placekitten\.com|via\.placeholder\.com|picsum\.photos|loremflickr\.com)/i.exec(
html,
);
if (extImg) {
out.push({
severity: 'P1',
id: 'external-image',
message: 'External placeholder image CDN detected — fragile, looks fake when it 404s.',
fix: 'Use the .ph-img placeholder class shipped in the seed templates instead.',
snippet: clip(extImg[0]),
});
}
// ── P1-2: raw hex outside :root ───────────────────────────────────
// Heuristic: count `#xxxxxx` occurrences inside the first <style> block,
// outside the `:root{...}` declaration. Many is suspicious.
const styleRe = /<style[^>]*>([\s\S]*?)<\/style>/i;
const styleMatch = styleRe.exec(html);
if (styleMatch) {
const css = styleMatch[1] ?? '';
const rootRe = /:root\s*\{[^}]*\}/g;
const cssWithoutRoot = css.replace(rootRe, '');
const hexes = cssWithoutRoot.match(/#[0-9a-fA-F]{3,8}\b/g) ?? [];
// Allow up to ~12 raw hex values outside :root. Device chrome
// (mobile-app frame: bezel gradient, side rails, status icons) has
// legitimate hardware-specific values in the 810 range; raise the
// threshold so seed templates pass without ceremony. More than ~12
// signals tokens weren't honoured by the agent's generation.
if (hexes.length > 12) {
out.push({
severity: 'P1',
id: 'raw-hex',
message: `${hexes.length} raw hex values found outside :root — design tokens probably not honoured.`,
fix: 'Move every color into the :root token block (--bg / --surface / --fg / --muted / --border / --accent) and reference via var(). Use color-mix() for derived tones.',
snippet: hexes.slice(0, 6).join(' '),
});
}
}
// ── P1-3: too many accent uses in the rendered body ───────────────
// Approximation: count `var(--accent)` references that appear OUTSIDE
// the <style> block — i.e. inline styles in the rendered DOM, not the
// class system definitions. The seed's <style> block defines the
// accent on many class selectors that won't all render on one page;
// the body is what the user actually sees.
const styleStripped = html.replace(/<style[\s\S]*?<\/style>/gi, '');
const accentUsesInBody = (styleStripped.match(/var\(--accent\)/g) ?? []).length;
if (accentUsesInBody > 6) {
out.push({
severity: 'P1',
id: 'accent-overuse',
message: `var(--accent) used ${accentUsesInBody} times inline in the body — likely overused per screen.`,
fix: 'Cap accent usage at 2 visible uses per screen (one eyebrow + one CTA, OR one accent card + one tab). Demote the rest to var(--fg) or var(--muted).',
});
}
// ── P2-1: missing comment-mode anchor on <section> ────────────────
// Either `data-ocd-id` (web/mobile prototypes) or `data-screen-label`
// (decks) counts. Whichever the artifact uses, every <section> should
// carry one so the chat layer can target it.
const sections = html.match(/<section\b[^>]*>/gi) ?? [];
const tagged = sections.filter(
(s) => /data-ocd-id\s*=/.test(s) || /data-screen-label\s*=/.test(s),
).length;
if (sections.length > 0 && tagged < sections.length) {
out.push({
severity: 'P2',
id: 'missing-section-anchor',
message: `${sections.length - tagged} of ${sections.length} <section>s lack data-ocd-id (or data-screen-label).`,
fix: 'Add data-ocd-id="kebab-slug" (or data-screen-label="01 Cover" for slides) to every top-level <section> so comment mode can target it.',
});
}
// ── P2-2: missing slide theme classes (deck specifically) ──────────
// Triggered only if the artifact looks deck-shaped (has .slide).
if (/class\s*=\s*["'][^"']*\bslide\b/.test(html)) {
const slideMatches = html.match(/<section\s+class\s*=\s*["'][^"']*\bslide\b[^"']*["']/gi) ?? [];
const themed = slideMatches.filter((s) =>
/\b(light|dark|hero\s+light|hero\s+dark)\b/.test(s),
).length;
if (slideMatches.length > 0 && themed < slideMatches.length) {
out.push({
severity: 'P0',
id: 'slide-theme-missing',
message: `${slideMatches.length - themed} of ${slideMatches.length} slides lack a theme class (light / dark / hero light / hero dark).`,
fix: 'Every <section class="slide"> must include exactly one theme class. Audit your slide list and add light/dark/hero modifiers.',
});
}
// Theme rhythm: no 3+ same-theme in a row.
const themeSeq = slideMatches
.map((s) => {
if (/hero\s+dark/.test(s)) return 'HD';
if (/hero\s+light/.test(s)) return 'HL';
if (/\bdark\b/.test(s)) return 'D';
if (/\blight\b/.test(s)) return 'L';
return '?';
})
.filter((t) => t !== '?');
for (let i = 0; i < themeSeq.length - 2; i++) {
const a = themeSeq[i];
const isLight = (t) => t === 'L' || t === 'HL';
const isDark = (t) => t === 'D' || t === 'HD';
if (
(isLight(a) && isLight(themeSeq[i + 1]) && isLight(themeSeq[i + 2])) ||
(isDark(a) && isDark(themeSeq[i + 1]) && isDark(themeSeq[i + 2]))
) {
out.push({
severity: 'P1',
id: 'slide-rhythm',
message: `Three same-theme slides in a row at position ${i + 1}${i + 3} — visual fatigue.`,
fix: 'Swap the middle slide to the opposite theme (light → dark, or dark → light). For 8+ slides, mix in at least one hero light AND one hero dark.',
});
break;
}
}
}
return out;
}
/**
* Format findings as a Markdown block ready to splice into a system
* reminder back to the agent. P0 findings appear first.
*
* @param {LintFinding[]} findings
* @returns {string}
*/
export function renderFindingsForAgent(findings) {
if (findings.length === 0) return '';
const sorted = [...findings].sort((a, b) => severity(a) - severity(b));
const lines = [
'<artifact-lint>',
'The artifact you just produced has the following anti-slop / design-token issues.',
`${findings.filter((f) => f.severity === 'P0').length} P0 (must fix), ${findings.filter((f) => f.severity === 'P1').length} P1 (should fix), ${findings.filter((f) => f.severity === 'P2').length} P2 (nice to have).`,
'Re-emit a corrected `<artifact>` in your next turn — do not write a separate explanation; the user has the previous version already.',
'',
];
for (const f of sorted) {
lines.push(`**[${f.severity}] ${f.id}** — ${f.message}`);
lines.push(` Fix: ${f.fix}`);
if (f.snippet) lines.push(` Snippet: \`${f.snippet}\``);
lines.push('');
}
lines.push('</artifact-lint>');
return lines.join('\n');
}
function severity(f) {
return f.severity === 'P0' ? 0 : f.severity === 'P1' ? 1 : 2;
}
function clip(s) {
if (!s) return '';
const trimmed = s.replace(/\s+/g, ' ').trim();
return trimmed.length > 200 ? trimmed.slice(0, 197) + '…' : trimmed;
}
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+183
View File
@@ -0,0 +1,183 @@
// Project files registry. Each project is a folder under
// <projectRoot>/.ocd/projects/<projectId>/. The frontend's project list
// (localStorage) carries metadata; this module is the single owner of the
// on-disk content (HTML artifacts, sketches, uploaded images, pasted text).
//
// All paths flowing in from HTTP handlers are validated against the project
// directory to prevent path traversal — see resolveSafe().
import { mkdir, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
const FORBIDDEN_NAME = /[\\/]|^\.\.?$/;
export function projectDir(projectsRoot, projectId) {
if (!isSafeId(projectId)) throw new Error('invalid project id');
return path.join(projectsRoot, projectId);
}
export async function ensureProject(projectsRoot, projectId) {
const dir = projectDir(projectsRoot, projectId);
await mkdir(dir, { recursive: true });
return dir;
}
export async function listFiles(projectsRoot, projectId) {
const dir = projectDir(projectsRoot, projectId);
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
if (err && err.code === 'ENOENT') return [];
throw err;
}
const out = [];
for (const e of entries) {
if (!e.isFile()) continue;
if (e.name.startsWith('.')) continue;
const full = path.join(dir, e.name);
const st = await stat(full);
out.push({
name: e.name,
// The project folder is flat today so `path` equals `name`. We emit
// both so frontend code that thinks in path terms (the @-mention
// picker, attachment chips) can stay path-shaped without a remap.
path: e.name,
type: 'file',
size: st.size,
mtime: st.mtimeMs,
kind: kindFor(e.name),
mime: mimeFor(e.name),
});
}
// Newest first — matches the visual order users expect after generating.
out.sort((a, b) => b.mtime - a.mtime);
return out;
}
export async function readProjectFile(projectsRoot, projectId, name) {
const dir = projectDir(projectsRoot, projectId);
const file = resolveSafe(dir, name);
const buf = await readFile(file);
const st = await stat(file);
return {
buffer: buf,
name: path.basename(file),
size: st.size,
mtime: st.mtimeMs,
mime: mimeFor(file),
kind: kindFor(file),
};
}
export async function writeProjectFile(
projectsRoot,
projectId,
name,
body,
{ overwrite = true } = {},
) {
const dir = await ensureProject(projectsRoot, projectId);
const safeName = sanitizeName(name);
const target = path.join(dir, safeName);
if (!overwrite) {
try {
await stat(target);
throw new Error('file already exists');
} catch (err) {
if (!err || err.code !== 'ENOENT') throw err;
}
}
await writeFile(target, body);
const st = await stat(target);
return {
name: safeName,
size: st.size,
mtime: st.mtimeMs,
kind: kindFor(safeName),
mime: mimeFor(safeName),
};
}
export async function deleteProjectFile(projectsRoot, projectId, name) {
const dir = projectDir(projectsRoot, projectId);
const file = resolveSafe(dir, name);
await unlink(file);
}
export async function removeProjectDir(projectsRoot, projectId) {
const dir = projectDir(projectsRoot, projectId);
await rm(dir, { recursive: true, force: true });
}
function resolveSafe(dir, name) {
if (typeof name !== 'string' || !name || FORBIDDEN_NAME.test(name)) {
throw new Error('invalid file name');
}
const target = path.resolve(dir, name);
if (!target.startsWith(dir + path.sep) && target !== dir) {
throw new Error('path escapes project dir');
}
return target;
}
// Replace anything outside [A-Za-z0-9._-] with underscore. Spaces collapse
// to dashes (matches the kebab-case style used by the agent's slugs).
export function sanitizeName(raw) {
const cleaned = String(raw ?? '')
.replace(/[\\/]/g, '_')
.replace(/\s+/g, '-')
.replace(/[^A-Za-z0-9._-]/g, '_')
.replace(/^\.+/, '_')
.trim();
return cleaned || `file-${Date.now()}`;
}
function isSafeId(id) {
return typeof id === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(id);
}
const EXT_MIME = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.cjs': 'text/javascript; charset=utf-8',
'.ts': 'text/typescript; charset=utf-8',
'.tsx': 'text/typescript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
};
export function mimeFor(name) {
const ext = path.extname(name).toLowerCase();
return EXT_MIME[ext] || 'application/octet-stream';
}
// Coarse kind buckets the frontend uses to pick a viewer.
export function kindFor(name) {
// Editable sketches use a compound extension so they slot into the
// "sketch" bucket while still being valid JSON on disk.
if (name.endsWith('.sketch.json')) return 'sketch';
const ext = path.extname(name).toLowerCase();
if (ext === '.html' || ext === '.htm') return 'html';
if (ext === '.svg') return 'sketch';
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif'].includes(ext)) {
if (name.startsWith('sketch-')) return 'sketch';
return 'image';
}
if (['.md', '.txt'].includes(ext)) return 'text';
if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.json', '.css'].includes(ext)) {
return 'code';
}
return 'binary';
}
+887
View File
@@ -0,0 +1,887 @@
import express from 'express';
import multer from 'multer';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { detectAgents, getAgentDef } from './agents.js';
import { listSkills } from './skills.js';
import { listDesignSystems, readDesignSystem } from './design-systems.js';
import { createClaudeStreamHandler } from './claude-stream.js';
import { renderDesignSystemPreview } from './design-system-preview.js';
import { renderDesignSystemShowcase } from './design-system-showcase.js';
import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js';
import {
deleteProjectFile,
ensureProject,
listFiles,
readProjectFile,
removeProjectDir,
sanitizeName,
writeProjectFile,
} from './projects.js';
import {
deleteConversation,
deleteProject as dbDeleteProject,
deleteTemplate,
getConversation,
getProject,
getTemplate,
insertConversation,
insertProject,
insertTemplate,
listConversations,
listMessages,
listProjects,
listTabs,
listTemplates,
openDatabase,
setTabs,
updateConversation,
updateProject,
upsertMessage,
} from './db.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PROJECT_ROOT = path.resolve(__dirname, '..');
const STATIC_DIR = path.join(PROJECT_ROOT, 'dist');
const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
const DESIGN_SYSTEMS_DIR = path.join(PROJECT_ROOT, 'design-systems');
const ARTIFACTS_DIR = path.join(PROJECT_ROOT, '.ocd', 'artifacts');
const PROJECTS_DIR = path.join(PROJECT_ROOT, '.ocd', 'projects');
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
const UPLOAD_DIR = path.join(os.tmpdir(), 'ocd-uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
const upload = multer({
storage: multer.diskStorage({
destination: UPLOAD_DIR,
filename: (_req, file, cb) => {
const safe = file.originalname.replace(/[^\w.\-]/g, '_');
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safe}`);
},
}),
limits: { fileSize: 20 * 1024 * 1024 },
});
// Project-scoped multi-file upload. Lands files directly in the project
// folder (flat — same shape FileWorkspace expects), so the composer's
// pasted/dropped/picked images become referenceable filenames the agent
// can Read or @-mention without any cross-folder gymnastics.
const projectUpload = multer({
storage: multer.diskStorage({
destination: async (req, _file, cb) => {
try {
const dir = await ensureProject(PROJECTS_DIR, req.params.id);
cb(null, dir);
} catch (err) {
cb(err, '');
}
},
filename: (_req, file, cb) => {
// Reuse the same sanitiser used everywhere else, then prepend a
// base36 timestamp so multiple uploads with the same original name
// don't clobber each other.
const safe = sanitizeName(file.originalname);
cb(null, `${Date.now().toString(36)}-${safe}`);
},
}),
limits: { fileSize: 20 * 1024 * 1024 },
});
export async function startServer({ port = 7456 } = {}) {
const app = express();
app.use(express.json({ limit: '4mb' }));
const db = openDatabase(PROJECT_ROOT);
if (fs.existsSync(STATIC_DIR)) {
app.use(express.static(STATIC_DIR));
}
app.get('/api/health', (_req, res) => {
res.json({ ok: true, version: '0.1.0' });
});
// ---- Projects (DB-backed) -------------------------------------------------
app.get('/api/projects', (_req, res) => {
try {
res.json({ projects: listProjects(db) });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.post('/api/projects', async (req, res) => {
try {
const { id, name, skillId, designSystemId, pendingPrompt, metadata } =
req.body || {};
if (typeof id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) {
return res.status(400).json({ error: 'invalid project id' });
}
if (typeof name !== 'string' || !name.trim()) {
return res.status(400).json({ error: 'name required' });
}
const now = Date.now();
const project = insertProject(db, {
id,
name: name.trim(),
skillId: skillId ?? null,
designSystemId: designSystemId ?? null,
pendingPrompt: pendingPrompt || null,
metadata: metadata && typeof metadata === 'object' ? metadata : null,
createdAt: now,
updatedAt: now,
});
// Seed a default conversation so the UI always has somewhere to write.
const cid = randomId();
insertConversation(db, {
id: cid,
projectId: id,
title: null,
createdAt: now,
updatedAt: now,
});
// For "from template" projects, seed the chosen template's snapshot
// HTML into the new project folder so the agent can Read/edit files
// on disk (the system prompt also embeds them, but a real on-disk
// copy lets the agent treat them as the project's working state).
if (
metadata &&
typeof metadata === 'object' &&
metadata.kind === 'template' &&
typeof metadata.templateId === 'string'
) {
const tpl = getTemplate(db, metadata.templateId);
if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) {
await ensureProject(PROJECTS_DIR, id);
for (const f of tpl.files) {
if (!f || typeof f.name !== 'string' || typeof f.content !== 'string') {
continue;
}
try {
await writeProjectFile(
PROJECTS_DIR,
id,
f.name,
Buffer.from(f.content, 'utf8'),
);
} catch {
// Skip individual file failures — the template snapshot is
// best-effort; the agent still has the embedded copy.
}
}
}
}
res.json({ project, conversationId: cid });
} catch (err) {
res.status(400).json({ error: String(err) });
}
});
app.get('/api/projects/:id', (req, res) => {
const project = getProject(db, req.params.id);
if (!project) return res.status(404).json({ error: 'not found' });
res.json({ project });
});
app.patch('/api/projects/:id', (req, res) => {
try {
const patch = req.body || {};
const project = updateProject(db, req.params.id, patch);
if (!project) return res.status(404).json({ error: 'not found' });
res.json({ project });
} catch (err) {
res.status(400).json({ error: String(err) });
}
});
app.delete('/api/projects/:id', async (req, res) => {
try {
dbDeleteProject(db, req.params.id);
await removeProjectDir(PROJECTS_DIR, req.params.id).catch(() => {});
res.json({ ok: true });
} catch (err) {
res.status(400).json({ error: String(err) });
}
});
// ---- Conversations --------------------------------------------------------
app.get('/api/projects/:id/conversations', (req, res) => {
if (!getProject(db, req.params.id)) {
return res.status(404).json({ error: 'project not found' });
}
res.json({ conversations: listConversations(db, req.params.id) });
});
app.post('/api/projects/:id/conversations', (req, res) => {
if (!getProject(db, req.params.id)) {
return res.status(404).json({ error: 'project not found' });
}
const { title } = req.body || {};
const now = Date.now();
const conv = insertConversation(db, {
id: randomId(),
projectId: req.params.id,
title: typeof title === 'string' ? title.trim() || null : null,
createdAt: now,
updatedAt: now,
});
res.json({ conversation: conv });
});
app.patch('/api/projects/:id/conversations/:cid', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'not found' });
}
const updated = updateConversation(db, req.params.cid, req.body || {});
res.json({ conversation: updated });
});
app.delete('/api/projects/:id/conversations/:cid', (req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'not found' });
}
deleteConversation(db, req.params.cid);
res.json({ ok: true });
});
// ---- Messages -------------------------------------------------------------
app.get(
'/api/projects/:id/conversations/:cid/messages',
(req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
res.json({ messages: listMessages(db, req.params.cid) });
},
);
app.put(
'/api/projects/:id/conversations/:cid/messages/:mid',
(req, res) => {
const conv = getConversation(db, req.params.cid);
if (!conv || conv.projectId !== req.params.id) {
return res.status(404).json({ error: 'conversation not found' });
}
const m = req.body || {};
if (m.id && m.id !== req.params.mid) {
return res.status(400).json({ error: 'id mismatch' });
}
const saved = upsertMessage(db, req.params.cid, { ...m, id: req.params.mid });
// Bump the parent project's updatedAt so the project list re-orders.
updateProject(db, req.params.id, {});
res.json({ message: saved });
},
);
// ---- Tabs -----------------------------------------------------------------
app.get('/api/projects/:id/tabs', (req, res) => {
if (!getProject(db, req.params.id)) {
return res.status(404).json({ error: 'project not found' });
}
res.json(listTabs(db, req.params.id));
});
app.put('/api/projects/:id/tabs', (req, res) => {
if (!getProject(db, req.params.id)) {
return res.status(404).json({ error: 'project not found' });
}
const { tabs = [], active = null } = req.body || {};
if (!Array.isArray(tabs) || !tabs.every((t) => typeof t === 'string')) {
return res.status(400).json({ error: 'tabs must be string[]' });
}
const result = setTabs(
db,
req.params.id,
tabs,
typeof active === 'string' ? active : null,
);
res.json(result);
});
// ---- Templates ----------------------------------------------------------
// User-saved snapshots of a project's HTML files. Surfaced in the
// "From template" tab of the new-project panel so a user can spin up
// a fresh project pre-seeded with another project's design as a
// starting point. Created via the project's Share menu (snapshots
// every .html file in the project folder at the moment of save).
app.get('/api/templates', (_req, res) => {
res.json({ templates: listTemplates(db) });
});
app.get('/api/templates/:id', (req, res) => {
const t = getTemplate(db, req.params.id);
if (!t) return res.status(404).json({ error: 'not found' });
res.json({ template: t });
});
app.post('/api/templates', async (req, res) => {
try {
const { name, description, sourceProjectId } = req.body || {};
if (typeof name !== 'string' || !name.trim()) {
return res.status(400).json({ error: 'name required' });
}
if (typeof sourceProjectId !== 'string') {
return res.status(400).json({ error: 'sourceProjectId required' });
}
if (!getProject(db, sourceProjectId)) {
return res.status(404).json({ error: 'source project not found' });
}
// Snapshot every HTML / sketch / text file in the source project.
// We deliberately skip binary uploads — templates are about the
// generated design, not the user's reference imagery.
const files = await listFiles(PROJECTS_DIR, sourceProjectId);
const snapshot = [];
for (const f of files) {
if (f.kind !== 'html' && f.kind !== 'text' && f.kind !== 'code') continue;
const entry = await readProjectFile(PROJECTS_DIR, sourceProjectId, f.name);
if (entry && Buffer.isBuffer(entry.buffer)) {
snapshot.push({ name: f.name, content: entry.buffer.toString('utf8') });
}
}
const t = insertTemplate(db, {
id: randomId(),
name: name.trim(),
description: typeof description === 'string' ? description : null,
sourceProjectId,
files: snapshot,
createdAt: Date.now(),
});
res.json({ template: t });
} catch (err) {
res.status(400).json({ error: String(err) });
}
});
app.delete('/api/templates/:id', (req, res) => {
deleteTemplate(db, req.params.id);
res.json({ ok: true });
});
app.get('/api/agents', async (_req, res) => {
try {
const list = await detectAgents();
res.json({ agents: list });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/skills', async (_req, res) => {
try {
const skills = await listSkills(SKILLS_DIR);
// Strip full body + on-disk dir from the listing — frontend fetches the
// body via /api/skills/:id when needed (keeps the listing payload small).
res.json({
skills: skills.map(({ body, dir: _dir, ...rest }) => ({
...rest,
hasBody: typeof body === 'string' && body.length > 0,
})),
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/skills/:id', async (req, res) => {
try {
const skills = await listSkills(SKILLS_DIR);
const skill = skills.find((s) => s.id === req.params.id);
if (!skill) return res.status(404).json({ error: 'skill not found' });
const { dir: _dir, ...serializable } = skill;
res.json(serializable);
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/design-systems', async (_req, res) => {
try {
const systems = await listDesignSystems(DESIGN_SYSTEMS_DIR);
res.json({
designSystems: systems.map(({ body, ...rest }) => rest),
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/design-systems/:id', async (req, res) => {
try {
const body = await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id);
if (body === null) return res.status(404).json({ error: 'design system not found' });
res.json({ id: req.params.id, body });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// Showcase HTML for a design system — palette swatches, typography
// samples, sample components, and the full DESIGN.md rendered as prose.
// Built at request time from the on-disk DESIGN.md so any update to the
// file shows up on the next view, no rebuild needed.
app.get('/api/design-systems/:id/preview', async (req, res) => {
try {
const body = await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id);
if (body === null) return res.status(404).type('text/plain').send('not found');
const html = renderDesignSystemPreview(req.params.id, body);
res.type('text/html').send(html);
} catch (err) {
res.status(500).type('text/plain').send(String(err));
}
});
// Marketing-style showcase derived from the same DESIGN.md — full landing
// page parameterised by the system's tokens. Same lazy-render strategy as
// /preview: built at request time, no caching.
app.get('/api/design-systems/:id/showcase', async (req, res) => {
try {
const body = await readDesignSystem(DESIGN_SYSTEMS_DIR, req.params.id);
if (body === null) return res.status(404).type('text/plain').send('not found');
const html = renderDesignSystemShowcase(req.params.id, body);
res.type('text/html').send(html);
} catch (err) {
res.status(500).type('text/plain').send(String(err));
}
});
// Pre-built example HTML for a skill — what a typical artifact from this
// skill looks like. Lets users browse skills without running an agent.
//
// The skill's `id` (from SKILL.md frontmatter `name`) can differ from its
// on-disk folder name (e.g. id `magazine-web-ppt` lives in `skills/guizang-ppt/`),
// so we resolve the actual directory via listSkills() rather than guessing.
//
// Resolution order:
// 1. <skillDir>/example.html — fully-baked static example (preferred)
// 2. <skillDir>/assets/template.html +
// <skillDir>/assets/example-slides.html — assemble at request time
// by replacing the `<!-- SLIDES_HERE -->` marker with the snippet
// and patching the placeholder <title>. Lets a skill ship one
// canonical seed plus a small content fragment, so the example
// never drifts from the seed.
// 3. <skillDir>/assets/template.html — raw template, no content slides
// 4. <skillDir>/assets/index.html — generic fallback
app.get('/api/skills/:id/example', async (req, res) => {
try {
const skills = await listSkills(SKILLS_DIR);
const skill = skills.find((s) => s.id === req.params.id);
if (!skill) {
return res.status(404).type('text/plain').send('skill not found');
}
const baked = path.join(skill.dir, 'example.html');
if (fs.existsSync(baked)) {
return res.type('text/html').sendFile(baked);
}
const tpl = path.join(skill.dir, 'assets', 'template.html');
const slides = path.join(skill.dir, 'assets', 'example-slides.html');
if (fs.existsSync(tpl) && fs.existsSync(slides)) {
try {
const tplHtml = await fs.promises.readFile(tpl, 'utf8');
const slidesHtml = await fs.promises.readFile(slides, 'utf8');
const assembled = assembleExample(tplHtml, slidesHtml, skill.name);
return res.type('text/html').send(assembled);
} catch {
// Fall through to raw template on read failure.
}
}
if (fs.existsSync(tpl)) {
return res.type('text/html').sendFile(tpl);
}
const idx = path.join(skill.dir, 'assets', 'index.html');
if (fs.existsSync(idx)) {
return res.type('text/html').sendFile(idx);
}
res
.status(404)
.type('text/plain')
.send('no example.html, assets/template.html, or assets/index.html for this skill');
} catch (err) {
res.status(500).type('text/plain').send(String(err));
}
});
app.post('/api/upload', upload.array('images', 8), (req, res) => {
const files = (req.files || []).map((f) => ({
name: f.originalname,
path: f.path,
size: f.size,
}));
res.json({ files });
});
// Persist a generated artifact (HTML) to disk so the user can re-open it
// in their browser or hand it off. Returns the on-disk path + a served URL.
// The body is also passed through the anti-slop linter; findings are
// returned alongside the path so the UI can render a P0/P1 badge and the
// chat layer can splice them into a system reminder for the agent.
app.post('/api/artifacts/save', (req, res) => {
try {
const { identifier, title, html } = req.body || {};
if (typeof html !== 'string' || html.length === 0) {
return res.status(400).json({ error: 'html required' });
}
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
const slug = sanitizeSlug(identifier || title || 'artifact');
const dir = path.join(ARTIFACTS_DIR, `${stamp}-${slug}`);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'index.html');
fs.writeFileSync(file, html, 'utf8');
const findings = lintArtifact(html);
res.json({
path: file,
url: `/artifacts/${path.basename(dir)}/index.html`,
lint: findings,
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// Standalone lint endpoint — POST raw HTML, get findings back.
// The chat layer uses this to lint streamed-in artifacts without writing
// them to disk first, so a P0 issue can be surfaced before save.
app.post('/api/artifacts/lint', (req, res) => {
try {
const { html } = req.body || {};
if (typeof html !== 'string' || html.length === 0) {
return res.status(400).json({ error: 'html required' });
}
const findings = lintArtifact(html);
res.json({
findings,
agentMessage: renderFindingsForAgent(findings),
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.use('/artifacts', express.static(ARTIFACTS_DIR));
// Shared device frames (iPhone, Android, iPad, MacBook, browser chrome).
// Skills can compose multi-screen / multi-device layouts by pointing at
// these files via `<iframe src="/frames/iphone-15-pro.html?screen=...">`.
// No mtime-based caching — frames are static and small.
app.use('/frames', express.static(path.join(PROJECT_ROOT, 'assets', 'frames')));
// Project files. Each project owns a flat folder under .ocd/projects/<id>/
// containing every file the user has uploaded, pasted, sketched, or that
// the agent has generated. Names are sanitized; paths are confined to the
// project's own folder (see daemon/projects.js).
app.get('/api/projects/:id/files', async (req, res) => {
try {
const files = await listFiles(PROJECTS_DIR, req.params.id);
res.json({ files });
} catch (err) {
res.status(400).json({ error: String(err) });
}
});
app.get('/api/projects/:id/files/:name', async (req, res) => {
try {
const file = await readProjectFile(PROJECTS_DIR, req.params.id, req.params.name);
res.type(file.mime).send(file.buffer);
} catch (err) {
const code = err && err.code === 'ENOENT' ? 404 : 400;
res.status(code).json({ error: String(err) });
}
});
// Two ways to upload: multipart for binary files (images), and JSON
// {name, content, encoding} for sketches and pasted text. The frontend
// uses both depending on the file source.
app.post(
'/api/projects/:id/files',
upload.single('file'),
async (req, res) => {
try {
await ensureProject(PROJECTS_DIR, req.params.id);
if (req.file) {
const buf = await fs.promises.readFile(req.file.path);
const desiredName = sanitizeName(req.body?.name || req.file.originalname);
const meta = await writeProjectFile(
PROJECTS_DIR,
req.params.id,
desiredName,
buf,
);
fs.promises.unlink(req.file.path).catch(() => {});
return res.json({ file: meta });
}
const { name, content, encoding } = req.body || {};
if (typeof name !== 'string' || typeof content !== 'string') {
return res.status(400).json({ error: 'name and content required' });
}
const buf =
encoding === 'base64'
? Buffer.from(content, 'base64')
: Buffer.from(content, 'utf8');
const meta = await writeProjectFile(PROJECTS_DIR, req.params.id, name, buf);
res.json({ file: meta });
} catch (err) {
res.status(400).json({ error: String(err) });
}
},
);
app.delete('/api/projects/:id/files/:name', async (req, res) => {
try {
await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params.name);
res.json({ ok: true });
} catch (err) {
const code = err && err.code === 'ENOENT' ? 404 : 400;
res.status(code).json({ error: String(err) });
}
});
// Multi-file upload that the chat composer uses for paste/drop/picker.
// Files land flat in the project folder; the response carries the same
// metadata as listFiles so the client can stage them as ChatAttachments
// without a separate refetch.
app.post(
'/api/projects/:id/upload',
projectUpload.array('files', 12),
async (req, res) => {
try {
const incoming = Array.isArray(req.files) ? req.files : [];
const out = [];
for (const f of incoming) {
try {
const stat = await fs.promises.stat(f.path);
out.push({
name: f.filename,
path: f.filename,
size: stat.size,
mtime: stat.mtimeMs,
originalName: f.originalname,
});
} catch {
// skip files that vanished mid-flight
}
}
res.json({ files: out });
} catch (err) {
res.status(400).json({ error: String(err) });
}
},
);
app.post('/api/chat', async (req, res) => {
const {
agentId,
message,
systemPrompt,
imagePaths = [],
projectId,
attachments = [],
} = req.body || {};
const def = getAgentDef(agentId);
if (!def) return res.status(400).json({ error: `unknown agent: ${agentId}` });
if (!def.bin) return res.status(400).json({ error: 'agent has no binary' });
if (typeof message !== 'string' || !message.trim()) {
return res.status(400).json({ error: 'message required' });
}
// Resolve the project working directory (creating the folder if it
// doesn't exist yet). Without one we don't pass cwd to spawn — the
// agent then runs in whatever inherited dir, which still lets API
// mode work but loses file-tool addressability.
let cwd = null;
let existingProjectFiles = [];
if (typeof projectId === 'string' && projectId) {
try {
cwd = await ensureProject(PROJECTS_DIR, projectId);
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId);
} catch {
cwd = null;
}
}
// Sanitise supplied image paths: must live under UPLOAD_DIR.
const safeImages = imagePaths.filter((p) => {
const resolved = path.resolve(p);
return resolved.startsWith(UPLOAD_DIR + path.sep) && fs.existsSync(resolved);
});
// Project-scoped attachments: project-relative paths inside cwd. Each
// is run through the same path-traversal guard the file CRUD endpoints
// use, then existence-checked. Whatever survives shows up as an
// explicit list at the bottom of the user message so the agent knows
// to Read it.
const safeAttachments = cwd
? (Array.isArray(attachments) ? attachments : [])
.filter((p) => typeof p === 'string' && p.length > 0)
.filter((p) => {
try {
const abs = path.resolve(cwd, p);
return (
(abs === cwd || abs.startsWith(cwd + path.sep)) &&
fs.existsSync(abs)
);
} catch {
return false;
}
})
: [];
// Local code agents don't accept a separate "system" channel the way the
// Messages API does — we fold the skill + design-system prompt into the
// user message. The <artifact> wrapping instruction comes from
// systemPrompt. We also stitch in the cwd hint so the agent knows
// where its file tools should write, and the attachment list so it
// doesn't have to guess what the user just dropped in.
// Also ship the current file listing so the agent can pick a unique
// filename instead of clobbering a previous artifact.
const filesListBlock = existingProjectFiles.length
? `\nFiles already in this folder (do NOT overwrite unless the user asks; pick a fresh, descriptive name for new artifacts):\n${existingProjectFiles
.map((f) => `- ${f.name}`)
.join('\n')}`
: '\nThis folder is empty. Choose a clear, descriptive filename for whatever you create.';
const cwdHint = cwd
? `\n\nYour working directory: ${cwd}\nWrite project files relative to it (e.g. \`index.html\`, \`assets/x.png\`). The user can browse those files in real time.${filesListBlock}`
: '';
const attachmentHint = safeAttachments.length
? `\n\nAttached project files: ${safeAttachments.map((p) => `\`${p}\``).join(', ')}`
: '';
const composed = [
systemPrompt && systemPrompt.trim()
? `# Instructions (read first)\n\n${systemPrompt.trim()}${cwdHint}\n\n---\n`
: cwdHint
? `# Instructions${cwdHint}\n\n---\n`
: '',
`# User request\n\n${message}${attachmentHint}`,
safeImages.length ? `\n\n${safeImages.map((p) => `@${p}`).join(' ')}` : '',
].join('');
const args = def.buildArgs(composed, safeImages);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
const send = (event, data) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
send('start', {
agentId,
bin: def.bin,
streamFormat: def.streamFormat ?? 'plain',
projectId: typeof projectId === 'string' ? projectId : null,
cwd,
});
let child;
try {
child = spawn(def.bin, args, {
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
cwd: cwd || undefined,
});
} catch (err) {
send('error', { message: `spawn failed: ${err.message}` });
return res.end();
}
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
// Structured streams (Claude Code) go through a line-delimited JSON
// parser that turns stream_event objects into UI-friendly events. For
// plain streams (most other CLIs) we forward raw chunks unchanged so
// the browser can append them to the assistant's text buffer.
if (def.streamFormat === 'claude-stream-json') {
const claude = createClaudeStreamHandler((ev) => send('agent', ev));
child.stdout.on('data', (chunk) => claude.feed(chunk));
child.on('close', () => claude.flush());
} else {
child.stdout.on('data', (chunk) => send('stdout', { chunk }));
}
child.stderr.on('data', (chunk) => send('stderr', { chunk }));
const kill = () => {
if (child && !child.killed) child.kill('SIGTERM');
};
res.on('close', () => {
if (!res.writableEnded) kill();
});
child.on('error', (err) => {
send('error', { message: err.message });
res.end();
});
child.on('close', (code, signal) => {
send('end', { code, signal });
res.end();
});
});
// SPA fallback for the built web app. Put this LAST so it never shadows
// /api routes. Only active when a dist/ exists (production mode).
if (fs.existsSync(STATIC_DIR)) {
app.get(/^\/(?!api\/|artifacts\/).*/, (_req, res) => {
res.sendFile(path.join(STATIC_DIR, 'index.html'));
});
}
return new Promise((resolve) => {
app.listen(port, '127.0.0.1', () => resolve(`http://localhost:${port}`));
});
}
// Assemble a skill's example deck from its seed template + a slides
// snippet. The seed contains the full CSS / WebGL / nav-JS shell with a
// `<!-- SLIDES_HERE -->` marker; the snippet contributes the actual
// `<section class="slide ...">` content. We also patch the placeholder
// `<title>` so the iframe's tab name reads as the skill, not the
// "[必填] 替换为 PPT 标题" stub.
function assembleExample(tplHtml, slidesHtml, skillName) {
const slidesMarker = /<!--\s*SLIDES_HERE\s*-->/i;
const titleTag = /<title>[^<]*<\/title>/i;
const safeTitle = `${skillName || 'Magazine Web PPT'} · Example Deck`;
const withSlides = slidesMarker.test(tplHtml)
? tplHtml.replace(slidesMarker, slidesHtml)
: tplHtml.replace(/<\/body>/i, `${slidesHtml}</body>`);
return titleTag.test(withSlides)
? withSlides.replace(titleTag, `<title>${escapeHtml(safeTitle)}</title>`)
: withSlides;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function sanitizeSlug(s) {
return String(s)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40) || 'artifact';
}
function randomId() {
return randomUUID();
}
+171
View File
@@ -0,0 +1,171 @@
// Skill registry. Scans <projectRoot>/skills/* for SKILL.md files, parses
// front-matter, returns listing. No watching in this MVP — re-scans on every
// GET /api/skills, which is fine for dozens of skills.
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { parseFrontmatter } from './frontmatter.js';
export async function listSkills(skillsRoot) {
const out = [];
let entries = [];
try {
entries = await readdir(skillsRoot, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dir = path.join(skillsRoot, entry.name);
const skillPath = path.join(dir, 'SKILL.md');
try {
const stats = await stat(skillPath);
if (!stats.isFile()) continue;
const raw = await readFile(skillPath, 'utf8');
const { data, body } = parseFrontmatter(raw);
const hasAttachments = await dirHasAttachments(dir);
const mode = data.ocd?.mode || inferMode(body, data.description);
out.push({
id: data.name || entry.name,
name: data.name || entry.name,
description: data.description || '',
triggers: Array.isArray(data.triggers) ? data.triggers : [],
mode,
platform: normalizePlatform(data.ocd?.platform, mode, body, data.description),
scenario: normalizeScenario(data.ocd?.scenario, body, data.description),
previewType: data.ocd?.preview?.type || 'html',
designSystemRequired: data.ocd?.design_system?.requires ?? true,
defaultFor: normalizeDefaultFor(data.ocd?.default_for),
upstream: typeof data.ocd?.upstream === 'string' ? data.ocd.upstream : null,
featured: normalizeFeatured(data.ocd?.featured),
examplePrompt: derivePrompt(data),
body: hasAttachments ? withSkillRootPreamble(body, dir) : body,
dir,
});
} catch {
// Skip unreadable entries — this is discovery, not validation.
}
}
return out;
}
// Skills that ship side files (e.g. `assets/template.html`, `references/*.md`)
// need the agent to know where the skill lives on disk — relative paths in the
// SKILL.md body resolve against the agent's CWD, which is the daemon root, not
// the skill folder. We prepend a short preamble so any capable code agent can
// open those files via absolute paths.
function withSkillRootPreamble(body, dir) {
const preamble = [
'> **Skill root (absolute):** `' + dir + '`',
'>',
'> This skill ships side files alongside `SKILL.md`. When the workflow',
'> below references relative paths such as `assets/template.html` or',
'> `references/layouts.md`, resolve them against the skill root above and',
'> open them via their full absolute path.',
'',
'',
].join('\n');
return preamble + body;
}
async function dirHasAttachments(dir) {
try {
const entries = await readdir(dir, { withFileTypes: true });
return entries.some(
(e) => e.name !== 'SKILL.md' && (e.isDirectory() || /\.(md|html|css|js|json|txt)$/i.test(e.name)),
);
} catch {
return false;
}
}
function normalizeDefaultFor(value) {
if (!value) return [];
if (Array.isArray(value)) return value.map(String);
return [String(value)];
}
// Coerce `ocd.featured` into a numeric priority. Lower numbers float to the
// top of the Examples gallery; `true` is treated as priority 1; anything
// missing/unrecognised becomes null so non-featured skills keep their
// natural alphabetical order.
function normalizeFeatured(value) {
if (value === true) return 1;
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const n = Number(value);
if (Number.isFinite(n)) return n;
}
return null;
}
// Prefer an explicitly authored `ocd.example_prompt`. Fall back to the
// skill description's first sentence — it's already written in actionable
// language ("Admin / analytics dashboard in a single HTML file…") so it
// serves as a passable starter prompt.
function derivePrompt(data) {
const explicit = data.ocd?.example_prompt;
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
const desc = typeof data.description === 'string' ? data.description.trim() : '';
if (!desc) return '';
const collapsed = desc.replace(/\s+/g, ' ').trim();
const firstSentence = collapsed.match(/^.+?[.!?。!?](?:\s|$)/)?.[0]?.trim();
return (firstSentence || collapsed).slice(0, 320);
}
function inferMode(body, description) {
const hay = `${description ?? ''}\n${body ?? ''}`.toLowerCase();
if (/\bppt|deck|slide|presentation|幻灯|投影/.test(hay)) return 'deck';
if (/\bdesign[- ]system|\bdesign\.md|\bdesign tokens/.test(hay)) return 'design-system';
if (/\btemplate\b/.test(hay)) return 'template';
return 'prototype';
}
// Validate platform tag — only desktop / mobile are meaningful for the
// Examples gallery. Falls back to autodetecting "mobile" from descriptions
// so legacy skills sort under the right pill without authoring changes.
function normalizePlatform(value, mode, body, description) {
if (value === 'desktop' || value === 'mobile') return value;
if (mode !== 'prototype') return null;
const hay = `${description ?? ''}\n${body ?? ''}`.toLowerCase();
if (/mobile|phone|ios|android|手机|移动端/.test(hay)) return 'mobile';
return 'desktop';
}
// Normalise a scenario tag to a small fixed vocabulary so the filter pills
// stay tidy. Unknown values pass through verbatim so authors can experiment;
// missing values default to "general".
const KNOWN_SCENARIOS = new Set([
'general',
'engineering',
'product',
'design',
'marketing',
'sales',
'finance',
'hr',
'operations',
'support',
'legal',
'education',
'personal',
]);
function normalizeScenario(value, body, description) {
if (typeof value === 'string') {
const v = value.trim().toLowerCase();
if (v) return v;
}
const hay = `${description ?? ''}\n${body ?? ''}`.toLowerCase();
if (/finance|invoice|expense|budget|p&l|revenue/.test(hay)) return 'finance';
if (/\bhr\b|onboarding|payroll|employee|人事/.test(hay)) return 'hr';
if (/marketing|campaign|brand|landing/.test(hay)) return 'marketing';
if (/runbook|incident|deploy|engineering|sre|api/.test(hay)) return 'engineering';
if (/spec|prd|roadmap|product manager|product team/.test(hay)) return 'product';
if (/design system|moodboard|mockup|ui kit/.test(hay)) return 'design';
if (/sales|quote|proposal|lead/.test(hay)) return 'sales';
if (/operations|ops|logistics|inventory/.test(hay)) return 'operations';
return 'general';
}
// Surface the vocabulary so callers (frontend filter UI) could mirror it
// later if they want to. Not exported today, kept here for documentation.
void KNOWN_SCENARIOS;