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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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 === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : ''',
|
||||
);
|
||||
}
|
||||
|
||||
// Tiny markdown renderer — enough for our DESIGN.md prose: H1–H4, 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>');
|
||||
}
|
||||
@@ -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/don’t" pair so engineers don’t 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. That’s 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 === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : ''',
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.6–1.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 8–10 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, '\\$&');
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function sanitizeSlug(s) {
|
||||
return String(s)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40) || 'artifact';
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return randomUUID();
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user