opencode-design-system 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/LICENSE +17 -0
- package/README.md +251 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1588 -0
- package/package.json +52 -0
- package/templates/generate-preview.mjs +101 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1588 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import path5 from "path";
|
|
4
|
+
import { Plugin } from "@opencode/plugin";
|
|
5
|
+
|
|
6
|
+
// src/generator.ts
|
|
7
|
+
import { mkdir as mkdir2, readFile as readFile2, readdir, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
8
|
+
import path3 from "path";
|
|
9
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
10
|
+
|
|
11
|
+
// src/paths.ts
|
|
12
|
+
import path from "path";
|
|
13
|
+
var DESIGN_SYSTEM_DIR = "design-system";
|
|
14
|
+
function resolveInside(root, relativePath) {
|
|
15
|
+
if (path.isAbsolute(relativePath)) throw new Error(`Absolute paths are not allowed: ${relativePath}`);
|
|
16
|
+
const resolvedRoot = path.resolve(root);
|
|
17
|
+
const resolved = path.resolve(resolvedRoot, relativePath);
|
|
18
|
+
const relative = path.relative(resolvedRoot, resolved);
|
|
19
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
20
|
+
throw new Error(`Path escapes the project directory: ${relativePath}`);
|
|
21
|
+
}
|
|
22
|
+
return resolved;
|
|
23
|
+
}
|
|
24
|
+
function slugify(value) {
|
|
25
|
+
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/content.ts
|
|
29
|
+
var AGENTS_START = "<!-- opencode-design-system:start -->";
|
|
30
|
+
var AGENTS_END = "<!-- opencode-design-system:end -->";
|
|
31
|
+
function componentMarkdown(component) {
|
|
32
|
+
return [
|
|
33
|
+
`# ${component.name}`,
|
|
34
|
+
"",
|
|
35
|
+
`## Purpose
|
|
36
|
+
${component.purpose}`,
|
|
37
|
+
`## Variants
|
|
38
|
+
${list(component.variants)}`,
|
|
39
|
+
`## Sizes
|
|
40
|
+
${list(component.sizes)}`,
|
|
41
|
+
`## Tokens
|
|
42
|
+
${tokenList(component.tokens)}`,
|
|
43
|
+
`## States
|
|
44
|
+
${list(component.states)}`,
|
|
45
|
+
`## Behavior
|
|
46
|
+
${component.behavior || "Follow the platform's expected interaction model."}`,
|
|
47
|
+
`## Accessibility
|
|
48
|
+
${component.accessibility || "Use semantic elements, keyboard interaction, visible focus, and accessible names."}`,
|
|
49
|
+
`## Responsive
|
|
50
|
+
${component.responsive || "Adapt to the available space without losing content or functionality."}`,
|
|
51
|
+
`## Use when
|
|
52
|
+
${component.useWhen || component.purpose}`,
|
|
53
|
+
`## Avoid when
|
|
54
|
+
${component.avoidWhen || "A simpler existing component or pattern already fits."}`,
|
|
55
|
+
`## Related components
|
|
56
|
+
${list(component.related)}`
|
|
57
|
+
].join("\n\n").trimEnd() + "\n";
|
|
58
|
+
}
|
|
59
|
+
function patternMarkdown(pattern) {
|
|
60
|
+
return [
|
|
61
|
+
`# ${pattern.name}`,
|
|
62
|
+
"",
|
|
63
|
+
`## Purpose
|
|
64
|
+
${pattern.purpose}`,
|
|
65
|
+
`## Composition
|
|
66
|
+
${list(pattern.composition)}`,
|
|
67
|
+
`## Behavior
|
|
68
|
+
${pattern.behavior || "Keep the sequence clear and preserve user input when recovering from errors."}`,
|
|
69
|
+
`## Responsive
|
|
70
|
+
${pattern.responsive || "Reflow the pattern for narrow screens while preserving task priority."}`,
|
|
71
|
+
`## Accessibility
|
|
72
|
+
${pattern.accessibility || "Use semantic structure, keyboard access, clear labels, and announced feedback."}`,
|
|
73
|
+
`## Guidance
|
|
74
|
+
${pattern.guidance || pattern.purpose}`,
|
|
75
|
+
`## Tokens
|
|
76
|
+
${tokenList(pattern.tokens)}`
|
|
77
|
+
].join("\n\n").trimEnd() + "\n";
|
|
78
|
+
}
|
|
79
|
+
function aiGuidelines(name, preferences) {
|
|
80
|
+
const explicit = preferences.filter((item) => item.explicit);
|
|
81
|
+
const preferenceLines = explicit.length ? explicit.map((item) => `- **${item.key}:** ${formatPreference(item.value)}${item.rationale ? ` \u2014 ${item.rationale}` : ""}`).join("\n") : "- Treat documented foundations and decisions as the project's visual contract.";
|
|
82
|
+
return `# AI Guidelines \u2014 ${name}
|
|
83
|
+
|
|
84
|
+
These instructions are the operational contract for any agent that designs or implements this project's UI.
|
|
85
|
+
|
|
86
|
+
## Load only what the task needs
|
|
87
|
+
|
|
88
|
+
1. Read [manifest.json](manifest.json) to discover current files and status.
|
|
89
|
+
2. Read the relevant portions of [AI-GUIDELINES.md](AI-GUIDELINES.md), [FOUNDATIONS.md](FOUNDATIONS.md), and [preferences.json](preferences.json).
|
|
90
|
+
3. Read only the component and pattern documents related to the requested screen or change. Use their token lists to identify relevant values in [tokens.json](tokens.json).
|
|
91
|
+
4. Do not load unrelated component documentation or the generated preview source as design authority.
|
|
92
|
+
|
|
93
|
+
## Non-negotiable rules
|
|
94
|
+
|
|
95
|
+
- Use existing semantic tokens. Do not invent colors, spacing, typography, radius, elevations, breakpoints, or motion values when an appropriate token exists.
|
|
96
|
+
- Honor explicit user preferences below and in [preferences.json](preferences.json). Suggestions or accessibility notes may explain tradeoffs, but never silently override a stated preference.
|
|
97
|
+
- Never introduce a forbidden visual treatment. Keep deliberate identity choices distinct from technical recommendations.
|
|
98
|
+
- Reuse and compose documented components before adding a new visual primitive. If a reusable component is missing, propose composition or document the component addition before treating it as part of the system.
|
|
99
|
+
- Follow component states, responsive behavior, interaction, and accessibility guidance. Provide visible focus and preserve keyboard operation.
|
|
100
|
+
- Keep application implementation framework-neutral in design decisions; framework adapters are implementation details, not the source of truth.
|
|
101
|
+
- Keep screen design and code implementation distinct. A screen brief belongs in [screens/](screens/); do not create application code when the task only asks for a design specification.
|
|
102
|
+
- Do not alter existing application components as part of Design System analysis or generation unless explicitly asked.
|
|
103
|
+
- When the system status is **draft** or **review**, communicate unresolved decisions rather than presenting them as settled.
|
|
104
|
+
- When changing the system without its plugin, edit the existing semantic token paths and affected specifications deliberately, record the decision, update the version and changelog, then run the included Node.js preview generator. Never treat regenerated HTML as input data.
|
|
105
|
+
|
|
106
|
+
## Explicit user preferences
|
|
107
|
+
|
|
108
|
+
${preferenceLines}
|
|
109
|
+
|
|
110
|
+
## Conflict handling
|
|
111
|
+
|
|
112
|
+
If a request conflicts with an explicit preference or the current system, identify the exact conflict and ask whether the user wants to change the system. For ambiguous identity decisions, ask a small, natural follow-up question. Resolve technical consequences from the documented system without asking about every implementation detail.
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
115
|
+
function projectAgentsBlock(systemName) {
|
|
116
|
+
return `${AGENTS_START}
|
|
117
|
+
## Project Design System: ${systemName}
|
|
118
|
+
|
|
119
|
+
This repository has a framework-neutral Design System at [design-system/manifest.json](design-system/manifest.json). Before creating or changing UI, read [design-system/AI-GUIDELINES.md](design-system/AI-GUIDELINES.md) and discover the relevant tokens, components, and patterns through the manifest. Load only task-relevant documents; the structured Markdown and JSON files are authoritative, and [design-system/preview/index.html](design-system/preview/index.html) is generated visualization only.
|
|
120
|
+
|
|
121
|
+
Honor explicit decisions in [design-system/preferences.json](design-system/preferences.json) and [design-system/DECISIONS.md](design-system/DECISIONS.md). Do not add arbitrary visual values or redesign existing identity during analysis. A screen brief is a design artifact in [design-system/screens/](design-system/screens/); implementation is a separate step. This guidance is intentionally tool- and framework-independent and applies even when the Design System plugin is unavailable.
|
|
122
|
+
${AGENTS_END}`;
|
|
123
|
+
}
|
|
124
|
+
var PORTABLE_SKILL = `---
|
|
125
|
+
name: Design System
|
|
126
|
+
description: Apply the project's design-system tokens, components, patterns, and accessibility guidance to UI design and implementation.
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
Before any UI task:
|
|
130
|
+
|
|
131
|
+
1. Check whether design-system/manifest.json exists. If it does not, continue normally without inventing a system.
|
|
132
|
+
2. Read the manifest, AI-GUIDELINES.md, and preferences.json. Use manifest paths to discover relevant documents.
|
|
133
|
+
3. Load only tokens and component/pattern documents needed by this task. For a form, for example, load input/select/button plus the form pattern; do not read every component.
|
|
134
|
+
4. Treat explicit preferences and DECISIONS.md as user-owned constraints. Ask before changing a design identity decision; do not silently override it.
|
|
135
|
+
5. Use documented semantic tokens and components; preserve responsive, state, keyboard, focus, and accessibility requirements.
|
|
136
|
+
6. For design-only requests, write a screen brief to design-system/screens/<screen-name>.md and do not implement application code unless requested.
|
|
137
|
+
7. The preview is generated from structured tokens and specifications. Never use it as the only source of truth.
|
|
138
|
+
|
|
139
|
+
For larger systems, read only the relevant paths discovered from the manifest and keep unrelated documentation out of context.
|
|
140
|
+
`;
|
|
141
|
+
var DESIGNER_AGENT = `---
|
|
142
|
+
description: Collaborates with users to create and evolve original, accessible UI design systems.
|
|
143
|
+
mode: subagent
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
You are a senior UI/UX designer, accessibility specialist, and design-system architect. Collaborate in natural language. Start by understanding the product, audience, desired mood, references, explicit avoidances, platforms, and relevant accessibility needs. Ask only a few high-value questions when identity choices are unclear; offer concrete alternatives in everyday language. Do not turn the process into a long questionnaire, and do not decide identity questions on the user's behalf.
|
|
147
|
+
|
|
148
|
+
Explicit preferences have priority. Record them structurally, repeat them into AI-GUIDELINES.md, and preserve them in every update. You may explain contrast or usability tradeoffs, but ask before departing from an explicit request. Build an original visual language rather than copying a known design system.
|
|
149
|
+
|
|
150
|
+
For an existing application, use the read-only project analysis tool when available; otherwise inspect likely UI/style files without changing them. Preserve its recognizable identity by default. Distinguish probable accidents from intentional variants, explain evidence and uncertainty, and ask the user before normalizing ambiguous inconsistencies. Analysis never authorizes changing application files.
|
|
151
|
+
|
|
152
|
+
When enough direction is known, summarize the proposed direction and ask for confirmation before committing a substantial initial system. Use Design System tools when available; otherwise create the documented Markdown/JSON files directly and use the included preview generator. Keep the system's status as draft/review until the user accepts it. Explain what changed and any unresolved choices.
|
|
153
|
+
`;
|
|
154
|
+
var SCREEN_AGENT = `---
|
|
155
|
+
description: Produces implementation-ready screen specifications using the project's Design System.
|
|
156
|
+
mode: subagent
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
You are a UI/UX screen designer. Before designing, check design-system/manifest.json and follow AI-GUIDELINES.md. Load only the tokens, components, and patterns relevant to the requested screen. Understand the user's task, hierarchy, content, states, interactions, responsive behavior, and accessibility. Reuse the system; flag a missing reusable component rather than silently inventing a design language.
|
|
160
|
+
|
|
161
|
+
Design is separate from code implementation. Produce a concise, implementation-ready Markdown specification with purpose, layout, hierarchy, components and token references, data/content, interactions and states, responsive behavior, and accessibility. Save it under design-system/screens/<kebab-case-name>.md using the screen-spec tool when available; otherwise write the Markdown file directly. Do not write React/Vue/etc. unless the user separately requests implementation. If no system exists, state that and create a coherent brief without claiming it follows a nonexistent system.
|
|
162
|
+
`;
|
|
163
|
+
function list(items) {
|
|
164
|
+
return items?.length ? items.map((item) => `- ${item}`).join("\n") : "- None specified.";
|
|
165
|
+
}
|
|
166
|
+
function tokenList(items) {
|
|
167
|
+
return items?.length ? items.map((item) => "- `" + item + "`").join("\n") : "- No direct token references declared.";
|
|
168
|
+
}
|
|
169
|
+
function formatPreference(value) {
|
|
170
|
+
return typeof value === "string" ? `\u201C${value}\u201D` : `\`${String(value)}\``;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/io.ts
|
|
174
|
+
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
175
|
+
import path2 from "path";
|
|
176
|
+
import { randomUUID } from "crypto";
|
|
177
|
+
async function readText(root, relativePath) {
|
|
178
|
+
return readFile(resolveInside(root, relativePath), "utf8");
|
|
179
|
+
}
|
|
180
|
+
async function readJson(root, relativePath) {
|
|
181
|
+
return JSON.parse(await readText(root, relativePath));
|
|
182
|
+
}
|
|
183
|
+
async function fileExists(root, relativePath) {
|
|
184
|
+
try {
|
|
185
|
+
await readFile(resolveInside(root, relativePath));
|
|
186
|
+
return true;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error.code === "ENOENT") return false;
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function atomicWrite(root, relativePath, content) {
|
|
193
|
+
const destination = resolveInside(root, relativePath);
|
|
194
|
+
const directory = path2.dirname(destination);
|
|
195
|
+
await mkdir(directory, { recursive: true });
|
|
196
|
+
const temporary = path2.join(directory, `.${path2.basename(destination)}.${randomUUID()}.tmp`);
|
|
197
|
+
try {
|
|
198
|
+
await writeFile(temporary, content, "utf8");
|
|
199
|
+
await rename(temporary, destination);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
await rm(temporary, { force: true }).catch(() => void 0);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function writeIfAbsent(root, relativePath, content) {
|
|
206
|
+
const destination = resolveInside(root, relativePath);
|
|
207
|
+
await mkdir(path2.dirname(destination), { recursive: true });
|
|
208
|
+
try {
|
|
209
|
+
await writeFile(destination, content, { encoding: "utf8", flag: "wx" });
|
|
210
|
+
return true;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error.code === "EEXIST") return false;
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function updateManagedBlock(root, relativePath, startMarker, endMarker, block) {
|
|
217
|
+
let current = "";
|
|
218
|
+
try {
|
|
219
|
+
current = await readText(root, relativePath);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error.code !== "ENOENT") throw error;
|
|
222
|
+
}
|
|
223
|
+
const start = current.indexOf(startMarker);
|
|
224
|
+
const end = current.indexOf(endMarker);
|
|
225
|
+
let next;
|
|
226
|
+
if (start >= 0 && end >= start) {
|
|
227
|
+
next = `${current.slice(0, start)}${block}${current.slice(end + endMarker.length)}`;
|
|
228
|
+
} else {
|
|
229
|
+
const separator = current.length === 0 || current.endsWith("\n") ? "" : "\n";
|
|
230
|
+
next = `${current}${separator}${current.length ? "\n" : ""}${block}
|
|
231
|
+
`;
|
|
232
|
+
}
|
|
233
|
+
await atomicWrite(root, relativePath, next);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/schema.ts
|
|
237
|
+
var manifestSchema = {
|
|
238
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
239
|
+
$id: "https://opencode.design/schema/manifest-1.0.json",
|
|
240
|
+
title: "OpenCode Design System manifest",
|
|
241
|
+
type: "object",
|
|
242
|
+
required: [
|
|
243
|
+
"designSystemVersion",
|
|
244
|
+
"schemaVersion",
|
|
245
|
+
"status",
|
|
246
|
+
"name",
|
|
247
|
+
"description",
|
|
248
|
+
"source",
|
|
249
|
+
"themes",
|
|
250
|
+
"tokens",
|
|
251
|
+
"preferences",
|
|
252
|
+
"foundations",
|
|
253
|
+
"guidelines",
|
|
254
|
+
"decisions",
|
|
255
|
+
"preview",
|
|
256
|
+
"screens",
|
|
257
|
+
"components",
|
|
258
|
+
"patterns"
|
|
259
|
+
],
|
|
260
|
+
properties: {
|
|
261
|
+
designSystemVersion: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
|
|
262
|
+
schemaVersion: { type: "string" },
|
|
263
|
+
status: { enum: ["draft", "review", "stable"] },
|
|
264
|
+
name: { type: "string", minLength: 1 },
|
|
265
|
+
description: { type: "string" },
|
|
266
|
+
source: {
|
|
267
|
+
type: "object",
|
|
268
|
+
required: ["type"],
|
|
269
|
+
properties: { type: { enum: ["from-scratch", "existing-project"] }, evidence: { type: "array", items: { type: "string" } } }
|
|
270
|
+
},
|
|
271
|
+
themes: { type: "array", items: { type: "string" }, minItems: 1 },
|
|
272
|
+
tokens: { type: "string" },
|
|
273
|
+
preferences: { type: "string" },
|
|
274
|
+
foundations: { type: "string" },
|
|
275
|
+
guidelines: { type: "string" },
|
|
276
|
+
decisions: { type: "string" },
|
|
277
|
+
preview: { type: "string" },
|
|
278
|
+
screens: { type: "string" },
|
|
279
|
+
components: { type: "array", items: { $ref: "#/$defs/document" } },
|
|
280
|
+
patterns: { type: "array", items: { $ref: "#/$defs/document" } }
|
|
281
|
+
},
|
|
282
|
+
$defs: {
|
|
283
|
+
document: {
|
|
284
|
+
type: "object",
|
|
285
|
+
required: ["name", "file", "tokens"],
|
|
286
|
+
properties: { name: { type: "string" }, file: { type: "string" }, tokens: { type: "array", items: { type: "string" } } }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
var tokensSchema = {
|
|
291
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
292
|
+
$id: "https://opencode.design/schema/tokens-1.0.json",
|
|
293
|
+
title: "Framework-neutral semantic design tokens",
|
|
294
|
+
type: "object",
|
|
295
|
+
required: ["schemaVersion", "themes"],
|
|
296
|
+
properties: {
|
|
297
|
+
schemaVersion: { type: "string" },
|
|
298
|
+
themes: {
|
|
299
|
+
type: "object",
|
|
300
|
+
minProperties: 1,
|
|
301
|
+
additionalProperties: { type: "object" }
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
additionalProperties: true
|
|
305
|
+
};
|
|
306
|
+
function validateTokens(value) {
|
|
307
|
+
const errors = [];
|
|
308
|
+
if (typeof value.schemaVersion !== "string") errors.push("tokens.schemaVersion must be a string");
|
|
309
|
+
if (!value.themes || typeof value.themes !== "object" || Array.isArray(value.themes)) {
|
|
310
|
+
errors.push("tokens.themes must be an object containing at least one theme");
|
|
311
|
+
} else if (Object.keys(value.themes).length === 0) {
|
|
312
|
+
errors.push("tokens.themes must contain at least one theme");
|
|
313
|
+
}
|
|
314
|
+
const themes = value.themes;
|
|
315
|
+
for (const [name, theme] of Object.entries(themes ?? {})) {
|
|
316
|
+
if (!theme || typeof theme !== "object" || Array.isArray(theme)) errors.push(`tokens.themes.${name} must be an object`);
|
|
317
|
+
}
|
|
318
|
+
return errors;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/preview.ts
|
|
322
|
+
function createPreviewHtml(input) {
|
|
323
|
+
const { manifest, tokens, components, patterns } = input;
|
|
324
|
+
const themes = tokens.themes ?? {};
|
|
325
|
+
const firstTheme = Object.keys(themes)[0] ?? "light";
|
|
326
|
+
const themePayload = safeJson(themes);
|
|
327
|
+
const componentCards = components.map((component) => `
|
|
328
|
+
<article class="spec-card">
|
|
329
|
+
<div class="spec-heading"><div><p class="eyebrow">Component</p><h3>${escapeHtml(component.name)}</h3></div><span class="tag">${escapeHtml(component.variants?.[0] ?? "base")}</span></div>
|
|
330
|
+
<p>${escapeHtml(component.purpose)}</p>
|
|
331
|
+
<div class="showcase">
|
|
332
|
+
${component.name.toLowerCase().includes("button") ? `<button class="button" type="button">${escapeHtml(component.name)}</button><button class="button secondary" type="button">Secondary</button><button class="button" type="button" disabled>Disabled</button>` : component.name.toLowerCase().includes("input") || component.name.toLowerCase().includes("search") ? `<label class="field-label">${escapeHtml(component.name)}<input type="text" placeholder="Enter a value" /></label><label class="field-label">Error state<input class="field-error" aria-invalid="true" value="Check this value" /></label>` : `<button class="button secondary" type="button">${escapeHtml(component.name)} example</button>`}
|
|
333
|
+
</div>
|
|
334
|
+
${component.tokens?.length ? `<small>Tokens: ${component.tokens.map((token) => `<code>${escapeHtml(token)}</code>`).join(" ")}</small>` : ""}
|
|
335
|
+
</article>`).join("\n");
|
|
336
|
+
const patternCards = patterns.map((pattern) => `
|
|
337
|
+
<article class="spec-card"><p class="eyebrow">Pattern</p><h3>${escapeHtml(pattern.name)}</h3><p>${escapeHtml(pattern.purpose)}</p><p class="muted">${escapeHtml(pattern.guidance ?? pattern.composition?.join(" \xB7 ") ?? "")}</p></article>`).join("\n");
|
|
338
|
+
const componentIndex = manifest.components.map((item) => `<li><a href="../${escapeHtml(item.file)}">${escapeHtml(item.name)}</a></li>`).join("");
|
|
339
|
+
const patternIndex = manifest.patterns.map((item) => `<li><a href="../${escapeHtml(item.file)}">${escapeHtml(item.name)}</a></li>`).join("");
|
|
340
|
+
return `<!doctype html>
|
|
341
|
+
<html lang="en" data-theme="${escapeHtml(firstTheme)}">
|
|
342
|
+
<head>
|
|
343
|
+
<meta charset="utf-8" />
|
|
344
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
345
|
+
<meta name="description" content="Generated interactive preview for ${escapeHtml(manifest.name)}." />
|
|
346
|
+
<title>${escapeHtml(manifest.name)} \u2014 Design System</title>
|
|
347
|
+
<style>
|
|
348
|
+
:root{font-family:var(--typography-font-family-sans,Inter,ui-sans-serif,system-ui,sans-serif);color:var(--color-text-primary,#17211f);background:var(--color-surface-base,#f6f8f7);font-synthesis:none;font-optical-sizing:auto;line-height:1.5}
|
|
349
|
+
*{box-sizing:border-box}body{margin:0;background:var(--color-surface-base,#f6f8f7);color:var(--color-text-primary,#17211f)}button,input,select{font:inherit}button{cursor:pointer}a{color:var(--color-accent-primary,#236b55)}
|
|
350
|
+
.shell{min-height:100vh;display:grid;grid-template-columns:250px minmax(0,1fr)}.sidebar{padding:28px 20px;border-right:1px solid var(--color-border-subtle,#dbe2de);background:var(--color-surface-raised,#fff)}.brand{font-size:1.05rem;font-weight:750;margin-bottom:4px}.side-note,.muted{color:var(--color-text-secondary,#65726d);font-size:.88rem}.nav{display:grid;gap:6px;margin:28px 0}.nav a{padding:8px 10px;text-decoration:none;border-radius:var(--radius-control,6px);color:var(--color-text-secondary,#65726d)}.nav a:hover{background:var(--color-surface-base,#f6f8f7);color:var(--color-text-primary,#17211f)}.nav-label{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--color-text-secondary,#65726d);margin:20px 8px 8px}
|
|
351
|
+
main{min-width:0;padding:34px clamp(20px,5vw,72px) 72px;max-width:1440px}.topbar{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:34px}.eyebrow{font-size:.72rem;text-transform:uppercase;letter-spacing:.1em;font-weight:700;color:var(--color-text-secondary,#65726d);margin:0 0 5px}.hero h1{font-size:clamp(2rem,4vw,3.4rem);letter-spacing:-.045em;line-height:1.08;margin:0}.hero>p{max-width:720px;color:var(--color-text-secondary,#65726d)}.status{display:inline-flex;border:1px solid var(--color-border-subtle,#dbe2de);border-radius:999px;padding:3px 10px;font-size:.76rem;text-transform:uppercase;letter-spacing:.06em}
|
|
352
|
+
section{margin-top:54px;scroll-margin-top:20px}.section-heading{display:flex;justify-content:space-between;align-items:end;gap:16px;border-bottom:1px solid var(--color-border-subtle,#dbe2de);padding-bottom:12px;margin-bottom:18px}.section-heading h2{margin:0;font-size:1.45rem;letter-spacing:-.025em}.section-heading p{margin:0;color:var(--color-text-secondary,#65726d);font-size:.9rem}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,290px),1fr));gap:14px}.spec-card,.surface{border:1px solid var(--color-border-subtle,#dbe2de);border-radius:var(--radius-card,10px);background:var(--color-surface-raised,#fff);padding:18px}.spec-card h3{margin:0 0 8px;font-size:1.05rem}.spec-card p{color:var(--color-text-secondary,#65726d);margin:8px 0}.spec-card small{display:block;margin-top:12px;color:var(--color-text-secondary,#65726d)}.spec-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.tag{border-radius:var(--radius-tag,4px);background:var(--color-accent-subtle,#e6f0eb);color:var(--color-accent-primary,#236b55);padding:3px 8px;font-size:.75rem}.showcase{display:flex;align-items:end;gap:10px;flex-wrap:wrap;margin:16px 0 4px}.button{border:1px solid var(--color-accent-primary,#236b55);border-radius:var(--radius-control,6px);background:var(--color-accent-primary,#236b55);color:var(--color-on-accent,#fff);padding:9px 14px;min-height:40px;font-weight:650;transition:background 140ms ease,border-color 140ms ease,transform 140ms ease}.button:hover{filter:brightness(.94)}.button:active{transform:translateY(1px)}.button:focus-visible,input:focus-visible,select:focus-visible,.tab:focus-visible{outline:3px solid var(--color-focus-ring,#79b8a0);outline-offset:2px}.button.secondary{background:var(--color-surface-raised,#fff);color:var(--color-text-primary,#17211f);border-color:var(--color-border-strong,#9aa9a1)}.button:disabled{opacity:.5;cursor:not-allowed}.field-label{display:grid;gap:5px;font-size:.82rem;color:var(--color-text-secondary,#65726d)}input,select{min-height:40px;border:1px solid var(--color-border-strong,#9aa9a1);border-radius:var(--radius-control,6px);padding:8px 10px;background:var(--color-surface-base,#f6f8f7);color:var(--color-text-primary,#17211f)}.field-error{border-color:var(--color-status-danger,#b83d48)}code{font-size:.78rem;background:var(--color-surface-base,#f6f8f7);padding:2px 5px;border-radius:3px}.swatches{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}.swatch{min-width:0;border:1px solid var(--color-border-subtle,#dbe2de);border-radius:var(--radius-control,6px);overflow:hidden;background:var(--color-surface-raised,#fff)}.swatch-color{height:68px;background:var(--swatch,#ddd);border-bottom:1px solid var(--color-border-subtle,#dbe2de)}.swatch-label{padding:8px 10px;font-size:.78rem}.swatch-label code{display:block;overflow-wrap:anywhere;background:none;padding:2px 0;color:var(--color-text-secondary,#65726d)}.tabs{display:flex;gap:4px;border-bottom:1px solid var(--color-border-subtle,#dbe2de);margin-bottom:14px}.tab{border:0;border-bottom:2px solid transparent;background:transparent;color:var(--color-text-secondary,#65726d);padding:9px 12px}.tab[aria-selected=true]{border-bottom-color:var(--color-accent-primary,#236b55);color:var(--color-accent-primary,#236b55);font-weight:700}.tab-panel{padding:12px 0}.table-wrap{overflow:auto;border:1px solid var(--color-border-subtle,#dbe2de);border-radius:var(--radius-card,10px)}table{width:100%;border-collapse:collapse;background:var(--color-surface-raised,#fff)}th,td{text-align:left;padding:11px 14px;border-bottom:1px solid var(--color-border-subtle,#dbe2de);white-space:nowrap}th{color:var(--color-text-secondary,#65726d);font-size:.78rem}.switch-row{display:flex;align-items:center;gap:12px}.switch{width:42px;height:24px;border:0;border-radius:999px;background:var(--color-border-strong,#9aa9a1);padding:3px}.switch:before{content:"";display:block;width:18px;height:18px;border-radius:50%;background:white;transition:transform .16s}.switch[aria-checked=true]{background:var(--color-accent-primary,#236b55)}.switch[aria-checked=true]:before{transform:translateX(18px)}.overlay{position:fixed;inset:0;display:none;place-items:center;background:rgba(12,22,18,.48);padding:20px;z-index:5}.overlay.open{display:grid}.dialog{width:min(100%,480px);padding:24px;background:var(--color-surface-raised,#fff);color:var(--color-text-primary,#17211f);border:1px solid var(--color-border-subtle,#dbe2de);border-radius:var(--radius-dialog,12px);box-shadow:var(--elevation-dialog,0 18px 60px rgba(0,0,0,.2))}.dialog h3{margin-top:0}.toast{position:fixed;right:24px;bottom:24px;display:none;padding:12px 16px;border-radius:var(--radius-control,6px);background:var(--color-text-primary,#17211f);color:var(--color-surface-raised,#fff);box-shadow:var(--elevation-popover,0 8px 24px rgba(0,0,0,.18));z-index:7}.toast.show{display:block}.token-table{display:grid;gap:16px}.token-group h3{font-size:1rem;margin-bottom:8px}.footer{margin-top:56px;padding-top:16px;border-top:1px solid var(--color-border-subtle,#dbe2de);color:var(--color-text-secondary,#65726d);font-size:.83rem}
|
|
353
|
+
.token-demo{height:68px;display:flex;align-items:center;justify-content:center;background:var(--color-surface-base,#f6f8f7);border-bottom:1px solid var(--color-border-subtle,#dbe2de);overflow:hidden}.token-demo-sample{display:block;background:var(--color-accent-primary,#236b55);min-width:12px;min-height:8px}.token-demo[data-group="typography"] .token-demo-sample{min-width:0;min-height:0;background:transparent;color:var(--color-text-primary,#17211f)}
|
|
354
|
+
@media(max-width:760px){.shell{grid-template-columns:1fr}.sidebar{border-right:0;border-bottom:1px solid var(--color-border-subtle,#dbe2de);padding:14px 18px}.nav{display:flex;overflow:auto;margin:12px 0 0}.nav a{white-space:nowrap}.sidebar .nav-label,.sidebar .side-note,.sidebar ul{display:none}main{padding:24px 18px 54px}.topbar{align-items:flex-start}.component-nav{display:none}}
|
|
355
|
+
@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
|
|
356
|
+
</style>
|
|
357
|
+
</head>
|
|
358
|
+
<body>
|
|
359
|
+
<div class="shell">
|
|
360
|
+
<aside class="sidebar" aria-label="Design system navigation">
|
|
361
|
+
<div class="brand">${escapeHtml(manifest.name)}</div><div class="side-note">Design system \xB7 v${escapeHtml(manifest.designSystemVersion)}</div>
|
|
362
|
+
<nav class="nav"><a href="#overview">Overview</a><a href="#colors">Tokens</a><a href="#components">Components</a><a href="#patterns">Patterns</a><a href="#interactions">Interactions</a></nav>
|
|
363
|
+
${manifest.components.length ? `<div class="nav-label">Components</div><ul class="component-nav">${componentIndex}</ul>` : ""}
|
|
364
|
+
${manifest.patterns.length ? `<div class="nav-label">Patterns</div><ul class="component-nav">${patternIndex}</ul>` : ""}
|
|
365
|
+
</aside>
|
|
366
|
+
<main>
|
|
367
|
+
<div class="topbar"><div class="status">${escapeHtml(manifest.status)}</div><div><button class="button secondary" id="theme-toggle" type="button">Toggle theme</button></div></div>
|
|
368
|
+
<header id="overview" class="hero"><p class="eyebrow">Framework-neutral design language</p><h1>${escapeHtml(manifest.name)}</h1><p>${escapeHtml(manifest.description)}</p></header>
|
|
369
|
+
<section id="colors"><div class="section-heading"><div><p class="eyebrow">Foundations</p><h2>Semantic tokens</h2></div><p>Values generated from tokens.json</p></div><div id="token-groups" class="token-table"></div></section>
|
|
370
|
+
<section id="components"><div class="section-heading"><div><p class="eyebrow">Building blocks</p><h2>Components</h2></div><p>${components.length} documented</p></div><div class="grid">${componentCards || `<p class="muted">No components documented yet.</p>`}</div></section>
|
|
371
|
+
<section id="patterns"><div class="section-heading"><div><p class="eyebrow">Compositions</p><h2>Patterns</h2></div><p>${patterns.length} documented</p></div><div class="grid">${patternCards || `<p class="muted">No patterns documented yet.</p>`}</div></section>
|
|
372
|
+
<section id="interactions"><div class="section-heading"><div><p class="eyebrow">Try it</p><h2>Interactive states</h2></div><p>Keyboard-accessible examples</p></div>
|
|
373
|
+
<div class="grid">
|
|
374
|
+
<article class="spec-card"><h3>Tabs</h3><div class="tabs" role="tablist" aria-label="Preview tabs"><button class="tab" role="tab" aria-selected="true" aria-controls="tab-a" id="tab-button-a">General</button><button class="tab" role="tab" aria-selected="false" aria-controls="tab-b" id="tab-button-b" tabindex="-1">Advanced</button></div><div id="tab-a" class="tab-panel" role="tabpanel" aria-labelledby="tab-button-a">General settings are visible.</div><div id="tab-b" class="tab-panel" role="tabpanel" aria-labelledby="tab-button-b" hidden>Advanced options are available here.</div></article>
|
|
375
|
+
<article class="spec-card"><h3>Switch</h3><div class="switch-row"><button class="switch" type="button" role="switch" aria-checked="false" aria-label="Enable notifications"></button><span>Enable notifications</span></div></article>
|
|
376
|
+
<article class="spec-card"><h3>Dialog and toast</h3><div class="showcase"><button class="button" type="button" id="open-dialog">Open dialog</button><button class="button secondary" type="button" id="show-toast">Show toast</button></div></article>
|
|
377
|
+
<article class="spec-card"><h3>Data table</h3><div class="table-wrap"><table><thead><tr><th>Name</th><th>Status</th><th>Role</th></tr></thead><tbody><tr><td>Jordan Lee</td><td><span class="tag">Active</span></td><td>Editor</td></tr><tr><td>Sam Rivera</td><td>Invited</td><td>Admin</td></tr></tbody></table></div></article>
|
|
378
|
+
</div>
|
|
379
|
+
</section>
|
|
380
|
+
<footer class="footer">Generated from manifest.json, tokens.json, component specifications, and patterns. Edit the structured sources and regenerate this preview; do not edit generated markup as system truth.</footer>
|
|
381
|
+
</main>
|
|
382
|
+
</div>
|
|
383
|
+
<div class="overlay" id="dialog-overlay" aria-hidden="true"><section class="dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><h3 id="dialog-title">Confirm an action</h3><p>This dialog demonstrates the documented surface and focus treatment.</p><div class="showcase"><button class="button" type="button" id="close-dialog">Continue</button><button class="button secondary" type="button" id="cancel-dialog">Cancel</button></div></section></div>
|
|
384
|
+
<div class="toast" id="toast" role="status" aria-live="polite">Changes saved</div>
|
|
385
|
+
<script type="application/json" id="theme-data">${themePayload}</script>
|
|
386
|
+
<script>
|
|
387
|
+
const themes=JSON.parse(document.getElementById('theme-data').textContent||'{}');
|
|
388
|
+
function flatten(value,prefix='',out={}){for(const [key,item] of Object.entries(value||{})){const name=prefix?prefix+'-'+key:key;if(item&&typeof item==='object'&&!Array.isArray(item))flatten(item,name,out);else if(['string','number'].includes(typeof item))out[name]=String(item)}return out}
|
|
389
|
+
function setTheme(name){const theme=themes[name];if(!theme)return;document.documentElement.dataset.theme=name;for(const [key,value] of Object.entries(flatten(theme)))document.documentElement.style.setProperty('--'+key,value);document.getElementById('theme-toggle').hidden=Object.keys(themes).length<2;renderTokens(theme)}
|
|
390
|
+
function renderTokens(theme){const holder=document.getElementById('token-groups');holder.replaceChildren();for(const [group,values] of Object.entries(theme)){if(!values||typeof values!=='object')continue;const section=document.createElement('div');section.className='token-group';const heading=document.createElement('h3');heading.textContent=group;section.append(heading);const swatches=document.createElement('div');swatches.className='swatches';for(const [name,value] of Object.entries(flatten(values,group))){const card=document.createElement('div');card.className='swatch';const sample=document.createElement('div');if(group==='color'){sample.className='swatch-color';sample.style.background=String(value)}else{sample.className='token-demo';sample.dataset.group=group;const shape=document.createElement('span');shape.className='token-demo-sample';shape.textContent=group==='typography'?'Aa':'';if(group==='spacing')shape.style.width=String(value);if(group==='radius'){shape.style.width='42px';shape.style.height='28px';shape.style.borderRadius=String(value)}if(group==='typography'&&/family/i.test(name))shape.style.fontFamily=String(value);if(group==='typography'&&/size/i.test(name))shape.style.fontSize=String(value);if(group==='elevation')shape.style.boxShadow=String(value);sample.append(shape)}const label=document.createElement('div');label.className='swatch-label';label.textContent=name;const code=document.createElement('code');code.textContent=String(value);label.append(code);card.append(sample,label);swatches.append(card)}section.append(swatches);holder.append(section)}}
|
|
391
|
+
document.getElementById('theme-toggle').addEventListener('click',()=>{const names=Object.keys(themes);const index=names.indexOf(document.documentElement.dataset.theme);setTheme(names[(index+1)%names.length])});
|
|
392
|
+
for(const button of document.querySelectorAll('[role=tab]'))button.addEventListener('click',()=>{for(const tab of document.querySelectorAll('[role=tab]')){const selected=tab===button;tab.setAttribute('aria-selected',String(selected));tab.tabIndex=selected?0:-1;document.getElementById(tab.getAttribute('aria-controls')).hidden=!selected}});
|
|
393
|
+
for(const tab of document.querySelectorAll('[role=tab]'))tab.addEventListener('keydown',event=>{if(!['ArrowLeft','ArrowRight'].includes(event.key))return;event.preventDefault();const tabs=[...document.querySelectorAll('[role=tab]')];const next=(tabs.indexOf(tab)+(event.key==='ArrowRight'?1:tabs.length-1))%tabs.length;tabs[next].focus();tabs[next].click()});
|
|
394
|
+
document.querySelector('[role=switch]').addEventListener('click',event=>{const control=event.currentTarget;control.setAttribute('aria-checked',String(control.getAttribute('aria-checked')!=='true'))});
|
|
395
|
+
const overlay=document.getElementById('dialog-overlay');const open=document.getElementById('open-dialog');function closeDialog(){overlay.classList.remove('open');overlay.setAttribute('aria-hidden','true');open.focus()}open.addEventListener('click',()=>{overlay.classList.add('open');overlay.setAttribute('aria-hidden','false');document.getElementById('close-dialog').focus()});document.getElementById('close-dialog').addEventListener('click',closeDialog);document.getElementById('cancel-dialog').addEventListener('click',closeDialog);overlay.addEventListener('click',event=>{if(event.target===overlay)closeDialog()});document.addEventListener('keydown',event=>{if(event.key==='Escape'&&overlay.classList.contains('open'))closeDialog()});
|
|
396
|
+
let toastTimeout;document.getElementById('show-toast').addEventListener('click',()=>{const toast=document.getElementById('toast');toast.classList.add('show');clearTimeout(toastTimeout);toastTimeout=setTimeout(()=>toast.classList.remove('show'),2200)});
|
|
397
|
+
setTheme(${safeJson(firstTheme)});
|
|
398
|
+
</script>
|
|
399
|
+
</body>
|
|
400
|
+
</html>
|
|
401
|
+
`;
|
|
402
|
+
}
|
|
403
|
+
function escapeHtml(value) {
|
|
404
|
+
return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]);
|
|
405
|
+
}
|
|
406
|
+
function safeJson(value) {
|
|
407
|
+
return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (character) => ({ "<": "\\u003c", ">": "\\u003e", "&": "\\u0026", "\u2028": "\\u2028", "\u2029": "\\u2029" })[character]);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/generator.ts
|
|
411
|
+
var SCHEMA_VERSION = "1.0.0";
|
|
412
|
+
var INITIAL_VERSION = "0.1.0";
|
|
413
|
+
async function createDesignSystem(root, input) {
|
|
414
|
+
validateCreateInput(input);
|
|
415
|
+
const target = resolveInside(root, DESIGN_SYSTEM_DIR);
|
|
416
|
+
let existingEntries = [];
|
|
417
|
+
try {
|
|
418
|
+
existingEntries = await readdir(target);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (error.code !== "ENOENT") throw error;
|
|
421
|
+
}
|
|
422
|
+
if (existingEntries.length > 0) {
|
|
423
|
+
if (await fileExists(root, `${DESIGN_SYSTEM_DIR}/manifest.json`)) {
|
|
424
|
+
throw new Error("A Design System already exists. Use design_system_read, then /design-system:update.");
|
|
425
|
+
}
|
|
426
|
+
throw new Error("design-system/ already contains user files. Move or review them before creating a system there.");
|
|
427
|
+
}
|
|
428
|
+
const preferences = input.preferences ?? [];
|
|
429
|
+
const tokens = { ...input.tokens, schemaVersion: typeof input.tokens.schemaVersion === "string" ? input.tokens.schemaVersion : SCHEMA_VERSION };
|
|
430
|
+
const themes = tokens.themes;
|
|
431
|
+
const tokenPaths = new Set(Object.values(themes).flatMap((theme) => semanticTokenPaths(theme)));
|
|
432
|
+
const components = normalizeComponents(input.components, tokenPaths);
|
|
433
|
+
const patterns = normalizePatterns(input.patterns, tokenPaths);
|
|
434
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
435
|
+
const records = (items, folder) => items.map((item) => ({
|
|
436
|
+
name: item.name,
|
|
437
|
+
file: `${folder}/${slugify(item.name)}.md`,
|
|
438
|
+
tokens: item.tokens ?? []
|
|
439
|
+
}));
|
|
440
|
+
const manifest = {
|
|
441
|
+
designSystemVersion: INITIAL_VERSION,
|
|
442
|
+
schemaVersion: SCHEMA_VERSION,
|
|
443
|
+
status: input.status ?? "draft",
|
|
444
|
+
name: input.name.trim(),
|
|
445
|
+
description: input.description.trim(),
|
|
446
|
+
createdAt: now,
|
|
447
|
+
updatedAt: now,
|
|
448
|
+
source: {
|
|
449
|
+
type: input.sourceType ?? "from-scratch",
|
|
450
|
+
...input.evidence?.length ? { evidence: input.evidence } : {}
|
|
451
|
+
},
|
|
452
|
+
themes: Object.keys(themes),
|
|
453
|
+
tokens: "tokens.json",
|
|
454
|
+
preferences: "preferences.json",
|
|
455
|
+
foundations: "FOUNDATIONS.md",
|
|
456
|
+
guidelines: "AI-GUIDELINES.md",
|
|
457
|
+
decisions: "DECISIONS.md",
|
|
458
|
+
changelog: "CHANGELOG.md",
|
|
459
|
+
preview: "preview/index.html",
|
|
460
|
+
screens: "screens/",
|
|
461
|
+
schema: { manifest: "schema/manifest.schema.json", tokens: "schema/tokens.schema.json" },
|
|
462
|
+
components: records(components, "components"),
|
|
463
|
+
patterns: records(patterns, "patterns")
|
|
464
|
+
};
|
|
465
|
+
const tokensDocument = { ...tokens, $schema: "./schema/tokens.schema.json" };
|
|
466
|
+
const manifestDocument = { $schema: "./schema/manifest.schema.json", ...manifest };
|
|
467
|
+
const preferencesDocument = { schemaVersion: SCHEMA_VERSION, preferences };
|
|
468
|
+
const files = /* @__PURE__ */ new Map([
|
|
469
|
+
["manifest.json", pretty(manifestDocument)],
|
|
470
|
+
["tokens.json", pretty(tokensDocument)],
|
|
471
|
+
["preferences.json", pretty(preferencesDocument)],
|
|
472
|
+
["FOUNDATIONS.md", `${input.foundations.trim()}
|
|
473
|
+
`],
|
|
474
|
+
["AI-GUIDELINES.md", aiGuidelines(manifest.name, preferences)],
|
|
475
|
+
["DECISIONS.md", initialDecisions(preferences)],
|
|
476
|
+
["CHANGELOG.md", `# Changelog
|
|
477
|
+
|
|
478
|
+
## ${INITIAL_VERSION} \u2014 ${now.slice(0, 10)}
|
|
479
|
+
|
|
480
|
+
- Initial ${manifest.status} Design System specification.
|
|
481
|
+
`],
|
|
482
|
+
["schema/manifest.schema.json", pretty(manifestSchema)],
|
|
483
|
+
["schema/tokens.schema.json", pretty(tokensSchema)],
|
|
484
|
+
["README.md", systemReadme(manifest)],
|
|
485
|
+
["tools/generate-preview.mjs", await readFile2(new URL("../templates/generate-preview.mjs", import.meta.url), "utf8")],
|
|
486
|
+
["preview/index.html", createPreviewHtml({ manifest, tokens, components, patterns })]
|
|
487
|
+
]);
|
|
488
|
+
for (const [index, component] of components.entries()) files.set(manifest.components[index].file, componentMarkdown(component));
|
|
489
|
+
for (const [index, pattern] of patterns.entries()) files.set(manifest.patterns[index].file, patternMarkdown(pattern));
|
|
490
|
+
await mkdir2(path3.dirname(target), { recursive: true });
|
|
491
|
+
const staging = path3.join(path3.dirname(target), `.design-system-${randomUUID2()}`);
|
|
492
|
+
try {
|
|
493
|
+
for (const [relative, content] of files) {
|
|
494
|
+
const destination = path3.join(staging, relative);
|
|
495
|
+
await mkdir2(path3.dirname(destination), { recursive: true });
|
|
496
|
+
await writeFile2(destination, content, "utf8");
|
|
497
|
+
}
|
|
498
|
+
if (existingEntries.length === 0) await rm2(target, { recursive: true, force: true });
|
|
499
|
+
await rename2(staging, target);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
await rm2(staging, { recursive: true, force: true }).catch(() => void 0);
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
const conflicts = [];
|
|
505
|
+
await updateManagedBlock(root, "AGENTS.md", "<!-- opencode-design-system:start -->", "<!-- opencode-design-system:end -->", projectAgentsBlock(manifest.name));
|
|
506
|
+
const supportFiles = [
|
|
507
|
+
[".opencode/skills/design-system/SKILL.md", PORTABLE_SKILL],
|
|
508
|
+
[".opencode/agents/design-system-designer.md", DESIGNER_AGENT],
|
|
509
|
+
[".opencode/agents/screen-designer.md", SCREEN_AGENT],
|
|
510
|
+
[".opencode/commands/design-system.md", `Create or continue the project's Design System. Treat the user as a collaborator: ask only relevant design identity questions, preserve explicit preferences, inspect existing UI read-only when appropriate, then create the framework-neutral files in design-system/. Read AGENTS.md and design-system/AI-GUIDELINES.md. If the OpenCode Design System tools are available, use them to validate and regenerate the preview. Do not edit application UI during analysis. User request: $ARGUMENTS
|
|
511
|
+
`],
|
|
512
|
+
[".opencode/commands/design-system/update.md", `Update the existing Design System in response to: $ARGUMENTS. Read its manifest, preferences, relevant tokens and component/pattern documents first. Explain affected parts and ask only if an identity choice is ambiguous. Keep the user's explicit preferences. Apply semantic updates, record the decision, update the version/changelog, run consistency checks, and regenerate the preview from tokens/specifications. If plugin tools are not available, edit the structured Markdown/JSON deliberately and run node design-system/tools/generate-preview.mjs. Do not use blind search/replace and do not modify application components unless explicitly asked.
|
|
513
|
+
`],
|
|
514
|
+
[".opencode/commands/design-system/preview.md", `Regenerate design-system/preview/index.html from manifest.json, tokens.json, component documents, and patterns. Treat structured Markdown/JSON as the source of truth. If the Design System plugin is available, use its preview tool; otherwise run node design-system/tools/generate-preview.mjs from the project root.
|
|
515
|
+
`],
|
|
516
|
+
[".opencode/commands/design-system/check.md", `Check project UI code against design-system/manifest.json and its semantic tokens. Report unknown values, component/state/layout deviations, and likely inconsistencies with file paths. Do not edit application code. If available, use the Design System check tool.
|
|
517
|
+
`],
|
|
518
|
+
[".opencode/commands/design-screen.md", `Design (do not implement) the requested screen using the Design System. Read AGENTS.md and design-system/AI-GUIDELINES.md, then load only the matching manifest entries, tokens, components, and patterns. Write an implementation-ready brief to design-system/screens/<kebab-case-name>.md with purpose, layout, hierarchy, components/token references, content/data, interactions/states, responsive behavior, and accessibility. If the Design System is absent, say so and write a portable brief without claiming system conformance. Request: $ARGUMENTS
|
|
519
|
+
`]
|
|
520
|
+
];
|
|
521
|
+
for (const [relative, content] of supportFiles) {
|
|
522
|
+
if (!await writeIfAbsent(root, relative, content)) conflicts.push(relative);
|
|
523
|
+
}
|
|
524
|
+
return { success: true, manifest, files: [...files.keys()].map((file) => `${DESIGN_SYSTEM_DIR}/${file}`), conflicts };
|
|
525
|
+
}
|
|
526
|
+
async function regeneratePreview(root) {
|
|
527
|
+
const manifest = await readManifest(root);
|
|
528
|
+
const tokens = await readJson(root, `${DESIGN_SYSTEM_DIR}/${manifest.tokens}`);
|
|
529
|
+
const components = await Promise.all(manifest.components.map(async (item) => parseComponent(item.name, await readText2(root, `${DESIGN_SYSTEM_DIR}/${item.file}`), item.tokens)));
|
|
530
|
+
const patterns = await Promise.all(manifest.patterns.map(async (item) => parsePattern(item.name, await readText2(root, `${DESIGN_SYSTEM_DIR}/${item.file}`), item.tokens)));
|
|
531
|
+
const html = createPreviewHtml({ manifest, tokens, components, patterns });
|
|
532
|
+
await atomicWrite(root, `${DESIGN_SYSTEM_DIR}/${manifest.preview}`, html);
|
|
533
|
+
return { preview: `${DESIGN_SYSTEM_DIR}/${manifest.preview}`, componentCount: components.length, patternCount: patterns.length };
|
|
534
|
+
}
|
|
535
|
+
async function readManifest(root) {
|
|
536
|
+
return readJson(root, `${DESIGN_SYSTEM_DIR}/manifest.json`);
|
|
537
|
+
}
|
|
538
|
+
var readText2 = readText;
|
|
539
|
+
function validateCreateInput(input) {
|
|
540
|
+
if (!input.name?.trim()) throw new Error("name is required");
|
|
541
|
+
if (!input.description?.trim()) throw new Error("description is required");
|
|
542
|
+
if (!input.foundations?.trim()) throw new Error("foundations must contain the agreed design foundations");
|
|
543
|
+
const tokenErrors = validateTokens(input.tokens);
|
|
544
|
+
if (tokenErrors.length) throw new Error(tokenErrors.join("; "));
|
|
545
|
+
if (input.preferences && input.preferences.some((item) => !item.key || item.value === void 0)) throw new Error("Each preference requires a key and value");
|
|
546
|
+
}
|
|
547
|
+
function normalizeComponents(input, availableTokens) {
|
|
548
|
+
const isDefault = !input?.length;
|
|
549
|
+
const items = isDefault ? [
|
|
550
|
+
{ name: "Button", purpose: "Triggers a clear, immediate action.", variants: ["primary", "secondary", "danger"], sizes: ["small", "medium", "large"], tokens: ["color.accent.primary", "radius.control", "spacing.control"] },
|
|
551
|
+
{ name: "Input", purpose: "Collects a single value with a persistent label and clear validation feedback.", variants: ["default", "error", "success"], sizes: ["medium", "large"], tokens: ["color.surface.base", "color.text.primary", "radius.control"] },
|
|
552
|
+
{ name: "Card", purpose: "Groups related content and actions into a distinct surface.", variants: ["default", "interactive"], tokens: ["color.surface.raised", "radius.card", "elevation.surface"] }
|
|
553
|
+
] : input;
|
|
554
|
+
return uniqueNamed(items, "component").map((item) => {
|
|
555
|
+
const tokens = item.tokens ?? [];
|
|
556
|
+
const missing = isDefault ? [] : tokens.filter((token) => !availableTokens.has(token));
|
|
557
|
+
if (missing.length) throw new Error(`${item.name} references unknown token(s): ${missing.join(", ")}`);
|
|
558
|
+
return { ...item, tokens: isDefault ? tokens.filter((token) => availableTokens.has(token)) : tokens };
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
function normalizePatterns(input, availableTokens) {
|
|
562
|
+
const isDefault = !input?.length;
|
|
563
|
+
const items = isDefault ? [{ name: "Form", purpose: "Collect and validate related information with clear progression and recovery.", composition: ["Input", "Button"], tokens: ["spacing.md", "color.status.danger"] }] : input;
|
|
564
|
+
return uniqueNamed(items, "pattern").map((item) => {
|
|
565
|
+
const tokens = item.tokens ?? [];
|
|
566
|
+
const missing = isDefault ? [] : tokens.filter((token) => !availableTokens.has(token));
|
|
567
|
+
if (missing.length) throw new Error(`${item.name} references unknown token(s): ${missing.join(", ")}`);
|
|
568
|
+
return { ...item, tokens: isDefault ? tokens.filter((token) => availableTokens.has(token)) : tokens };
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
function semanticTokenPaths(value, prefix = "") {
|
|
572
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return prefix ? [prefix] : [];
|
|
573
|
+
return Object.entries(value).flatMap(([key, item]) => semanticTokenPaths(item, prefix ? `${prefix}.${key}` : key));
|
|
574
|
+
}
|
|
575
|
+
function uniqueNamed(items, kind) {
|
|
576
|
+
const seen = /* @__PURE__ */ new Set();
|
|
577
|
+
return items.map((item) => {
|
|
578
|
+
if (!item.name?.trim()) throw new Error(`Every ${kind} requires a name`);
|
|
579
|
+
const key = slugify(item.name);
|
|
580
|
+
if (seen.has(key)) throw new Error(`Duplicate ${kind} name: ${item.name}`);
|
|
581
|
+
seen.add(key);
|
|
582
|
+
return { ...item, name: item.name.trim() };
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
function initialDecisions(preferences) {
|
|
586
|
+
const lines = preferences.length ? preferences.map((item) => `- **${item.key}:** ${JSON.stringify(item.value)}${item.rationale ? ` \u2014 ${item.rationale}` : ""}`).join("\n") : "- No explicit preferences recorded yet. Add decisions as the system is reviewed.";
|
|
587
|
+
return `# Design decisions
|
|
588
|
+
|
|
589
|
+
These decisions preserve user intent across future design and implementation work.
|
|
590
|
+
|
|
591
|
+
## Initial direction
|
|
592
|
+
|
|
593
|
+
${lines}
|
|
594
|
+
`;
|
|
595
|
+
}
|
|
596
|
+
function systemReadme(manifest) {
|
|
597
|
+
return `# ${manifest.name}
|
|
598
|
+
|
|
599
|
+
${manifest.description}
|
|
600
|
+
|
|
601
|
+
- **Status:** ${manifest.status}
|
|
602
|
+
- **Design System version:** ${manifest.designSystemVersion}
|
|
603
|
+
- **Schema version:** ${manifest.schemaVersion}
|
|
604
|
+
- **Source:** ${manifest.source.type}
|
|
605
|
+
|
|
606
|
+
## Source of truth
|
|
607
|
+
|
|
608
|
+
Start with [manifest.json](manifest.json), which indexes the [semantic tokens](tokens.json), [foundations](FOUNDATIONS.md), [AI guidelines](AI-GUIDELINES.md), [preferences](preferences.json), [decisions](DECISIONS.md), component and pattern documentation, and the generated [interactive preview](preview/index.html).
|
|
609
|
+
|
|
610
|
+
The definition is framework-neutral. The HTML is a generated view, not an independent design specification. Update structured files and regenerate the preview.
|
|
611
|
+
|
|
612
|
+
## Progressive loading
|
|
613
|
+
|
|
614
|
+
Read the manifest and AI guidelines first. Load only task-relevant component and pattern files and the token branches they reference. Screen design briefs go in [screens/](screens/).
|
|
615
|
+
|
|
616
|
+
## Plugin-independent maintenance
|
|
617
|
+
|
|
618
|
+
This project includes [tools/generate-preview.mjs](tools/generate-preview.mjs), a dependency-free Node.js renderer. After editing structured tokens/specifications without the plugin, run node design-system/tools/generate-preview.mjs from the project root. The project [AGENTS.md](../AGENTS.md) and local OpenCode Skill/agents/commands preserve usage guidance when the plugin is not installed.
|
|
619
|
+
`;
|
|
620
|
+
}
|
|
621
|
+
function pretty(value) {
|
|
622
|
+
return `${JSON.stringify(value, null, 2)}
|
|
623
|
+
`;
|
|
624
|
+
}
|
|
625
|
+
function parseComponent(name, markdown, tokens) {
|
|
626
|
+
return {
|
|
627
|
+
name,
|
|
628
|
+
purpose: section(markdown, "Purpose") || `Documented ${name} component.`,
|
|
629
|
+
variants: bullets(section(markdown, "Variants")),
|
|
630
|
+
sizes: bullets(section(markdown, "Sizes")),
|
|
631
|
+
tokens: tokens.length ? tokens : bullets(section(markdown, "Tokens")).map((value) => value.replaceAll("`", "")),
|
|
632
|
+
states: bullets(section(markdown, "States")),
|
|
633
|
+
behavior: section(markdown, "Behavior"),
|
|
634
|
+
accessibility: section(markdown, "Accessibility"),
|
|
635
|
+
responsive: section(markdown, "Responsive"),
|
|
636
|
+
useWhen: section(markdown, "Use when"),
|
|
637
|
+
avoidWhen: section(markdown, "Avoid when"),
|
|
638
|
+
related: bullets(section(markdown, "Related components"))
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function parsePattern(name, markdown, tokens) {
|
|
642
|
+
return {
|
|
643
|
+
name,
|
|
644
|
+
purpose: section(markdown, "Purpose") || `Documented ${name} pattern.`,
|
|
645
|
+
composition: bullets(section(markdown, "Composition")),
|
|
646
|
+
behavior: section(markdown, "Behavior"),
|
|
647
|
+
responsive: section(markdown, "Responsive"),
|
|
648
|
+
accessibility: section(markdown, "Accessibility"),
|
|
649
|
+
guidance: section(markdown, "Guidance"),
|
|
650
|
+
tokens: tokens.length ? tokens : bullets(section(markdown, "Tokens")).map((value) => value.replaceAll("`", ""))
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
function section(markdown, heading) {
|
|
654
|
+
const match = markdown.match(new RegExp(`^## ${heading}\\s*\\n([\\s\\S]*?)(?=\\n## |$)`, "mi"));
|
|
655
|
+
return match?.[1]?.replace(/^- None specified\.$/m, "").trim() ?? "";
|
|
656
|
+
}
|
|
657
|
+
function bullets(value) {
|
|
658
|
+
return value.split("\n").map((line) => line.match(/^\s*-\s+(.*)$/)?.[1]?.trim()).filter((item) => Boolean(item) && item !== "None specified." && item !== "No direct token references declared.");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// src/project-analysis.ts
|
|
662
|
+
import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
|
|
663
|
+
import path4 from "path";
|
|
664
|
+
var OMIT_DIRS = /* @__PURE__ */ new Set([
|
|
665
|
+
".git",
|
|
666
|
+
".hg",
|
|
667
|
+
".svn",
|
|
668
|
+
"node_modules",
|
|
669
|
+
"vendor",
|
|
670
|
+
"dist",
|
|
671
|
+
"build",
|
|
672
|
+
"coverage",
|
|
673
|
+
".next",
|
|
674
|
+
".nuxt",
|
|
675
|
+
".svelte-kit",
|
|
676
|
+
"target",
|
|
677
|
+
"out",
|
|
678
|
+
"design-system",
|
|
679
|
+
".opencode",
|
|
680
|
+
".turbo",
|
|
681
|
+
".cache",
|
|
682
|
+
"storybook-static"
|
|
683
|
+
]);
|
|
684
|
+
var UI_EXTENSIONS = /* @__PURE__ */ new Set([".css", ".scss", ".sass", ".less", ".html", ".tsx", ".jsx", ".vue", ".svelte", ".astro"]);
|
|
685
|
+
var STYLE_EXTENSIONS = /* @__PURE__ */ new Set([".css", ".scss", ".sass", ".less"]);
|
|
686
|
+
var ASSET_EXTENSIONS = /* @__PURE__ */ new Set([".svg", ".woff", ".woff2", ".ttf", ".otf"]);
|
|
687
|
+
var MAX_FILES = 160;
|
|
688
|
+
var MAX_FILE_BYTES = 48e3;
|
|
689
|
+
var MAX_TOTAL_BYTES = 75e4;
|
|
690
|
+
async function analyzeProject(root) {
|
|
691
|
+
const files = [];
|
|
692
|
+
let truncated = false;
|
|
693
|
+
const assetCandidates = [];
|
|
694
|
+
async function walk(directory, depth) {
|
|
695
|
+
if (depth > 7 || files.length >= MAX_FILES) {
|
|
696
|
+
truncated = true;
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
let entries;
|
|
700
|
+
try {
|
|
701
|
+
entries = await readdir2(directory, { withFileTypes: true });
|
|
702
|
+
} catch {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
for (const entry of entries) {
|
|
706
|
+
if (entry.isSymbolicLink()) continue;
|
|
707
|
+
const absolute = path4.join(directory, entry.name);
|
|
708
|
+
if (entry.isDirectory()) {
|
|
709
|
+
if (!entry.name.startsWith(".") && !OMIT_DIRS.has(entry.name)) await walk(absolute, depth + 1);
|
|
710
|
+
} else if (entry.isFile()) {
|
|
711
|
+
const extension = path4.extname(entry.name).toLowerCase();
|
|
712
|
+
const relative = path4.relative(root, absolute).split(path4.sep).join("/");
|
|
713
|
+
if (ASSET_EXTENSIONS.has(extension)) {
|
|
714
|
+
if (assetCandidates.length < 80) assetCandidates.push(relative);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (!UI_EXTENSIONS.has(extension) && !isFrameworkStyleConfig(entry.name)) continue;
|
|
718
|
+
files.push(relative);
|
|
719
|
+
if (files.length >= MAX_FILES) {
|
|
720
|
+
truncated = true;
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
await walk(root, 0);
|
|
727
|
+
let totalBytes = 0;
|
|
728
|
+
const contentByFile = /* @__PURE__ */ new Map();
|
|
729
|
+
for (const relative of files) {
|
|
730
|
+
try {
|
|
731
|
+
const content = await readFile3(path4.join(root, relative), "utf8");
|
|
732
|
+
const bytes = Buffer.byteLength(content);
|
|
733
|
+
if (bytes > MAX_FILE_BYTES || totalBytes + bytes > MAX_TOTAL_BYTES) {
|
|
734
|
+
truncated = true;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
totalBytes += bytes;
|
|
738
|
+
contentByFile.set(relative, content);
|
|
739
|
+
} catch {
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const colorStats = /* @__PURE__ */ new Map();
|
|
743
|
+
const radiusStats = /* @__PURE__ */ new Map();
|
|
744
|
+
const spacingStats = /* @__PURE__ */ new Map();
|
|
745
|
+
const styleSources = [];
|
|
746
|
+
const componentCandidates = /* @__PURE__ */ new Set();
|
|
747
|
+
for (const [file, content] of contentByFile) {
|
|
748
|
+
const ext = path4.extname(file).toLowerCase();
|
|
749
|
+
const inStyle = STYLE_EXTENSIONS.has(ext) || isFrameworkStyleConfig(path4.basename(file));
|
|
750
|
+
if (inStyle || /\.(?:tsx|jsx|vue|svelte|astro)$/.test(ext)) {
|
|
751
|
+
const variables = [];
|
|
752
|
+
for (const match of content.matchAll(/(--[\w-]+)\s*:\s*([^;{}]+)\s*;/g)) {
|
|
753
|
+
variables.push({ name: match[1], value: compact(match[2]) });
|
|
754
|
+
}
|
|
755
|
+
const colors = uniqueMatches(content, /#[\da-f]{3,8}\b|\b(?:rgb|rgba|hsl|hsla)\([^)]{1,80}\)/gi);
|
|
756
|
+
const radii2 = uniqueMatches(content, /(?:border-radius|borderRadius|radius)\s*[:=]\s*["']?([^;'"{}]+)/gi, 1).map(compact);
|
|
757
|
+
const breakpoints = uniqueMatches(content, /@media\s*\([^)]*(?:min-width|max-width)\s*:\s*([^;)]+)/gi, 1).map(compact);
|
|
758
|
+
styleSources.push({ file, variables: variables.slice(0, 50), colors: colors.slice(0, 35), radii: radii2.slice(0, 25), breakpoints: breakpoints.slice(0, 20) });
|
|
759
|
+
for (const color of colors) addOccurrence(colorStats, color.toLowerCase(), file);
|
|
760
|
+
for (const radius of radii2) addOccurrence(radiusStats, radius.toLowerCase(), file);
|
|
761
|
+
for (const match of content.matchAll(/(?:padding|margin|gap|grid-gap|gridGap|spacing)(?:-[\w]+)?\s*[:=]\s*["']?([^;'"{}]+)/gi)) {
|
|
762
|
+
for (const value of match[1].matchAll(/\b\d+(?:\.\d+)?(?:px|rem|em)\b/gi)) {
|
|
763
|
+
spacingStats.set(value[0].toLowerCase(), (spacingStats.get(value[0].toLowerCase()) ?? 0) + 1);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (/\.(?:tsx|jsx|vue|svelte|astro)$/i.test(file)) {
|
|
768
|
+
for (const match of content.matchAll(/(?:export\s+)?(?:function|class|const)\s+([A-Z][A-Za-z0-9]{1,50})\b/g)) {
|
|
769
|
+
componentCandidates.add(match[1]);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const dependencies = await readDependencies(root);
|
|
774
|
+
const frameworks = dependencies.filter((name) => ["react", "react-dom", "vue", "svelte", "@angular/core", "solid-js", "next", "nuxt", "astro"].includes(name));
|
|
775
|
+
const uiLibraries = dependencies.filter((name) => /(?:mui|material|chakra|radix|shadcn|antd|ant-design|mantine|headlessui|fluent|carbon|prime|vuetify|naive-ui|bootstrap|tailwind)/i.test(name));
|
|
776
|
+
const iconPackages = dependencies.filter((name) => /(?:icon|icons|lucide|heroicons|phosphor|fontawesome|react-icons)/i.test(name));
|
|
777
|
+
const radii = summarize(radiusStats);
|
|
778
|
+
const probableInconsistencies = [];
|
|
779
|
+
const pixelRadii = radii.flatMap((item) => {
|
|
780
|
+
const match = item.value.match(/^([\d.]+)px$/);
|
|
781
|
+
return match ? [{ value: item.value, pixels: Number(match[1]), occurrences: item.occurrences }] : [];
|
|
782
|
+
});
|
|
783
|
+
if (pixelRadii.length >= 2) {
|
|
784
|
+
const close = pixelRadii.filter((item) => pixelRadii.some((candidate) => candidate.value !== item.value && Math.abs(candidate.pixels - item.pixels) <= 4));
|
|
785
|
+
if (close.length >= 2) probableInconsistencies.push(`Border radii are close but distinct (${[...new Set(close.map((item) => item.value))].join(", ")}). They may be accidental drift; confirm before normalizing.`);
|
|
786
|
+
}
|
|
787
|
+
if (![...contentByFile.keys()].some((file) => STYLE_EXTENSIONS.has(path4.extname(file).toLowerCase()) || isFrameworkStyleConfig(path4.basename(file)))) {
|
|
788
|
+
probableInconsistencies.push("No stylesheet or recognized style configuration was found in the bounded scan; visual values may be defined by utility classes, runtime styles, or a dependency.");
|
|
789
|
+
}
|
|
790
|
+
const repeatedColors = summarize(colorStats);
|
|
791
|
+
if (repeatedColors.length > 14) probableInconsistencies.push(`The UI uses ${repeatedColors.length} distinct color literals. Determine which are semantic roles and which are one-off values before proposing consolidation.`);
|
|
792
|
+
const notes = [
|
|
793
|
+
"Read-only analysis: no application files were changed.",
|
|
794
|
+
"Evidence is an inference from source, not proof that each observed variation is intentional.",
|
|
795
|
+
...truncated ? [`Scan bounded at ${MAX_FILES} candidate files and/or ${Math.round(MAX_TOTAL_BYTES / 1e3)} KB of file contents; results may be incomplete.`] : []
|
|
796
|
+
];
|
|
797
|
+
return {
|
|
798
|
+
readOnly: true,
|
|
799
|
+
scannedFiles: [...contentByFile.keys()],
|
|
800
|
+
truncated,
|
|
801
|
+
frameworks,
|
|
802
|
+
uiLibraries,
|
|
803
|
+
iconPackages,
|
|
804
|
+
assetCandidates: assetCandidates.slice(0, 60),
|
|
805
|
+
responsiveBreakpoints: [...new Set(styleSources.flatMap((item) => item.breakpoints))].slice(0, 30),
|
|
806
|
+
styleSources,
|
|
807
|
+
colors: repeatedColors.slice(0, 30),
|
|
808
|
+
radii: radii.slice(0, 24),
|
|
809
|
+
spacing: [...spacingStats].sort((left, right) => right[1] - left[1]).slice(0, 24).map(([value, occurrences]) => ({ value, occurrences })),
|
|
810
|
+
componentCandidates: [...componentCandidates].slice(0, 50),
|
|
811
|
+
probableInconsistencies,
|
|
812
|
+
notes
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
async function checkProject(root) {
|
|
816
|
+
const analysis = await analyzeProject(root);
|
|
817
|
+
const warnings = [];
|
|
818
|
+
const findings = [...analysis.probableInconsistencies];
|
|
819
|
+
let manifest;
|
|
820
|
+
let tokens;
|
|
821
|
+
try {
|
|
822
|
+
manifest = await readJson(root, "design-system/manifest.json");
|
|
823
|
+
tokens = await readJson(root, `design-system/${manifest.tokens}`);
|
|
824
|
+
} catch {
|
|
825
|
+
return { checkedFiles: analysis.scannedFiles.length, warnings: ["No readable design-system/manifest.json and token file were found."], findings };
|
|
826
|
+
}
|
|
827
|
+
const tokenValues = flattenTokenValues(tokens);
|
|
828
|
+
const knownColors = new Set(tokenValues.filter((item) => item.path.toLowerCase().includes("color")).map((item) => normalizeValue(item.value)));
|
|
829
|
+
const knownRadii = new Set(tokenValues.filter((item) => item.path.toLowerCase().includes("radius")).map((item) => normalizeValue(item.value)));
|
|
830
|
+
for (const style of analysis.styleSources) {
|
|
831
|
+
const content = await readReadOnlyFile(root, style.file);
|
|
832
|
+
if (!content) continue;
|
|
833
|
+
for (const color of style.colors) {
|
|
834
|
+
const normalized = normalizeValue(color);
|
|
835
|
+
if (!knownColors.has(normalized)) warnings.push(`${style.file}: color literal ${color} is not an exact token value; verify whether a semantic token should be used.`);
|
|
836
|
+
}
|
|
837
|
+
for (const radius of style.radii) {
|
|
838
|
+
const concrete = radius.match(/^([\d.]+(?:px|rem|em))$/i)?.[1];
|
|
839
|
+
if (concrete && !knownRadii.has(normalizeValue(concrete))) warnings.push(`${style.file}: border-radius ${concrete} is not an exact token value.`);
|
|
840
|
+
}
|
|
841
|
+
if (/(?:button|\.btn)[^{]{0,60}\{[^}]{0,800}(?:min-height|height)\s*:\s*([\d.]+px)/i.test(content)) {
|
|
842
|
+
const height = content.match(/(?:button|\.btn)[^{]{0,60}\{[^}]{0,800}(?:min-height|height)\s*:\s*([\d.]+px)/i)?.[1];
|
|
843
|
+
if (height && !tokenValues.some((item) => /size|height|control/i.test(item.path) && normalizeValue(item.value) === normalizeValue(height))) {
|
|
844
|
+
warnings.push(`${style.file}: button height ${height} has no matching documented size token.`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return { checkedFiles: analysis.scannedFiles.length, warnings: unique(warnings).slice(0, 80), findings };
|
|
849
|
+
}
|
|
850
|
+
function uniqueMatches(content, expression, group = 0) {
|
|
851
|
+
return [...new Set([...content.matchAll(expression)].map((match) => compact(match[group])))];
|
|
852
|
+
}
|
|
853
|
+
function addOccurrence(stats, value, file) {
|
|
854
|
+
const files = stats.get(value) ?? /* @__PURE__ */ new Set();
|
|
855
|
+
files.add(file);
|
|
856
|
+
stats.set(value, files);
|
|
857
|
+
}
|
|
858
|
+
function summarize(stats) {
|
|
859
|
+
return [...stats].map(([value, files]) => ({ value, occurrences: files.size, files: [...files].slice(0, 8) })).sort((a, b) => b.occurrences - a.occurrences || a.value.localeCompare(b.value));
|
|
860
|
+
}
|
|
861
|
+
function compact(value) {
|
|
862
|
+
return value.replace(/\s+/g, " ").trim().slice(0, 140);
|
|
863
|
+
}
|
|
864
|
+
async function readDependencies(root) {
|
|
865
|
+
try {
|
|
866
|
+
const pkg = JSON.parse(await readFile3(path4.join(root, "package.json"), "utf8"));
|
|
867
|
+
const dependencies = [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies].filter((value) => value && typeof value === "object");
|
|
868
|
+
return [...new Set(dependencies.flatMap((item) => Object.keys(item)))];
|
|
869
|
+
} catch {
|
|
870
|
+
return [];
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async function readReadOnlyFile(root, relative) {
|
|
874
|
+
try {
|
|
875
|
+
return await readFile3(path4.join(root, relative), "utf8");
|
|
876
|
+
} catch {
|
|
877
|
+
return void 0;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function flattenTokenValues(value, prefix = "") {
|
|
881
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return typeof value === "string" || typeof value === "number" ? [{ path: prefix, value: String(value) }] : [];
|
|
882
|
+
return Object.entries(value).flatMap(([key, item]) => flattenTokenValues(item, prefix ? `${prefix}.${key}` : key));
|
|
883
|
+
}
|
|
884
|
+
function normalizeValue(value) {
|
|
885
|
+
return value.trim().toLowerCase().replace(/\s+/g, " ");
|
|
886
|
+
}
|
|
887
|
+
function unique(values) {
|
|
888
|
+
return [...new Set(values)];
|
|
889
|
+
}
|
|
890
|
+
function isFrameworkStyleConfig(filename) {
|
|
891
|
+
return /^(?:tailwind|postcss|vite|next|nuxt|svelte|astro)\.config\.(?:[cm]?js|[cm]?ts)$/i.test(filename);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// src/screen.ts
|
|
895
|
+
async function saveScreenSpec(root, name, specification) {
|
|
896
|
+
if (!name.trim()) throw new Error("screen name is required");
|
|
897
|
+
if (!specification.trim()) throw new Error("screen specification is required");
|
|
898
|
+
if (specification.length > 6e4) throw new Error("screen specification exceeds 60 KB");
|
|
899
|
+
const file = `${DESIGN_SYSTEM_DIR}/screens/${slugify(name)}.md`;
|
|
900
|
+
const updated = await fileExists(root, file);
|
|
901
|
+
await atomicWrite(root, file, `# ${name.trim()}
|
|
902
|
+
|
|
903
|
+
${specification.trim()}
|
|
904
|
+
`);
|
|
905
|
+
return { file, updated, hasDesignSystem: await fileExists(root, `${DESIGN_SYSTEM_DIR}/manifest.json`) };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// src/update.ts
|
|
909
|
+
async function updateDesignSystem(root, input) {
|
|
910
|
+
if (!input.request.trim()) throw new Error("request is required");
|
|
911
|
+
if (!input.decision.trim()) throw new Error("decision is required to preserve the rationale");
|
|
912
|
+
if (!input.tokenUpdates.length && !input.tokenAdds?.length && !input.componentUpdates?.length && !input.patternUpdates?.length && !input.newComponents?.length && !input.newPatterns?.length && !input.foundationUpdate && !input.preferenceUpdates?.length && !input.status) {
|
|
913
|
+
throw new Error("The update has no token, foundation, or preference change to apply");
|
|
914
|
+
}
|
|
915
|
+
const manifest = await readManifest(root);
|
|
916
|
+
const previousVersion = manifest.designSystemVersion;
|
|
917
|
+
const tokens = await readJson(root, `${DESIGN_SYSTEM_DIR}/${manifest.tokens}`);
|
|
918
|
+
const preferencesDocument = await readJson(root, `${DESIGN_SYSTEM_DIR}/${manifest.preferences}`);
|
|
919
|
+
const preferences = mergePreferences(preferencesDocument.preferences ?? [], input.preferenceUpdates ?? []);
|
|
920
|
+
const updatedTokens = [];
|
|
921
|
+
const addedTokens = [];
|
|
922
|
+
for (const token of input.tokenUpdates) {
|
|
923
|
+
const parts = parseTokenPath(token.path);
|
|
924
|
+
setSemanticToken(tokens, parts, token.value);
|
|
925
|
+
updatedTokens.push(parts.join("."));
|
|
926
|
+
}
|
|
927
|
+
for (const token of input.tokenAdds ?? []) {
|
|
928
|
+
const parts = parseTokenPath(token.path);
|
|
929
|
+
if (parts[0] === "themes") throw new Error("New semantic tokens must be added to every theme; use an unprefixed semantic path");
|
|
930
|
+
addSemanticToken(tokens, parts, token.values);
|
|
931
|
+
updatedTokens.push(parts.join("."));
|
|
932
|
+
addedTokens.push(parts.join("."));
|
|
933
|
+
}
|
|
934
|
+
const tokenErrors = validateTokens(tokens);
|
|
935
|
+
if (tokenErrors.length) throw new Error(tokenErrors.join("; "));
|
|
936
|
+
const componentAdditions = await prepareComponentAdditions(root, manifest, tokens, input.newComponents ?? []);
|
|
937
|
+
const patternAdditions = await preparePatternAdditions(root, manifest, tokens, input.newPatterns ?? []);
|
|
938
|
+
manifest.components.push(...componentAdditions.map((item) => ({ name: item.name, file: item.file, tokens: item.definition.tokens ?? [] })));
|
|
939
|
+
manifest.patterns.push(...patternAdditions.map((item) => ({ name: item.name, file: item.file, tokens: item.definition.tokens ?? [] })));
|
|
940
|
+
const documentUpdates = await validateDocumentUpdates(manifest, input);
|
|
941
|
+
const updatedNames = new Set(documentUpdates.map((item) => `${item.kind}:${item.name.toLowerCase()}`));
|
|
942
|
+
for (const item of componentAdditions) if (updatedNames.has(`component:${item.name.toLowerCase()}`)) throw new Error(`Use either newComponents or componentUpdates for ${item.name}, not both`);
|
|
943
|
+
for (const item of patternAdditions) if (updatedNames.has(`pattern:${item.name.toLowerCase()}`)) throw new Error(`Use either newPatterns or patternUpdates for ${item.name}, not both`);
|
|
944
|
+
const addedDocuments = [
|
|
945
|
+
...componentAdditions.map((item) => ({ kind: "component", name: item.name, file: item.file, content: componentMarkdown(item.definition) })),
|
|
946
|
+
...patternAdditions.map((item) => ({ kind: "pattern", name: item.name, file: item.file, content: patternMarkdown(item.definition) }))
|
|
947
|
+
];
|
|
948
|
+
const updatedDocuments = [...documentUpdates, ...addedDocuments];
|
|
949
|
+
for (const document of updatedDocuments) {
|
|
950
|
+
if (document.kind === "component") {
|
|
951
|
+
const entry = manifest.components.find((item) => item.name === document.name);
|
|
952
|
+
const updatedTokenReferences = extractTokenReferences(document.content);
|
|
953
|
+
if (updatedTokenReferences.length) entry.tokens = updatedTokenReferences;
|
|
954
|
+
} else {
|
|
955
|
+
const entry = manifest.patterns.find((item) => item.name === document.name);
|
|
956
|
+
const updatedTokenReferences = extractTokenReferences(document.content);
|
|
957
|
+
if (updatedTokenReferences.length) entry.tokens = updatedTokenReferences;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const dependency = await findTokenDependents(root, manifest, updatedTokens.map((item) => item.replace(/^themes\.[^.]+\./, "")), updatedDocuments);
|
|
961
|
+
const warnings = await tokenReferenceWarnings(root, manifest, tokens, updatedDocuments);
|
|
962
|
+
const now = /* @__PURE__ */ new Date();
|
|
963
|
+
const impact = componentAdditions.length || patternAdditions.length || addedTokens.length ? input.impact === "major" ? "major" : "minor" : input.impact;
|
|
964
|
+
const version = bumpVersion(previousVersion, impact);
|
|
965
|
+
const date = now.toISOString().slice(0, 10);
|
|
966
|
+
manifest.designSystemVersion = version;
|
|
967
|
+
manifest.updatedAt = now.toISOString();
|
|
968
|
+
manifest.status = input.status ?? "draft";
|
|
969
|
+
manifest.themes = Object.keys(tokens.themes);
|
|
970
|
+
const changelogEntry = `## ${version} \u2014 ${date} (${impact.toUpperCase()})
|
|
971
|
+
|
|
972
|
+
- ${input.request.trim()}
|
|
973
|
+
- Decision: ${input.decision.trim()}
|
|
974
|
+
- Updated tokens: ${updatedTokens.length ? updatedTokens.map((item) => `\`${item}\``).join(", ") : "none"}.
|
|
975
|
+
- Added components: ${componentAdditions.map((item) => item.name).join(", ") || "none"}.
|
|
976
|
+
- Added patterns: ${patternAdditions.map((item) => item.name).join(", ") || "none"}.
|
|
977
|
+
- Affected components: ${dependency.components.length ? dependency.components.join(", ") : "none detected"}.
|
|
978
|
+
- Affected patterns: ${dependency.patterns.length ? dependency.patterns.join(", ") : "none detected"}.
|
|
979
|
+
`;
|
|
980
|
+
const [changelog, decisions, foundations] = await Promise.all([
|
|
981
|
+
readText(root, `${DESIGN_SYSTEM_DIR}/${manifest.changelog}`),
|
|
982
|
+
readText(root, `${DESIGN_SYSTEM_DIR}/${manifest.decisions}`),
|
|
983
|
+
readText(root, `${DESIGN_SYSTEM_DIR}/${manifest.foundations}`)
|
|
984
|
+
]);
|
|
985
|
+
const decisionEntry = `
|
|
986
|
+
## ${date} \u2014 ${input.request.trim()}
|
|
987
|
+
|
|
988
|
+
${input.decision.trim()}
|
|
989
|
+
|
|
990
|
+
- Version: ${version} (${impact.toUpperCase()})
|
|
991
|
+
${updatedTokens.map((item) => `- Token: \`${item}\``).join("\n")}
|
|
992
|
+
`;
|
|
993
|
+
await atomicWrite(root, `${DESIGN_SYSTEM_DIR}/${manifest.tokens}`, `${JSON.stringify({ ...tokens, $schema: "./schema/tokens.schema.json" }, null, 2)}
|
|
994
|
+
`);
|
|
995
|
+
await atomicWrite(root, `${DESIGN_SYSTEM_DIR}/${manifest.preferences}`, `${JSON.stringify({ schemaVersion: preferencesDocument.schemaVersion ?? manifest.schemaVersion, preferences }, null, 2)}
|
|
996
|
+
`);
|
|
997
|
+
for (const document of updatedDocuments) {
|
|
998
|
+
const entry = document.kind === "component" ? manifest.components.find((item) => item.name === document.name) : manifest.patterns.find((item) => item.name === document.name);
|
|
999
|
+
await atomicWrite(root, `${DESIGN_SYSTEM_DIR}/${entry.file}`, document.content.endsWith("\n") ? document.content : `${document.content}
|
|
1000
|
+
`);
|
|
1001
|
+
}
|
|
1002
|
+
if (input.foundationUpdate?.trim()) {
|
|
1003
|
+
await atomicWrite(root, `${DESIGN_SYSTEM_DIR}/${manifest.foundations}`, `${foundations.trimEnd()}
|
|
1004
|
+
|
|
1005
|
+
## Iteration \u2014 ${date}
|
|
1006
|
+
|
|
1007
|
+
${input.foundationUpdate.trim()}
|
|
1008
|
+
`);
|
|
1009
|
+
}
|
|
1010
|
+
await atomicWrite(root, `${DESIGN_SYSTEM_DIR}/${manifest.decisions}`, `${decisions.trimEnd()}
|
|
1011
|
+
${decisionEntry}`);
|
|
1012
|
+
await atomicWrite(root, `${DESIGN_SYSTEM_DIR}/${manifest.changelog}`, `# Changelog
|
|
1013
|
+
|
|
1014
|
+
${changelogEntry}
|
|
1015
|
+
${changelog.replace(/^# Changelog\s*/i, "").trim()}
|
|
1016
|
+
`);
|
|
1017
|
+
await atomicWrite(root, `${DESIGN_SYSTEM_DIR}/${manifest.guidelines}`, aiGuidelines(manifest.name, preferences));
|
|
1018
|
+
await atomicWrite(root, `${DESIGN_SYSTEM_DIR}/manifest.json`, `${JSON.stringify({ $schema: "./schema/manifest.schema.json", ...manifest }, null, 2)}
|
|
1019
|
+
`);
|
|
1020
|
+
const readme = await readText(root, `${DESIGN_SYSTEM_DIR}/README.md`);
|
|
1021
|
+
const updatedReadme = readme.replace(/^- \*\*Status:\*\* .*$/m, `- **Status:** ${manifest.status}`).replace(/^- \*\*Design System version:\*\* .*$/m, `- **Design System version:** ${version}`);
|
|
1022
|
+
await atomicWrite(root, `${DESIGN_SYSTEM_DIR}/README.md`, updatedReadme);
|
|
1023
|
+
const preview = await regeneratePreview(root);
|
|
1024
|
+
return {
|
|
1025
|
+
success: true,
|
|
1026
|
+
previousVersion,
|
|
1027
|
+
version,
|
|
1028
|
+
status: manifest.status,
|
|
1029
|
+
impact,
|
|
1030
|
+
updatedTokens,
|
|
1031
|
+
addedTokens,
|
|
1032
|
+
addedComponents: componentAdditions.map((item) => item.name),
|
|
1033
|
+
addedPatterns: patternAdditions.map((item) => item.name),
|
|
1034
|
+
affectedComponents: dependency.components,
|
|
1035
|
+
affectedPatterns: dependency.patterns,
|
|
1036
|
+
updatedDocuments: updatedDocuments.map((item) => `${DESIGN_SYSTEM_DIR}/${item.file}`),
|
|
1037
|
+
consistencyWarnings: warnings,
|
|
1038
|
+
regenerated: [`${DESIGN_SYSTEM_DIR}/${manifest.tokens}`, `${DESIGN_SYSTEM_DIR}/${manifest.preferences}`, `${DESIGN_SYSTEM_DIR}/${manifest.guidelines}`, `${DESIGN_SYSTEM_DIR}/${manifest.decisions}`, `${DESIGN_SYSTEM_DIR}/${manifest.changelog}`, `${DESIGN_SYSTEM_DIR}/${manifest.preview}`, ...input.foundationUpdate?.trim() ? [`${DESIGN_SYSTEM_DIR}/${manifest.foundations}`] : []]
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
async function validateDocumentUpdates(manifest, input) {
|
|
1042
|
+
const updates = [];
|
|
1043
|
+
for (const [kind, documents, records] of [
|
|
1044
|
+
["component", input.componentUpdates ?? [], manifest.components],
|
|
1045
|
+
["pattern", input.patternUpdates ?? [], manifest.patterns]
|
|
1046
|
+
]) {
|
|
1047
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1048
|
+
for (const document of documents) {
|
|
1049
|
+
if (!document.name?.trim() || !document.content?.trim()) throw new Error(`Each ${kind} update requires a name and Markdown content`);
|
|
1050
|
+
if (document.content.length > 4e4) throw new Error(`${kind} document ${document.name} exceeds 40 KB`);
|
|
1051
|
+
const record = records.find((item) => item.name.toLowerCase() === document.name.toLowerCase());
|
|
1052
|
+
if (!record) throw new Error(`Unknown ${kind} document: ${document.name}`);
|
|
1053
|
+
if (seen.has(record.name)) throw new Error(`Duplicate ${kind} update: ${record.name}`);
|
|
1054
|
+
seen.add(record.name);
|
|
1055
|
+
updates.push({ kind, name: record.name, file: record.file, content: document.content.trim() });
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return updates;
|
|
1059
|
+
}
|
|
1060
|
+
async function prepareComponentAdditions(root, manifest, tokens, definitions) {
|
|
1061
|
+
return prepareAdditions(root, manifest.components, "components", tokens, definitions);
|
|
1062
|
+
}
|
|
1063
|
+
async function preparePatternAdditions(root, manifest, tokens, definitions) {
|
|
1064
|
+
return prepareAdditions(root, manifest.patterns, "patterns", tokens, definitions);
|
|
1065
|
+
}
|
|
1066
|
+
async function prepareAdditions(root, existing, folder, tokenDocument, definitions) {
|
|
1067
|
+
const themes = tokenDocument.themes;
|
|
1068
|
+
const knownTokens = new Set(Object.values(themes ?? {}).flatMap((theme) => flattenPaths(theme)));
|
|
1069
|
+
const names = new Set(existing.map((item) => slugify(item.name)));
|
|
1070
|
+
const additions = [];
|
|
1071
|
+
for (const source of definitions) {
|
|
1072
|
+
const name = source.name?.trim();
|
|
1073
|
+
if (!name || !source.purpose?.trim()) throw new Error(`Each new ${folder.slice(0, -1)} requires a name and purpose`);
|
|
1074
|
+
const slug = slugify(name);
|
|
1075
|
+
if (names.has(slug)) throw new Error(`A ${folder.slice(0, -1)} with this name already exists: ${name}`);
|
|
1076
|
+
names.add(slug);
|
|
1077
|
+
const missing = (source.tokens ?? []).filter((token) => !knownTokens.has(token));
|
|
1078
|
+
if (missing.length) throw new Error(`${name} references unknown token(s): ${missing.join(", ")}`);
|
|
1079
|
+
const file = `${folder}/${slug}.md`;
|
|
1080
|
+
if (await fileExists(root, `${DESIGN_SYSTEM_DIR}/${file}`)) throw new Error(`Refusing to overwrite an existing file while adding ${name}: ${file}`);
|
|
1081
|
+
additions.push({ name, file, definition: { ...source, name } });
|
|
1082
|
+
}
|
|
1083
|
+
return additions;
|
|
1084
|
+
}
|
|
1085
|
+
function extractTokenReferences(markdown) {
|
|
1086
|
+
const section2 = markdown.match(/^## Tokens\s*\n([\s\S]*?)(?=\n## |$)/mi)?.[1];
|
|
1087
|
+
if (!section2) return [];
|
|
1088
|
+
return [...section2.matchAll(/^\s*-\s+`([a-zA-Z][\w-]*(?:\.[a-zA-Z][\w-]*)+)`\s*$/gm)].map((item) => item[1]);
|
|
1089
|
+
}
|
|
1090
|
+
async function findTokenDependents(root, manifest, paths, documentOverrides = []) {
|
|
1091
|
+
const check = async (entries) => {
|
|
1092
|
+
const result = [];
|
|
1093
|
+
for (const entry of entries) {
|
|
1094
|
+
const content = documentOverrides.find((item) => item.file === entry.file)?.content ?? await readText(root, `${DESIGN_SYSTEM_DIR}/${entry.file}`);
|
|
1095
|
+
const declared = /* @__PURE__ */ new Set([...entry.tokens, ...pathsFromMarkdown(content)]);
|
|
1096
|
+
if (paths.some((token) => declared.has(token))) result.push(entry.name);
|
|
1097
|
+
}
|
|
1098
|
+
return result;
|
|
1099
|
+
};
|
|
1100
|
+
return { components: await check(manifest.components), patterns: await check(manifest.patterns) };
|
|
1101
|
+
}
|
|
1102
|
+
function parseTokenPath(value) {
|
|
1103
|
+
const parts = value.split(".");
|
|
1104
|
+
if (parts.length < 2 || parts.some((part) => !/^[a-zA-Z][\w-]*$/.test(part) || ["__proto__", "prototype", "constructor"].includes(part))) {
|
|
1105
|
+
throw new Error(`Invalid semantic token path: ${value}`);
|
|
1106
|
+
}
|
|
1107
|
+
return parts;
|
|
1108
|
+
}
|
|
1109
|
+
function setSemanticToken(root, parts, value) {
|
|
1110
|
+
if (parts[0] === "themes") {
|
|
1111
|
+
setExistingPath(root, parts, value);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
const themes = root.themes;
|
|
1115
|
+
if (!themes || typeof themes !== "object" || Array.isArray(themes)) throw new Error("tokens.themes is missing");
|
|
1116
|
+
const updated = [];
|
|
1117
|
+
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1118
|
+
if (!theme || typeof theme !== "object" || Array.isArray(theme)) continue;
|
|
1119
|
+
try {
|
|
1120
|
+
setExistingPath(theme, parts, value);
|
|
1121
|
+
updated.push(themeName);
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
if (!(error instanceof Error) || !error.message.startsWith("Token path does not exist:")) throw error;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
if (!updated.length) throw new Error(`Token path does not exist in any theme: ${parts.join(".")}`);
|
|
1127
|
+
}
|
|
1128
|
+
function setExistingPath(root, parts, value) {
|
|
1129
|
+
let current = root;
|
|
1130
|
+
for (const part of parts.slice(0, -1)) {
|
|
1131
|
+
const next = current[part];
|
|
1132
|
+
if (!next || typeof next !== "object" || Array.isArray(next)) throw new Error(`Token path does not exist: ${parts.join(".")}`);
|
|
1133
|
+
current = next;
|
|
1134
|
+
}
|
|
1135
|
+
const key = parts.at(-1);
|
|
1136
|
+
if (!(key in current)) throw new Error(`Token path does not exist: ${parts.join(".")}; add new tokens through a reviewed system expansion`);
|
|
1137
|
+
current[key] = value;
|
|
1138
|
+
}
|
|
1139
|
+
function addSemanticToken(document, parts, values) {
|
|
1140
|
+
const themes = document.themes;
|
|
1141
|
+
if (!themes || typeof themes !== "object" || Array.isArray(themes)) throw new Error("tokens.themes is missing");
|
|
1142
|
+
const themeNames = Object.keys(themes);
|
|
1143
|
+
const suppliedThemes = Object.keys(values ?? {});
|
|
1144
|
+
const missingThemes = themeNames.filter((name) => !Object.hasOwn(values ?? {}, name));
|
|
1145
|
+
const unknownThemes = suppliedThemes.filter((name) => !themeNames.includes(name));
|
|
1146
|
+
if (missingThemes.length || unknownThemes.length) throw new Error(`New token ${parts.join(".")} requires one value for each theme. Missing: ${missingThemes.join(", ") || "none"}; unknown: ${unknownThemes.join(", ") || "none"}.`);
|
|
1147
|
+
for (const themeName of themeNames) {
|
|
1148
|
+
const theme = themes[themeName];
|
|
1149
|
+
const value = values[themeName];
|
|
1150
|
+
if (!theme || typeof theme !== "object" || Array.isArray(theme)) throw new Error(`tokens.themes.${themeName} must be an object`);
|
|
1151
|
+
if (!["string", "number", "boolean"].includes(typeof value)) throw new Error(`Token value for theme ${themeName} must be a string, number, or boolean`);
|
|
1152
|
+
setNewPath(theme, parts, value);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function setNewPath(root, parts, value) {
|
|
1156
|
+
let current = root;
|
|
1157
|
+
for (const part of parts.slice(0, -1)) {
|
|
1158
|
+
const existing = current[part];
|
|
1159
|
+
if (existing === void 0) current[part] = {};
|
|
1160
|
+
else if (!existing || typeof existing !== "object" || Array.isArray(existing)) throw new Error(`Token path conflicts with an existing value: ${parts.join(".")}`);
|
|
1161
|
+
current = current[part];
|
|
1162
|
+
}
|
|
1163
|
+
const leaf = parts.at(-1);
|
|
1164
|
+
if (Object.hasOwn(current, leaf)) throw new Error(`Token path already exists: ${parts.join(".")}`);
|
|
1165
|
+
current[leaf] = value;
|
|
1166
|
+
}
|
|
1167
|
+
function mergePreferences(current, updates) {
|
|
1168
|
+
const result = new Map(current.map((item) => [item.key, item]));
|
|
1169
|
+
for (const item of updates) {
|
|
1170
|
+
if (!item.key || item.value === void 0) throw new Error("Each preference update requires a key and value");
|
|
1171
|
+
result.set(item.key, { ...result.get(item.key), ...item });
|
|
1172
|
+
}
|
|
1173
|
+
return [...result.values()];
|
|
1174
|
+
}
|
|
1175
|
+
function pathsFromMarkdown(content) {
|
|
1176
|
+
return [...content.matchAll(/`([a-zA-Z][\w-]*(?:\.[a-zA-Z][\w-]*)+)`/g)].map((item) => item[1]);
|
|
1177
|
+
}
|
|
1178
|
+
async function tokenReferenceWarnings(root, manifest, tokens, documentOverrides = []) {
|
|
1179
|
+
const themes = tokens.themes;
|
|
1180
|
+
const known = new Set(Object.values(themes ?? {}).flatMap((theme) => flattenPaths(theme)));
|
|
1181
|
+
const missing = /* @__PURE__ */ new Set();
|
|
1182
|
+
for (const entry of [...manifest.components, ...manifest.patterns]) {
|
|
1183
|
+
const content = documentOverrides.find((item) => item.file === entry.file)?.content ?? await readText(root, `${DESIGN_SYSTEM_DIR}/${entry.file}`);
|
|
1184
|
+
for (const token of [...entry.tokens, ...pathsFromMarkdown(content)]) if (!known.has(token)) missing.add(`${entry.name}: ${token}`);
|
|
1185
|
+
}
|
|
1186
|
+
return [...missing].map((item) => `Unresolved token reference ${item}`);
|
|
1187
|
+
}
|
|
1188
|
+
function flattenPaths(value, prefix = "") {
|
|
1189
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return prefix ? [prefix] : [];
|
|
1190
|
+
return Object.entries(value).flatMap(([key, item]) => flattenPaths(item, prefix ? `${prefix}.${key}` : key));
|
|
1191
|
+
}
|
|
1192
|
+
function bumpVersion(version, impact) {
|
|
1193
|
+
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
1194
|
+
if (!match) throw new Error(`Invalid designSystemVersion: ${version}`);
|
|
1195
|
+
let major = Number(match[1]);
|
|
1196
|
+
let minor = Number(match[2]);
|
|
1197
|
+
let patch = Number(match[3]);
|
|
1198
|
+
if (impact === "major") {
|
|
1199
|
+
major += 1;
|
|
1200
|
+
minor = 0;
|
|
1201
|
+
patch = 0;
|
|
1202
|
+
} else if (impact === "minor") {
|
|
1203
|
+
minor += 1;
|
|
1204
|
+
patch = 0;
|
|
1205
|
+
} else patch += 1;
|
|
1206
|
+
return `${major}.${minor}.${patch}`;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// src/index.ts
|
|
1210
|
+
var commandPrompts = [
|
|
1211
|
+
{
|
|
1212
|
+
name: "design-system",
|
|
1213
|
+
description: "Create a Design System collaboratively, from scratch or from an existing UI",
|
|
1214
|
+
instruction: `Act as the project's design-system-designer. Collaborate naturally and gather only the identity decisions that are genuinely unclear. Explicit preferences always win. If a Design System already exists, read it and offer an update/continue path rather than overwriting it. If the repository has UI and the user has not said whether to formalize that UI or start fresh, call the read-only analysis tool and ask them which path they prefer; do not assume. Distinguish evidence from inference and ask about important inconsistencies before normalization. For a new system, confirm a concise visual direction before writing files; then call design_system_create with neutral tokens, foundations, explicit preferences, a few useful components and patterns, and any source evidence. Keep status draft until reviewed. Do not modify application files.
|
|
1215
|
+
|
|
1216
|
+
User request:`
|
|
1217
|
+
},
|
|
1218
|
+
{
|
|
1219
|
+
name: "design-system/update",
|
|
1220
|
+
description: "Make a coherent, versioned change to the existing Design System",
|
|
1221
|
+
instruction: `Read the Design System first using design_system_read. When the design-system-designer subagent is available, delegate the design decision to it; otherwise adopt its collaborative role. Interpret the user's request semantically, identify impacted token paths and dependent components/patterns, and honor recorded decisions. If the request conflicts with an explicit preference, ask before changing that preference. For a clear requested change, apply it with design_system_update, explain the dependency impact, provide revised full componentUpdates/patternUpdates where documented behavior or guidance needs a semantic change, add tokens only when existing semantic paths do not fit and then provide a value for every theme, add reusable components/patterns when composition is insufficient, update preferences/decisions/foundations where appropriate, choose patch/minor/major impact (expansion requires at least minor), and report any unresolved references. Do not use blind text replacement and do not modify app UI code.
|
|
1222
|
+
|
|
1223
|
+
User request:`
|
|
1224
|
+
},
|
|
1225
|
+
{
|
|
1226
|
+
name: "design-system/preview",
|
|
1227
|
+
description: "Generate or refresh the interactive Design System preview",
|
|
1228
|
+
instruction: `Call design_system_preview to regenerate the interactive preview from the structured manifest, tokens, foundations, components, and patterns. Summarize the output file and whether light/dark themes and interactive examples are present. Do not treat the HTML as source of truth.
|
|
1229
|
+
|
|
1230
|
+
User request:`
|
|
1231
|
+
},
|
|
1232
|
+
{
|
|
1233
|
+
name: "design-system/check",
|
|
1234
|
+
description: "Check UI styles for values that drift from the Design System",
|
|
1235
|
+
instruction: `Call design_system_check. Report findings with file paths and explain which are exact deviations versus heuristic candidates. The check is read-only and bounded; do not automatically fix application files. Suggest a semantic token or component where possible.
|
|
1236
|
+
|
|
1237
|
+
User request:`
|
|
1238
|
+
},
|
|
1239
|
+
{
|
|
1240
|
+
name: "design-screen",
|
|
1241
|
+
description: "Design a screen specification using relevant Design System documentation",
|
|
1242
|
+
instruction: `Use the screen-designer subagent when available; otherwise adopt its system prompt. First call design_system_read with the user's task to load only the relevant tokens, components, patterns, preferences, and system guidelines. Design and document hierarchy, content, layout, states, interactions, responsive behavior, and accessibility. Keep design separate from implementation. Call design_system_screen_spec to save an implementation-ready Markdown specification under design-system/screens/. Do not write UI code unless asked separately.
|
|
1243
|
+
|
|
1244
|
+
User request:`
|
|
1245
|
+
}
|
|
1246
|
+
];
|
|
1247
|
+
var index_default = Plugin.define({
|
|
1248
|
+
id: "opencode-design-system",
|
|
1249
|
+
async setup(ctx) {
|
|
1250
|
+
const projectRoot = path5.resolve(ctx.location.project.canonical || ctx.location.directory);
|
|
1251
|
+
const designSystemPath = path5.join(projectRoot, DESIGN_SYSTEM_DIR, "manifest.json");
|
|
1252
|
+
const skillPath = path5.join(projectRoot, ".opencode", "skills", "design-system", "SKILL.md");
|
|
1253
|
+
await ctx.skill.transform((editor) => {
|
|
1254
|
+
editor.add({
|
|
1255
|
+
id: "design-system",
|
|
1256
|
+
name: "Design System",
|
|
1257
|
+
description: "Apply this project's semantic design tokens, documented components, patterns, preferences, and accessibility guidance to UI work with progressive loading.",
|
|
1258
|
+
path: skillPath,
|
|
1259
|
+
content: skillBody(),
|
|
1260
|
+
autoinvoke: true
|
|
1261
|
+
});
|
|
1262
|
+
});
|
|
1263
|
+
await ctx.session.hook("context", (event) => {
|
|
1264
|
+
if (!existsSync(designSystemPath)) return;
|
|
1265
|
+
event.system.push({
|
|
1266
|
+
type: "text",
|
|
1267
|
+
text: "This project has a portable design-system/manifest.json. For UI tasks, load the design-system skill and read only the relevant token/component/pattern files. Honor AI-GUIDELINES.md, preferences.json, and DECISIONS.md. The HTML preview is generated output, not the source of truth."
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
await ctx.command.transform((editor) => {
|
|
1271
|
+
for (const command of commandPrompts) {
|
|
1272
|
+
editor.add({
|
|
1273
|
+
name: command.name,
|
|
1274
|
+
description: command.description,
|
|
1275
|
+
execute: async ({ sessionID, prompt, delivery }) => {
|
|
1276
|
+
const suffix = prompt.text?.trim() ? `
|
|
1277
|
+
|
|
1278
|
+
${prompt.text.trim()}` : "";
|
|
1279
|
+
await ctx.session.prompt({
|
|
1280
|
+
...prompt,
|
|
1281
|
+
sessionID,
|
|
1282
|
+
text: `${command.instruction}${suffix}`,
|
|
1283
|
+
delivery
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
await ctx.tool.transform((editor) => {
|
|
1290
|
+
editor.namespace({
|
|
1291
|
+
name: "design_system",
|
|
1292
|
+
description: "Create, inspect, update, validate, preview, and write screen specifications for the portable project Design System."
|
|
1293
|
+
});
|
|
1294
|
+
editor.add({
|
|
1295
|
+
name: "create",
|
|
1296
|
+
description: "Create the project's framework-neutral Design System after the user has agreed on a design direction. Refuses to overwrite an existing design-system/ folder.",
|
|
1297
|
+
options: { namespace: "design_system", codemode: true },
|
|
1298
|
+
input: {
|
|
1299
|
+
type: "object",
|
|
1300
|
+
properties: {
|
|
1301
|
+
name: { type: "string", description: "Short Design System name." },
|
|
1302
|
+
description: { type: "string", description: "Product context and concise visual direction." },
|
|
1303
|
+
tokens: { type: "object", description: "Framework-neutral semantic tokens. Include schemaVersion and themes, with semantic groups such as color, typography, spacing, radius, elevation, motion, and breakpoints." },
|
|
1304
|
+
foundations: { type: "string", description: "Human-readable design philosophy and foundation rules in Markdown." },
|
|
1305
|
+
preferences: { type: "array", items: preferenceSchema },
|
|
1306
|
+
components: { type: "array", items: componentSchema },
|
|
1307
|
+
patterns: { type: "array", items: patternSchema },
|
|
1308
|
+
sourceType: { type: "string", enum: ["from-scratch", "existing-project"] },
|
|
1309
|
+
evidence: { type: "array", items: { type: "string" } },
|
|
1310
|
+
status: { type: "string", enum: ["draft", "review", "stable"] }
|
|
1311
|
+
},
|
|
1312
|
+
required: ["name", "description", "tokens", "foundations"],
|
|
1313
|
+
additionalProperties: false
|
|
1314
|
+
},
|
|
1315
|
+
execute: async (raw) => {
|
|
1316
|
+
const result = await createDesignSystem(projectRoot, raw);
|
|
1317
|
+
await ctx.skill.reload();
|
|
1318
|
+
return { content: JSON.stringify(result, null, 2) };
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
editor.add({
|
|
1322
|
+
name: "read",
|
|
1323
|
+
description: "Read the manifest and only those Design System tokens, foundation sections, components, and patterns relevant to a UI task. This is the preferred progressive-loading entry point.",
|
|
1324
|
+
options: { namespace: "design_system", codemode: true },
|
|
1325
|
+
input: {
|
|
1326
|
+
type: "object",
|
|
1327
|
+
properties: { task: { type: "string", description: "The screen, component, or UI change being designed/implemented." } },
|
|
1328
|
+
required: ["task"],
|
|
1329
|
+
additionalProperties: false
|
|
1330
|
+
},
|
|
1331
|
+
execute: async (raw) => ({ content: JSON.stringify(await readRelevantSystem(projectRoot, String(raw.task)), null, 2) })
|
|
1332
|
+
});
|
|
1333
|
+
editor.add({
|
|
1334
|
+
name: "analyze",
|
|
1335
|
+
description: "Read-only bounded analysis of an existing app's UI/style sources, frameworks, tokens, component candidates, and likely inconsistencies. It never edits app files.",
|
|
1336
|
+
options: { namespace: "design_system", codemode: true },
|
|
1337
|
+
input: { type: "object", properties: {}, additionalProperties: false },
|
|
1338
|
+
execute: async () => ({ content: JSON.stringify(summarizeAnalysis(await analyzeProject(projectRoot)), null, 2) })
|
|
1339
|
+
});
|
|
1340
|
+
editor.add({
|
|
1341
|
+
name: "update",
|
|
1342
|
+
description: "Apply a semantic, versioned Design System update; changes existing token paths, records the user decision, refreshes guidelines and preview, and reports dependent components/patterns.",
|
|
1343
|
+
options: { namespace: "design_system", codemode: true },
|
|
1344
|
+
input: {
|
|
1345
|
+
type: "object",
|
|
1346
|
+
properties: {
|
|
1347
|
+
request: { type: "string" },
|
|
1348
|
+
tokenUpdates: { type: "array", items: { type: "object", properties: { path: { type: "string", description: "Existing semantic token path such as color.accent.primary. Prefix with themes.dark. only for a theme-specific change." }, value: { type: ["string", "number", "boolean"] }, reason: { type: "string" } }, required: ["path", "value"], additionalProperties: false } },
|
|
1349
|
+
tokenAdds: { type: "array", description: "Reviewed new semantic token paths. Supply a value for every existing theme; new token additions require at least a MINOR version impact.", items: { type: "object", properties: { path: { type: "string" }, values: { type: "object", additionalProperties: { type: ["string", "number", "boolean"] } }, reason: { type: "string" } }, required: ["path", "values"], additionalProperties: false } },
|
|
1350
|
+
componentUpdates: { type: "array", items: { type: "object", properties: { name: { type: "string" }, content: { type: "string", description: "Full semantically revised Markdown for an affected existing component." } }, required: ["name", "content"], additionalProperties: false } },
|
|
1351
|
+
patternUpdates: { type: "array", items: { type: "object", properties: { name: { type: "string" }, content: { type: "string", description: "Full semantically revised Markdown for an affected existing pattern." } }, required: ["name", "content"], additionalProperties: false } },
|
|
1352
|
+
newComponents: { type: "array", items: componentSchema },
|
|
1353
|
+
newPatterns: { type: "array", items: patternSchema },
|
|
1354
|
+
preferenceUpdates: { type: "array", items: preferenceSchema },
|
|
1355
|
+
foundationUpdate: { type: "string" },
|
|
1356
|
+
decision: { type: "string" },
|
|
1357
|
+
impact: { type: "string", enum: ["patch", "minor", "major"] },
|
|
1358
|
+
status: { type: "string", enum: ["draft", "review", "stable"] }
|
|
1359
|
+
},
|
|
1360
|
+
required: ["request", "tokenUpdates", "decision", "impact"],
|
|
1361
|
+
additionalProperties: false
|
|
1362
|
+
},
|
|
1363
|
+
execute: async (raw) => ({ content: JSON.stringify(await updateDesignSystem(projectRoot, raw), null, 2) })
|
|
1364
|
+
});
|
|
1365
|
+
editor.add({
|
|
1366
|
+
name: "preview",
|
|
1367
|
+
description: "Regenerate the self-contained interactive HTML preview from the current structured Design System files.",
|
|
1368
|
+
options: { namespace: "design_system", codemode: true },
|
|
1369
|
+
input: { type: "object", properties: {}, additionalProperties: false },
|
|
1370
|
+
execute: async () => ({ content: JSON.stringify(await regeneratePreview(projectRoot), null, 2) })
|
|
1371
|
+
});
|
|
1372
|
+
editor.add({
|
|
1373
|
+
name: "check",
|
|
1374
|
+
description: "Read-only heuristic check for UI color literals, border radii, button heights, and inconsistencies not represented by existing Design System tokens.",
|
|
1375
|
+
options: { namespace: "design_system", codemode: true },
|
|
1376
|
+
input: { type: "object", properties: {}, additionalProperties: false },
|
|
1377
|
+
execute: async () => ({ content: JSON.stringify(await checkProject(projectRoot), null, 2) })
|
|
1378
|
+
});
|
|
1379
|
+
editor.add({
|
|
1380
|
+
name: "screen_spec",
|
|
1381
|
+
description: "Save an implementation-ready, framework-neutral screen design brief to design-system/screens/<name>.md without modifying application code.",
|
|
1382
|
+
options: { namespace: "design_system", codemode: true },
|
|
1383
|
+
input: {
|
|
1384
|
+
type: "object",
|
|
1385
|
+
properties: {
|
|
1386
|
+
name: { type: "string" },
|
|
1387
|
+
specification: { type: "string", description: "Purpose, layout, hierarchy, components/tokens, data, states, interactions, responsive behavior, and accessibility." }
|
|
1388
|
+
},
|
|
1389
|
+
required: ["name", "specification"],
|
|
1390
|
+
additionalProperties: false
|
|
1391
|
+
},
|
|
1392
|
+
execute: async (raw) => ({ content: JSON.stringify(await saveScreenSpec(projectRoot, String(raw.name), String(raw.specification)), null, 2) })
|
|
1393
|
+
});
|
|
1394
|
+
});
|
|
1395
|
+
return () => void 0;
|
|
1396
|
+
}
|
|
1397
|
+
});
|
|
1398
|
+
var preferenceSchema = {
|
|
1399
|
+
type: "object",
|
|
1400
|
+
properties: {
|
|
1401
|
+
key: { type: "string" },
|
|
1402
|
+
value: { type: ["string", "number", "boolean"] },
|
|
1403
|
+
explicit: { type: "boolean" },
|
|
1404
|
+
rationale: { type: "string" }
|
|
1405
|
+
},
|
|
1406
|
+
required: ["key", "value"],
|
|
1407
|
+
additionalProperties: false
|
|
1408
|
+
};
|
|
1409
|
+
var componentSchema = {
|
|
1410
|
+
type: "object",
|
|
1411
|
+
properties: {
|
|
1412
|
+
name: { type: "string" },
|
|
1413
|
+
purpose: { type: "string" },
|
|
1414
|
+
variants: stringArray(),
|
|
1415
|
+
sizes: stringArray(),
|
|
1416
|
+
tokens: stringArray(),
|
|
1417
|
+
states: stringArray(),
|
|
1418
|
+
behavior: { type: "string" },
|
|
1419
|
+
accessibility: { type: "string" },
|
|
1420
|
+
responsive: { type: "string" },
|
|
1421
|
+
useWhen: { type: "string" },
|
|
1422
|
+
avoidWhen: { type: "string" },
|
|
1423
|
+
related: stringArray()
|
|
1424
|
+
},
|
|
1425
|
+
required: ["name", "purpose"],
|
|
1426
|
+
additionalProperties: false
|
|
1427
|
+
};
|
|
1428
|
+
var patternSchema = {
|
|
1429
|
+
type: "object",
|
|
1430
|
+
properties: {
|
|
1431
|
+
name: { type: "string" },
|
|
1432
|
+
purpose: { type: "string" },
|
|
1433
|
+
composition: stringArray(),
|
|
1434
|
+
behavior: { type: "string" },
|
|
1435
|
+
responsive: { type: "string" },
|
|
1436
|
+
accessibility: { type: "string" },
|
|
1437
|
+
guidance: { type: "string" },
|
|
1438
|
+
tokens: stringArray()
|
|
1439
|
+
},
|
|
1440
|
+
required: ["name", "purpose"],
|
|
1441
|
+
additionalProperties: false
|
|
1442
|
+
};
|
|
1443
|
+
function stringArray() {
|
|
1444
|
+
return { type: "array", items: { type: "string" } };
|
|
1445
|
+
}
|
|
1446
|
+
function skillBody() {
|
|
1447
|
+
return PORTABLE_SKILL.replace(/^---\n[\s\S]*?\n---\n\n/, "");
|
|
1448
|
+
}
|
|
1449
|
+
async function readRelevantSystem(root, task) {
|
|
1450
|
+
try {
|
|
1451
|
+
const manifest = await readManifest(root);
|
|
1452
|
+
const tokenDoc = await readJson(root, `${DESIGN_SYSTEM_DIR}/${manifest.tokens}`);
|
|
1453
|
+
const preferences = await readJson(root, `${DESIGN_SYSTEM_DIR}/${manifest.preferences}`);
|
|
1454
|
+
const guidelines = await readText(root, `${DESIGN_SYSTEM_DIR}/${manifest.guidelines}`);
|
|
1455
|
+
const foundations = await readText(root, `${DESIGN_SYSTEM_DIR}/${manifest.foundations}`);
|
|
1456
|
+
const decisions = await readText(root, `${DESIGN_SYSTEM_DIR}/${manifest.decisions}`);
|
|
1457
|
+
const terms = `${task} ${taskTerms(task)}`.toLowerCase();
|
|
1458
|
+
const componentEntries = manifest.components.filter((item) => terms.includes(item.name.toLowerCase()) || item.tokens.some((token) => terms.split(/\W+/).some((term) => term.length > 3 && token.includes(term))));
|
|
1459
|
+
const patternEntries = manifest.patterns.filter((item) => terms.includes(item.name.toLowerCase()));
|
|
1460
|
+
for (const item of manifest.components) {
|
|
1461
|
+
if (componentEntries.includes(item)) continue;
|
|
1462
|
+
if (await fileExists(root, `${DESIGN_SYSTEM_DIR}/${item.file}`)) {
|
|
1463
|
+
const summary = (await readText(root, `${DESIGN_SYSTEM_DIR}/${item.file}`)).toLowerCase();
|
|
1464
|
+
if (summary.split(/\W+/).some((term) => term.length > 4 && terms.includes(term))) componentEntries.push(item);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
for (const item of manifest.patterns) {
|
|
1468
|
+
if (patternEntries.includes(item)) continue;
|
|
1469
|
+
if (await fileExists(root, `${DESIGN_SYSTEM_DIR}/${item.file}`)) {
|
|
1470
|
+
const summary = (await readText(root, `${DESIGN_SYSTEM_DIR}/${item.file}`)).toLowerCase();
|
|
1471
|
+
if (summary.split(/\W+/).some((term) => term.length > 4 && terms.includes(term))) patternEntries.push(item);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (!componentEntries.length) componentEntries.push(...manifest.components.slice(0, 5));
|
|
1475
|
+
if (!patternEntries.length) patternEntries.push(...manifest.patterns.slice(0, 3));
|
|
1476
|
+
const components = await Promise.all(componentEntries.slice(0, 8).map(async (item) => ({ ...item, content: await readText(root, `${DESIGN_SYSTEM_DIR}/${item.file}`) })));
|
|
1477
|
+
const patterns = await Promise.all(patternEntries.slice(0, 5).map(async (item) => ({ ...item, content: await readText(root, `${DESIGN_SYSTEM_DIR}/${item.file}`) })));
|
|
1478
|
+
const preferredPaths = new Set([...components, ...patterns].flatMap((item) => item.tokens));
|
|
1479
|
+
const selectedTokens = selectTokens(tokenDoc, task, preferredPaths, 90);
|
|
1480
|
+
const relevantFoundations = selectFoundationSections(foundations, task);
|
|
1481
|
+
return {
|
|
1482
|
+
exists: true,
|
|
1483
|
+
progressiveLoading: true,
|
|
1484
|
+
manifest: {
|
|
1485
|
+
designSystemVersion: manifest.designSystemVersion,
|
|
1486
|
+
schemaVersion: manifest.schemaVersion,
|
|
1487
|
+
status: manifest.status,
|
|
1488
|
+
name: manifest.name,
|
|
1489
|
+
source: manifest.source,
|
|
1490
|
+
themes: manifest.themes,
|
|
1491
|
+
components: manifest.components,
|
|
1492
|
+
patterns: manifest.patterns,
|
|
1493
|
+
files: { tokens: manifest.tokens, foundations: manifest.foundations, guidelines: manifest.guidelines, preferences: manifest.preferences, decisions: manifest.decisions }
|
|
1494
|
+
},
|
|
1495
|
+
guidelines,
|
|
1496
|
+
preferences,
|
|
1497
|
+
decisions: decisions.slice(-5e3),
|
|
1498
|
+
foundations: relevantFoundations,
|
|
1499
|
+
tokens: selectedTokens,
|
|
1500
|
+
components,
|
|
1501
|
+
patterns,
|
|
1502
|
+
excluded: manifest.components.filter((item) => !componentEntries.includes(item)).map((item) => item.name)
|
|
1503
|
+
};
|
|
1504
|
+
} catch (error) {
|
|
1505
|
+
if (error.code !== "ENOENT") {
|
|
1506
|
+
return { exists: false, error: error instanceof Error ? error.message : String(error), nextStep: "Use design_system_analyze if documenting an existing app, or create a new Design System first." };
|
|
1507
|
+
}
|
|
1508
|
+
return { exists: false, nextStep: "Use design_system_analyze if the project already has UI, or ask for the user's visual direction before creating a new system." };
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
function taskTerms(task) {
|
|
1512
|
+
const lower = task.toLowerCase();
|
|
1513
|
+
const groups = [
|
|
1514
|
+
[/form|login|sign.?in|settings|input|field|validation|filter|search/, "button input textarea select checkbox radio switch form"],
|
|
1515
|
+
[/table|list|admin|management|users|records/, "table pagination card badge navigation sidebar"],
|
|
1516
|
+
[/nav|menu|header|sidebar|breadcrumb/, "navigation menu header sidebar breadcrumb"],
|
|
1517
|
+
[/modal|dialog|confirm|delete|destructive/, "modal drawer alert button"],
|
|
1518
|
+
[/empty|loading|error|success|toast/, "empty state loading skeleton alert toast"]
|
|
1519
|
+
];
|
|
1520
|
+
return groups.filter(([pattern]) => pattern.test(lower)).map(([, terms]) => terms).join(" ");
|
|
1521
|
+
}
|
|
1522
|
+
function selectTokens(document, task, preferredPaths, limit) {
|
|
1523
|
+
const themes = document.themes ?? {};
|
|
1524
|
+
const taskTermsLower = `${task} ${taskTerms(task)}`.toLowerCase();
|
|
1525
|
+
const selected = { schemaVersion: document.schemaVersion, themes: {} };
|
|
1526
|
+
for (const [themeName, value] of Object.entries(themes)) {
|
|
1527
|
+
if (!value || typeof value !== "object") continue;
|
|
1528
|
+
const theme = {};
|
|
1529
|
+
let remaining = limit;
|
|
1530
|
+
for (const group of Object.keys(value)) {
|
|
1531
|
+
const flattened = flatten(value[group], group);
|
|
1532
|
+
const candidates = flattened.filter(({ path: tokenPath }) => preferredPaths.has(tokenPath) || tokenPath.split(".").some((part) => part.length > 3 && taskTermsLower.includes(part.toLowerCase())));
|
|
1533
|
+
const baseline = flattened.filter(({ path: tokenPath }) => baselineTokenPath(tokenPath));
|
|
1534
|
+
const chosen = (candidates.length ? candidates : baseline).slice(0, remaining);
|
|
1535
|
+
if (chosen.length) {
|
|
1536
|
+
theme[group] = unflattenGroup(chosen);
|
|
1537
|
+
remaining -= chosen.length;
|
|
1538
|
+
}
|
|
1539
|
+
if (remaining <= 0) break;
|
|
1540
|
+
}
|
|
1541
|
+
;
|
|
1542
|
+
selected.themes[themeName] = theme;
|
|
1543
|
+
if (remaining <= 0) break;
|
|
1544
|
+
}
|
|
1545
|
+
return selected;
|
|
1546
|
+
}
|
|
1547
|
+
function baselineTokenPath(tokenPath) {
|
|
1548
|
+
return /^color\.(?:surface\.(?:base|raised)|text\.(?:primary|secondary)|accent\.primary|border\.subtle|focus\.ring)$/.test(tokenPath) || /^spacing\.(?:xs|sm|md|lg|control)$/.test(tokenPath) || /^radius\.(?:control|card|sm|md|lg)$/.test(tokenPath) || /^typography\.(?:fontFamily\.sans|fontSize\.(?:body|heading|title))$/.test(tokenPath) || /^breakpoints\.(?:compact|tablet|wide|desktop)$/.test(tokenPath) || /^(?:elevation|motion)\.(?:surface|dialog|popover|duration|easing)$/.test(tokenPath);
|
|
1549
|
+
}
|
|
1550
|
+
function flatten(value, prefix) {
|
|
1551
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [{ path: prefix, value }];
|
|
1552
|
+
return Object.entries(value).flatMap(([key, item]) => flatten(item, `${prefix}.${key}`));
|
|
1553
|
+
}
|
|
1554
|
+
function unflattenGroup(items) {
|
|
1555
|
+
const result = {};
|
|
1556
|
+
for (const item of items) {
|
|
1557
|
+
const parts = item.path.split(".").slice(1);
|
|
1558
|
+
let current = result;
|
|
1559
|
+
parts.forEach((part, index) => {
|
|
1560
|
+
if (index === parts.length - 1) current[part] = item.value;
|
|
1561
|
+
else current = current[part] ??= {};
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
return result;
|
|
1565
|
+
}
|
|
1566
|
+
function selectFoundationSections(markdown, task) {
|
|
1567
|
+
const relevant = `${task} ${taskTerms(task)}`.toLowerCase().split(/\W+/).filter((term) => term.length > 3);
|
|
1568
|
+
const sections = markdown.split(/(?=^#{1,3} )/m);
|
|
1569
|
+
const selected = sections.filter((section2, index) => {
|
|
1570
|
+
if (index === 0) return true;
|
|
1571
|
+
const heading = section2.slice(0, section2.indexOf("\n")).toLowerCase();
|
|
1572
|
+
return /accessibility|responsive|interaction|state/.test(heading) || relevant.some((term) => heading.includes(term));
|
|
1573
|
+
});
|
|
1574
|
+
return selected.join("\n").slice(0, 9e3);
|
|
1575
|
+
}
|
|
1576
|
+
function summarizeAnalysis(analysis) {
|
|
1577
|
+
return {
|
|
1578
|
+
...analysis,
|
|
1579
|
+
styleSources: analysis.styleSources.slice(0, 20).map((item) => ({ ...item, variables: item.variables.slice(0, 20), colors: item.colors.slice(0, 20), radii: item.radii.slice(0, 12) })),
|
|
1580
|
+
colors: analysis.colors.slice(0, 20),
|
|
1581
|
+
radii: analysis.radii.slice(0, 16),
|
|
1582
|
+
spacing: analysis.spacing.slice(0, 18),
|
|
1583
|
+
componentCandidates: analysis.componentCandidates.slice(0, 30)
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
export {
|
|
1587
|
+
index_default as default
|
|
1588
|
+
};
|