use-agentz 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +308 -0
- package/dist/authoring/define.js +132 -0
- package/dist/cli.js +26581 -0
- package/dist/config/define.js +36 -0
- package/dist/ui/web/index.html +16 -0
- package/dist/ui/web/logo.png +0 -0
- package/dist/ui/web/main.tsx +2406 -0
- package/dist/ui/web/styles.css +2338 -0
- package/package.json +54 -0
- package/registry/r/agent.analyst.json +44 -0
- package/registry/r/agent.designer.json +34 -0
- package/registry/r/agent.developer.json +31 -0
- package/registry/r/agent.expert-orchestrator.json +36 -0
- package/registry/r/agent.expert.json +33 -0
- package/registry/r/agent.orchestrator.json +39 -0
- package/registry/r/agent.quick.json +28 -0
- package/registry/r/agent.reviewer-anthropic.json +34 -0
- package/registry/r/agent.reviewer-gpt.json +34 -0
- package/registry/r/agent.scout.json +34 -0
- package/registry/r/command.caveman.json +20 -0
- package/registry/r/command.grill-me.json +20 -0
- package/registry/r/group.cli-bun.json +13 -0
- package/registry/r/group.cli-go.json +13 -0
- package/registry/r/group.database-design.json +12 -0
- package/registry/r/group.design.json +14 -0
- package/registry/r/group.documents.json +13 -0
- package/registry/r/group.engineering-process.json +18 -0
- package/registry/r/group.go.json +21 -0
- package/registry/r/group.python.json +10 -0
- package/registry/r/group.react.json +11 -0
- package/registry/r/group.typescript.json +10 -0
- package/registry/r/lsp.proto.json +41 -0
- package/registry/r/mcp.context7.json +13 -0
- package/registry/r/mcp.playwright.json +50 -0
- package/registry/r/pack.basic.json +78 -0
- package/registry/r/pack.gpt-5-6.json +78 -0
- package/registry/r/pack.optimized.json +78 -0
- package/registry/r/pack.opus-fable.json +78 -0
- package/registry/r/plugin.caveman-compress.json +20 -0
- package/registry/r/preset.caveman-only.json +13 -0
- package/registry/r/preset.cost-aware-trio.json +41 -0
- package/registry/r/preset.cross-model-review.json +34 -0
- package/registry/r/preset.design.json +39 -0
- package/registry/r/preset.fable-pack.json +41 -0
- package/registry/r/preset.full.json +67 -0
- package/registry/r/rule.always-cross-review.json +25 -0
- package/registry/r/rule.always-design.json +25 -0
- package/registry/r/rule.always-qa.json +25 -0
- package/registry/r/rule.always-test.json +25 -0
- package/registry/r/rule.caveman-ultra.json +23 -0
- package/registry/r/rule.never-cross-review.json +25 -0
- package/registry/r/rule.never-design.json +25 -0
- package/registry/r/rule.never-qa.json +25 -0
- package/registry/r/rule.never-test.json +25 -0
- package/registry/r/rule.routing.json +26 -0
- package/registry/r/rule.use-question-tool.json +20 -0
- package/registry/r/skill.bun-cli.json +20 -0
- package/registry/r/skill.bun-tui.json +20 -0
- package/registry/r/skill.caveman-compress.json +17 -0
- package/registry/r/skill.caveman.json +20 -0
- package/registry/r/skill.cli-design.json +15 -0
- package/registry/r/skill.code-review.json +18 -0
- package/registry/r/skill.cost-aware-orchestration.json +14 -0
- package/registry/r/skill.cross-model-review.json +14 -0
- package/registry/r/skill.db-schema-design.json +15 -0
- package/registry/r/skill.design-system.json +19 -0
- package/registry/r/skill.design-taste.json +116 -0
- package/registry/r/skill.diagnosing-bugs.json +23 -0
- package/registry/r/skill.diagrams.json +21 -0
- package/registry/r/skill.docx-create.json +20 -0
- package/registry/r/skill.domain-modeling.json +18 -0
- package/registry/r/skill.frontend-qa.json +19 -0
- package/registry/r/skill.go-architecture.json +21 -0
- package/registry/r/skill.go-ci.json +21 -0
- package/registry/r/skill.go-cli.json +20 -0
- package/registry/r/skill.go-data-structures.json +21 -0
- package/registry/r/skill.go-dependencies.json +21 -0
- package/registry/r/skill.go-dev.json +20 -0
- package/registry/r/skill.go-documentation.json +21 -0
- package/registry/r/skill.go-ecosystem.json +21 -0
- package/registry/r/skill.go-qa.json +21 -0
- package/registry/r/skill.go-quality.json +21 -0
- package/registry/r/skill.go-refactoring.json +21 -0
- package/registry/r/skill.go-tooling.json +21 -0
- package/registry/r/skill.go-tui.json +20 -0
- package/registry/r/skill.go-types.json +21 -0
- package/registry/r/skill.grilling.json +18 -0
- package/registry/r/skill.improve-architecture.json +18 -0
- package/registry/r/skill.pdf-create.json +23 -0
- package/registry/r/skill.pptx-create.json +23 -0
- package/registry/r/skill.product-ui.json +22 -0
- package/registry/r/skill.python-dev.json +20 -0
- package/registry/r/skill.react-dev.json +20 -0
- package/registry/r/skill.resolving-merge-conflicts.json +18 -0
- package/registry/r/skill.scratchpad.json +17 -0
- package/registry/r/skill.skill-authoring.json +17 -0
- package/registry/r/skill.software-design.json +28 -0
- package/registry/r/skill.sql-migrations.json +15 -0
- package/registry/r/skill.tdd.json +28 -0
- package/registry/r/skill.typescript-dev.json +20 -0
- package/registry/r/skill.visual-assets.json +17 -0
- package/registry/r/skill.wayfinder.json +18 -0
- package/registry/r/skill.xlsx-create.json +20 -0
- package/registry/r/tool.a11y-quick.json +24 -0
- package/registry/r/tool.contrast-check.json +24 -0
- package/registry/r/tool.design-lint.json +24 -0
- package/registry/r/tool.design-search.json +23 -0
- package/registry/r/tool.pdf-create.json +23 -0
- package/registry/r/tool.pptx-create.json +23 -0
- package/registry/r/tool.scratchpad.json +68 -0
- package/registry/r/tool.token-validate.json +24 -0
- package/registry/registry.json +676 -0
- package/schema/agentz.schema.json +330 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "a11y-quick",
|
|
4
|
+
"type": "tool",
|
|
5
|
+
"title": "a11y-quick (design)",
|
|
6
|
+
"description": "Static accessibility scan: missing alt text, unlabeled icon buttons, unlabeled form controls, suppressed focus styles, positive tabindex, missing lang/viewport, empty links (opencode only). Pairs with the frontend-qa and design-taste skills.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"skill:design-taste",
|
|
9
|
+
"skill:frontend-qa"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "a11y-quick.ts",
|
|
14
|
+
"content": "// managed by AgentZ — edit freely; remove this line to stop AgentZ from updating this file\nimport { tool } from \"@opencode-ai/plugin\"\nimport { existsSync, readFileSync, readdirSync, statSync } from \"node:fs\"\nimport { isAbsolute, join, relative } from \"node:path\"\n\n/**\n * a11y-quick — fast static accessibility checker for frontend source. Regex\n * heuristics (not a full parser) that catch common a11y omissions: images\n * without alt, icon-only buttons without a label, form controls without a\n * label, suppressed focus outlines, positive tabindex, missing html lang /\n * viewport meta, and empty links. No dependencies. Same file-walking args as\n * design-lint. Heuristic: false positives/negatives possible on complex markup.\n */\n\nconst DEFAULT_EXTS = [\"html\", \"tsx\", \"jsx\", \"vue\", \"svelte\"]\nconst DEFAULT_MAX = 200\nconst MAX_FINDINGS_CAP = 1000\nconst SKIP_DIRS = new Set([\"node_modules\", \"dist\", \"build\", \".git\"])\n\n/**\n * Resolve `p` under `root` (context directory). Returns the absolute path when\n * it stays inside `root`, or null when it escapes (path traversal). Duplicated\n * per tool file by design: opencode tools stay single-file.\n */\nfunction resolveInRoot(root: string, p: string): string | null {\n const abs = isAbsolute(p) ? p : join(root, p)\n const rel = relative(root, abs)\n if (rel === \"\" || (!rel.startsWith(\"..\") && !isAbsolute(rel))) return abs\n return null\n}\n\ntype Severity = \"ERROR\" | \"WARN\" | \"INFO\"\n\ninterface Finding {\n file: string\n line: number\n severity: Severity\n ruleId: string\n snippet: string\n}\n\nfunction collectFiles(root: string, exts: string[]): string[] {\n const extSet = new Set(exts.map((e) => e.replace(/^\\./, \"\").toLowerCase()))\n const out: string[] = []\n const walk = (dir: string) => {\n let entries: string[]\n try {\n entries = readdirSync(dir)\n } catch {\n return\n }\n for (const entry of entries.sort()) {\n if (SKIP_DIRS.has(entry)) continue\n const full = join(dir, entry)\n let st\n try {\n st = statSync(full)\n } catch {\n continue\n }\n if (st.isDirectory()) walk(full)\n else if (st.isFile()) {\n const dot = entry.lastIndexOf(\".\")\n if (dot >= 0 && extSet.has(entry.slice(dot + 1).toLowerCase())) out.push(full)\n }\n }\n }\n walk(root)\n return out\n}\n\n// ---- per-line heuristics ----\n// <img ...> without an alt= attribute.\nconst IMG_RE = /<img\\b[^>]*>/gi\nconst hasAlt = (tag: string) => /\\balt\\s*=/.test(tag)\n\n// <button ...>inner</button> on one line where inner is only an svg/icon and\n// no aria-label / title on the button. Single-line heuristic.\nconst BUTTON_RE = /<button\\b([^>]*)>([\\s\\S]*?)<\\/button>/gi\nconst hasLabelAttr = (attrs: string) => /\\baria-label\\s*=|\\btitle\\s*=|\\baria-labelledby\\s*=/.test(attrs)\nconst innerIsIconOnly = (inner: string) => {\n const stripped = inner.replace(/<svg\\b[\\s\\S]*?<\\/svg>/gi, \"\").replace(/<[^>]+>/g, \"\").trim()\n const hadSvgOrIcon = /<svg\\b|<[A-Za-z]*Icon\\b|<Icon\\b|<i\\b/.test(inner)\n return hadSvgOrIcon && stripped.length === 0\n}\n\n// form controls without an accessible name (heuristic: no aria-label and no\n// matching <label for=id> in the same file).\nconst CONTROL_RE = /<(input|select|textarea)\\b([^>]*)>/gi\nconst getAttr = (attrs: string, name: string): string | null => {\n const m = attrs.match(new RegExp(`\\\\b${name}\\\\s*=\\\\s*[\"']([^\"']*)[\"']`, \"i\"))\n return m ? m[1]! : null\n}\n\n// outline suppression without a visible focus alternative on the same line.\nconst outlineSuppressed = (l: string) =>\n /outline-none|outline:\\s*none/.test(l) && !/focus-visible|\\bring\\b|ring-/.test(l)\n\nconst positiveTabindex = (l: string) => /tabindex\\s*=\\s*[\"']?[1-9]/i.test(l)\n\n// <a ...>text</a> where text is empty and no aria-label.\nconst ANCHOR_RE = /<a\\b([^>]*)>([\\s\\S]*?)<\\/a>/gi\nconst anchorEmpty = (attrs: string, inner: string) => {\n const text = inner.replace(/<[^>]+>/g, \"\").trim()\n const hasLabel = /\\baria-label\\s*=|\\baria-labelledby\\s*=|\\btitle\\s*=/.test(attrs)\n return text.length === 0 && !hasLabel\n}\n\nfunction lineOf(content: string, index: number): number {\n let line = 1\n for (let i = 0; i < index && i < content.length; i++) {\n if (content[i] === \"\\n\") line++\n }\n return line\n}\n\nfunction snippet(content: string, index: number): string {\n const nl = content.indexOf(\"\\n\", index)\n const end = nl === -1 ? content.length : nl\n const start = content.lastIndexOf(\"\\n\", index) + 1\n const s = content.slice(start, end).trim()\n return s.length > 80 ? s.slice(0, 80) : s\n}\n\nfunction scanFile(filePath: string): Finding[] {\n let content: string\n try {\n content = readFileSync(filePath, \"utf-8\")\n } catch {\n return []\n }\n const findings: Finding[] = []\n const add = (index: number, severity: Severity, ruleId: string) =>\n findings.push({ file: filePath, line: lineOf(content, index), severity, ruleId, snippet: snippet(content, index) })\n\n // Pre-index label for= targets in the whole file.\n const labelFors = new Set<string>()\n for (const m of content.matchAll(/<label\\b[^>]*\\bfor\\s*=\\s*[\"']([^\"']+)[\"']/gi)) {\n labelFors.add(m[1]!)\n }\n\n // img alt\n for (const m of content.matchAll(IMG_RE)) {\n if (!hasAlt(m[0])) add(m.index!, \"ERROR\", \"img-alt\")\n }\n\n // icon-only button without label\n for (const m of content.matchAll(BUTTON_RE)) {\n const attrs = m[1] ?? \"\"\n const inner = m[2] ?? \"\"\n if (!hasLabelAttr(attrs) && innerIsIconOnly(inner)) add(m.index!, \"ERROR\", \"icon-button-label\")\n }\n\n // form controls without accessible name\n for (const m of content.matchAll(CONTROL_RE)) {\n const attrs = m[2] ?? \"\"\n const ariaLabel = /\\baria-label\\s*=|\\baria-labelledby\\s*=/.test(attrs)\n if (ariaLabel) continue\n const id = getAttr(attrs, \"id\")\n const labelled = id !== null && labelFors.has(id)\n if (!labelled) add(m.index!, \"WARN\", \"form-label\")\n }\n\n // empty links\n for (const m of content.matchAll(ANCHOR_RE)) {\n if (anchorEmpty(m[1] ?? \"\", m[2] ?? \"\")) add(m.index!, \"WARN\", \"empty-link\")\n }\n\n // per-line: focus suppression, positive tabindex\n const lines = content.split(\"\\n\")\n let offset = 0\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i]!\n if (outlineSuppressed(line)) add(offset, \"WARN\", \"focus-suppressed\")\n if (positiveTabindex(line)) add(offset, \"WARN\", \"positive-tabindex\")\n offset += line.length + 1\n }\n\n // document-level checks: only when the file looks like a full HTML document.\n const htmlTagMatch = content.match(/<html\\b[^>]*>/i)\n if (htmlTagMatch) {\n if (!/\\blang\\s*=/.test(htmlTagMatch[0])) {\n add(htmlTagMatch.index!, \"WARN\", \"html-lang\")\n }\n if (!/<meta\\b[^>]*name\\s*=\\s*[\"']viewport[\"']/i.test(content)) {\n add(htmlTagMatch.index!, \"WARN\", \"viewport-meta\")\n }\n }\n\n return findings\n}\n\nexport default tool({\n description:\n \"Fast static accessibility checker for frontend source. Regex heuristics flag: <img> without \" +\n \"alt (ERROR), icon-only <button> without aria-label/title (ERROR), form controls without a \" +\n \"label/aria-label (WARN), suppressed focus outlines (WARN), positive tabindex (WARN), <html> \" +\n \"without lang and missing viewport meta (WARN), and empty links (WARN). Pass `files` and/or \" +\n \"`dir` (recursive). Reports file:line + severity + rule id, grouped per file, with a PASS/FAIL \" +\n \"verdict on error count. Heuristic: verify hits on complex markup.\",\n args: {\n files: tool.schema.array(tool.schema.string()).optional().describe(\"Specific file paths to check.\"),\n dir: tool.schema.string().optional().describe(\"Directory scanned recursively.\"),\n exts: tool.schema\n .array(tool.schema.string())\n .optional()\n .describe(`File extensions to include when scanning a dir (default ${DEFAULT_EXTS.join(\",\")}).`),\n maxFindings: tool.schema\n .number()\n .int()\n .optional()\n .describe(`Max findings to report (default ${DEFAULT_MAX}).`),\n },\n async execute(args, context) {\n const root = context.directory\n const exts = args.exts && args.exts.length > 0 ? args.exts : DEFAULT_EXTS\n const maxFindings = Math.min(\n MAX_FINDINGS_CAP,\n Math.max(1, Math.trunc(args.maxFindings ?? DEFAULT_MAX)),\n )\n\n const targets = new Set<string>()\n const missing: string[] = [] // explicitly requested paths not found / rejected\n if (args.files) {\n for (const f of args.files) {\n const abs = resolveInRoot(root, f)\n if (abs === null) missing.push(`${f} (outside project directory)`)\n else if (existsSync(abs)) targets.add(abs)\n else missing.push(f)\n }\n }\n if (args.dir) {\n const dirPath = resolveInRoot(root, args.dir)\n if (dirPath === null) {\n return `dir '${args.dir}' resolves outside the project directory — refused`\n }\n for (const f of collectFiles(dirPath, exts)) targets.add(f)\n }\n\n if (targets.size === 0 && missing.length === 0) {\n return \"No files to check. Pass `files` (paths) and/or `dir` (scanned recursively).\"\n }\n\n const allFindings: Finding[] = []\n for (const f of [...targets].sort()) allFindings.push(...scanFile(f))\n\n const counts = { ERROR: 0, WARN: 0, INFO: 0 }\n for (const f of allFindings) counts[f.severity]++\n\n const total = allFindings.length\n const truncated = total > maxFindings\n const shown = truncated ? allFindings.slice(0, maxFindings) : allFindings\n\n const byFile = new Map<string, Finding[]>()\n for (const f of shown) {\n const arr = byFile.get(f.file) ?? []\n arr.push(f)\n byFile.set(f.file, arr)\n }\n\n const rel = (p: string) => {\n const r = relative(root, p)\n return r.startsWith(\"..\") ? p : r\n }\n\n const lines: string[] = []\n for (const [file, fs] of [...byFile.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {\n lines.push(rel(file))\n // Stable order: by line number.\n for (const f of [...fs].sort((a, b) => a.line - b.line)) {\n lines.push(` ${String(f.line).padStart(4)}: [${f.severity}] ${f.ruleId} ${f.snippet}`)\n }\n lines.push(\"\")\n }\n\n // Requested-but-missing paths are ERROR-level notes and force FAIL.\n if (missing.length > 0) {\n lines.push(\"requested paths not found:\")\n for (const m of missing) lines.push(` [ERROR] requested file not found: ${m}`)\n lines.push(\"\")\n }\n\n lines.push(\"-\".repeat(60))\n lines.push(\n `findings: ${total}${truncated ? ` (showing ${maxFindings})` : \"\"} | ERROR ${counts.ERROR} WARN ${counts.WARN} INFO ${counts.INFO}` +\n (missing.length > 0 ? ` | ${missing.length} requested paths not found` : \"\"),\n )\n const pass = counts.ERROR === 0 && missing.length === 0\n lines.push(pass ? \"PASS (0 errors)\" : `FAIL (${counts.ERROR} errors${missing.length ? `, ${missing.length} missing` : \"\"})`)\n return lines.join(\"\\n\")\n },\n})\n",
|
|
15
|
+
"kind": "raw",
|
|
16
|
+
"paths": {
|
|
17
|
+
"opencode": "a11y-quick.ts"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"targets": [
|
|
22
|
+
"opencode"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "contrast-check",
|
|
4
|
+
"type": "tool",
|
|
5
|
+
"title": "contrast-check (design)",
|
|
6
|
+
"description": "WCAG 2.x contrast ratio checker for foreground/background hex pairs, with AA/AAA verdicts (opencode only). Pairs with the design-system and design-taste skills.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"skill:design-system",
|
|
9
|
+
"skill:design-taste"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "contrast-check.ts",
|
|
14
|
+
"content": "// managed by AgentZ — edit freely; remove this line to stop AgentZ from updating this file\nimport { tool } from \"@opencode-ai/plugin\"\n\n/**\n * contrast-check — WCAG 2.x contrast ratio checker for foreground/background\n * hex color pairs. Reports the ratio plus AA/AAA pass/fail for normal and large\n * text. Pure math, no dependencies.\n *\n * WCAG relative luminance:\n * channel c in [0,1]: c <= 0.03928 ? c/12.92 : ((c+0.055)/1.055)^2.4\n * L = 0.2126*R + 0.7152*G + 0.0722*B\n * ratio = (Lmax + 0.05) / (Lmin + 0.05)\n * Thresholds: AA normal >= 4.5, AA large >= 3, AAA normal >= 7, AAA large >= 4.5.\n */\n\n/** Parse a #rgb or #rrggbb hex string into [r,g,b] 0-255, or null if invalid. */\nfunction parseHex(input: string): [number, number, number] | null {\n const s = input.trim().replace(/^#/, \"\")\n let hex = s\n if (/^[0-9A-Fa-f]{3}$/.test(s)) {\n // Expand 3-digit shorthand: abc -> aabbcc\n hex = s\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n }\n if (!/^[0-9A-Fa-f]{6}$/.test(hex)) return null\n const r = parseInt(hex.slice(0, 2), 16)\n const g = parseInt(hex.slice(2, 4), 16)\n const b = parseInt(hex.slice(4, 6), 16)\n return [r, g, b]\n}\n\nfunction channelLuminance(v: number): number {\n const c = v / 255\n return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)\n}\n\nfunction relativeLuminance([r, g, b]: [number, number, number]): number {\n return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b)\n}\n\nfunction contrastRatio(fg: [number, number, number], bg: [number, number, number]): number {\n const l1 = relativeLuminance(fg)\n const l2 = relativeLuminance(bg)\n const lmax = Math.max(l1, l2)\n const lmin = Math.min(l1, l2)\n return (lmax + 0.05) / (lmin + 0.05)\n}\n\nconst mark = (ok: boolean) => (ok ? \"PASS\" : \"FAIL\")\n\nexport default tool({\n description:\n \"WCAG 2.x contrast ratio checker. Pass `pairs` as 'fg,bg' hex strings (e.g. '#111827,#FFFFFF'); \" +\n \"3-digit shorthand hex is accepted. For each pair reports the contrast ratio and AA normal \" +\n \"(>=4.5), AA large (>=3), AAA normal (>=7), AAA large (>=4.5) pass/fail plus an overall verdict.\",\n args: {\n pairs: tool.schema\n .array(tool.schema.string())\n .describe(\"Foreground/background hex pairs, each 'fg,bg' e.g. '#111827,#FFFFFF'.\"),\n },\n async execute(args) {\n if (!args.pairs || args.pairs.length === 0) {\n return \"No pairs given. Pass `pairs` as 'fg,bg' hex strings, e.g. ['#111827,#FFFFFF'].\"\n }\n\n const lines: string[] = []\n let allPassAA = true\n\n for (const raw of args.pairs) {\n const parts = raw.split(\",\").map((p) => p.trim())\n if (parts.length !== 2) {\n lines.push(`# ${raw}`)\n lines.push(` error: expected 'fg,bg' (two comma-separated hex colors)`)\n lines.push(\"\")\n allPassAA = false\n continue\n }\n const fg = parseHex(parts[0]!)\n const bg = parseHex(parts[1]!)\n if (!fg || !bg) {\n lines.push(`# ${raw}`)\n if (!fg) lines.push(` error: invalid fg hex '${parts[0]}'`)\n if (!bg) lines.push(` error: invalid bg hex '${parts[1]}'`)\n lines.push(\"\")\n allPassAA = false\n continue\n }\n\n const ratio = contrastRatio(fg, bg)\n const r2 = ratio.toFixed(2)\n const aaNormal = ratio >= 4.5\n const aaLarge = ratio >= 3\n const aaaNormal = ratio >= 7\n const aaaLarge = ratio >= 4.5\n if (!aaNormal) allPassAA = false\n\n lines.push(`# ${parts[0]} on ${parts[1]}`)\n lines.push(` ratio: ${r2}:1`)\n lines.push(` AA normal (>=4.5): ${mark(aaNormal)} AA large (>=3.0): ${mark(aaLarge)}`)\n lines.push(` AAA normal (>=7.0): ${mark(aaaNormal)} AAA large (>=4.5): ${mark(aaaLarge)}`)\n const verdict = aaaNormal\n ? \"AAA (normal text and up)\"\n : aaNormal\n ? \"AA normal text\"\n : aaLarge\n ? \"AA large text only\"\n : \"fails WCAG AA\"\n lines.push(` verdict: ${verdict}`)\n lines.push(\"\")\n }\n\n lines.push(\"-\".repeat(50))\n lines.push(allPassAA ? \"PASS: all pairs meet AA normal (>=4.5)\" : \"FAIL: some pairs below AA normal (>=4.5)\")\n return lines.join(\"\\n\")\n },\n})\n",
|
|
15
|
+
"kind": "raw",
|
|
16
|
+
"paths": {
|
|
17
|
+
"opencode": "contrast-check.ts"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"targets": [
|
|
22
|
+
"opencode"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "design-lint",
|
|
4
|
+
"type": "tool",
|
|
5
|
+
"title": "design-lint (design)",
|
|
6
|
+
"description": "Anti-slop lint on user-visible copy (em/en-dashes in headings/buttons/labels, placeholder brand names, marketing cliches, emoji) plus code smells (scroll listeners, 100vh, z-index spam, colored shadows) (opencode only). Pairs with the frontend-qa and design-taste skills.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"skill:design-taste",
|
|
9
|
+
"skill:frontend-qa"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "design-lint.ts",
|
|
14
|
+
"content": "// managed by AgentZ — edit freely; remove this line to stop AgentZ from updating this file\nimport { tool } from \"@opencode-ai/plugin\"\nimport { existsSync, readFileSync, readdirSync, statSync } from \"node:fs\"\nimport { isAbsolute, join, relative } from \"node:path\"\n\n/**\n * design-lint — static \"anti-slop\" linter for frontend source. Regex line\n * scanner that flags telltale signs of low-effort / AI-generated UI.\n *\n * Two rule classes:\n * - COPY rules (em-dash-ban, placeholder-content, ai-cliche, emoji-in-ui)\n * fire only on USER-VISIBLE text: JSX/HTML text nodes, string literals, and\n * title/alt/aria-label/placeholder attribute values. They never see code,\n * comments, or import/export lines. Extraction is regex-based heuristic —\n * good enough for lint, not a real parser; verify hits on complex markup.\n * - CODE / whole-line rules (scroll-listener-ban, viewport-height,\n * z-index-spam, pure-bw, inter-default, tiny-text, heavy-shadow) scan the\n * line (scroll-listener the raw line — it IS a code rule; the rest a\n * comment-stripped view where sensible).\n *\n * Escape hatches: `design-lint-disable-next-line [ruleId,...]` on a line\n * suppresses findings on the NEXT line (all rules, or only the listed ids);\n * `design-lint-disable-file` in the first 5 lines skips the whole file.\n *\n * No dependencies.\n */\n\nconst DEFAULT_EXTS = [\"html\", \"tsx\", \"jsx\", \"vue\", \"svelte\", \"css\"]\nconst DEFAULT_MAX = 200\nconst MAX_FINDINGS_CAP = 1000\nconst SKIP_DIRS = new Set([\"node_modules\", \"dist\", \"build\", \".git\"])\n\n/**\n * Resolve `p` under `root` (context directory). Returns the absolute path when\n * it stays inside `root`, or null when it escapes (path traversal). Duplicated\n * per tool file by design: opencode tools stay single-file.\n */\nfunction resolveInRoot(root: string, p: string): string | null {\n const abs = isAbsolute(p) ? p : join(root, p)\n const rel = relative(root, abs)\n if (rel === \"\" || (!rel.startsWith(\"..\") && !isAbsolute(rel))) return abs\n return null\n}\n\ntype Severity = \"ERROR\" | \"WARN\" | \"INFO\"\n\n/**\n * FileCtx carries the per-file derived views a rule may test against.\n * - raw: the untouched line\n * - code: line with line/block comment fragments blanked out; empty string\n * if the whole line is a comment or an import/export statement\n * - visible: concatenation of user-visible strings extracted from `code`\n * (JSX/HTML text nodes, string literals, visible attr values)\n * - fileHasDvh: whole-file flag — true if `dvh` appears anywhere in the file\n */\ninterface FileCtx {\n raw: string\n code: string\n visible: string\n fileHasDvh: boolean\n}\n\ninterface Rule {\n id: string\n severity: Severity\n // A rule reads whichever view it needs off the ctx.\n test: (ctx: FileCtx) => boolean\n}\n\nconst EMOJI_RE = /\\p{Extended_Pictographic}/u\n\n// ---- copy-rule matchers (run against ctx.visible only) --------------------\nconst emDash = (s: string) => /\\u2014|\\u2013/.test(s)\nconst placeholder = (s: string) =>\n /\\b(John Doe|Jane Doe|Acme|Nexus Corp|SmartFlow|Cloudly|Lorem ipsum)\\b/i.test(s)\n// Title-case, case-sensitive, word-bounded — matches marketing copy, not props.\nconst aiCliche = (s: string) =>\n /\\b(Elevate|Seamless|Unleash|Next-Gen|Revolutionize|Game-changer|Supercharge|Delve)\\b/.test(s)\nconst emojiInUi = (s: string) => EMOJI_RE.test(s)\n\n// ---- pure-bw context: page-level background only --------------------------\n// Only flag pure black/white on lines that define a PAGE background, i.e. a\n// body{ / html{ / :root / --background context — not arbitrary bg- classes.\nconst pageBgContext = (line: string) =>\n /(^|[^-\\w])(body|html)\\s*\\{/i.test(line) || /:root\\b/.test(line) || /--background\\b/i.test(line)\nconst pureBW = (line: string) =>\n (/#000000\\b|#000\\b|#ffffff\\b|#fff\\b/i.test(line) ? pageBgContext(line) : false)\n\nconst RULES: Rule[] = [\n // ---- copy rules ----\n {\n // WARN, not ERROR: design-taste bans dashes in headlines/buttons/labels\n // but allows them in long-form body copy; a line scanner cannot tell the\n // two apart, so this stays a judgement call.\n id: \"em-dash-ban\",\n severity: \"WARN\",\n test: (c) => emDash(c.visible),\n },\n {\n id: \"placeholder-content\",\n severity: \"ERROR\",\n test: (c) => placeholder(c.visible),\n },\n // ---- ERROR (code) ----\n {\n // Code rule: scan the RAW line so it fires even inside strings/handlers.\n id: \"scroll-listener-ban\",\n severity: \"ERROR\",\n test: (c) => /addEventListener\\(\\s*['\"]scroll/.test(c.raw),\n },\n // ---- WARN (copy) ----\n {\n id: \"ai-cliche\",\n severity: \"WARN\",\n test: (c) => aiCliche(c.visible),\n },\n {\n id: \"emoji-in-ui\",\n severity: \"WARN\",\n test: (c) => emojiInUi(c.visible),\n },\n // ---- WARN (code) ----\n {\n // Suppressed entirely when the file provides a dvh fallback anywhere.\n id: \"viewport-height\",\n severity: \"WARN\",\n test: (c) => (c.fileHasDvh ? false : /\\bh-screen\\b|100vh/.test(c.code)),\n },\n {\n id: \"z-index-spam\",\n severity: \"WARN\",\n test: (c) => /z-\\[\\d{3,}\\]|z-index:\\s*[1-9]\\d{3,}/.test(c.code),\n },\n {\n // Only extreme elevation and saturated glow shadows. shadow-md/lg/xl are\n // normal, subtle elevation and no longer flagged.\n id: \"heavy-shadow\",\n severity: \"WARN\",\n test: (c) =>\n /\\bshadow-2xl\\b/.test(c.code) ||\n // colored glow: shadow-[0_0_...<color>...] where a color token appears.\n /shadow-\\[0_0_[^\\]]*(?:#[0-9a-f]{3,8}|rgb|rgba|hsl|oklch|[a-z]+-\\d{2,3})[^\\]]*\\]/i.test(c.code),\n },\n // ---- INFO (code) ----\n {\n id: \"pure-bw\",\n severity: \"INFO\",\n test: (c) => pureBW(c.code),\n },\n {\n id: \"inter-default\",\n severity: \"INFO\",\n test: (c) =>\n // font-family with Inter as the first family\n /font-family:\\s*[\"']?Inter\\b/i.test(c.code) ||\n // next/font import line that pulls in Inter\n (/next\\/font/.test(c.raw) && /\\bInter\\b/.test(c.raw)),\n },\n {\n id: \"tiny-text\",\n severity: \"INFO\",\n test: (c) => /text-\\[1[01]px\\]|font-size:\\s*(?:[0-9]|1[01])px/.test(c.code),\n },\n]\n\nconst RULE_IDS = new Set(RULES.map((r) => r.id))\n\ninterface Finding {\n file: string\n line: number\n severity: Severity\n ruleId: string\n snippet: string\n}\n\nfunction collectFiles(root: string, exts: string[]): string[] {\n const extSet = new Set(exts.map((e) => e.replace(/^\\./, \"\").toLowerCase()))\n const out: string[] = []\n const walk = (dir: string) => {\n let entries: string[]\n try {\n entries = readdirSync(dir)\n } catch {\n return\n }\n for (const entry of entries.sort()) {\n if (SKIP_DIRS.has(entry)) continue\n const full = join(dir, entry)\n let st\n try {\n st = statSync(full)\n } catch {\n continue\n }\n if (st.isDirectory()) walk(full)\n else if (st.isFile()) {\n const dot = entry.lastIndexOf(\".\")\n if (dot >= 0 && extSet.has(entry.slice(dot + 1).toLowerCase())) out.push(full)\n }\n }\n }\n walk(root)\n return out\n}\n\n// ---------------------------------------------------------------------------\n// Preprocessing (all regex heuristics, documented as such).\n// ---------------------------------------------------------------------------\n\n/**\n * stripComments — blank out comment fragments and drop import/export lines so\n * copy rules never see code or documentation.\n *\n * Heuristic, per-line, no cross-line block-comment state tracking:\n * - a line whose trimmed content starts with //, /*, *, or */ becomes \"\"\n * - a trailing `// ...` or `/* ... */` fragment is blanked in place\n * - import / export ... from lines become \"\"\n * Good enough for lint; multi-line block comments with code on the closing\n * line may leak, which is acceptable (verify on complex markup).\n */\nfunction stripComments(line: string): string {\n const t = line.trim()\n if (t === \"\") return \"\"\n // Whole-line comment forms (JS/TS/CSS/JSX-block-lines).\n if (t.startsWith(\"//\") || t.startsWith(\"/*\") || t.startsWith(\"*/\") || t.startsWith(\"*\")) return \"\"\n // Module plumbing — never visible copy. Drop bare imports and re-exports;\n // keep `export const/function/...` since those bodies can hold visible text.\n if (/^import\\b/.test(t)) return \"\"\n if (/^export\\s+(\\{|\\*|type\\b|default\\s+function\\b)/.test(t)) return \"\"\n let out = line\n // Strip trailing line comment (best-effort; ignores // inside strings/URLs\n // which is acceptable for a copy-scoping heuristic).\n out = out.replace(/\\/\\/[^\\n]*$/, \"\")\n // Strip inline block comment fragments on a single line.\n out = out.replace(/\\/\\*[^]*?\\*\\//g, \"\")\n return out\n}\n\n/**\n * extractVisible — pull user-visible text out of a (comment-stripped) line.\n * Returns the concatenation of:\n * (a) JSX/HTML text content between `>` and `<`\n * (b) string / template literal contents ('...', \"...\", `...`)\n * (c) visible attribute values (title, alt, aria-label, placeholder)\n *\n * Regex heuristic — not a parser. For .css lines there is little \"visible\"\n * text; copy rules effectively no-op there, which is fine.\n */\nfunction extractVisible(code: string): string {\n if (code.trim() === \"\") return \"\"\n const parts: string[] = []\n\n // (a) JSX / HTML text nodes: text sitting between a `>` and the next `<`.\n // Skips pure-whitespace and expression-only ({...}) chunks.\n for (const m of code.matchAll(/>([^<>{}]+)</g)) {\n const txt = m[1]!.trim()\n if (txt) parts.push(txt)\n }\n\n // (b) string + template literals.\n for (const m of code.matchAll(/\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"/g)) parts.push(m[1]!)\n for (const m of code.matchAll(/'([^'\\\\]*(?:\\\\.[^'\\\\]*)*)'/g)) parts.push(m[1]!)\n for (const m of code.matchAll(/`([^`\\\\]*(?:\\\\.[^`\\\\]*)*)`/g)) parts.push(m[1]!)\n\n // (c) visible attribute values (also covered by (b) for quoted forms, but\n // kept explicit so intent is clear and unquoted-ish cases still land).\n for (const m of code.matchAll(\n /\\b(?:title|alt|aria-label|placeholder)\\s*=\\s*[\"']([^\"']*)[\"']/gi,\n ))\n parts.push(m[1]!)\n\n return parts.join(\"\\n\")\n}\n\n/**\n * parseDisable — read a `design-lint-disable-next-line` directive off a line.\n * Returns null if absent, `\"ALL\"` for a bare directive, or a Set of rule ids.\n */\nfunction parseDisableNext(line: string): \"ALL\" | Set<string> | null {\n const m = line.match(/design-lint-disable-next-line\\s*([^\\n*]*)/)\n if (!m) return null\n const rest = m[1]!.trim().replace(/\\*\\/\\s*$/, \"\").trim()\n if (rest === \"\") return \"ALL\"\n const ids = new Set(\n rest\n .split(/[\\s,]+/)\n .map((s) => s.trim())\n .filter(Boolean),\n )\n return ids.size > 0 ? ids : \"ALL\"\n}\n\nfunction scanFile(filePath: string): Finding[] {\n let content: string\n try {\n content = readFileSync(filePath, \"utf-8\")\n } catch {\n return []\n }\n const lines = content.split(\"\\n\")\n\n // File-level: whole-file disable (first 5 lines).\n for (let i = 0; i < Math.min(5, lines.length); i++) {\n if (lines[i]!.includes(\"design-lint-disable-file\")) return []\n }\n\n // File-level: dvh presence anywhere suppresses viewport-height everywhere.\n // Plain substring — `100dvh` has no word boundary before `dvh`, so \\b fails.\n const fileHasDvh = content.includes(\"dvh\")\n\n const findings: Finding[] = []\n for (let i = 0; i < lines.length; i++) {\n const raw = lines[i]!\n\n // A disable-next-line directive on the PREVIOUS line gates this line.\n const prev = i > 0 ? lines[i - 1]! : \"\"\n const disable = parseDisableNext(prev)\n\n const code = stripComments(raw)\n const ctx: FileCtx = {\n raw,\n code,\n visible: extractVisible(code),\n fileHasDvh,\n }\n\n for (const rule of RULES) {\n // Honour disable-next-line: skip all rules, or only the named ones.\n if (disable === \"ALL\") continue\n if (disable && disable.has(rule.id)) continue\n\n if (rule.test(ctx)) {\n const trimmed = raw.trim()\n findings.push({\n file: filePath,\n line: i + 1,\n severity: rule.severity,\n ruleId: rule.id,\n snippet: trimmed.length > 80 ? trimmed.slice(0, 80) : trimmed,\n })\n }\n }\n }\n return findings\n}\n\nexport default tool({\n description:\n \"Static anti-slop linter for frontend source. Copy rules (em/en-dashes WARN (banned in headings/\" +\n \"buttons/labels, allowed in body copy), placeholder brand names ERROR, marketing cliches WARN, \" +\n \"emoji WARN) fire ONLY on user-visible text: JSX/HTML text, \" +\n \"string literals, and title/alt/aria-label/placeholder values — never on code, comments, or \" +\n \"imports (regex heuristic; verify on complex markup). Code rules: scroll listeners (ERROR), \" +\n \"100vh/h-screen (WARN, suppressed if the file uses dvh), z-index spam and colored/2xl shadows \" +\n \"(WARN), pure black/white PAGE backgrounds, reflexive Inter and tiny text (INFO). Escape hatches: \" +\n \"`design-lint-disable-next-line [ids]` suppresses the next line; `design-lint-disable-file` in the \" +\n \"first 5 lines skips the file. Pass `files` and/or `dir` (recursive). Reports file:line + severity \" +\n \"+ rule id, grouped per file. Only ERROR blocks (PASS/FAIL on error count); WARN/INFO are advisory \" +\n \"review notes, not mandatory fixes.\",\n args: {\n files: tool.schema.array(tool.schema.string()).optional().describe(\"Specific file paths to lint.\"),\n dir: tool.schema.string().optional().describe(\"Directory scanned recursively.\"),\n exts: tool.schema\n .array(tool.schema.string())\n .optional()\n .describe(`File extensions to include when scanning a dir (default ${DEFAULT_EXTS.join(\",\")}).`),\n maxFindings: tool.schema\n .number()\n .int()\n .optional()\n .describe(`Max findings to report (default ${DEFAULT_MAX}).`),\n },\n async execute(args, context) {\n const root = context.directory\n const exts = args.exts && args.exts.length > 0 ? args.exts : DEFAULT_EXTS\n const maxFindings = Math.min(\n MAX_FINDINGS_CAP,\n Math.max(1, Math.trunc(args.maxFindings ?? DEFAULT_MAX)),\n )\n\n const targets = new Set<string>()\n const missing: string[] = [] // explicitly requested paths not found / rejected\n if (args.files) {\n for (const f of args.files) {\n const abs = resolveInRoot(root, f)\n if (abs === null) missing.push(`${f} (outside project directory)`)\n else if (existsSync(abs)) targets.add(abs)\n else missing.push(f)\n }\n }\n if (args.dir) {\n const dirPath = resolveInRoot(root, args.dir)\n if (dirPath === null) {\n return `dir '${args.dir}' resolves outside the project directory — refused`\n }\n for (const f of collectFiles(dirPath, exts)) targets.add(f)\n }\n\n if (targets.size === 0 && missing.length === 0) {\n return \"No files to lint. Pass `files` (paths) and/or `dir` (scanned recursively).\"\n }\n\n const allFindings: Finding[] = []\n for (const f of [...targets].sort()) allFindings.push(...scanFile(f))\n\n // Severity counts over the FULL set (verdict must not be understated by truncation).\n const counts = { ERROR: 0, WARN: 0, INFO: 0 }\n for (const f of allFindings) counts[f.severity]++\n\n const total = allFindings.length\n const truncated = total > maxFindings\n const shown = truncated ? allFindings.slice(0, maxFindings) : allFindings\n\n // Group shown findings per file.\n const byFile = new Map<string, Finding[]>()\n for (const f of shown) {\n const arr = byFile.get(f.file) ?? []\n arr.push(f)\n byFile.set(f.file, arr)\n }\n\n const rel = (p: string) => {\n const r = relative(root, p)\n return r.startsWith(\"..\") ? p : r\n }\n\n const lines: string[] = []\n for (const [file, fs] of [...byFile.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {\n lines.push(rel(file))\n for (const f of fs) {\n lines.push(` ${String(f.line).padStart(4)}: [${f.severity}] ${f.ruleId} ${f.snippet}`)\n }\n lines.push(\"\")\n }\n\n // Requested-but-missing paths are ERROR-level notes and force FAIL.\n if (missing.length > 0) {\n lines.push(\"requested paths not found:\")\n for (const m of missing) lines.push(` [ERROR] requested file not found: ${m}`)\n lines.push(\"\")\n }\n\n lines.push(\"-\".repeat(60))\n lines.push(\n `findings: ${total}${truncated ? ` (showing ${maxFindings})` : \"\"} | ERROR ${counts.ERROR} WARN ${counts.WARN} INFO ${counts.INFO}` +\n (missing.length > 0 ? ` | ${missing.length} requested paths not found` : \"\"),\n )\n lines.push(\"ERROR blocks. WARN/INFO = review notes, not mandatory fixes.\")\n const pass = counts.ERROR === 0 && missing.length === 0\n lines.push(pass ? \"PASS (0 errors)\" : `FAIL (${counts.ERROR} errors${missing.length ? `, ${missing.length} missing` : \"\"})`)\n return lines.join(\"\\n\")\n },\n})\n\n// Exported for the test harness only (not part of the tool contract).\nexport const __test = { scanFile, stripComments, extractVisible, parseDisableNext, RULE_IDS }\n",
|
|
15
|
+
"kind": "raw",
|
|
16
|
+
"paths": {
|
|
17
|
+
"opencode": "design-lint.ts"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"targets": [
|
|
22
|
+
"opencode"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "design-search",
|
|
4
|
+
"type": "tool",
|
|
5
|
+
"title": "design-search (design)",
|
|
6
|
+
"description": "BM25 search over a curated UI/UX design database plus a design-system generator (opencode only). Pairs with the design-taste skill.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"skill:design-taste"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"path": "design-search.ts",
|
|
13
|
+
"content": "// managed by AgentZ — edit freely; remove this line to stop AgentZ from updating this file\nimport { tool } from \"@opencode-ai/plugin\"\nimport { readFileSync, existsSync, mkdirSync, writeFileSync } from \"node:fs\"\nimport { isAbsolute, join, relative, resolve } from \"node:path\"\nimport { fileURLToPath } from \"node:url\"\n\n/**\n * design-search — BM25 search over a curated UI/UX design database plus a full\n * design-system generator. Self-contained port of ui-ux-pro-max's uiux_search\n * tool and its lib/{csv,core,format,design_system}.ts modules, merged into one\n * file so it registers as a single opencode tool (subdir imports are not loaded).\n *\n * Data lives next to the design-taste skill. Override the data directory with\n * OC_DESIGN_DATA_DIR when the CSVs are not a sibling of the skill dir.\n */\n\n// Data dir: env override, else resolve relative to this tool file ->\n// ../skill/design-taste/data (tool file lives in .opencode/tool/).\nconst DATA_DIR =\n process.env[\"OC_DESIGN_DATA_DIR\"] ??\n fileURLToPath(new URL(\"../skill/design-taste/data\", import.meta.url))\n\n// ============================================================================\n// CSV parser — mirrors Python csv.DictReader semantics.\n// ============================================================================\n\n/** Parse CSV text into an array of records (header row -> field name). */\nfunction parseCsv(text: string): Array<Record<string, string>> {\n const rows = parseRows(text)\n if (rows.length === 0) return []\n const header = rows[0]!\n const out: Array<Record<string, string>> = []\n for (let r = 1; r < rows.length; r++) {\n const row = rows[r]!\n if (row.length === 1 && row[0] === \"\") continue\n const rec: Record<string, string> = {}\n for (let c = 0; c < header.length; c++) {\n rec[header[c]!] = c < row.length ? row[c]! : \"\"\n }\n out.push(rec)\n }\n return out\n}\n\nfunction parseRows(text: string): string[][] {\n const rows: string[][] = []\n let field = \"\"\n let row: string[] = []\n let inQuotes = false\n let i = 0\n const n = text.length\n\n const endField = () => {\n row.push(field)\n field = \"\"\n }\n const endRow = () => {\n endField()\n rows.push(row)\n row = []\n }\n\n while (i < n) {\n const ch = text[i]!\n if (inQuotes) {\n if (ch === '\"') {\n if (text[i + 1] === '\"') {\n field += '\"'\n i += 2\n continue\n }\n inQuotes = false\n i++\n continue\n }\n field += ch\n i++\n continue\n }\n if (ch === '\"') {\n inQuotes = true\n i++\n continue\n }\n if (ch === \",\") {\n endField()\n i++\n continue\n }\n if (ch === \"\\r\") {\n if (text[i + 1] === \"\\n\") i++\n endRow()\n i++\n continue\n }\n if (ch === \"\\n\") {\n endRow()\n i++\n continue\n }\n field += ch\n i++\n }\n if (field !== \"\" || row.length > 0) endRow()\n return rows\n}\n\n// ============================================================================\n// Core BM25 search engine — faithful port of scripts/core.py.\n// ============================================================================\n\nconst MAX_RESULTS = 3\n\ninterface DomainConfig {\n file: string\n search_cols: string[]\n output_cols: string[]\n}\n\nconst CSV_CONFIG: Record<string, DomainConfig> = {\n style: {\n file: \"styles.csv\",\n search_cols: [\"Style Category\", \"Keywords\", \"Best For\", \"Type\", \"AI Prompt Keywords\"],\n output_cols: [\"Style Category\", \"Type\", \"Keywords\", \"Primary Colors\", \"Effects & Animation\", \"Best For\", \"Light Mode \\u2713\", \"Dark Mode \\u2713\", \"Performance\", \"Accessibility\", \"Framework Compatibility\", \"Complexity\", \"AI Prompt Keywords\", \"CSS/Technical Keywords\", \"Implementation Checklist\", \"Design System Variables\"],\n },\n color: {\n file: \"colors.csv\",\n search_cols: [\"Product Type\", \"Notes\"],\n output_cols: [\"Product Type\", \"Primary\", \"On Primary\", \"Secondary\", \"On Secondary\", \"Accent\", \"On Accent\", \"Background\", \"Foreground\", \"Card\", \"Card Foreground\", \"Muted\", \"Muted Foreground\", \"Border\", \"Destructive\", \"On Destructive\", \"Ring\", \"Notes\"],\n },\n chart: {\n file: \"charts.csv\",\n search_cols: [\"Data Type\", \"Keywords\", \"Best Chart Type\", \"When to Use\", \"When NOT to Use\", \"Accessibility Notes\"],\n output_cols: [\"Data Type\", \"Keywords\", \"Best Chart Type\", \"Secondary Options\", \"When to Use\", \"When NOT to Use\", \"Data Volume Threshold\", \"Color Guidance\", \"Accessibility Grade\", \"Accessibility Notes\", \"A11y Fallback\", \"Library Recommendation\", \"Interactive Level\"],\n },\n landing: {\n file: \"landing.csv\",\n search_cols: [\"Pattern Name\", \"Keywords\", \"Conversion Optimization\", \"Section Order\"],\n output_cols: [\"Pattern Name\", \"Keywords\", \"Section Order\", \"Primary CTA Placement\", \"Color Strategy\", \"Conversion Optimization\"],\n },\n product: {\n file: \"products.csv\",\n search_cols: [\"Product Type\", \"Keywords\", \"Primary Style Recommendation\", \"Key Considerations\"],\n output_cols: [\"Product Type\", \"Keywords\", \"Primary Style Recommendation\", \"Secondary Styles\", \"Landing Page Pattern\", \"Dashboard Style (if applicable)\", \"Color Palette Focus\"],\n },\n ux: {\n file: \"ux-guidelines.csv\",\n search_cols: [\"Category\", \"Issue\", \"Description\", \"Platform\"],\n output_cols: [\"Category\", \"Issue\", \"Platform\", \"Description\", \"Do\", \"Don't\", \"Code Example Good\", \"Code Example Bad\", \"Severity\"],\n },\n typography: {\n file: \"typography.csv\",\n search_cols: [\"Font Pairing Name\", \"Category\", \"Mood/Style Keywords\", \"Best For\", \"Heading Font\", \"Body Font\"],\n output_cols: [\"Font Pairing Name\", \"Category\", \"Heading Font\", \"Body Font\", \"Mood/Style Keywords\", \"Best For\", \"Google Fonts URL\", \"CSS Import\", \"Tailwind Config\", \"Notes\"],\n },\n icons: {\n file: \"icons.csv\",\n search_cols: [\"Category\", \"Icon Name\", \"Keywords\", \"Best For\"],\n output_cols: [\"Category\", \"Icon Name\", \"Keywords\", \"Library\", \"Import Code\", \"Usage\", \"Best For\", \"Style\"],\n },\n gsap: {\n file: \"motion.csv\",\n search_cols: [\"Category\", \"Intensity Tier\", \"Keywords\", \"Trigger\"],\n output_cols: [\"Category\", \"Intensity Tier\", \"Trigger\", \"Duration\", \"Easing\", \"GSAP Snippet\", \"Framework Notes\", \"Do\", \"Don't\", \"Performance Notes\"],\n },\n react: {\n file: \"react-performance.csv\",\n search_cols: [\"Category\", \"Issue\", \"Keywords\", \"Description\"],\n output_cols: [\"Category\", \"Issue\", \"Platform\", \"Description\", \"Do\", \"Don't\", \"Code Example Good\", \"Code Example Bad\", \"Severity\"],\n },\n web: {\n file: \"app-interface.csv\",\n search_cols: [\"Category\", \"Issue\", \"Keywords\", \"Description\"],\n output_cols: [\"Category\", \"Issue\", \"Platform\", \"Description\", \"Do\", \"Don't\", \"Code Example Good\", \"Code Example Bad\", \"Severity\"],\n },\n \"google-fonts\": {\n file: \"google-fonts.csv\",\n search_cols: [\"Family\", \"Category\", \"Stroke\", \"Classifications\", \"Keywords\", \"Subsets\", \"Designers\"],\n output_cols: [\"Family\", \"Category\", \"Stroke\", \"Classifications\", \"Styles\", \"Variable Axes\", \"Subsets\", \"Designers\", \"Popularity Rank\", \"Google Fonts URL\"],\n },\n}\n\nconst STACK_CONFIG: Record<string, { file: string }> = {\n react: { file: \"stacks/react.csv\" },\n \"html-tailwind\": { file: \"stacks/html-tailwind.csv\" },\n shadcn: { file: \"stacks/shadcn.csv\" },\n threejs: { file: \"stacks/threejs.csv\" },\n}\n\nconst STACK_COLS = {\n search_cols: [\"Category\", \"Guideline\", \"Description\", \"Do\", \"Don't\"],\n output_cols: [\"Category\", \"Guideline\", \"Description\", \"Do\", \"Don't\", \"Code Good\", \"Code Bad\", \"Severity\", \"Docs URL\"],\n}\n\nconst AVAILABLE_STACKS = Object.keys(STACK_CONFIG)\nconst AVAILABLE_DOMAINS = Object.keys(CSV_CONFIG)\n\n// maxResults is clamped to this inclusive range before any search runs.\nconst MAX_RESULTS_MIN = 1\nconst MAX_RESULTS_MAX = 25\n\n/** Clamp an arbitrary maxResults request into [MAX_RESULTS_MIN, MAX_RESULTS_MAX]. */\nfunction clampMaxResults(n: number | undefined): number {\n if (n === undefined || !Number.isFinite(n)) return MAX_RESULTS\n const int = Math.trunc(n)\n if (int < MAX_RESULTS_MIN) return MAX_RESULTS_MIN\n if (int > MAX_RESULTS_MAX) return MAX_RESULTS_MAX\n return int\n}\n\n/**\n * Slugify a project/page name into a filesystem-safe token: lowercase, any run\n * of non [a-z0-9] characters collapsed to a single '-', leading/trailing '-'\n * trimmed. Returns null when nothing usable remains (caller must reject).\n */\nfunction slugify(name: string): string | null {\n const slug = name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\")\n return slug.length > 0 ? slug : null\n}\n\ntype Row = Record<string, string>\n\ninterface SearchResult {\n domain?: string\n stack?: string\n query?: string\n file?: string\n count?: number\n results: Row[]\n error?: string\n}\n\n// ============ BM25 ============\nconst NON_WORD = /[^\\p{L}\\p{N}_\\s]/gu\n\nfunction tokenize(text: string): string[] {\n const cleaned = String(text).toLowerCase().replace(NON_WORD, \" \")\n const out: string[] = []\n for (const w of cleaned.split(/\\s+/)) {\n if (w.length >= 2) out.push(w)\n }\n return out\n}\n\nclass BM25 {\n private k1 = 1.5\n private b = 0.75\n private corpus: string[][] = []\n private docLengths: number[] = []\n private avgdl = 0\n private idf = new Map<string, number>()\n private N = 0\n\n fit(documents: string[]): void {\n this.corpus = documents.map(tokenize)\n this.N = this.corpus.length\n if (this.N === 0) return\n this.docLengths = this.corpus.map((d) => d.length)\n this.avgdl = this.docLengths.reduce((a, b) => a + b, 0) / this.N\n\n const docFreqs = new Map<string, number>()\n for (const doc of this.corpus) {\n const seen = new Set<string>()\n for (const word of doc) {\n if (!seen.has(word)) {\n docFreqs.set(word, (docFreqs.get(word) ?? 0) + 1)\n seen.add(word)\n }\n }\n }\n for (const [word, freq] of docFreqs) {\n this.idf.set(word, Math.log((this.N - freq + 0.5) / (freq + 0.5) + 1))\n }\n }\n\n score(query: string): Array<[number, number]> {\n const queryTokens = tokenize(query)\n const scores: Array<[number, number]> = []\n for (let idx = 0; idx < this.corpus.length; idx++) {\n const doc = this.corpus[idx]!\n let score = 0\n const docLen = this.docLengths[idx]!\n const termFreqs = new Map<string, number>()\n for (const word of doc) termFreqs.set(word, (termFreqs.get(word) ?? 0) + 1)\n\n for (const token of queryTokens) {\n const idf = this.idf.get(token)\n if (idf !== undefined) {\n const tf = termFreqs.get(token) ?? 0\n const numerator = tf * (this.k1 + 1)\n const denominator = tf + this.k1 * (1 - this.b + (this.b * docLen) / this.avgdl)\n score += (idf * numerator) / denominator\n }\n }\n scores.push([idx, score])\n }\n scores.sort((a, b) => (b[1] !== a[1] ? b[1] - a[1] : a[0] - b[0]))\n return scores\n }\n}\n\n// ============ SEARCH ============\nfunction searchCsv(\n dataDir: string,\n rel: string,\n searchCols: string[],\n outputCols: string[],\n query: string,\n maxResults: number,\n): Row[] {\n const filepath = join(dataDir, rel)\n if (!existsSync(filepath)) return []\n const data = parseCsv(readFileSync(filepath, \"utf8\"))\n\n const documents = data.map((row) =>\n searchCols.map((col) => String(row[col] ?? \"\")).join(\" \"),\n )\n\n const bm25 = new BM25()\n bm25.fit(documents)\n const ranked = bm25.score(query)\n\n const results: Row[] = []\n for (const [idx, score] of ranked.slice(0, maxResults)) {\n if (score > 0) {\n const row = data[idx]!\n const picked: Row = {}\n for (const col of outputCols) {\n if (col in row) picked[col] = row[col]!\n }\n results.push(picked)\n }\n }\n return results\n}\n\nconst DOMAIN_KEYWORDS: Array<[string, string[]]> = [\n [\"color\", [\"color\", \"palette\", \"hex\", \"#\", \"rgb\", \"token\", \"semantic\", \"accent\", \"destructive\", \"muted\", \"foreground\"]],\n [\"chart\", [\"chart\", \"graph\", \"visualization\", \"trend\", \"bar\", \"pie\", \"scatter\", \"heatmap\", \"funnel\"]],\n [\"landing\", [\"landing\", \"page\", \"cta\", \"conversion\", \"hero\", \"testimonial\", \"pricing\", \"section\"]],\n [\"product\", [\"saas\", \"ecommerce\", \"e-commerce\", \"fintech\", \"healthcare\", \"gaming\", \"portfolio\", \"crypto\", \"dashboard\", \"fitness\", \"restaurant\", \"hotel\", \"travel\", \"music\", \"education\", \"learning\", \"legal\", \"insurance\", \"medical\", \"beauty\", \"pharmacy\", \"dental\", \"pet\", \"dating\", \"wedding\", \"recipe\", \"delivery\", \"ride\", \"booking\", \"calendar\", \"timer\", \"tracker\", \"diary\", \"note\", \"chat\", \"messenger\", \"crm\", \"invoice\", \"parking\", \"transit\", \"vpn\", \"alarm\", \"weather\", \"sleep\", \"meditation\", \"fasting\", \"habit\", \"grocery\", \"meme\", \"wardrobe\", \"plant care\", \"reading\", \"flashcard\", \"puzzle\", \"trivia\", \"arcade\", \"photography\", \"streaming\", \"podcast\", \"newsletter\", \"marketplace\", \"freelancer\", \"coworking\", \"airline\", \"museum\", \"theater\", \"church\", \"non-profit\", \"charity\", \"kindergarten\", \"daycare\", \"senior care\", \"veterinary\", \"florist\", \"bakery\", \"brewery\", \"construction\", \"automotive\", \"real estate\", \"logistics\", \"agriculture\", \"coding bootcamp\"]],\n [\"style\", [\"style\", \"design\", \"ui\", \"minimalism\", \"glassmorphism\", \"neumorphism\", \"brutalism\", \"dark mode\", \"flat\", \"aurora\", \"prompt\", \"css\", \"implementation\", \"variable\", \"checklist\", \"tailwind\"]],\n [\"ux\", [\"ux\", \"usability\", \"accessibility\", \"wcag\", \"touch\", \"scroll\", \"animation\", \"keyboard\", \"navigation\", \"mobile\"]],\n [\"typography\", [\"font pairing\", \"typography pairing\", \"heading font\", \"body font\"]],\n [\"google-fonts\", [\"google font\", \"font family\", \"font weight\", \"font style\", \"variable font\", \"noto\", \"font for\", \"find font\", \"font subset\", \"font language\", \"monospace font\", \"serif font\", \"sans serif font\", \"display font\", \"handwriting font\", \"font\", \"typography\", \"serif\", \"sans\"]],\n [\"icons\", [\"icon\", \"icons\", \"lucide\", \"heroicons\", \"symbol\", \"glyph\", \"pictogram\", \"svg icon\"]],\n [\"gsap\", [\"gsap\", \"quickto\", \"scrolltrigger\", \"stagger\", \"magnetic cursor\", \"parallax\", \"page transition\", \"scroll reveal\", \"scroll-triggered\", \"scrollytelling\", \"flip plugin\", \"splittext\", \"shimmer\", \"skeleton loader\"]],\n [\"react\", [\"react\", \"next.js\", \"nextjs\", \"suspense\", \"memo\", \"usecallback\", \"useeffect\", \"rerender\", \"bundle\", \"waterfall\", \"barrel\", \"dynamic import\", \"rsc\", \"server component\"]],\n [\"web\", [\"aria\", \"focus\", \"outline\", \"semantic\", \"virtualize\", \"autocomplete\", \"form\", \"input type\", \"preconnect\"]],\n]\n\nfunction escapeRegex(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")\n}\n\n/** Mirror Python re.search(r'\\b' + re.escape(kw) + r'\\b', query_lower). */\nfunction wordBoundaryMatch(kw: string, haystack: string): boolean {\n return new RegExp(\"\\\\b\" + escapeRegex(kw) + \"\\\\b\").test(haystack)\n}\n\nfunction detectDomain(query: string): string {\n const q = query.toLowerCase()\n let best = \"\"\n let bestScore = -1\n for (const [domain, keywords] of DOMAIN_KEYWORDS) {\n let score = 0\n for (const kw of keywords) if (wordBoundaryMatch(kw, q)) score++\n if (score > bestScore) {\n bestScore = score\n best = domain\n }\n }\n return bestScore > 0 ? best : \"style\"\n}\n\nfunction search(\n dataDir: string,\n query: string,\n domain: string | null = null,\n maxResults: number = MAX_RESULTS,\n): SearchResult {\n const dom = domain ?? detectDomain(query)\n const config = CSV_CONFIG[dom]\n if (!config) {\n return {\n error: `Unknown domain: ${dom}. Available: ${AVAILABLE_DOMAINS.join(\", \")}`,\n domain: dom,\n results: [],\n }\n }\n const filepath = join(dataDir, config.file)\n if (!existsSync(filepath)) {\n return { error: `File not found: ${filepath}`, domain: dom, results: [] }\n }\n const results = searchCsv(dataDir, config.file, config.search_cols, config.output_cols, query, maxResults)\n return {\n domain: dom,\n query,\n file: config.file,\n count: results.length,\n results,\n }\n}\n\nfunction searchStack(\n dataDir: string,\n query: string,\n stack: string,\n maxResults: number = MAX_RESULTS,\n): SearchResult {\n if (!(stack in STACK_CONFIG)) {\n return { error: `Unknown stack: ${stack}. Available: ${AVAILABLE_STACKS.join(\", \")}`, results: [] }\n }\n const rel = STACK_CONFIG[stack]!.file\n const filepath = join(dataDir, rel)\n if (!existsSync(filepath)) {\n return { error: `Stack file not found: ${filepath}`, stack, results: [] }\n }\n const results = searchCsv(dataDir, rel, STACK_COLS.search_cols, STACK_COLS.output_cols, query, maxResults)\n return {\n domain: \"stack\",\n stack,\n query,\n file: rel,\n count: results.length,\n results,\n }\n}\n\n// ============================================================================\n// Search-result formatters — port of scripts/search.py format_output + JSON.\n// ============================================================================\n\n/** Token-optimized text formatter, mirrors search.py:format_output. */\nfunction formatOutput(result: SearchResult): string {\n if (result.error !== undefined) return `Error: ${result.error}`\n\n const output: string[] = []\n if (result.stack) {\n output.push(`## UI Pro Max Stack Guidelines`)\n output.push(`**Stack:** ${result.stack} | **Query:** ${result.query}`)\n } else {\n output.push(`## UI Pro Max Search Results`)\n output.push(`**Domain:** ${result.domain} | **Query:** ${result.query}`)\n }\n output.push(`**Source:** ${result.file} | **Found:** ${result.count} results\\n`)\n\n result.results.forEach((row, i) => {\n output.push(`### Result ${i + 1}`)\n for (const [key, value] of Object.entries(row)) {\n let valueStr = String(value)\n if (valueStr.length > 300) valueStr = valueStr.slice(0, 300) + \"...\"\n output.push(`- **${key}:** ${valueStr}`)\n }\n output.push(\"\")\n })\n\n return output.join(\"\\n\")\n}\n\n/** JSON formatter matching Python json.dumps(result, indent=2, ensure_ascii=False). */\nfunction formatJson(result: SearchResult): string {\n const ordered: Record<string, unknown> = {}\n if (result.error !== undefined) {\n ordered[\"error\"] = result.error\n if (result.domain !== undefined) ordered[\"domain\"] = result.domain\n if (result.stack !== undefined) ordered[\"stack\"] = result.stack\n return JSON.stringify(ordered, null, 2)\n }\n if (result.stack !== undefined) {\n ordered[\"domain\"] = result.domain\n ordered[\"stack\"] = result.stack\n ordered[\"query\"] = result.query\n ordered[\"file\"] = result.file\n ordered[\"count\"] = result.count\n ordered[\"results\"] = result.results\n } else {\n ordered[\"domain\"] = result.domain\n ordered[\"query\"] = result.query\n ordered[\"file\"] = result.file\n ordered[\"count\"] = result.count\n ordered[\"results\"] = result.results\n }\n return JSON.stringify(ordered, null, 2)\n}\n\n// ============================================================================\n// Design system generator + formatters — port of scripts/design_system.py.\n// ============================================================================\n\nconst REASONING_FILE = \"ui-reasoning.csv\"\n\nconst SEARCH_CONFIG: Record<string, { max_results: number }> = {\n product: { max_results: 1 },\n style: { max_results: 3 },\n color: { max_results: 2 },\n landing: { max_results: 2 },\n typography: { max_results: 2 },\n}\n\n// ============================================================================\n// Design dials (1-10) — port of design_system.py DIAL_TIERS. Three optional\n// sliders that bias the existing query-based search instead of replacing it.\n// Each buckets into a low/mid/high tier.\n// ============================================================================\n\ntype SpacingScale = Record<string, string>\n\ninterface VarianceTier {\n label: string\n style_keywords: string[]\n}\ninterface MotionTier {\n label: string\n tier: string\n}\ninterface DensityTier {\n label: string\n spacing: SpacingScale\n}\n\nconst VARIANCE_TIERS: Array<[number, number, VarianceTier]> = [\n [1, 3, { label: \"Centered / Minimal\", style_keywords: [\"Minimalism\", \"Exaggerated Minimalism\", \"centered\", \"symmetric\", \"grid-based\"] }],\n [4, 7, { label: \"Balanced / Modern\", style_keywords: [\"modern\", \"structured\", \"balanced\"] }],\n [8, 10, { label: \"Bold / Asymmetric\", style_keywords: [\"Brutalism\", \"Bento Grids\", \"asymmetric\", \"experimental\"] }],\n]\n\nconst MOTION_TIERS: Array<[number, number, MotionTier]> = [\n [1, 3, { label: \"Subtle\", tier: \"Subtle\" }],\n [4, 7, { label: \"Standard\", tier: \"Standard\" }],\n [8, 10, { label: \"Complex\", tier: \"Complex\" }],\n]\n\nconst DENSITY_TIERS: Array<[number, number, DensityTier]> = [\n [1, 3, { label: \"Spacious\", spacing: { xs: \"4px\", sm: \"8px\", md: \"24px\", lg: \"32px\", xl: \"48px\", \"2xl\": \"64px\", \"3xl\": \"96px\" } }],\n [4, 7, { label: \"Standard\", spacing: { xs: \"4px\", sm: \"8px\", md: \"16px\", lg: \"24px\", xl: \"32px\", \"2xl\": \"48px\", \"3xl\": \"64px\" } }],\n [8, 10, { label: \"Dense / Dashboard\", spacing: { xs: \"2px\", sm: \"4px\", md: \"8px\", lg: \"12px\", xl: \"16px\", \"2xl\": \"24px\", \"3xl\": \"32px\" } }],\n]\n\n// Mid-tier density = historical default spacing scale.\nconst DEFAULT_SPACING = DENSITY_TIERS[1]![2].spacing\n\ninterface ResolvedVariance extends VarianceTier {\n value: number\n}\ninterface ResolvedMotion extends MotionTier {\n value: number\n}\ninterface ResolvedDensity extends DensityTier {\n value: number\n}\n\n/** Bucket a 1-10 dial value into its tier config. Returns null when unset. */\nfunction resolveVariance(value: number | undefined | null): ResolvedVariance | null {\n if (value === undefined || value === null || !Number.isFinite(value)) return null\n const v = Math.max(1, Math.min(10, Math.trunc(value)))\n for (const [lo, hi, info] of VARIANCE_TIERS) if (lo <= v && v <= hi) return { ...info, value: v }\n return null\n}\nfunction resolveMotion(value: number | undefined | null): ResolvedMotion | null {\n if (value === undefined || value === null || !Number.isFinite(value)) return null\n const v = Math.max(1, Math.min(10, Math.trunc(value)))\n for (const [lo, hi, info] of MOTION_TIERS) if (lo <= v && v <= hi) return { ...info, value: v }\n return null\n}\nfunction resolveDensity(value: number | undefined | null): ResolvedDensity | null {\n if (value === undefined || value === null || !Number.isFinite(value)) return null\n const v = Math.max(1, Math.min(10, Math.trunc(value)))\n for (const [lo, hi, info] of DENSITY_TIERS) if (lo <= v && v <= hi) return { ...info, value: v }\n return null\n}\n\ninterface Reasoning {\n pattern: string\n style_priority: string[]\n color_mood: string\n typography_mood: string\n key_effects: string\n anti_patterns: string\n decision_rules: Record<string, unknown>\n severity: string\n}\n\ninterface DesignSystem {\n project_name: string\n category: string\n pattern: {\n name: string\n sections: string\n cta_placement: string\n color_strategy: string\n conversion: string\n }\n style: {\n name: string\n type: string\n effects: string\n keywords: string\n best_for: string\n performance: string\n accessibility: string\n light_mode: string\n dark_mode: string\n }\n colors: Record<string, string>\n typography: {\n heading: string\n body: string\n mood: string\n best_for: string\n google_fonts_url: string\n css_import: string\n }\n key_effects: string\n anti_patterns: string\n decision_rules: Record<string, unknown>\n severity: string\n dials: {\n variance: number | null\n variance_label: string | null\n motion: number | null\n motion_label: string | null\n density: number | null\n density_label: string | null\n }\n motion_snippet: Row\n spacing_scale: SpacingScale | null\n}\n\n// ============ GENERATOR ============\nclass DesignSystemGenerator {\n private reasoningData: Row[]\n\n constructor(private dataDir: string) {\n this.reasoningData = this.loadReasoning()\n }\n\n private loadReasoning(): Row[] {\n const filepath = join(this.dataDir, REASONING_FILE)\n if (!existsSync(filepath)) return []\n return parseCsv(readFileSync(filepath, \"utf8\"))\n }\n\n private findReasoningRule(category: string): Row | null {\n const categoryLower = category.toLowerCase()\n\n for (const rule of this.reasoningData) {\n if ((rule[\"UI_Category\"] ?? \"\").toLowerCase() === categoryLower) return rule\n }\n for (const rule of this.reasoningData) {\n const uiCat = (rule[\"UI_Category\"] ?? \"\").toLowerCase()\n if (uiCat.includes(categoryLower) || categoryLower.includes(uiCat)) return rule\n }\n for (const rule of this.reasoningData) {\n const uiCat = (rule[\"UI_Category\"] ?? \"\").toLowerCase()\n const keywords = uiCat.replace(/\\//g, \" \").replace(/-/g, \" \").split(/\\s+/).filter((s) => s.length > 0)\n if (keywords.some((kw) => categoryLower.includes(kw))) return rule\n }\n return null\n }\n\n private applyReasoning(category: string): Reasoning {\n const rule = this.findReasoningRule(category)\n if (!rule) {\n return {\n pattern: \"Hero + Features + CTA\",\n style_priority: [\"Minimalism\", \"Flat Design\"],\n color_mood: \"Professional\",\n typography_mood: \"Clean\",\n key_effects: \"Subtle hover transitions\",\n anti_patterns: \"\",\n decision_rules: {},\n severity: \"MEDIUM\",\n }\n }\n let decisionRules: Record<string, unknown> = {}\n try {\n decisionRules = JSON.parse(rule[\"Decision_Rules\"] ?? \"{}\")\n } catch {\n // JSONDecodeError -> keep {}\n }\n return {\n pattern: rule[\"Recommended_Pattern\"] ?? \"\",\n style_priority: (rule[\"Style_Priority\"] ?? \"\").split(\"+\").map((s) => s.trim()),\n color_mood: rule[\"Color_Mood\"] ?? \"\",\n typography_mood: rule[\"Typography_Mood\"] ?? \"\",\n key_effects: rule[\"Key_Effects\"] ?? \"\",\n anti_patterns: rule[\"Anti_Patterns\"] ?? \"\",\n decision_rules: decisionRules,\n severity: rule[\"Severity\"] ?? \"MEDIUM\",\n }\n }\n\n private multiDomainSearch(query: string, stylePriority: string[]): Record<string, SearchResult> {\n const results: Record<string, SearchResult> = {}\n for (const [domain, config] of Object.entries(SEARCH_CONFIG)) {\n if (domain === \"style\" && stylePriority && stylePriority.length > 0) {\n const priorityQuery = stylePriority.slice(0, 2).join(\" \")\n const combinedQuery = `${query} ${priorityQuery}`\n results[domain] = search(this.dataDir, combinedQuery, domain, config.max_results)\n } else {\n results[domain] = search(this.dataDir, query, domain, config.max_results)\n }\n }\n return results\n }\n\n private selectBestMatch(results: Row[], priorityKeywords: string[]): Row {\n if (results.length === 0) return {}\n if (!priorityKeywords || priorityKeywords.length === 0) return results[0]!\n\n for (const priority of priorityKeywords) {\n const priorityLower = priority.toLowerCase().trim()\n for (const result of results) {\n const styleName = (result[\"Style Category\"] ?? \"\").toLowerCase()\n if (styleName.includes(priorityLower) || priorityLower.includes(styleName)) return result\n }\n }\n\n const scored: Array<[number, Row]> = []\n for (const result of results) {\n const resultStr = pyDictRepr(result).toLowerCase()\n let score = 0\n for (const kw of priorityKeywords) {\n const kwLower = kw.toLowerCase().trim()\n if ((result[\"Style Category\"] ?? \"\").toLowerCase().includes(kwLower)) score += 10\n else if ((result[\"Keywords\"] ?? \"\").toLowerCase().includes(kwLower)) score += 3\n else if (resultStr.includes(kwLower)) score += 1\n }\n scored.push([score, result])\n }\n scored.sort((a, b) => b[0] - a[0])\n return scored.length > 0 && scored[0]![0] > 0 ? scored[0]![1] : results[0]!\n }\n\n generate(query: string, projectName: string | null, dials: DialInputs = {}): DesignSystem {\n const varianceInfo = resolveVariance(dials.variance)\n const motionInfo = resolveMotion(dials.motion)\n const densityInfo = resolveDensity(dials.density)\n\n const productResult = search(this.dataDir, query, \"product\", 1)\n const productResults = productResult.results\n let category = \"General\"\n if (productResults.length > 0) category = productResults[0]![\"Product Type\"] ?? \"General\"\n\n const reasoning = this.applyReasoning(category)\n const stylePriority = reasoning.style_priority\n\n // DESIGN_VARIANCE dial: bias style retrieval toward centered-minimal (low)\n // or bold-asymmetric (high) keywords.\n const effectiveStylePriority = varianceInfo\n ? [...varianceInfo.style_keywords, ...stylePriority]\n : stylePriority\n\n const searchResults = this.multiDomainSearch(query, effectiveStylePriority)\n searchResults[\"product\"] = productResult\n\n const styleResults = searchResults[\"style\"]?.results ?? []\n const colorResults = searchResults[\"color\"]?.results ?? []\n const typographyResults = searchResults[\"typography\"]?.results ?? []\n const landingResults = searchResults[\"landing\"]?.results ?? []\n\n const bestStyle = this.selectBestMatch(styleResults, effectiveStylePriority)\n const bestColor = colorResults.length > 0 ? colorResults[0]! : {}\n const bestTypography = typographyResults.length > 0 ? typographyResults[0]! : {}\n const bestLanding = landingResults.length > 0 ? landingResults[0]! : {}\n\n // MOTION_INTENSITY dial: pull a matching GSAP skeleton from motion.csv\n // (domain key is \"gsap\"; prefer a row whose Intensity Tier matches the dial).\n let motionSnippet: Row = {}\n if (motionInfo) {\n const motionResult = search(this.dataDir, `${query} ${motionInfo.tier}`, \"gsap\", 5)\n const motionMatches = motionResult.results\n const tiered = motionMatches.filter((m) => m[\"Intensity Tier\"] === motionInfo.tier)\n if (tiered.length > 0) motionSnippet = tiered[0]!\n else if (motionMatches.length > 0) motionSnippet = motionMatches[0]!\n }\n\n const styleEffects = bestStyle[\"Effects & Animation\"] ?? \"\"\n const reasoningEffects = reasoning.key_effects\n const combinedEffects = styleEffects ? styleEffects : reasoningEffects\n\n const accent = bestColor[\"Accent\"] ?? \"#F97316\"\n const foreground = bestColor[\"Foreground\"] ?? \"#1E293B\"\n\n return {\n project_name: projectName || query.toUpperCase(),\n category,\n pattern: {\n name: bestLanding[\"Pattern Name\"] ?? reasoning.pattern ?? \"Hero + Features + CTA\",\n sections: bestLanding[\"Section Order\"] ?? \"Hero > Features > CTA\",\n cta_placement: bestLanding[\"Primary CTA Placement\"] ?? \"Above fold\",\n color_strategy: bestLanding[\"Color Strategy\"] ?? \"\",\n conversion: bestLanding[\"Conversion Optimization\"] ?? \"\",\n },\n style: {\n name: bestStyle[\"Style Category\"] ?? \"Minimalism\",\n type: bestStyle[\"Type\"] ?? \"General\",\n effects: styleEffects,\n keywords: bestStyle[\"Keywords\"] ?? \"\",\n best_for: bestStyle[\"Best For\"] ?? \"\",\n performance: bestStyle[\"Performance\"] ?? \"\",\n accessibility: bestStyle[\"Accessibility\"] ?? \"\",\n light_mode: bestStyle[\"Light Mode \\u2713\"] ?? \"\",\n dark_mode: bestStyle[\"Dark Mode \\u2713\"] ?? \"\",\n },\n colors: {\n primary: bestColor[\"Primary\"] ?? \"#2563EB\",\n on_primary: bestColor[\"On Primary\"] ?? \"\",\n secondary: bestColor[\"Secondary\"] ?? \"#3B82F6\",\n accent,\n background: bestColor[\"Background\"] ?? \"#F8FAFC\",\n foreground,\n muted: bestColor[\"Muted\"] ?? \"\",\n border: bestColor[\"Border\"] ?? \"\",\n destructive: bestColor[\"Destructive\"] ?? \"\",\n ring: bestColor[\"Ring\"] ?? \"\",\n notes: bestColor[\"Notes\"] ?? \"\",\n cta: accent,\n text: foreground,\n },\n typography: {\n heading: bestTypography[\"Heading Font\"] ?? \"Inter\",\n body: bestTypography[\"Body Font\"] ?? \"Inter\",\n mood: bestTypography[\"Mood/Style Keywords\"] ?? reasoning.typography_mood ?? \"\",\n best_for: bestTypography[\"Best For\"] ?? \"\",\n google_fonts_url: bestTypography[\"Google Fonts URL\"] ?? \"\",\n css_import: bestTypography[\"CSS Import\"] ?? \"\",\n },\n key_effects: combinedEffects,\n anti_patterns: reasoning.anti_patterns,\n decision_rules: reasoning.decision_rules,\n severity: reasoning.severity,\n dials: {\n variance: varianceInfo ? varianceInfo.value : null,\n variance_label: varianceInfo ? varianceInfo.label : null,\n motion: motionInfo ? motionInfo.value : null,\n motion_label: motionInfo ? motionInfo.label : null,\n density: densityInfo ? densityInfo.value : null,\n density_label: densityInfo ? densityInfo.label : null,\n },\n motion_snippet: motionSnippet,\n spacing_scale: densityInfo ? densityInfo.spacing : null,\n }\n }\n}\n\n/** Approximate Python str(dict).lower() for a dict of str->str. */\nfunction pyDictRepr(row: Row): string {\n const parts = Object.entries(row).map(([k, v]) => `'${k}': '${v}'`)\n return \"{\" + parts.join(\", \") + \"}\"\n}\n\n// ============ CODEPOINT-AWARE STRING HELPERS ============\nfunction cpLen(s: string): number {\n return Array.from(s).length\n}\n\nfunction ljustCp(s: string, width: number): string {\n const pad = width - cpLen(s)\n return pad > 0 ? s + \" \".repeat(pad) : s\n}\n\nfunction fmtLjust(s: string, width: number): string {\n return ljustCp(s, width)\n}\n\nconst ANSI_RE = /\\x1b\\[[0-9;]*m/g\n\nfunction ansiLjust(s: string, width: number): string {\n const visibleLen = cpLen(s.replace(ANSI_RE, \"\"))\n const pad = width - visibleLen\n return s + \" \".repeat(Math.max(0, pad))\n}\n\n// ============ FORMATTERS ============\nconst BOX_WIDTH = 90\n\nfunction hexToAnsi(hexColor: string): string {\n if (!hexColor || !hexColor.startsWith(\"#\")) return \"\"\n const colorterm = process.env[\"COLORTERM\"] ?? \"\"\n if (colorterm !== \"truecolor\" && colorterm !== \"24bit\") return \"\"\n const hex = hexColor.replace(/^#+/, \"\")\n if (hex.length !== 6) return \"\"\n const r = parseInt(hex.slice(0, 2), 16)\n const g = parseInt(hex.slice(2, 4), 16)\n const b = parseInt(hex.slice(4, 6), 16)\n return `\\x1b[38;2;${r};${g};${b}m\\u2588\\u2588\\x1b[0m `\n}\n\nfunction sectionHeader(name: string, width: number): string {\n const label = `\\u2500\\u2500\\u2500 ${name} `\n const fill = \"\\u2500\".repeat(width - cpLen(label) - 1)\n return `\\u251c${label}${fill}\\u2524`\n}\n\n/** wrap_text from format_ascii_box. */\nfunction wrapText(text: string, prefix: string, width: number): string[] {\n if (!text) return []\n const words = text.split(/\\s+/).filter((w) => w.length > 0)\n const lines: string[] = []\n let currentLine = prefix\n for (const word of words) {\n if (cpLen(currentLine) + cpLen(word) + 1 <= width - 2) {\n currentLine += (currentLine !== prefix ? \" \" : \"\") + word\n } else {\n if (currentLine !== prefix) lines.push(currentLine)\n currentLine = prefix + word\n }\n }\n if (currentLine !== prefix) lines.push(currentLine)\n return lines\n}\n\nfunction formatAsciiBox(ds: DesignSystem): string {\n const project = ds.project_name ?? \"PROJECT\"\n const pattern = ds.pattern\n const style = ds.style\n const colors = ds.colors\n const typography = ds.typography\n const effects = ds.key_effects\n const antiPatterns = ds.anti_patterns\n\n const sections = (pattern.sections ?? \"\").split(\">\").map((s) => s.trim()).filter((s) => s.length > 0)\n\n const lines: string[] = []\n const w = BOX_WIDTH - 1\n\n lines.push(\"\\u2554\" + \"\\u2550\".repeat(w) + \"\\u2557\")\n lines.push(ansiLjust(`\\u2551 TARGET: ${project} - RECOMMENDED DESIGN SYSTEM`, BOX_WIDTH) + \"\\u2551\")\n lines.push(\"\\u255a\" + \"\\u2550\".repeat(w) + \"\\u255d\")\n lines.push(\"\\u250c\" + \"\\u2500\".repeat(w) + \"\\u2510\")\n\n // Design Dials (only if at least one dial set)\n const dials = ds.dials\n if (dials.variance !== null || dials.motion !== null || dials.density !== null) {\n lines.push(sectionHeader(\"DESIGN DIALS\", BOX_WIDTH + 1))\n if (dials.variance !== null) lines.push(ljustCp(`\\u2502 Variance: ${dials.variance}/10 \\u2014 ${dials.variance_label}`, BOX_WIDTH) + \"\\u2502\")\n if (dials.motion !== null) lines.push(ljustCp(`\\u2502 Motion: ${dials.motion}/10 \\u2014 ${dials.motion_label}`, BOX_WIDTH) + \"\\u2502\")\n if (dials.density !== null) lines.push(ljustCp(`\\u2502 Density: ${dials.density}/10 \\u2014 ${dials.density_label}`, BOX_WIDTH) + \"\\u2502\")\n }\n\n // Pattern\n lines.push(sectionHeader(\"PATTERN\", BOX_WIDTH + 1))\n lines.push(ljustCp(`\\u2502 Name: ${pattern.name ?? \"\"}`, BOX_WIDTH) + \"\\u2502\")\n if (pattern.conversion) lines.push(ljustCp(`\\u2502 Conversion: ${pattern.conversion}`, BOX_WIDTH) + \"\\u2502\")\n if (pattern.cta_placement) lines.push(ljustCp(`\\u2502 CTA: ${pattern.cta_placement}`, BOX_WIDTH) + \"\\u2502\")\n lines.push(ljustCp(\"\\u2502 Sections:\", BOX_WIDTH) + \"\\u2502\")\n sections.forEach((section, i) => {\n lines.push(ljustCp(`\\u2502 ${i + 1}. ${section}`, BOX_WIDTH) + \"\\u2502\")\n })\n\n // Style\n lines.push(sectionHeader(\"STYLE\", BOX_WIDTH + 1))\n lines.push(ljustCp(`\\u2502 Name: ${style.name ?? \"\"}`, BOX_WIDTH) + \"\\u2502\")\n const light = style.light_mode ?? \"\"\n const dark = style.dark_mode ?? \"\"\n if (light || dark) lines.push(ljustCp(`\\u2502 Mode Support: Light ${light} Dark ${dark}`, BOX_WIDTH) + \"\\u2502\")\n if (style.keywords) {\n for (const line of wrapText(`Keywords: ${style.keywords}`, \"\\u2502 \", BOX_WIDTH)) {\n lines.push(ljustCp(line, BOX_WIDTH) + \"\\u2502\")\n }\n }\n if (style.best_for) {\n for (const line of wrapText(`Best For: ${style.best_for}`, \"\\u2502 \", BOX_WIDTH)) {\n lines.push(ljustCp(line, BOX_WIDTH) + \"\\u2502\")\n }\n }\n if (style.performance || style.accessibility) {\n const perfA11y = `Performance: ${style.performance ?? \"\"} | Accessibility: ${style.accessibility ?? \"\"}`\n lines.push(ljustCp(`\\u2502 ${perfA11y}`, BOX_WIDTH) + \"\\u2502\")\n }\n\n // Colors\n lines.push(sectionHeader(\"COLORS\", BOX_WIDTH + 1))\n const colorEntries: Array<[string, string, string]> = [\n [\"Primary\", \"primary\", \"--color-primary\"],\n [\"On Primary\", \"on_primary\", \"--color-on-primary\"],\n [\"Secondary\", \"secondary\", \"--color-secondary\"],\n [\"Accent/CTA\", \"accent\", \"--color-accent\"],\n [\"Background\", \"background\", \"--color-background\"],\n [\"Foreground\", \"foreground\", \"--color-foreground\"],\n [\"Muted\", \"muted\", \"--color-muted\"],\n [\"Border\", \"border\", \"--color-border\"],\n [\"Destructive\", \"destructive\", \"--color-destructive\"],\n [\"Ring\", \"ring\", \"--color-ring\"],\n ]\n for (const [label, key, cssVar] of colorEntries) {\n const hexVal = colors[key] ?? \"\"\n if (!hexVal) continue\n const swatch = hexToAnsi(hexVal)\n const content = `\\u2502 ${swatch}${fmtLjust(label + \":\", 14)} ${fmtLjust(hexVal, 10)} (${cssVar})`\n lines.push(ansiLjust(content, BOX_WIDTH) + \"\\u2502\")\n }\n if (colors[\"notes\"]) {\n for (const line of wrapText(`Notes: ${colors[\"notes\"]}`, \"\\u2502 \", BOX_WIDTH)) {\n lines.push(ljustCp(line, BOX_WIDTH) + \"\\u2502\")\n }\n }\n\n // Typography\n lines.push(sectionHeader(\"TYPOGRAPHY\", BOX_WIDTH + 1))\n lines.push(ljustCp(`\\u2502 ${typography.heading ?? \"\"} / ${typography.body ?? \"\"}`, BOX_WIDTH) + \"\\u2502\")\n if (typography.mood) {\n for (const line of wrapText(`Mood: ${typography.mood}`, \"\\u2502 \", BOX_WIDTH)) {\n lines.push(ljustCp(line, BOX_WIDTH) + \"\\u2502\")\n }\n }\n if (typography.best_for) {\n for (const line of wrapText(`Best For: ${typography.best_for}`, \"\\u2502 \", BOX_WIDTH)) {\n lines.push(ljustCp(line, BOX_WIDTH) + \"\\u2502\")\n }\n }\n if (typography.google_fonts_url) {\n lines.push(ljustCp(`\\u2502 Google Fonts: ${typography.google_fonts_url}`, BOX_WIDTH) + \"\\u2502\")\n }\n if (typography.css_import) {\n lines.push(ljustCp(`\\u2502 CSS Import: ${sliceCp(typography.css_import, 70)}...`, BOX_WIDTH) + \"\\u2502\")\n }\n\n // Key Effects\n if (effects) {\n lines.push(sectionHeader(\"KEY EFFECTS\", BOX_WIDTH + 1))\n for (const line of wrapText(effects, \"\\u2502 \", BOX_WIDTH)) {\n lines.push(ljustCp(line, BOX_WIDTH) + \"\\u2502\")\n }\n }\n\n // Motion (GSAP skeleton, only if motion dial set)\n const motionSnippet = ds.motion_snippet\n if (motionSnippet && Object.keys(motionSnippet).length > 0) {\n lines.push(sectionHeader(\"MOTION\", BOX_WIDTH + 1))\n lines.push(ljustCp(`\\u2502 ${motionSnippet[\"Category\"] ?? \"\"} (${motionSnippet[\"Intensity Tier\"] ?? \"\"})`, BOX_WIDTH) + \"\\u2502\")\n lines.push(ljustCp(`\\u2502 Trigger: ${motionSnippet[\"Trigger\"] ?? \"\"} | Duration: ${motionSnippet[\"Duration\"] ?? \"\"} | Easing: ${motionSnippet[\"Easing\"] ?? \"\"}`, BOX_WIDTH) + \"\\u2502\")\n for (const line of wrapText(`GSAP: ${motionSnippet[\"GSAP Snippet\"] ?? \"\"}`, \"\\u2502 \", BOX_WIDTH)) {\n lines.push(ljustCp(line, BOX_WIDTH) + \"\\u2502\")\n }\n if (motionSnippet[\"Framework Notes\"]) {\n for (const line of wrapText(`Framework: ${motionSnippet[\"Framework Notes\"]}`, \"\\u2502 \", BOX_WIDTH)) {\n lines.push(ljustCp(line, BOX_WIDTH) + \"\\u2502\")\n }\n }\n }\n\n // Anti-patterns\n if (antiPatterns) {\n lines.push(sectionHeader(\"AVOID\", BOX_WIDTH + 1))\n for (const line of wrapText(antiPatterns, \"\\u2502 \", BOX_WIDTH)) {\n lines.push(ljustCp(line, BOX_WIDTH) + \"\\u2502\")\n }\n }\n\n // Pre-Delivery Checklist\n lines.push(sectionHeader(\"PRE-DELIVERY CHECKLIST\", BOX_WIDTH + 1))\n const checklistItems = [\n \"[ ] No emojis as icons (use SVG: Heroicons/Lucide)\",\n \"[ ] cursor-pointer on all clickable elements\",\n \"[ ] Hover states with smooth transitions (150-300ms)\",\n \"[ ] Light mode: text contrast 4.5:1 minimum\",\n \"[ ] Focus states visible for keyboard nav\",\n \"[ ] prefers-reduced-motion respected\",\n \"[ ] Responsive: 375px, 768px, 1024px, 1440px\",\n ]\n for (const item of checklistItems) {\n lines.push(ljustCp(`\\u2502 ${item}`, BOX_WIDTH) + \"\\u2502\")\n }\n\n lines.push(\"\\u2514\" + \"\\u2500\".repeat(w) + \"\\u2518\")\n\n return lines.join(\"\\n\")\n}\n\n/** Codepoint-aware slice (Python str[:n]). */\nfunction sliceCp(s: string, n: number): string {\n return Array.from(s).slice(0, n).join(\"\")\n}\n\nfunction formatMarkdown(ds: DesignSystem): string {\n const project = ds.project_name ?? \"PROJECT\"\n const pattern = ds.pattern\n const style = ds.style\n const colors = ds.colors\n const typography = ds.typography\n const effects = ds.key_effects\n const antiPatterns = ds.anti_patterns\n\n const lines: string[] = []\n lines.push(`## Design System: ${project}`)\n lines.push(\"\")\n\n const dials = ds.dials\n if (dials.variance !== null || dials.motion !== null || dials.density !== null) {\n lines.push(\"### Design Dials\")\n if (dials.variance !== null) lines.push(`- **Variance:** ${dials.variance}/10 \\u2014 ${dials.variance_label}`)\n if (dials.motion !== null) lines.push(`- **Motion:** ${dials.motion}/10 \\u2014 ${dials.motion_label}`)\n if (dials.density !== null) lines.push(`- **Density:** ${dials.density}/10 \\u2014 ${dials.density_label}`)\n lines.push(\"\")\n }\n\n lines.push(\"### Pattern\")\n lines.push(`- **Name:** ${pattern.name ?? \"\"}`)\n if (pattern.conversion) lines.push(`- **Conversion Focus:** ${pattern.conversion}`)\n if (pattern.cta_placement) lines.push(`- **CTA Placement:** ${pattern.cta_placement}`)\n if (pattern.color_strategy) lines.push(`- **Color Strategy:** ${pattern.color_strategy}`)\n lines.push(`- **Sections:** ${pattern.sections ?? \"\"}`)\n lines.push(\"\")\n\n lines.push(\"### Style\")\n lines.push(`- **Name:** ${style.name ?? \"\"}`)\n const light = style.light_mode ?? \"\"\n const dark = style.dark_mode ?? \"\"\n if (light || dark) lines.push(`- **Mode Support:** Light ${light} | Dark ${dark}`)\n if (style.keywords) lines.push(`- **Keywords:** ${style.keywords}`)\n if (style.best_for) lines.push(`- **Best For:** ${style.best_for}`)\n if (style.performance || style.accessibility) {\n lines.push(`- **Performance:** ${style.performance ?? \"\"} | **Accessibility:** ${style.accessibility ?? \"\"}`)\n }\n lines.push(\"\")\n\n lines.push(\"### Colors\")\n lines.push(\"| Role | Hex | CSS Variable |\")\n lines.push(\"|------|-----|--------------|\")\n const mdColorEntries: Array<[string, string, string]> = [\n [\"Primary\", \"primary\", \"--color-primary\"],\n [\"On Primary\", \"on_primary\", \"--color-on-primary\"],\n [\"Secondary\", \"secondary\", \"--color-secondary\"],\n [\"Accent/CTA\", \"accent\", \"--color-accent\"],\n [\"Background\", \"background\", \"--color-background\"],\n [\"Foreground\", \"foreground\", \"--color-foreground\"],\n [\"Muted\", \"muted\", \"--color-muted\"],\n [\"Border\", \"border\", \"--color-border\"],\n [\"Destructive\", \"destructive\", \"--color-destructive\"],\n [\"Ring\", \"ring\", \"--color-ring\"],\n ]\n for (const [label, key, cssVar] of mdColorEntries) {\n const hexVal = colors[key] ?? \"\"\n if (hexVal) lines.push(`| ${label} | \\`${hexVal}\\` | \\`${cssVar}\\` |`)\n }\n if (colors[\"notes\"]) lines.push(`\\n*Notes: ${colors[\"notes\"]}*`)\n lines.push(\"\")\n\n lines.push(\"### Typography\")\n lines.push(`- **Heading:** ${typography.heading ?? \"\"}`)\n lines.push(`- **Body:** ${typography.body ?? \"\"}`)\n if (typography.mood) lines.push(`- **Mood:** ${typography.mood}`)\n if (typography.best_for) lines.push(`- **Best For:** ${typography.best_for}`)\n if (typography.google_fonts_url) lines.push(`- **Google Fonts:** ${typography.google_fonts_url}`)\n if (typography.css_import) {\n lines.push(`- **CSS Import:**`)\n lines.push(\"```css\")\n lines.push(`${typography.css_import}`)\n lines.push(\"```\")\n }\n lines.push(\"\")\n\n if (effects) {\n lines.push(\"### Key Effects\")\n lines.push(`${effects}`)\n lines.push(\"\")\n }\n\n const motionSnippet = ds.motion_snippet\n if (motionSnippet && Object.keys(motionSnippet).length > 0) {\n lines.push(\"### Motion\")\n lines.push(`**${motionSnippet[\"Category\"] ?? \"\"}** (${motionSnippet[\"Intensity Tier\"] ?? \"\"}) \\u2014 Trigger: ${motionSnippet[\"Trigger\"] ?? \"\"} | Duration: ${motionSnippet[\"Duration\"] ?? \"\"} | Easing: \\`${motionSnippet[\"Easing\"] ?? \"\"}\\``)\n lines.push(\"```js\")\n lines.push(motionSnippet[\"GSAP Snippet\"] ?? \"\")\n lines.push(\"```\")\n if (motionSnippet[\"Framework Notes\"]) lines.push(`*Framework notes: ${motionSnippet[\"Framework Notes\"]}*`)\n if (motionSnippet[\"Do\"]) lines.push(`- \\u2705 ${motionSnippet[\"Do\"]}`)\n if (motionSnippet[\"Don't\"]) lines.push(`- \\u274c ${motionSnippet[\"Don't\"]}`)\n lines.push(\"\")\n }\n\n if (antiPatterns) {\n lines.push(\"### Avoid (Anti-patterns)\")\n lines.push(`- ${antiPatterns.split(\" + \").join(\"\\n- \")}`)\n lines.push(\"\")\n }\n\n lines.push(\"### Pre-Delivery Checklist\")\n lines.push(\"- [ ] No emojis as icons (use SVG: Heroicons/Lucide)\")\n lines.push(\"- [ ] cursor-pointer on all clickable elements\")\n lines.push(\"- [ ] Hover states with smooth transitions (150-300ms)\")\n lines.push(\"- [ ] Light mode: text contrast 4.5:1 minimum\")\n lines.push(\"- [ ] Focus states visible for keyboard nav\")\n lines.push(\"- [ ] prefers-reduced-motion respected\")\n lines.push(\"- [ ] Responsive: 375px, 768px, 1024px, 1440px\")\n lines.push(\"\")\n\n return lines.join(\"\\n\")\n}\n\n// ============ MAIN ENTRY ============\ninterface DialInputs {\n variance?: number | null\n motion?: number | null\n density?: number | null\n}\n\ninterface GenerateOptions {\n projectName?: string | null\n outputFormat?: \"ascii\" | \"markdown\"\n persist?: boolean\n page?: string | null\n outputDir?: string | null\n /** Containment root for persisted files (the opencode project directory). */\n projectDir?: string\n /** Optional 1-10 design dials biasing style, motion, and spacing. */\n variance?: number | null\n motion?: number | null\n density?: number | null\n}\n\nfunction generateDesignSystem(dataDir: string, query: string, opts: GenerateOptions = {}): string {\n const {\n projectName = null,\n outputFormat = \"ascii\",\n persist = false,\n page = null,\n outputDir = null,\n projectDir = process.cwd(),\n variance = null,\n motion = null,\n density = null,\n } = opts\n const generator = new DesignSystemGenerator(dataDir)\n const designSystem = generator.generate(query, projectName ?? null, { variance, motion, density })\n\n if (persist) {\n persistDesignSystem(dataDir, designSystem, page ?? null, outputDir ?? null, query, projectDir)\n }\n\n if (outputFormat === \"markdown\") return formatMarkdown(designSystem)\n return formatAsciiBox(designSystem)\n}\n\n// ============ PERSISTENCE ============\nfunction nowTimestamp(): string {\n const d = new Date()\n const p = (n: number) => String(n).padStart(2, \"0\")\n return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`\n}\n\ninterface PersistResult {\n status: string\n design_system_dir: string\n created_files: string[]\n}\n\n/**\n * Resolve `outputDir` against the project directory and confirm the result\n * stays inside the project subtree. An absolute outputDir is honored only when\n * it resolves within projectDir; anything escaping (via `..` or an unrelated\n * absolute path) is rejected. Returns the safe base directory.\n */\nfunction resolveOutputBase(projectDir: string, outputDir: string | null): string {\n const root = resolve(projectDir)\n const base = outputDir\n ? isAbsolute(outputDir)\n ? resolve(outputDir)\n : resolve(root, outputDir)\n : root\n const rel = relative(root, base)\n const inside = base === root || (!rel.startsWith(\"..\") && !isAbsolute(rel))\n if (!inside) {\n throw new Error(\n `outputDir resolves outside the project directory (${base}). ` +\n `Persisted files must stay within ${root}.`,\n )\n }\n return base\n}\n\nfunction persistDesignSystem(\n _dataDir: string,\n designSystem: DesignSystem,\n page: string | null,\n outputDir: string | null,\n pageQuery: string | null,\n projectDir: string,\n): PersistResult {\n const baseDir = resolveOutputBase(projectDir, outputDir)\n const projectSlug = slugify(designSystem.project_name || \"default\")\n if (!projectSlug) {\n throw new Error(\n `projectName '${designSystem.project_name}' has no filesystem-safe characters ` +\n `(need at least one of a-z or 0-9).`,\n )\n }\n\n let pageSlug: string | null = null\n if (page) {\n pageSlug = slugify(page)\n if (!pageSlug) {\n throw new Error(\n `page '${page}' has no filesystem-safe characters (need at least one of a-z or 0-9).`,\n )\n }\n }\n\n const designSystemDir = join(baseDir, \"design-system\", projectSlug)\n const pagesDir = join(designSystemDir, \"pages\")\n\n const createdFiles: string[] = []\n\n mkdirSync(designSystemDir, { recursive: true })\n mkdirSync(pagesDir, { recursive: true })\n\n const masterFile = join(designSystemDir, \"MASTER.md\")\n writeFileSync(masterFile, formatMasterMd(designSystem), \"utf8\")\n createdFiles.push(masterFile)\n\n if (page && pageSlug) {\n const pageFile = join(pagesDir, `${pageSlug}.md`)\n writeFileSync(pageFile, formatPageOverrideMd(_dataDir, designSystem, page, pageQuery), \"utf8\")\n createdFiles.push(pageFile)\n }\n\n return { status: \"success\", design_system_dir: designSystemDir, created_files: createdFiles }\n}\n\nfunction formatMasterMd(ds: DesignSystem): string {\n const project = ds.project_name ?? \"PROJECT\"\n const pattern = ds.pattern\n const style = ds.style\n const colors = ds.colors\n const typography = ds.typography\n const effects = ds.key_effects\n const antiPatterns = ds.anti_patterns\n const timestamp = nowTimestamp()\n\n const lines: string[] = []\n lines.push(\"# Design System Master File\")\n lines.push(\"\")\n lines.push(\"> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.\")\n lines.push(\"> If that file exists, its rules **override** this Master file.\")\n lines.push(\"> If not, strictly follow the rules below.\")\n lines.push(\"\")\n lines.push(\"---\")\n lines.push(\"\")\n lines.push(`**Project:** ${project}`)\n lines.push(`**Generated:** ${timestamp}`)\n lines.push(`**Category:** ${ds.category ?? \"General\"}`)\n const dials = ds.dials\n if (dials.variance !== null || dials.motion !== null || dials.density !== null) {\n const dialParts: string[] = []\n if (dials.variance !== null) dialParts.push(`Variance ${dials.variance}/10 (${dials.variance_label})`)\n if (dials.motion !== null) dialParts.push(`Motion ${dials.motion}/10 (${dials.motion_label})`)\n if (dials.density !== null) dialParts.push(`Density ${dials.density}/10 (${dials.density_label})`)\n lines.push(`**Design Dials:** ${dialParts.join(\" | \")}`)\n }\n lines.push(\"\")\n lines.push(\"---\")\n lines.push(\"\")\n\n lines.push(\"## Global Rules\")\n lines.push(\"\")\n\n lines.push(\"### Color Palette\")\n lines.push(\"\")\n lines.push(\"| Role | Hex | CSS Variable |\")\n lines.push(\"|------|-----|--------------|\")\n const masterColorEntries: Array<[string, string, string]> = [\n [\"Primary\", \"primary\", \"--color-primary\"],\n [\"On Primary\", \"on_primary\", \"--color-on-primary\"],\n [\"Secondary\", \"secondary\", \"--color-secondary\"],\n [\"Accent/CTA\", \"accent\", \"--color-accent\"],\n [\"Background\", \"background\", \"--color-background\"],\n [\"Foreground\", \"foreground\", \"--color-foreground\"],\n [\"Muted\", \"muted\", \"--color-muted\"],\n [\"Border\", \"border\", \"--color-border\"],\n [\"Destructive\", \"destructive\", \"--color-destructive\"],\n [\"Ring\", \"ring\", \"--color-ring\"],\n ]\n for (const [label, key, cssVar] of masterColorEntries) {\n const hexVal = colors[key] ?? \"\"\n if (hexVal) lines.push(`| ${label} | \\`${hexVal}\\` | \\`${cssVar}\\` |`)\n }\n lines.push(\"\")\n if (colors[\"notes\"]) {\n lines.push(`**Color Notes:** ${colors[\"notes\"]}`)\n lines.push(\"\")\n }\n\n lines.push(\"### Typography\")\n lines.push(\"\")\n lines.push(`- **Heading Font:** ${typography.heading ?? \"Inter\"}`)\n lines.push(`- **Body Font:** ${typography.body ?? \"Inter\"}`)\n if (typography.mood) lines.push(`- **Mood:** ${typography.mood}`)\n if (typography.google_fonts_url) {\n lines.push(`- **Google Fonts:** [${typography.heading ?? \"\"} + ${typography.body ?? \"\"}](${typography.google_fonts_url})`)\n }\n lines.push(\"\")\n if (typography.css_import) {\n lines.push(\"**CSS Import:**\")\n lines.push(\"```css\")\n lines.push(typography.css_import)\n lines.push(\"```\")\n lines.push(\"\")\n }\n\n lines.push(\"### Spacing Variables\")\n lines.push(\"\")\n const spacingScale = ds.spacing_scale ?? DEFAULT_SPACING\n if (ds.spacing_scale) {\n lines.push(`*Density: ${dials.density}/10 \\u2014 ${dials.density_label}*`)\n lines.push(\"\")\n }\n const spacingUsage: Record<string, string> = {\n xs: \"Tight gaps\",\n sm: \"Icon gaps, inline spacing\",\n md: \"Standard padding\",\n lg: \"Section padding\",\n xl: \"Large gaps\",\n \"2xl\": \"Section margins\",\n \"3xl\": \"Hero padding\",\n }\n lines.push(\"| Token | Value | Usage |\")\n lines.push(\"|-------|-------|-------|\")\n for (const token of [\"xs\", \"sm\", \"md\", \"lg\", \"xl\", \"2xl\", \"3xl\"]) {\n const pxValue = spacingScale[token] ?? \"\"\n const remValue = `${parseInt(pxValue.replace(/px$/, \"\"), 10) / 16}rem`\n lines.push(`| \\`--space-${token}\\` | \\`${pxValue}\\` / \\`${remValue}\\` | ${spacingUsage[token]} |`)\n }\n lines.push(\"\")\n\n lines.push(\"### Shadow Depths\")\n lines.push(\"\")\n lines.push(\"| Level | Value | Usage |\")\n lines.push(\"|-------|-------|-------|\")\n lines.push(\"| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |\")\n lines.push(\"| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |\")\n lines.push(\"| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |\")\n lines.push(\"| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |\")\n lines.push(\"\")\n\n lines.push(\"---\")\n lines.push(\"\")\n lines.push(\"## Component Specs\")\n lines.push(\"\")\n\n lines.push(\"### Buttons\")\n lines.push(\"\")\n lines.push(\"```css\")\n lines.push(\"/* Primary Button */\")\n lines.push(\".btn-primary {\")\n lines.push(` background: ${colors[\"cta\"] ?? \"#F97316\"};`)\n lines.push(\" color: white;\")\n lines.push(\" padding: 12px 24px;\")\n lines.push(\" border-radius: 8px;\")\n lines.push(\" font-weight: 600;\")\n lines.push(\" transition: all 200ms ease;\")\n lines.push(\" cursor: pointer;\")\n lines.push(\"}\")\n lines.push(\"\")\n lines.push(\".btn-primary:hover {\")\n lines.push(\" opacity: 0.9;\")\n lines.push(\" transform: translateY(-1px);\")\n lines.push(\"}\")\n lines.push(\"\")\n lines.push(\"/* Secondary Button */\")\n lines.push(\".btn-secondary {\")\n lines.push(` background: transparent;`)\n lines.push(` color: ${colors[\"primary\"] ?? \"#2563EB\"};`)\n lines.push(` border: 2px solid ${colors[\"primary\"] ?? \"#2563EB\"};`)\n lines.push(\" padding: 12px 24px;\")\n lines.push(\" border-radius: 8px;\")\n lines.push(\" font-weight: 600;\")\n lines.push(\" transition: all 200ms ease;\")\n lines.push(\" cursor: pointer;\")\n lines.push(\"}\")\n lines.push(\"```\")\n lines.push(\"\")\n\n lines.push(\"### Cards\")\n lines.push(\"\")\n lines.push(\"```css\")\n lines.push(\".card {\")\n lines.push(` background: ${colors[\"background\"] ?? \"#FFFFFF\"};`)\n lines.push(\" border-radius: 12px;\")\n lines.push(\" padding: 24px;\")\n lines.push(\" box-shadow: var(--shadow-md);\")\n lines.push(\" transition: all 200ms ease;\")\n lines.push(\" cursor: pointer;\")\n lines.push(\"}\")\n lines.push(\"\")\n lines.push(\".card:hover {\")\n lines.push(\" box-shadow: var(--shadow-lg);\")\n lines.push(\" transform: translateY(-2px);\")\n lines.push(\"}\")\n lines.push(\"```\")\n lines.push(\"\")\n\n lines.push(\"### Inputs\")\n lines.push(\"\")\n lines.push(\"```css\")\n lines.push(\".input {\")\n lines.push(\" padding: 12px 16px;\")\n lines.push(\" border: 1px solid #E2E8F0;\")\n lines.push(\" border-radius: 8px;\")\n lines.push(\" font-size: 16px;\")\n lines.push(\" transition: border-color 200ms ease;\")\n lines.push(\"}\")\n lines.push(\"\")\n lines.push(\".input:focus {\")\n lines.push(` border-color: ${colors[\"primary\"] ?? \"#2563EB\"};`)\n lines.push(\" outline: none;\")\n lines.push(` box-shadow: 0 0 0 3px ${colors[\"primary\"] ?? \"#2563EB\"}20;`)\n lines.push(\"}\")\n lines.push(\"```\")\n lines.push(\"\")\n\n lines.push(\"### Modals\")\n lines.push(\"\")\n lines.push(\"```css\")\n lines.push(\".modal-overlay {\")\n lines.push(\" background: rgba(0, 0, 0, 0.5);\")\n lines.push(\" backdrop-filter: blur(4px);\")\n lines.push(\"}\")\n lines.push(\"\")\n lines.push(\".modal {\")\n lines.push(\" background: white;\")\n lines.push(\" border-radius: 16px;\")\n lines.push(\" padding: 32px;\")\n lines.push(\" box-shadow: var(--shadow-xl);\")\n lines.push(\" max-width: 500px;\")\n lines.push(\" width: 90%;\")\n lines.push(\"}\")\n lines.push(\"```\")\n lines.push(\"\")\n\n lines.push(\"---\")\n lines.push(\"\")\n lines.push(\"## Style Guidelines\")\n lines.push(\"\")\n lines.push(`**Style:** ${style.name ?? \"Minimalism\"}`)\n lines.push(\"\")\n if (style.keywords) {\n lines.push(`**Keywords:** ${style.keywords}`)\n lines.push(\"\")\n }\n if (style.best_for) {\n lines.push(`**Best For:** ${style.best_for}`)\n lines.push(\"\")\n }\n if (effects) {\n lines.push(`**Key Effects:** ${effects}`)\n lines.push(\"\")\n }\n\n lines.push(\"### Page Pattern\")\n lines.push(\"\")\n lines.push(`**Pattern Name:** ${pattern.name ?? \"\"}`)\n lines.push(\"\")\n if (pattern.conversion) lines.push(`- **Conversion Strategy:** ${pattern.conversion}`)\n if (pattern.cta_placement) lines.push(`- **CTA Placement:** ${pattern.cta_placement}`)\n lines.push(`- **Section Order:** ${pattern.sections ?? \"\"}`)\n lines.push(\"\")\n\n const motionSnippet = ds.motion_snippet\n if (motionSnippet && Object.keys(motionSnippet).length > 0) {\n lines.push(\"---\")\n lines.push(\"\")\n lines.push(\"## Motion\")\n lines.push(\"\")\n lines.push(`**${motionSnippet[\"Category\"] ?? \"\"}** (${motionSnippet[\"Intensity Tier\"] ?? \"\"}) \\u2014 Trigger: ${motionSnippet[\"Trigger\"] ?? \"\"} | Duration: ${motionSnippet[\"Duration\"] ?? \"\"} | Easing: \\`${motionSnippet[\"Easing\"] ?? \"\"}\\``)\n lines.push(\"\")\n lines.push(\"```js\")\n lines.push(motionSnippet[\"GSAP Snippet\"] ?? \"\")\n lines.push(\"```\")\n lines.push(\"\")\n if (motionSnippet[\"Framework Notes\"]) {\n lines.push(`**Framework notes:** ${motionSnippet[\"Framework Notes\"]}`)\n lines.push(\"\")\n }\n if (motionSnippet[\"Do\"]) lines.push(`- \\u2705 ${motionSnippet[\"Do\"]}`)\n if (motionSnippet[\"Don't\"]) lines.push(`- \\u274c ${motionSnippet[\"Don't\"]}`)\n if (motionSnippet[\"Performance Notes\"]) lines.push(`- \\u26a1 ${motionSnippet[\"Performance Notes\"]}`)\n lines.push(\"\")\n }\n\n lines.push(\"---\")\n lines.push(\"\")\n lines.push(\"## Anti-Patterns (Do NOT Use)\")\n lines.push(\"\")\n if (antiPatterns) {\n const antiList = antiPatterns.split(\"+\").map((a) => a.trim())\n for (const anti of antiList) {\n if (anti) lines.push(`- \\u274c ${anti}`)\n }\n }\n lines.push(\"\")\n lines.push(\"### Additional Forbidden Patterns\")\n lines.push(\"\")\n lines.push(\"- \\u274c **Emojis as icons** \\u2014 Use SVG icons (Heroicons, Lucide, Simple Icons)\")\n lines.push(\"- \\u274c **Missing cursor:pointer** \\u2014 All clickable elements must have cursor:pointer\")\n lines.push(\"- \\u274c **Layout-shifting hovers** \\u2014 Avoid scale transforms that shift layout\")\n lines.push(\"- \\u274c **Low contrast text** \\u2014 Maintain 4.5:1 minimum contrast ratio\")\n lines.push(\"- \\u274c **Instant state changes** \\u2014 Always use transitions (150-300ms)\")\n lines.push(\"- \\u274c **Invisible focus states** \\u2014 Focus states must be visible for a11y\")\n lines.push(\"\")\n\n lines.push(\"---\")\n lines.push(\"\")\n lines.push(\"## Pre-Delivery Checklist\")\n lines.push(\"\")\n lines.push(\"Before delivering any UI code, verify:\")\n lines.push(\"\")\n lines.push(\"- [ ] No emojis used as icons (use SVG instead)\")\n lines.push(\"- [ ] All icons from consistent icon set (Heroicons/Lucide)\")\n lines.push(\"- [ ] `cursor-pointer` on all clickable elements\")\n lines.push(\"- [ ] Hover states with smooth transitions (150-300ms)\")\n lines.push(\"- [ ] Light mode: text contrast 4.5:1 minimum\")\n lines.push(\"- [ ] Focus states visible for keyboard navigation\")\n lines.push(\"- [ ] `prefers-reduced-motion` respected\")\n lines.push(\"- [ ] Responsive: 375px, 768px, 1024px, 1440px\")\n lines.push(\"- [ ] No content hidden behind fixed navbars\")\n lines.push(\"- [ ] No horizontal scroll on mobile\")\n lines.push(\"\")\n\n return lines.join(\"\\n\")\n}\n\n// ============ PAGE OVERRIDE ============\ninterface PageOverrides {\n page_type: string\n layout: Record<string, string>\n spacing: Record<string, string>\n typography: Record<string, string>\n colors: Record<string, string>\n components: string[]\n unique_components: string[]\n recommendations: string[]\n}\n\nfunction titleCase(s: string): string {\n return s.replace(/[A-Za-z]+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())\n}\n\nfunction formatPageOverrideMd(\n dataDir: string,\n ds: DesignSystem,\n pageName: string,\n pageQuery: string | null,\n): string {\n const project = ds.project_name ?? \"PROJECT\"\n const timestamp = nowTimestamp()\n const pageTitle = titleCase(pageName.replace(/-/g, \" \").replace(/_/g, \" \"))\n\n const pageOverrides = generateIntelligentOverrides(dataDir, pageName, pageQuery, ds)\n\n const lines: string[] = []\n lines.push(`# ${pageTitle} Page Overrides`)\n lines.push(\"\")\n lines.push(`> **PROJECT:** ${project}`)\n lines.push(`> **Generated:** ${timestamp}`)\n lines.push(`> **Page Type:** ${pageOverrides.page_type ?? \"General\"}`)\n lines.push(\"\")\n lines.push(\"> \\u26a0\\ufe0f **IMPORTANT:** Rules in this file **override** the Master file (`design-system/MASTER.md`).\")\n lines.push(\"> Only deviations from the Master are documented here. For all other rules, refer to the Master.\")\n lines.push(\"\")\n lines.push(\"---\")\n lines.push(\"\")\n\n lines.push(\"## Page-Specific Rules\")\n lines.push(\"\")\n\n lines.push(\"### Layout Overrides\")\n lines.push(\"\")\n const layout = pageOverrides.layout\n if (Object.keys(layout).length > 0) {\n for (const [key, value] of Object.entries(layout)) lines.push(`- **${key}:** ${value}`)\n } else {\n lines.push(\"- No overrides \\u2014 use Master layout\")\n }\n lines.push(\"\")\n\n lines.push(\"### Spacing Overrides\")\n lines.push(\"\")\n const spacing = pageOverrides.spacing\n if (Object.keys(spacing).length > 0) {\n for (const [key, value] of Object.entries(spacing)) lines.push(`- **${key}:** ${value}`)\n } else {\n lines.push(\"- No overrides \\u2014 use Master spacing\")\n }\n lines.push(\"\")\n\n lines.push(\"### Typography Overrides\")\n lines.push(\"\")\n const typography = pageOverrides.typography\n if (Object.keys(typography).length > 0) {\n for (const [key, value] of Object.entries(typography)) lines.push(`- **${key}:** ${value}`)\n } else {\n lines.push(\"- No overrides \\u2014 use Master typography\")\n }\n lines.push(\"\")\n\n lines.push(\"### Color Overrides\")\n lines.push(\"\")\n const colors = pageOverrides.colors\n if (Object.keys(colors).length > 0) {\n for (const [key, value] of Object.entries(colors)) lines.push(`- **${key}:** ${value}`)\n } else {\n lines.push(\"- No overrides \\u2014 use Master colors\")\n }\n lines.push(\"\")\n\n lines.push(\"### Component Overrides\")\n lines.push(\"\")\n const components = pageOverrides.components\n if (components.length > 0) {\n for (const comp of components) lines.push(`- ${comp}`)\n } else {\n lines.push(\"- No overrides \\u2014 use Master component specs\")\n }\n lines.push(\"\")\n\n lines.push(\"---\")\n lines.push(\"\")\n lines.push(\"## Page-Specific Components\")\n lines.push(\"\")\n const uniqueComponents = pageOverrides.unique_components\n if (uniqueComponents.length > 0) {\n for (const comp of uniqueComponents) lines.push(`- ${comp}`)\n } else {\n lines.push(\"- No unique components for this page\")\n }\n lines.push(\"\")\n\n lines.push(\"---\")\n lines.push(\"\")\n lines.push(\"## Recommendations\")\n lines.push(\"\")\n const recommendations = pageOverrides.recommendations\n if (recommendations.length > 0) {\n for (const rec of recommendations) lines.push(`- ${rec}`)\n }\n lines.push(\"\")\n\n return lines.join(\"\\n\")\n}\n\nfunction generateIntelligentOverrides(\n dataDir: string,\n pageName: string,\n pageQuery: string | null,\n _ds: DesignSystem,\n): PageOverrides {\n const pageLower = pageName.toLowerCase()\n const queryLower = (pageQuery ?? \"\").toLowerCase()\n const combinedContext = `${pageLower} ${queryLower}`\n\n const styleSearch = search(dataDir, combinedContext, \"style\", 1)\n const uxSearch = search(dataDir, combinedContext, \"ux\", 3)\n const landingSearch = search(dataDir, combinedContext, \"landing\", 1)\n\n const styleResults = styleSearch.results\n const uxResults = uxSearch.results\n const landingResults = landingSearch.results\n\n const pageType = detectPageType(combinedContext, styleResults)\n\n const layout: Record<string, string> = {}\n const spacing: Record<string, string> = {}\n const typography: Record<string, string> = {}\n const colors: Record<string, string> = {}\n const components: string[] = []\n const uniqueComponents: string[] = []\n const recommendations: string[] = []\n\n if (styleResults.length > 0) {\n const style = styleResults[0]!\n const keywords = style[\"Keywords\"] ?? \"\"\n const effects = style[\"Effects & Animation\"] ?? \"\"\n const kwLower = keywords.toLowerCase()\n\n if ([\"data\", \"dense\", \"dashboard\", \"grid\"].some((kw) => kwLower.includes(kw))) {\n layout[\"Max Width\"] = \"1400px or full-width\"\n layout[\"Grid\"] = \"12-column grid for data flexibility\"\n spacing[\"Content Density\"] = \"High \\u2014 optimize for information display\"\n } else if ([\"minimal\", \"simple\", \"clean\", \"single\"].some((kw) => kwLower.includes(kw))) {\n layout[\"Max Width\"] = \"800px (narrow, focused)\"\n layout[\"Layout\"] = \"Single column, centered\"\n spacing[\"Content Density\"] = \"Low \\u2014 focus on clarity\"\n } else {\n layout[\"Max Width\"] = \"1200px (standard)\"\n layout[\"Layout\"] = \"Full-width sections, centered content\"\n }\n\n if (effects) recommendations.push(`Effects: ${effects}`)\n }\n\n for (const ux of uxResults) {\n const category = ux[\"Category\"] ?? \"\"\n const doText = ux[\"Do\"] ?? \"\"\n const dontText = ux[\"Don't\"] ?? \"\"\n if (doText) recommendations.push(`${category}: ${doText}`)\n if (dontText) components.push(`Avoid: ${dontText}`)\n }\n\n if (landingResults.length > 0) {\n const landing = landingResults[0]!\n const sections = landing[\"Section Order\"] ?? \"\"\n const ctaPlacement = landing[\"Primary CTA Placement\"] ?? \"\"\n const colorStrategy = landing[\"Color Strategy\"] ?? \"\"\n if (sections) layout[\"Sections\"] = sections\n if (ctaPlacement) recommendations.push(`CTA Placement: ${ctaPlacement}`)\n if (colorStrategy) colors[\"Strategy\"] = colorStrategy\n }\n\n if (Object.keys(layout).length === 0) {\n layout[\"Max Width\"] = \"1200px\"\n layout[\"Layout\"] = \"Responsive grid\"\n }\n\n let recs = recommendations\n if (recs.length === 0) {\n recs = [\"Refer to MASTER.md for all design rules\", \"Add specific overrides as needed for this page\"]\n }\n\n return {\n page_type: pageType,\n layout,\n spacing,\n typography,\n colors,\n components,\n unique_components: uniqueComponents,\n recommendations: recs,\n }\n}\n\nfunction detectPageType(context: string, styleResults: Row[]): string {\n const contextLower = context.toLowerCase()\n\n const pagePatterns: Array<[string[], string]> = [\n [[\"dashboard\", \"admin\", \"analytics\", \"data\", \"metrics\", \"stats\", \"monitor\", \"overview\"], \"Dashboard / Data View\"],\n [[\"checkout\", \"payment\", \"cart\", \"purchase\", \"order\", \"billing\"], \"Checkout / Payment\"],\n [[\"settings\", \"profile\", \"account\", \"preferences\", \"config\"], \"Settings / Profile\"],\n [[\"landing\", \"marketing\", \"homepage\", \"hero\", \"home\", \"promo\"], \"Landing / Marketing\"],\n [[\"login\", \"signin\", \"signup\", \"register\", \"auth\", \"password\"], \"Authentication\"],\n [[\"pricing\", \"plans\", \"subscription\", \"tiers\", \"packages\"], \"Pricing / Plans\"],\n [[\"blog\", \"article\", \"post\", \"news\", \"content\", \"story\"], \"Blog / Article\"],\n [[\"product\", \"item\", \"detail\", \"pdp\", \"shop\", \"store\"], \"Product Detail\"],\n [[\"search\", \"results\", \"browse\", \"filter\", \"catalog\", \"list\"], \"Search Results\"],\n [[\"empty\", \"404\", \"error\", \"not found\", \"zero\"], \"Empty State\"],\n ]\n\n for (const [keywords, pageType] of pagePatterns) {\n if (keywords.some((kw) => contextLower.includes(kw))) return pageType\n }\n\n if (styleResults.length > 0) {\n const bestFor = (styleResults[0]![\"Best For\"] ?? \"\").toLowerCase()\n if (bestFor.includes(\"dashboard\") || bestFor.includes(\"data\")) return \"Dashboard / Data View\"\n if (bestFor.includes(\"landing\") || bestFor.includes(\"marketing\")) return \"Landing / Marketing\"\n }\n\n return \"General\"\n}\n\n// ============================================================================\n// Tool definition\n// ============================================================================\n\nconst DOMAINS = Object.keys(CSV_CONFIG).join(\", \")\n\nexport default tool({\n description:\n \"Search curated UI/UX design database (84 styles, 192 palettes, 73 font pairings, 192 product \" +\n \"types, UX rules, charts, landing patterns, icons, 4 framework guides: \" +\n `${AVAILABLE_STACKS.join(\", \")}). designSystem=true ` +\n \"generates complete design system for a product; persist writes design-system/MASTER.md.\",\n args: {\n query: tool.schema.string().describe(\"Search query, e.g. 'fintech dashboard', 'minimalism dark mode', 'list performance'.\"),\n domain: tool.schema\n .string()\n .optional()\n .describe(`Force a search domain instead of auto-detecting. One of: ${DOMAINS}.`),\n stack: tool.schema\n .string()\n .optional()\n .describe(`Search framework/stack-specific guidelines instead of a domain. One of: ${AVAILABLE_STACKS.join(\", \")}.`),\n maxResults: tool.schema\n .number()\n .int()\n .optional()\n .describe(\n `Max results for plain domain/stack search (default ${MAX_RESULTS}, clamped to ${MAX_RESULTS_MIN}-${MAX_RESULTS_MAX}).`,\n ),\n designSystem: tool.schema\n .boolean()\n .optional()\n .describe(\"Generate a complete design-system recommendation instead of a raw search.\"),\n projectName: tool.schema\n .string()\n .optional()\n .describe(\"Project name used in design-system output and the persisted folder slug.\"),\n format: tool.schema\n .enum([\"ascii\", \"markdown\"])\n .optional()\n .describe(\"Design-system render format: 'ascii' box (default) or 'markdown'.\"),\n persist: tool.schema\n .boolean()\n .optional()\n .describe(\"With designSystem: write MASTER.md (and page override) to disk.\"),\n page: tool.schema\n .string()\n .optional()\n .describe(\"With persist: also create a page-specific override file pages/<page>.md.\"),\n outputDir: tool.schema\n .string()\n .optional()\n .describe(\"Base directory for persisted files (default: the project directory).\"),\n variance: tool.schema\n .number()\n .int()\n .optional()\n .describe(\"With designSystem: DESIGN_VARIANCE dial 1-10 (1=centered/minimal, 10=bold/asymmetric); biases style selection.\"),\n motion: tool.schema\n .number()\n .int()\n .optional()\n .describe(\"With designSystem: MOTION_INTENSITY dial 1-10 (1=subtle, 10=complex); pulls a matching GSAP snippet from motion.csv.\"),\n density: tool.schema\n .number()\n .int()\n .optional()\n .describe(\"With designSystem: VISUAL_DENSITY dial 1-10 (1=spacious, 10=dense/dashboard); overrides the spacing scale.\"),\n json: tool.schema\n .boolean()\n .optional()\n .describe(\"For domain/stack search, return raw JSON instead of formatted text.\"),\n },\n async execute(args, context) {\n const maxResults = clampMaxResults(args.maxResults)\n const projectDir = context.directory\n\n // Design system takes priority (mirrors search.py).\n if (args.designSystem) {\n let result: string\n try {\n result = generateDesignSystem(DATA_DIR, args.query, {\n projectName: args.projectName ?? null,\n outputFormat: args.format ?? \"ascii\",\n persist: args.persist ?? false,\n page: args.page ?? null,\n outputDir: args.outputDir ?? null,\n projectDir,\n variance: args.variance ?? null,\n motion: args.motion ?? null,\n density: args.density ?? null,\n })\n } catch (err) {\n return `Error: ${err instanceof Error ? err.message : String(err)}`\n }\n\n if (!args.persist) return result\n\n // Persistence confirmation banner (search.py lines 87-98). Slugs mirror\n // persistDesignSystem so the banner matches what was actually written.\n const projectSlug = slugify(args.projectName ?? args.query)\n if (!projectSlug) return result\n const banner: string[] = []\n banner.push(\"\")\n banner.push(\"=\".repeat(60))\n banner.push(`\\u2705 Design system persisted to design-system/${projectSlug}/`)\n banner.push(` \\ud83d\\udcc4 design-system/${projectSlug}/MASTER.md (Global Source of Truth)`)\n if (args.page) {\n const pageFilename = slugify(args.page)\n if (pageFilename) {\n banner.push(` \\ud83d\\udcc4 design-system/${projectSlug}/pages/${pageFilename}.md (Page Overrides)`)\n }\n }\n banner.push(\"\")\n banner.push(`\\ud83d\\udcd6 Usage: When building a page, check design-system/${projectSlug}/pages/[page].md first.`)\n banner.push(` If exists, its rules override MASTER.md. Otherwise, use MASTER.md.`)\n banner.push(\"=\".repeat(60))\n return result + \"\\n\" + banner.join(\"\\n\")\n }\n\n // Stack search\n if (args.stack) {\n const result = searchStack(DATA_DIR, args.query, args.stack, maxResults)\n return args.json ? formatJson(result) : formatOutput(result)\n }\n\n // Domain search: explicit invalid domain errors (search() lists valid ones);\n // auto-detect when domain omitted.\n const domain = args.domain ?? detectDomain(args.query)\n const result = search(DATA_DIR, args.query, domain, maxResults)\n return args.json ? formatJson(result) : formatOutput(result)\n },\n})\n",
|
|
14
|
+
"kind": "raw",
|
|
15
|
+
"paths": {
|
|
16
|
+
"opencode": "design-search.ts"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"targets": [
|
|
21
|
+
"opencode"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "pdf-create",
|
|
4
|
+
"type": "tool",
|
|
5
|
+
"title": "pdf-create (documents)",
|
|
6
|
+
"description": "Markdown-lite text -> paginated PDF via pdf-lib (opencode only; needs `bun add -d pdf-lib`). Pairs with the pdf-create skill.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"skill:pdf-create"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"path": "pdf-create.ts",
|
|
13
|
+
"content": "// managed by AgentZ — edit freely; remove this line to stop AgentZ from updating this file\nimport { tool } from \"@opencode-ai/plugin\"\nimport { mkdirSync, writeFileSync } from \"fs\"\nimport { dirname, isAbsolute, join, relative } from \"path\"\n\n/**\n * pdf-create — render markdown-lite text into a paginated PDF via pdf-lib.\n * Companion to the `pdf-create` skill; for full layout control (tables,\n * images, forms, merging) write a pdf-lib script instead.\n *\n * Needs `pdf-lib` resolvable from the tool file (project node_modules or\n * `.opencode/`): `bun add -d pdf-lib`. Loaded lazily so a missing package\n * degrades into a helpful error instead of breaking tool loading.\n */\n\nconst PAGE_SIZES = {\n A4: [595.28, 841.89],\n Letter: [612, 792],\n} as const\n\nconst MARGIN = 72 // 1 inch\nconst BODY_SIZE = 11\nconst HEADING = { 1: 20, 2: 16, 3: 13 } as const\n\n// Unicode beyond Latin-1 that WinAnsi (CP1252) can still encode — pdf-lib's\n// standard fonts throw on anything outside this set.\nconst WINANSI_EXTRA = new Set(\"€‚ƒ„…†‡ˆ‰Š‹ŒŽ\\u2018\\u2019\\u201C\\u201D•–—˜™š›œžŸ\")\n\nfunction sanitize(text: string): string {\n return [...text.replace(/\\t/g, \" \").replace(/\\r/g, \"\")]\n .map((ch) => (ch.charCodeAt(0) <= 0xff || WINANSI_EXTRA.has(ch) ? ch : \"?\"))\n .join(\"\")\n}\n\ntype Font = { widthOfTextAtSize(text: string, size: number): number }\n\n/** Greedy word-wrap measured with the actual font metrics. */\nfunction wrap(text: string, font: Font, size: number, maxWidth: number): string[] {\n const lines: string[] = []\n for (const word of text.split(/\\s+/).filter(Boolean)) {\n const last = lines[lines.length - 1]\n const candidate = last === undefined ? word : `${last} ${word}`\n if (last !== undefined && font.widthOfTextAtSize(candidate, size) <= maxWidth) {\n lines[lines.length - 1] = candidate\n } else {\n lines.push(word)\n }\n }\n return lines.length ? lines : [\"\"]\n}\n\n/** Markdown-lite blocks: headings (#..###), bullets (-/*), paragraphs. */\nfunction parseBlocks(body: string): { kind: \"h1\" | \"h2\" | \"h3\" | \"bullet\" | \"para\"; text: string }[] {\n const blocks: { kind: \"h1\" | \"h2\" | \"h3\" | \"bullet\" | \"para\"; text: string }[] = []\n let para: string[] = []\n const flush = () => {\n if (para.length) blocks.push({ kind: \"para\", text: para.join(\" \") })\n para = []\n }\n for (const raw of body.split(\"\\n\")) {\n const line = raw.trim()\n const heading = line.match(/^(#{1,3})\\s+(.*)$/)\n const bullet = line.match(/^[-*]\\s+(.*)$/)\n if (!line) flush()\n else if (heading) {\n flush()\n blocks.push({ kind: `h${heading[1]!.length}` as \"h1\" | \"h2\" | \"h3\", text: heading[2]! })\n } else if (bullet) {\n flush()\n blocks.push({ kind: \"bullet\", text: bullet[1]! })\n } else para.push(line)\n }\n flush()\n return blocks\n}\n\n/**\n * Resolve `p` under `root` (context directory). Returns the absolute path when\n * it stays inside `root`, or null when it escapes (path traversal). Duplicated\n * per tool file by design: opencode tools stay single-file.\n */\nfunction resolveInRoot(root: string, p: string): string | null {\n const abs = isAbsolute(p) ? p : join(root, p)\n const rel = relative(root, abs)\n if (rel === \"\" || (!rel.startsWith(\"..\") && !isAbsolute(rel))) return abs\n return null\n}\n\nexport default tool({\n description:\n \"Create a PDF file from markdown-lite text: #/##/### headings, '-' bullets, blank-line \" +\n \"paragraph breaks; auto-wrapped and paginated (A4/Letter, Helvetica). Latin-1 text only — \" +\n \"other characters are replaced with '?'. Use for plain text documents (reports, letters, \" +\n \"notes); for tables, images, or precise layout write a pdf-lib script instead (pdf-create skill).\",\n args: {\n path: tool.schema.string().describe(\"Output .pdf path, relative to the project directory\"),\n title: tool.schema.string().optional().describe(\"Title rendered on top of page 1\"),\n body: tool.schema\n .string()\n .describe(\"Markdown-lite content: # ## ### headings, '-' bullets, blank lines between paragraphs\"),\n pageSize: tool.schema.enum([\"A4\", \"Letter\"]).optional().describe(\"Page size (default A4)\"),\n },\n async execute(args, context) {\n // Validate inputs before any expensive work.\n if (!args.path || args.path.trim() === \"\") {\n return \"path is empty — pass an output .pdf path relative to the project directory\"\n }\n if (!args.body || args.body.trim() === \"\") {\n return \"body is empty — pass markdown-lite content (headings, bullets, paragraphs)\"\n }\n const resolved = resolveInRoot(context.directory, args.path)\n if (resolved === null) {\n return `path '${args.path}' resolves outside the project directory — refused`\n }\n const file = resolved.endsWith(\".pdf\") ? resolved : `${resolved}.pdf`\n\n let pdfLib: typeof import(\"pdf-lib\")\n try {\n pdfLib = await import(\"pdf-lib\")\n } catch {\n return \"pdf-lib is not installed — run `bun add -d pdf-lib` in the project root, then retry\"\n }\n const { PDFDocument, StandardFonts } = pdfLib\n\n try {\n const doc = await PDFDocument.create()\n const regular = await doc.embedFont(StandardFonts.Helvetica)\n const bold = await doc.embedFont(StandardFonts.HelveticaBold)\n const [pageW, pageH] = PAGE_SIZES[args.pageSize ?? \"A4\"]\n const contentW = pageW - 2 * MARGIN\n\n let page = doc.addPage([pageW, pageH])\n let y = pageH - MARGIN\n const drawLines = (text: string, size: number, font: typeof regular, indent = 0, gapAfter = 0) => {\n const lineH = size * 1.4\n for (const line of wrap(sanitize(text), font, size, contentW - indent)) {\n if (y - lineH < MARGIN) {\n page = doc.addPage([pageW, pageH])\n y = pageH - MARGIN\n }\n y -= lineH\n page.drawText(line, { x: MARGIN + indent, y, size, font })\n }\n y -= gapAfter\n }\n\n if (args.title) drawLines(args.title, 24, bold, 0, 14)\n for (const block of parseBlocks(args.body)) {\n switch (block.kind) {\n case \"h1\":\n case \"h2\":\n case \"h3\":\n y -= 6 // breathing room above headings\n drawLines(block.text, HEADING[Number(block.kind[1]) as 1 | 2 | 3], bold, 0, 4)\n break\n case \"bullet\":\n drawLines(`• ${block.text}`, BODY_SIZE, regular, 12, 2)\n break\n case \"para\":\n drawLines(block.text, BODY_SIZE, regular, 0, 6)\n break\n }\n }\n\n mkdirSync(dirname(file), { recursive: true })\n writeFileSync(file, await doc.save())\n return `wrote ${doc.getPageCount()} page(s) to ${relative(context.directory, file)}`\n } catch (err) {\n return `failed to create PDF: ${err instanceof Error ? err.message : String(err)}`\n }\n },\n})\n",
|
|
14
|
+
"kind": "raw",
|
|
15
|
+
"paths": {
|
|
16
|
+
"opencode": "pdf-create.ts"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"targets": [
|
|
21
|
+
"opencode"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "pptx-create",
|
|
4
|
+
"type": "tool",
|
|
5
|
+
"title": "pptx-create (documents)",
|
|
6
|
+
"description": "Title + bullet slides -> .pptx deck via pptxgenjs (opencode only; needs `bun add -d pptxgenjs`). Pairs with the pptx-create skill.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"skill:pptx-create"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"path": "pptx-create.ts",
|
|
13
|
+
"content": "// managed by AgentZ — edit freely; remove this line to stop AgentZ from updating this file\nimport { tool } from \"@opencode-ai/plugin\"\nimport { mkdirSync } from \"fs\"\nimport { dirname, isAbsolute, join, relative } from \"path\"\n\n/**\n * pptx-create — build a clean 16:9 title + bullet-slide deck via pptxgenjs.\n * Companion to the `pptx-create` skill; for custom layouts, images, charts,\n * or tables write a pptxgenjs script instead.\n *\n * Needs `pptxgenjs` resolvable from the tool file (project node_modules or\n * `.opencode/`): `bun add -d pptxgenjs`. Loaded lazily so a missing package\n * degrades into a helpful error instead of breaking tool loading.\n */\n\nconst DARK = \"1E2761\" // default accent: deep navy\nconst LIGHT = \"F5F6FA\"\nconst TEXT = \"2B2B2B\"\nconst MUTED = \"CADCFC\"\n\n/**\n * Resolve `p` under `root` (context directory). Returns the absolute path when\n * it stays inside `root`, or null when it escapes (path traversal). Duplicated\n * per tool file by design: opencode tools stay single-file.\n */\nfunction resolveInRoot(root: string, p: string): string | null {\n const abs = isAbsolute(p) ? p : join(root, p)\n const rel = relative(root, abs)\n if (rel === \"\" || (!rel.startsWith(\"..\") && !isAbsolute(rel))) return abs\n return null\n}\n\nexport default tool({\n description:\n \"Create a PowerPoint (.pptx) deck: dark title slide plus one content slide per entry \" +\n \"(title, bullets, optional speaker notes), 16:9, consistent typography and an accent color. \" +\n \"Use for straightforward content decks; for images, charts, tables, or custom layouts \" +\n \"write a pptxgenjs script instead (pptx-create skill).\",\n args: {\n path: tool.schema.string().describe(\"Output .pptx path, relative to the project directory\"),\n title: tool.schema.string().describe(\"Deck title (title slide)\"),\n subtitle: tool.schema.string().optional().describe(\"Subtitle / author / date on the title slide\"),\n slides: tool.schema\n .array(\n tool.schema.object({\n title: tool.schema.string().describe(\"Slide headline\"),\n bullets: tool.schema.array(tool.schema.string()).optional().describe(\"Bullet lines\"),\n notes: tool.schema.string().optional().describe(\"Speaker notes\"),\n }),\n )\n .describe(\"Content slides, one entry per slide\"),\n accent: tool.schema\n .string()\n .optional()\n .describe(\"Accent hex color without '#' (default 1E2761 deep navy)\"),\n },\n async execute(args, context) {\n // Validate inputs before any expensive work.\n if (!args.path || args.path.trim() === \"\") {\n return \"path is empty — pass an output .pptx path relative to the project directory\"\n }\n if (!args.slides || args.slides.length === 0) {\n return \"slides is empty — pass at least one content slide\"\n }\n const accent = (args.accent ?? DARK).replace(/^#/, \"\")\n if (!/^[0-9a-fA-F]{6}$/.test(accent)) {\n return `accent '${args.accent}' is not a valid color — expected a 6-digit hex like '1E2761' or '#1E2761'`\n }\n const resolved = resolveInRoot(context.directory, args.path)\n if (resolved === null) {\n return `path '${args.path}' resolves outside the project directory — refused`\n }\n const file = resolved.endsWith(\".pptx\") ? resolved : `${resolved}.pptx`\n\n let Pptxgen: typeof import(\"pptxgenjs\").default\n try {\n Pptxgen = (await import(\"pptxgenjs\")).default\n } catch {\n return \"pptxgenjs is not installed — run `bun add -d pptxgenjs` in the project root, then retry\"\n }\n\n try {\n const pres = new Pptxgen()\n pres.layout = \"LAYOUT_16x9\" // 10 x 5.625 in\n\n // Title slide — dark background, big left-aligned title.\n const titleSlide = pres.addSlide()\n titleSlide.background = { color: accent }\n titleSlide.addText(args.title, {\n x: 0.6, y: 2.0, w: 8.8, h: 1.2,\n fontSize: 40, bold: true, color: \"FFFFFF\", align: \"left\",\n })\n if (args.subtitle) {\n titleSlide.addText(args.subtitle, {\n x: 0.6, y: 3.2, w: 8.8, h: 0.6,\n fontSize: 18, color: MUTED, align: \"left\",\n })\n }\n\n // Content slides — light background, accent headline, left-aligned bullets.\n for (const def of args.slides) {\n const slide = pres.addSlide()\n slide.background = { color: LIGHT }\n slide.addText(def.title, {\n x: 0.6, y: 0.4, w: 8.8, h: 0.8,\n fontSize: 28, bold: true, color: accent, align: \"left\",\n })\n if (def.bullets?.length) {\n slide.addText(\n def.bullets.map((text) => ({\n text,\n options: { bullet: { code: \"2022\", indent: 12 }, breakLine: true },\n })),\n {\n x: 0.8, y: 1.5, w: 8.4, h: 3.6,\n fontSize: 16, color: TEXT, align: \"left\", paraSpaceAfter: 8, valign: \"top\",\n },\n )\n }\n if (def.notes) slide.addNotes(def.notes)\n }\n\n mkdirSync(dirname(file), { recursive: true })\n await pres.writeFile({ fileName: file })\n return `wrote ${args.slides.length + 1} slide(s) to ${relative(context.directory, file)}`\n } catch (err) {\n return `failed to create PPTX: ${err instanceof Error ? err.message : String(err)}`\n }\n },\n})\n",
|
|
14
|
+
"kind": "raw",
|
|
15
|
+
"paths": {
|
|
16
|
+
"opencode": "pptx-create.ts"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"targets": [
|
|
21
|
+
"opencode"
|
|
22
|
+
]
|
|
23
|
+
}
|