Files
open-design/daemon/design-systems.js
T
pftom 976a6eadf2 feat(media): add image/video/audio project kinds via od media generate
Introduce non-web media surfaces (image, video, audio) as first-class
project kinds. The unifying contract is "skill workflow + project
metadata tell the agent WHAT to make; one shell command — od media
generate — is HOW bytes are produced", so any code-agent CLI with
shell access can drive it without bespoke tools.

- Frontend: New Project panel gains Image/Video/Audio tabs with model
  picker, aspect/length/duration controls, and audio kind/voice
  selection. Examples and Design Systems tabs gain layered sections.
  FileViewer renders the generated image/video/audio files.
- Shared registry: src/media/models.ts is the single source of truth
  for image/video/audio model IDs, aspects, and defaults — consumed
  by the picker AND the daemon dispatcher.
- Prompts: media-contract.ts is pinned LAST in the system prompt for
  media surfaces so its hard rules (call od media generate, don't
  emit binary in <artifact>, allowed model IDs) win over softer
  earlier wording.
- Daemon: new media.js dispatcher + media-models.js JSON view of the
  registry; cli.js gets the `od media generate` subcommand wired up
  via server.js / projects.js so the daemon writes files back into
  the project dir.
- Skills: audio-jingle, image-poster, video-shortform seed examples
  for the three surfaces.

Made-with: Cursor
2026-04-28 22:41:14 +08:00

171 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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),
// Optional `> Surface: image|video|audio` blockquote line. Most
// existing systems target the web surface and don't declare it;
// we default to 'web' so the right-side filter classifies them
// correctly.
surface: extractSurface(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];
}
const KNOWN_SURFACES = new Set(['web', 'image', 'video', 'audio']);
function extractSurface(raw) {
const m = /^>\s*Surface:\s*(.+?)\s*$/im.exec(raw);
if (!m) return 'web';
const v = m[1].trim().toLowerCase();
return KNOWN_SURFACES.has(v) ? v : 'web';
}
// 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();
}