openpencil/scripts/generate-iconify-catalog.mjs
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

165 lines
5.4 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const SETS = ['lucide', 'feather', 'simple-icons'];
// The catalog is split: the general-purpose UI sets (lucide + feather) are
// embedded in the wasm/binary; the ~3700 simple-icons brand logos are loaded at
// runtime (desktop: include_str at startup; web: fetched from the daemon) so
// they never bloat the wasm first-load.
const CORE_SETS = new Set(['lucide', 'feather']);
const OUT_DIR = path.resolve('crates/op-editor-ui/assets');
const OUT_CORE = path.join(OUT_DIR, 'iconify-catalog-core.json');
const OUT_BRANDS = path.join(OUT_DIR, 'iconify-catalog-brands.json');
function attr(tag, name) {
const match = tag.match(new RegExp(`\\b${name}="([^"]*)"`));
return match ? match[1] : undefined;
}
function num(value, fallback = 0) {
if (value === undefined || value === '') return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function circlePath(cx, cy, r) {
return `M${cx - r} ${cy}A${r} ${r} 0 1 0 ${cx + r} ${cy}A${r} ${r} 0 1 0 ${cx - r} ${cy}Z`;
}
function ellipsePath(cx, cy, rx, ry) {
return `M${cx - rx} ${cy}A${rx} ${ry} 0 1 0 ${cx + rx} ${cy}A${rx} ${ry} 0 1 0 ${cx - rx} ${cy}Z`;
}
function rectPath(x, y, w, h, r) {
if (r <= 0) return `M${x} ${y}H${x + w}V${y + h}H${x}Z`;
const rr = Math.min(r, w / 2, h / 2);
return [
`M${x + rr} ${y}`,
`H${x + w - rr}`,
`A${rr} ${rr} 0 0 1 ${x + w} ${y + rr}`,
`V${y + h - rr}`,
`A${rr} ${rr} 0 0 1 ${x + w - rr} ${y + h}`,
`H${x + rr}`,
`A${rr} ${rr} 0 0 1 ${x} ${y + h - rr}`,
`V${y + rr}`,
`A${rr} ${rr} 0 0 1 ${x + rr} ${y}`,
'Z',
].join('');
}
function pointsPath(points, close) {
const nums = points
.trim()
.split(/[\s,]+/)
.map(Number)
.filter(Number.isFinite);
if (nums.length < 4) return null;
const pairs = [];
for (let i = 0; i + 1 < nums.length; i += 2) pairs.push([nums[i], nums[i + 1]]);
return `M${pairs.map((p) => `${p[0]} ${p[1]}`).join('L')}${close ? 'Z' : ''}`;
}
function pathsFromBody(body) {
const paths = [];
for (const match of body.matchAll(/<path\b[^>]*?\bd="([^"]+)"[^>]*>/gi)) {
paths.push(match[1]);
}
for (const match of body.matchAll(/<line\b[^>]*>/gi)) {
const tag = match[0];
paths.push(
`M${num(attr(tag, 'x1'))} ${num(attr(tag, 'y1'))}L${num(attr(tag, 'x2'))} ${num(attr(tag, 'y2'))}`,
);
}
for (const match of body.matchAll(/<circle\b[^>]*>/gi)) {
const tag = match[0];
paths.push(circlePath(num(attr(tag, 'cx')), num(attr(tag, 'cy')), num(attr(tag, 'r'))));
}
for (const match of body.matchAll(/<ellipse\b[^>]*>/gi)) {
const tag = match[0];
paths.push(
ellipsePath(
num(attr(tag, 'cx')),
num(attr(tag, 'cy')),
num(attr(tag, 'rx')),
num(attr(tag, 'ry')),
),
);
}
for (const match of body.matchAll(/<rect\b[^>]*>/gi)) {
const tag = match[0];
paths.push(
rectPath(
num(attr(tag, 'x')),
num(attr(tag, 'y')),
num(attr(tag, 'width')),
num(attr(tag, 'height')),
num(attr(tag, 'rx'), num(attr(tag, 'ry'))),
),
);
}
for (const match of body.matchAll(/<polyline\b[^>]*?\bpoints="([^"]+)"[^>]*>/gi)) {
const d = pointsPath(match[1], false);
if (d) paths.push(d);
}
for (const match of body.matchAll(/<polygon\b[^>]*?\bpoints="([^"]+)"[^>]*>/gi)) {
const d = pointsPath(match[1], true);
if (d) paths.push(d);
}
for (let i = 1; i < paths.length; i += 1) {
if (paths[i].startsWith('m')) paths[i] = `M${paths[i].slice(1)}`;
}
return paths;
}
function styleFromBody(body) {
const hasStroke = /\bstroke=|\bstroke-width=|\bstroke-linecap=|\bfill="none"/i.test(body);
const hasFill = /\bfill="(?!none)[^"]*"/i.test(body);
return hasStroke && !hasFill ? 'stroke' : 'fill';
}
const icons = [];
for (const collection of SETS) {
const data = require(`../node_modules/@iconify-json/${collection}/icons.json`);
const names = Object.keys(data.icons).sort((a, b) => a.localeCompare(b));
for (const name of names) {
const icon = data.icons[name];
const paths = pathsFromBody(icon.body);
if (paths.length === 0) continue;
icons.push({
collection,
name,
width: icon.width ?? data.width ?? 24,
height: icon.height ?? data.height ?? 24,
style: styleFromBody(icon.body),
d: paths.join(' '),
});
}
}
for (const [alias, target] of Object.entries({ home: 'house', unlock: 'lock-open' })) {
if (
!icons.some((icon) => icon.collection === 'lucide' && icon.name === alias) &&
icons.some((icon) => icon.collection === 'lucide' && icon.name === target)
) {
const source = icons.find((icon) => icon.collection === 'lucide' && icon.name === target);
icons.push({ ...source, name: alias });
}
}
icons.sort((a, b) => {
const setDelta = SETS.indexOf(a.collection) - SETS.indexOf(b.collection);
return setDelta || a.name.localeCompare(b.name);
});
const coreIcons = icons.filter((icon) => CORE_SETS.has(icon.collection));
const brandIcons = icons.filter((icon) => !CORE_SETS.has(icon.collection));
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(OUT_CORE, JSON.stringify({ icons: coreIcons }));
fs.writeFileSync(OUT_BRANDS, JSON.stringify({ icons: brandIcons }));
console.log(`wrote ${coreIcons.length} core icons to ${OUT_CORE}`);
console.log(`wrote ${brandIcons.length} brand icons to ${OUT_BRANDS}`);