@ijfw/memory-server 1.3.0 → 1.4.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/fixtures/team/book.json +47 -0
- package/fixtures/team/business.json +47 -0
- package/fixtures/team/content.json +47 -0
- package/fixtures/team/design.json +47 -0
- package/fixtures/team/mixed.json +59 -0
- package/fixtures/team/research.json +47 -0
- package/fixtures/team/software.json +47 -0
- package/package.json +1 -9
- package/src/active-extension-writer.js +116 -0
- package/src/blackboard.js +360 -0
- package/src/cli-run.js +91 -0
- package/src/codex-agents.js +177 -0
- package/src/compute/extract.js +3 -0
- package/src/compute/fts5.js +4 -4
- package/src/compute/graph-lock.js +0 -2
- package/src/compute/migrations/003-tier-semantic.js +3 -3
- package/src/compute/runner.js +44 -15
- package/src/compute/schema.sql +1 -1
- package/src/cross-orchestrator-cli.js +974 -13
- package/src/cross-orchestrator.js +9 -1
- package/src/dashboard-client.html +144 -1
- package/src/dashboard-server.js +75 -2
- package/src/design-intelligence.js +721 -0
- package/src/dispatch/colon-syntax.js +31 -3
- package/src/dispatch/domain-manifest.js +251 -0
- package/src/dispatch/extension.js +404 -0
- package/src/dispatch/override.js +221 -0
- package/src/dispatch-planner.js +1 -0
- package/src/dream/runner.mjs +3 -3
- package/src/extension-installer.js +1230 -0
- package/src/extension-manifest-schema.js +301 -0
- package/src/extension-signer.js +740 -0
- package/src/gate-result-formatter.js +95 -0
- package/src/gate-result-schema.js +274 -0
- package/src/gate-result.js +195 -0
- package/src/intent-router.js +2 -0
- package/src/lib/npm-view.js +1 -0
- package/src/memory/fts5.js +3 -3
- package/src/memory/migrations/002-tier-semantic.js +2 -2
- package/src/memory/staleness.js +1 -1
- package/src/memory/tier-promotion.js +6 -6
- package/src/memory/tokenize.js +1 -1
- package/src/memory-feedback.js +188 -0
- package/src/override-manifest-schema.js +146 -0
- package/src/override-resolver.js +699 -0
- package/src/override-use-registry.js +307 -0
- package/src/overrides/presets/academic.md +101 -0
- package/src/overrides/presets/book.md +87 -0
- package/src/overrides/presets/campaign.md +95 -0
- package/src/overrides/presets/screenplay.md +99 -0
- package/src/recovery/checkpoint.js +191 -0
- package/src/redactor.js +2 -0
- package/src/runtime-mediator.js +178 -0
- package/src/sandbox.js +17 -3
- package/src/server.js +94 -2
- package/src/swarm/dispatch-prompt.js +154 -0
- package/src/swarm/planner.js +399 -0
- package/src/swarm/review.js +136 -0
- package/src/swarm/worktree.js +239 -0
- package/src/team/generator.js +119 -0
- package/src/team/schemas.js +341 -0
- package/src/trident/dispatch.js +47 -0
- package/src/update-check.js +1 -1
- package/src/vectors.js +7 -8
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IJFW design intelligence helpers.
|
|
3
|
+
*
|
|
4
|
+
* This module is intentionally static and dependency-free. It does not render
|
|
5
|
+
* pages; it scans DESIGN.md, HTML, and CSS text for practical design signals
|
|
6
|
+
* that are useful across software, content, research, book, and business work.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const DESIGN_FILE = 'DESIGN.md';
|
|
13
|
+
const DEFAULT_MAX_SUMMARY_CHARS = 1400;
|
|
14
|
+
|
|
15
|
+
const ACTION_GUIDANCE = {
|
|
16
|
+
plan: {
|
|
17
|
+
intent: 'Create or sharpen the design contract before producing artifacts.',
|
|
18
|
+
steps: [
|
|
19
|
+
'Load DESIGN.md when present; otherwise initialize a concise project-agnostic contract.',
|
|
20
|
+
'Name audience, artifact type, tone, palette roles, typography, spacing, and constraints.',
|
|
21
|
+
'For visual work, prepare real HTML mockups and use the live design companion when available.',
|
|
22
|
+
],
|
|
23
|
+
output: 'A DESIGN.md-aligned plan with design risks and next artifacts.',
|
|
24
|
+
},
|
|
25
|
+
audit: {
|
|
26
|
+
intent: 'Check an existing artifact against durable design constraints.',
|
|
27
|
+
steps: [
|
|
28
|
+
'Run static HTML/CSS audit for typography, palette, contrast, depth, card/hero, and repetition signals.',
|
|
29
|
+
'Separate objective issues from taste critiques and note detector limits.',
|
|
30
|
+
'Prioritize fixes that improve readability, hierarchy, accessibility, and consistency.',
|
|
31
|
+
],
|
|
32
|
+
output: 'An ordered issue list with evidence, severity, and suggested fixes.',
|
|
33
|
+
},
|
|
34
|
+
critique: {
|
|
35
|
+
intent: 'Challenge design decisions before implementation locks in.',
|
|
36
|
+
steps: [
|
|
37
|
+
'Identify the intended user, task, environment, and artifact format.',
|
|
38
|
+
'Flag weak hierarchy, unclear emphasis, overloaded visuals, and missing constraints.',
|
|
39
|
+
'Suggest focused alternatives without rewriting the entire direction.',
|
|
40
|
+
],
|
|
41
|
+
output: 'A concise critique with tradeoffs and recommended direction.',
|
|
42
|
+
},
|
|
43
|
+
polish: {
|
|
44
|
+
intent: 'Improve an already viable artifact without changing its core structure.',
|
|
45
|
+
steps: [
|
|
46
|
+
'Tighten spacing, type scale, contrast, alignment, states, and responsive fit.',
|
|
47
|
+
'Reduce decorative noise before adding new visual elements.',
|
|
48
|
+
'Preserve the artifact purpose and existing project conventions.',
|
|
49
|
+
],
|
|
50
|
+
output: 'A short polish pass and verification checklist.',
|
|
51
|
+
},
|
|
52
|
+
normalize: {
|
|
53
|
+
intent: 'Bring divergent artifacts back onto a shared design system.',
|
|
54
|
+
steps: [
|
|
55
|
+
'Extract repeated colors, fonts, spacing, radii, and shadows into reusable tokens.',
|
|
56
|
+
'Replace one-off styling with DESIGN.md roles and local conventions.',
|
|
57
|
+
'Keep normalization project-agnostic: tokens may apply to documents, slides, dashboards, or apps.',
|
|
58
|
+
],
|
|
59
|
+
output: 'A normalization map from current values to design tokens.',
|
|
60
|
+
},
|
|
61
|
+
bolder: {
|
|
62
|
+
intent: 'Increase distinction, hierarchy, and memorability while keeping the work usable.',
|
|
63
|
+
steps: [
|
|
64
|
+
'Raise contrast between primary and secondary elements.',
|
|
65
|
+
'Use fewer, stronger accents and clearer scale jumps.',
|
|
66
|
+
'Add expressive moments only where they support the artifact goal.',
|
|
67
|
+
],
|
|
68
|
+
output: 'A bolder variant direction with guardrails.',
|
|
69
|
+
},
|
|
70
|
+
quieter: {
|
|
71
|
+
intent: 'Reduce visual intensity and make repeated use more comfortable.',
|
|
72
|
+
steps: [
|
|
73
|
+
'Lower saturation, shadow, borders, and competing accents.',
|
|
74
|
+
'Increase whitespace and simplify hierarchy.',
|
|
75
|
+
'Keep critical affordances visible; quiet does not mean low contrast.',
|
|
76
|
+
],
|
|
77
|
+
output: 'A quieter variant direction with accessibility checks.',
|
|
78
|
+
},
|
|
79
|
+
handoff: {
|
|
80
|
+
intent: 'Preserve design state for future agents and sessions.',
|
|
81
|
+
steps: [
|
|
82
|
+
'Record the chosen design contract, open questions, decisions, and artifacts touched.',
|
|
83
|
+
'Include audit findings, remaining risks, and commands or files needed to resume.',
|
|
84
|
+
'Update memory or handoff notes at workflow stage boundaries.',
|
|
85
|
+
],
|
|
86
|
+
output: 'A compact handoff note suitable for project memory.',
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const DESIGN_ACTIONS = Object.keys(ACTION_GUIDANCE);
|
|
91
|
+
|
|
92
|
+
export function findDesignFile(startDir = process.cwd(), options = {}) {
|
|
93
|
+
const filename = normalizeString(options.filename) || DESIGN_FILE;
|
|
94
|
+
const root = resolve(startDir || process.cwd());
|
|
95
|
+
let current = root;
|
|
96
|
+
|
|
97
|
+
while (true) {
|
|
98
|
+
const candidate = join(current, filename);
|
|
99
|
+
if (existsSync(candidate)) {
|
|
100
|
+
return { found: true, path: candidate, directory: current };
|
|
101
|
+
}
|
|
102
|
+
const parent = dirname(current);
|
|
103
|
+
if (parent === current) break;
|
|
104
|
+
current = parent;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { found: false, path: null, directory: null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function loadDesignFile(startDir = process.cwd(), options = {}) {
|
|
111
|
+
const found = findDesignFile(startDir, options);
|
|
112
|
+
if (!found.found) {
|
|
113
|
+
return {
|
|
114
|
+
found: false,
|
|
115
|
+
path: null,
|
|
116
|
+
content: '',
|
|
117
|
+
summary: 'No DESIGN.md found. Initialize one to create a durable design contract for this project.',
|
|
118
|
+
details: {},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const content = readFileSync(found.path, 'utf8');
|
|
123
|
+
return {
|
|
124
|
+
...found,
|
|
125
|
+
content,
|
|
126
|
+
summary: summarizeDesignContent(content, options),
|
|
127
|
+
details: extractDesignDetails(content),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function summarizeDesignContent(content, options = {}) {
|
|
132
|
+
const maxChars = Math.max(300, Number(options.maxChars) || DEFAULT_MAX_SUMMARY_CHARS);
|
|
133
|
+
const details = extractDesignDetails(content);
|
|
134
|
+
const lines = [];
|
|
135
|
+
|
|
136
|
+
if (details.title) lines.push(`# ${details.title}`);
|
|
137
|
+
if (details.headings.length) lines.push(`Sections: ${details.headings.slice(0, 8).join(', ')}`);
|
|
138
|
+
if (details.colors.length) lines.push(`Color roles: ${details.colors.slice(0, 12).join(', ')}`);
|
|
139
|
+
if (details.fonts.length) lines.push(`Fonts: ${details.fonts.slice(0, 8).join(', ')}`);
|
|
140
|
+
if (details.spacing.length) lines.push(`Spacing/scale: ${details.spacing.slice(0, 8).join(', ')}`);
|
|
141
|
+
if (details.constraints.length) lines.push(`Constraints: ${details.constraints.slice(0, 8).join('; ')}`);
|
|
142
|
+
|
|
143
|
+
const summary = lines.join('\n').trim();
|
|
144
|
+
if (!summary) return compactWhitespace(content).slice(0, maxChars);
|
|
145
|
+
return summary.length > maxChars ? `${summary.slice(0, maxChars - 1).trim()}...` : summary;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createDesignMdContent(options = {}) {
|
|
149
|
+
const projectName = normalizeString(options.projectName) || 'Project';
|
|
150
|
+
const artifactType = normalizeString(options.artifactType) || 'project artifacts';
|
|
151
|
+
const audience = normalizeString(options.audience) || 'the intended user or reader';
|
|
152
|
+
const tone = normalizeString(options.tone) || 'clear, useful, and consistent';
|
|
153
|
+
|
|
154
|
+
return `# ${projectName} DESIGN.md
|
|
155
|
+
|
|
156
|
+
## Purpose
|
|
157
|
+
Design direction for ${artifactType}. This contract is project-agnostic: use it for interfaces, documents, dashboards, research outputs, presentations, or other visual artifacts.
|
|
158
|
+
|
|
159
|
+
## Audience
|
|
160
|
+
Designed for ${audience}. Optimize for comprehension, trust, and repeated use.
|
|
161
|
+
|
|
162
|
+
## Visual Direction
|
|
163
|
+
- Tone: ${tone}
|
|
164
|
+
- Hierarchy: make primary actions and key ideas obvious before secondary detail.
|
|
165
|
+
- Density: match the artifact's job; operational tools can be dense, editorial work can breathe.
|
|
166
|
+
- Imagery: use real, relevant visuals when they help the user inspect or understand the subject.
|
|
167
|
+
|
|
168
|
+
## Color Roles
|
|
169
|
+
- Background:
|
|
170
|
+
- Surface:
|
|
171
|
+
- Text primary:
|
|
172
|
+
- Text secondary:
|
|
173
|
+
- Border:
|
|
174
|
+
- Accent:
|
|
175
|
+
- Critical/warning/success:
|
|
176
|
+
|
|
177
|
+
## Typography
|
|
178
|
+
- Display:
|
|
179
|
+
- Body:
|
|
180
|
+
- Mono/data:
|
|
181
|
+
- Scale:
|
|
182
|
+
- Line height:
|
|
183
|
+
- Letter spacing: 0 unless a deliberate brand exception is documented.
|
|
184
|
+
|
|
185
|
+
## Layout
|
|
186
|
+
- Grid:
|
|
187
|
+
- Spacing scale:
|
|
188
|
+
- Max width or page format:
|
|
189
|
+
- Responsive or format constraints:
|
|
190
|
+
|
|
191
|
+
## Components And Artifact Patterns
|
|
192
|
+
- Buttons/actions:
|
|
193
|
+
- Cards/panels:
|
|
194
|
+
- Tables/lists:
|
|
195
|
+
- Forms/inputs:
|
|
196
|
+
- Navigation/wayfinding:
|
|
197
|
+
- Document or slide patterns:
|
|
198
|
+
|
|
199
|
+
## Accessibility And Quality Gates
|
|
200
|
+
- Maintain readable contrast for text and controls.
|
|
201
|
+
- Avoid too many font families, one-note palettes, excessive shadows, and unnecessary rounded surfaces.
|
|
202
|
+
- Check mobile/compact fit for interfaces and scanability for documents.
|
|
203
|
+
|
|
204
|
+
## Agent Notes
|
|
205
|
+
- Preserve this file as the design source of truth.
|
|
206
|
+
- Update decisions and unresolved questions during handoff.
|
|
207
|
+
- Prefer real HTML mockups for interface brainstorming; avoid ASCII mockups unless explicitly requested.
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function auditStaticDesign(input) {
|
|
212
|
+
const source = normalizeAuditInput(input);
|
|
213
|
+
const css = `${extractStyleBlocks(source.html)}\n${source.css}`;
|
|
214
|
+
const html = source.html;
|
|
215
|
+
const issues = [];
|
|
216
|
+
|
|
217
|
+
const fonts = analyzeFonts(css);
|
|
218
|
+
const colors = analyzeColors(css);
|
|
219
|
+
const contrast = analyzeContrast(css);
|
|
220
|
+
const depth = analyzeDepth(css);
|
|
221
|
+
const structure = analyzeStructure(html, css);
|
|
222
|
+
const repetition = analyzeRepetition(html, css);
|
|
223
|
+
|
|
224
|
+
addFontIssues(issues, fonts);
|
|
225
|
+
addColorIssues(issues, colors);
|
|
226
|
+
for (const item of contrast.issues) issues.push(item);
|
|
227
|
+
addDepthIssues(issues, depth);
|
|
228
|
+
addStructureIssues(issues, structure);
|
|
229
|
+
addRepetitionIssues(issues, repetition);
|
|
230
|
+
|
|
231
|
+
const sorted = issues.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
|
|
232
|
+
return {
|
|
233
|
+
summary: summarizeAudit(sorted),
|
|
234
|
+
issues: sorted,
|
|
235
|
+
metrics: {
|
|
236
|
+
fonts,
|
|
237
|
+
colors,
|
|
238
|
+
contrast,
|
|
239
|
+
depth,
|
|
240
|
+
structure,
|
|
241
|
+
repetition,
|
|
242
|
+
},
|
|
243
|
+
limits: [
|
|
244
|
+
'Static audit only; it does not render layout, compute inherited styles, or inspect screenshots.',
|
|
245
|
+
'Contrast checks only evaluate color/background-color pairs declared in the same CSS rule.',
|
|
246
|
+
],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function getDesignActionGuidance(mode, context = {}) {
|
|
251
|
+
const key = normalizeString(mode).toLowerCase();
|
|
252
|
+
const guidance = ACTION_GUIDANCE[key] || ACTION_GUIDANCE.plan;
|
|
253
|
+
return {
|
|
254
|
+
mode: ACTION_GUIDANCE[key] ? key : 'plan',
|
|
255
|
+
...guidance,
|
|
256
|
+
context: {
|
|
257
|
+
artifactType: normalizeString(context.artifactType) || 'unspecified',
|
|
258
|
+
hasDesignFile: Boolean(context.hasDesignFile),
|
|
259
|
+
hasWebApp: Boolean(context.hasWebApp),
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function listDesignActionModes() {
|
|
265
|
+
return Object.keys(ACTION_GUIDANCE);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function initialDesignMarkdown(options = {}) {
|
|
269
|
+
return createDesignMdContent({
|
|
270
|
+
projectName: options.projectName,
|
|
271
|
+
tone: options.direction,
|
|
272
|
+
artifactType: options.artifactType,
|
|
273
|
+
audience: options.audience,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function loadDesignContext(projectRoot = process.cwd(), options = {}) {
|
|
278
|
+
const loaded = loadDesignFile(projectRoot, { maxChars: options.maxChars });
|
|
279
|
+
return {
|
|
280
|
+
ok: loaded.found,
|
|
281
|
+
exists: loaded.found,
|
|
282
|
+
path: loaded.path || join(resolve(projectRoot), DESIGN_FILE),
|
|
283
|
+
summary: loaded.summary,
|
|
284
|
+
text: loaded.content,
|
|
285
|
+
details: loaded.details,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function auditDesignText(input, options = {}) {
|
|
290
|
+
const audit = auditStaticDesign(input, options);
|
|
291
|
+
return {
|
|
292
|
+
ok: true,
|
|
293
|
+
summary: audit.summary,
|
|
294
|
+
findings: audit.issues.map((item) => ({
|
|
295
|
+
severity: item.severity,
|
|
296
|
+
rule: item.code,
|
|
297
|
+
message: item.message,
|
|
298
|
+
details: item.details,
|
|
299
|
+
})),
|
|
300
|
+
metrics: audit.metrics,
|
|
301
|
+
limits: audit.limits,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function designActionGuide(action, context = {}) {
|
|
306
|
+
const guide = getDesignActionGuidance(action, {
|
|
307
|
+
hasDesignFile: context.exists || context.found || context.hasDesignFile,
|
|
308
|
+
artifactType: context.artifactType,
|
|
309
|
+
hasWebApp: context.hasWebApp,
|
|
310
|
+
});
|
|
311
|
+
return {
|
|
312
|
+
action: guide.mode,
|
|
313
|
+
uses_design_md: guide.context.hasDesignFile,
|
|
314
|
+
reminder: 'Live preview is transient. DESIGN.md is durable design memory.',
|
|
315
|
+
guidance: [guide.intent, ...guide.steps],
|
|
316
|
+
output: guide.output,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function extractDesignDetails(content) {
|
|
321
|
+
const text = String(content || '');
|
|
322
|
+
const headings = [...text.matchAll(/^#{1,3}\s+(.+)$/gm)].map((m) => cleanMarkdown(m[1]));
|
|
323
|
+
const title = headings[0] || '';
|
|
324
|
+
const colors = unique([...text.matchAll(/(--[\w-]+)\s*:\s*(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-zA-Z]+)|`([^`]+)`\s*:\s*(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-zA-Z]+)/g)]
|
|
325
|
+
.map((m) => compactWhitespace(`${m[1] || m[3]} ${m[2] || m[4]}`)));
|
|
326
|
+
const fonts = extractFontNames(text);
|
|
327
|
+
const spacing = unique([...text.matchAll(/\b(?:spacing|padding|margin|gutter|radius|width|scale)[^:\n]*:\s*([^\n]+)/gi)]
|
|
328
|
+
.map((m) => compactWhitespace(m[1]).slice(0, 120)));
|
|
329
|
+
const constraints = unique([...text.matchAll(/(?:^|\n)\s*[-*]\s+(.{12,180})/g)]
|
|
330
|
+
.map((m) => cleanMarkdown(m[1]))
|
|
331
|
+
.filter((line) => /(avoid|never|must|minimum|keep|prefer|do not|don't|contrast|responsive|mobile|accessibility)/i.test(line)));
|
|
332
|
+
|
|
333
|
+
return { title, headings: headings.slice(1), colors, fonts, spacing, constraints };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function normalizeAuditInput(input) {
|
|
337
|
+
if (typeof input === 'string') {
|
|
338
|
+
return /<\/?[a-z][\s\S]*>/i.test(input)
|
|
339
|
+
? { html: input, css: '' }
|
|
340
|
+
: { html: '', css: input };
|
|
341
|
+
}
|
|
342
|
+
const source = input && typeof input === 'object' ? input : {};
|
|
343
|
+
return {
|
|
344
|
+
html: String(source.html || ''),
|
|
345
|
+
css: String(source.css || ''),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function extractStyleBlocks(html) {
|
|
350
|
+
return [...String(html || '').matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)]
|
|
351
|
+
.map((m) => m[1])
|
|
352
|
+
.join('\n');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function analyzeFonts(css) {
|
|
356
|
+
const declarations = [...css.matchAll(/font-family\s*:\s*([^;}{]+)/gi)].map((m) => m[1]);
|
|
357
|
+
const families = unique(declarations.flatMap(parseFontFamilies));
|
|
358
|
+
const selectorFamilies = {};
|
|
359
|
+
for (const rule of cssRules(css)) {
|
|
360
|
+
const match = rule.body.match(/font-family\s*:\s*([^;}{]+)/i);
|
|
361
|
+
if (match) selectorFamilies[rule.selector] = parseFontFamilies(match[1]);
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
families,
|
|
365
|
+
count: families.length,
|
|
366
|
+
declarations: declarations.length,
|
|
367
|
+
selectorFamilies,
|
|
368
|
+
hasConvergence: declarations.length >= 3 && families.length <= 1,
|
|
369
|
+
hasTooMany: families.length > 3,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function analyzeColors(css) {
|
|
374
|
+
const values = extractColorValues(css);
|
|
375
|
+
const uniqueValues = unique(values.map(normalizeColorToken));
|
|
376
|
+
const parsed = uniqueValues.map(parseColor).filter(Boolean);
|
|
377
|
+
const hueBuckets = {};
|
|
378
|
+
const chromaticBuckets = {};
|
|
379
|
+
for (const color of parsed) {
|
|
380
|
+
const bucket = color.saturation < 0.12 ? 'neutral' : String(Math.round(color.hue / 30) * 30);
|
|
381
|
+
hueBuckets[bucket] = (hueBuckets[bucket] || 0) + 1;
|
|
382
|
+
if (bucket !== 'neutral') chromaticBuckets[bucket] = (chromaticBuckets[bucket] || 0) + 1;
|
|
383
|
+
}
|
|
384
|
+
const largestBucket = Object.entries(hueBuckets).sort((a, b) => b[1] - a[1])[0] || ['', 0];
|
|
385
|
+
const largestChromaticBucket = Object.entries(chromaticBuckets).sort((a, b) => b[1] - a[1])[0] || ['', 0];
|
|
386
|
+
const chromaticCount = Object.values(chromaticBuckets).reduce((sum, count) => sum + count, 0);
|
|
387
|
+
return {
|
|
388
|
+
values: uniqueValues,
|
|
389
|
+
count: uniqueValues.length,
|
|
390
|
+
hueBuckets,
|
|
391
|
+
hasTooMany: uniqueValues.length > 12,
|
|
392
|
+
hasOneNote: parsed.length >= 5 && chromaticCount >= 4 && largestChromaticBucket[1] / chromaticCount >= 0.7,
|
|
393
|
+
dominantHue: largestChromaticBucket[0] || largestBucket[0] || null,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function analyzeContrast(css) {
|
|
398
|
+
const issues = [];
|
|
399
|
+
const pairs = [];
|
|
400
|
+
for (const rule of cssRules(css)) {
|
|
401
|
+
const fg = declarationValue(rule.body, 'color');
|
|
402
|
+
const bg = declarationValue(rule.body, 'background-color') || declarationValue(rule.body, 'background');
|
|
403
|
+
if (!fg || !bg) continue;
|
|
404
|
+
const fgColor = parseColor(firstColor(fg));
|
|
405
|
+
const bgColor = parseColor(firstColor(bg));
|
|
406
|
+
if (!fgColor || !bgColor) continue;
|
|
407
|
+
const ratio = contrastRatio(fgColor, bgColor);
|
|
408
|
+
pairs.push({ selector: rule.selector, foreground: fgColor.raw, background: bgColor.raw, ratio });
|
|
409
|
+
if (ratio < 4.5) {
|
|
410
|
+
issues.push(issue('contrast.low', 'high', `Low text contrast in ${rule.selector}`, {
|
|
411
|
+
selector: rule.selector,
|
|
412
|
+
ratio: Number(ratio.toFixed(2)),
|
|
413
|
+
foreground: fgColor.raw,
|
|
414
|
+
background: bgColor.raw,
|
|
415
|
+
suggestion: 'Adjust foreground or background toward at least 4.5:1 for normal text.',
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return { pairs, issues };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function analyzeDepth(css) {
|
|
423
|
+
const radii = [...css.matchAll(/border-radius\s*:\s*([^;}{]+)/gi)].map((m) => parseLargestPx(m[1]));
|
|
424
|
+
const shadows = [...css.matchAll(/box-shadow\s*:\s*([^;}{]+)/gi)].map((m) => m[1].trim()).filter((v) => !/^none$/i.test(v));
|
|
425
|
+
const largeShadowCount = shadows.filter((shadow) => parseLargestPx(shadow) > 32).length;
|
|
426
|
+
return {
|
|
427
|
+
radii,
|
|
428
|
+
maxRadius: radii.length ? Math.max(...radii) : 0,
|
|
429
|
+
highRadiusCount: radii.filter((v) => v > 16).length,
|
|
430
|
+
shadows,
|
|
431
|
+
shadowCount: shadows.length,
|
|
432
|
+
largeShadowCount,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function analyzeStructure(html, css) {
|
|
437
|
+
const combined = `${html}\n${css}`;
|
|
438
|
+
const heroCard = /class\s*=\s*["'][^"']*hero[^"']*["'][\s\S]{0,1600}class\s*=\s*["'][^"']*card/i.test(html)
|
|
439
|
+
|| /\.hero\s+\.card|\.hero[^{]*{[\s\S]{0,500}(box-shadow|border-radius:\s*(?:2[0-9]|[3-9][0-9])px)/i.test(css);
|
|
440
|
+
const nestedCards = /class\s*=\s*["'][^"']*card[^"']*["'][\s\S]{0,800}class\s*=\s*["'][^"']*card/i.test(html)
|
|
441
|
+
|| /\.card\s+\.card/i.test(css);
|
|
442
|
+
const cardCount = countMatches(combined, /\bcard\b/gi);
|
|
443
|
+
const heroCount = countMatches(combined, /\bhero\b/gi);
|
|
444
|
+
const splitHero = /\.hero[^{]*{[^}]*grid-template-columns\s*:\s*(?:1fr\s+1fr|repeat\(2|[^\n;]*50%)/i.test(css)
|
|
445
|
+
|| /class\s*=\s*["'][^"']*hero[^"']*["'][\s\S]{0,1800}(class\s*=\s*["'][^"']*(?:media|image|preview)[^"']*["'])/i.test(html);
|
|
446
|
+
return { heroCard, nestedCards, cardCount, heroCount, splitHero };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function analyzeRepetition(html, css) {
|
|
450
|
+
const bodies = cssRules(css).map((rule) => compactWhitespace(rule.body));
|
|
451
|
+
const bodyCounts = countBy(bodies.filter(Boolean));
|
|
452
|
+
const repeatedRuleBodies = Object.entries(bodyCounts)
|
|
453
|
+
.filter(([, count]) => count >= 4)
|
|
454
|
+
.map(([body, count]) => ({ body, count }));
|
|
455
|
+
const numberedClasses = [...`${html}\n${css}`.matchAll(/\b([a-z]+)[-_](\d{1,3})\b/gi)].map((m) => m[1].toLowerCase());
|
|
456
|
+
const numberedClassGroups = Object.entries(countBy(numberedClasses)).filter(([, count]) => count >= 5);
|
|
457
|
+
const repeatedInlineStyles = countMatches(html, /style\s*=\s*["'][^"']{20,}["']/gi);
|
|
458
|
+
return { repeatedRuleBodies, numberedClassGroups, repeatedInlineStyles };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function addFontIssues(issues, fonts) {
|
|
462
|
+
if (fonts.hasTooMany) {
|
|
463
|
+
issues.push(issue('font.too_many_families', 'medium', 'Too many font families create weak typography cohesion.', {
|
|
464
|
+
count: fonts.count,
|
|
465
|
+
families: fonts.families,
|
|
466
|
+
suggestion: 'Keep most artifacts to display/body/mono or fewer unless DESIGN.md documents the exception.',
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
if (fonts.hasConvergence) {
|
|
470
|
+
issues.push(issue('font.converged', 'low', 'All font-family declarations converge on one family.', {
|
|
471
|
+
families: fonts.families,
|
|
472
|
+
suggestion: 'Confirm hierarchy still has enough contrast through size, weight, line height, or a documented mono/data face.',
|
|
473
|
+
}));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function addColorIssues(issues, colors) {
|
|
478
|
+
if (colors.hasTooMany) {
|
|
479
|
+
issues.push(issue('palette.too_many_colors', 'medium', 'Too many unique colors make the palette harder to govern.', {
|
|
480
|
+
count: colors.count,
|
|
481
|
+
colors: colors.values.slice(0, 20),
|
|
482
|
+
suggestion: 'Normalize values into background, surface, text, border, accent, and semantic roles.',
|
|
483
|
+
}));
|
|
484
|
+
}
|
|
485
|
+
if (colors.hasOneNote) {
|
|
486
|
+
issues.push(issue('palette.one_note', 'low', 'Most detected colors sit in one hue family.', {
|
|
487
|
+
dominantHue: colors.dominantHue,
|
|
488
|
+
hueBuckets: colors.hueBuckets,
|
|
489
|
+
suggestion: 'Add clearer role separation with neutrals, text colors, or a restrained accent.',
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function addDepthIssues(issues, depth) {
|
|
495
|
+
if (depth.maxRadius > 28 || depth.highRadiusCount >= 4) {
|
|
496
|
+
issues.push(issue('depth.excessive_radius', 'medium', 'Large or repeated border-radius values may make the system feel soft and generic.', {
|
|
497
|
+
maxRadius: depth.maxRadius,
|
|
498
|
+
highRadiusCount: depth.highRadiusCount,
|
|
499
|
+
suggestion: 'Reserve high radius for deliberately pill-shaped controls or document the style in DESIGN.md.',
|
|
500
|
+
}));
|
|
501
|
+
}
|
|
502
|
+
if (depth.shadowCount > 8 || depth.largeShadowCount >= 3) {
|
|
503
|
+
issues.push(issue('depth.excessive_shadow', 'medium', 'Heavy shadow usage can blur hierarchy and add decorative noise.', {
|
|
504
|
+
shadowCount: depth.shadowCount,
|
|
505
|
+
largeShadowCount: depth.largeShadowCount,
|
|
506
|
+
suggestion: 'Use borders, spacing, or one elevation scale instead of many one-off shadows.',
|
|
507
|
+
}));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function addStructureIssues(issues, structure) {
|
|
512
|
+
if (structure.heroCard) {
|
|
513
|
+
issues.push(issue('structure.hero_card_misuse', 'medium', 'Hero content appears to be placed inside a card or card-like container.', {
|
|
514
|
+
suggestion: 'For landing or brand-first screens, keep primary hero text/content unframed and let the next section peek into view.',
|
|
515
|
+
}));
|
|
516
|
+
}
|
|
517
|
+
if (structure.nestedCards) {
|
|
518
|
+
issues.push(issue('structure.nested_cards', 'medium', 'Detected card nesting.', {
|
|
519
|
+
suggestion: 'Use cards for repeated items or tools; avoid placing cards inside cards.',
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
if (structure.cardCount > 30) {
|
|
523
|
+
issues.push(issue('structure.card_heavy', 'low', 'The artifact is heavily card-oriented.', {
|
|
524
|
+
cardSignals: structure.cardCount,
|
|
525
|
+
suggestion: 'Check whether full-width sections, tables, lists, or grouped controls would scan better.',
|
|
526
|
+
}));
|
|
527
|
+
}
|
|
528
|
+
if (structure.splitHero) {
|
|
529
|
+
issues.push(issue('structure.split_hero', 'low', 'Hero appears to use a split text/media layout.', {
|
|
530
|
+
suggestion: 'For landing-page heroes, consider a stronger first-viewport signal with real product/place/object imagery or a full-width scene.',
|
|
531
|
+
}));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function addRepetitionIssues(issues, repetition) {
|
|
536
|
+
if (repetition.repeatedRuleBodies.length) {
|
|
537
|
+
issues.push(issue('layout.repeated_rules', 'low', 'Several CSS rules repeat identical declarations.', {
|
|
538
|
+
repeated: repetition.repeatedRuleBodies.slice(0, 5),
|
|
539
|
+
suggestion: 'Extract repeated declarations into shared classes, tokens, or component styles.',
|
|
540
|
+
}));
|
|
541
|
+
}
|
|
542
|
+
if (repetition.numberedClassGroups.length) {
|
|
543
|
+
issues.push(issue('layout.numbered_classes', 'low', 'Numbered class patterns suggest repeated manual layout variants.', {
|
|
544
|
+
groups: repetition.numberedClassGroups,
|
|
545
|
+
suggestion: 'Replace numbered variants with reusable layout primitives or data-driven repeated items.',
|
|
546
|
+
}));
|
|
547
|
+
}
|
|
548
|
+
if (repetition.repeatedInlineStyles >= 5) {
|
|
549
|
+
issues.push(issue('layout.inline_style_repetition', 'low', 'Repeated inline styles make visual normalization harder.', {
|
|
550
|
+
count: repetition.repeatedInlineStyles,
|
|
551
|
+
suggestion: 'Move repeated inline styles into named classes or tokens.',
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function summarizeAudit(issues) {
|
|
557
|
+
if (!issues.length) return 'No major static design issues detected. Renderer-based visual review may still find layout or responsive problems.';
|
|
558
|
+
const high = issues.filter((item) => item.severity === 'high').length;
|
|
559
|
+
const medium = issues.filter((item) => item.severity === 'medium').length;
|
|
560
|
+
const low = issues.filter((item) => item.severity === 'low').length;
|
|
561
|
+
return `Static audit found ${issues.length} issue(s): ${high} high, ${medium} medium, ${low} low.`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function cssRules(css) {
|
|
565
|
+
const rules = [];
|
|
566
|
+
const stripped = String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
567
|
+
for (const match of stripped.matchAll(/([^{}@]+)\{([^{}]+)\}/g)) {
|
|
568
|
+
const selector = compactWhitespace(match[1]);
|
|
569
|
+
if (!selector || selector.includes('@')) continue;
|
|
570
|
+
rules.push({ selector, body: match[2] });
|
|
571
|
+
}
|
|
572
|
+
return rules;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function declarationValue(body, property) {
|
|
576
|
+
const pattern = new RegExp(`(?:^|;)\\s*${escapeRegex(property)}\\s*:\\s*([^;]+)`, 'i');
|
|
577
|
+
const match = String(body || '').match(pattern);
|
|
578
|
+
return match ? match[1].trim() : '';
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function extractColorValues(css) {
|
|
582
|
+
const text = String(css || '');
|
|
583
|
+
const hex = [...text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map((m) => m[0]);
|
|
584
|
+
const rgb = [...text.matchAll(/rgba?\([^)]+\)/gi)].map((m) => m[0]);
|
|
585
|
+
return [...hex, ...rgb];
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function firstColor(value) {
|
|
589
|
+
const match = String(value || '').match(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]+\)/i);
|
|
590
|
+
return match ? match[0] : '';
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function parseFontFamilies(value) {
|
|
594
|
+
return String(value || '')
|
|
595
|
+
.split(',')
|
|
596
|
+
.map((part) => part.trim().replace(/^['"]|['"]$/g, ''))
|
|
597
|
+
.filter((part) => part && !/^(sans-serif|serif|monospace|system-ui|-apple-system|BlinkMacSystemFont)$/i.test(part));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function extractFontNames(text) {
|
|
601
|
+
const fromDeclarations = [...String(text || '').matchAll(/font-family\s*:\s*([^;`\n]+)/gi)].flatMap((m) => parseFontFamilies(m[1]));
|
|
602
|
+
const fromMd = [...String(text || '').matchAll(/\*\*(?:Display|Body|Mono|Data)?\s*font\*\*:\s*([^-\n]+)/gi)]
|
|
603
|
+
.map((m) => compactWhitespace(m[1]));
|
|
604
|
+
return unique([...fromDeclarations, ...fromMd].filter(Boolean));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function parseColor(value) {
|
|
608
|
+
const raw = normalizeColorToken(value);
|
|
609
|
+
let r;
|
|
610
|
+
let g;
|
|
611
|
+
let b;
|
|
612
|
+
|
|
613
|
+
if (raw.startsWith('#')) {
|
|
614
|
+
const hex = raw.slice(1);
|
|
615
|
+
if (hex.length === 3) {
|
|
616
|
+
r = parseInt(hex[0] + hex[0], 16);
|
|
617
|
+
g = parseInt(hex[1] + hex[1], 16);
|
|
618
|
+
b = parseInt(hex[2] + hex[2], 16);
|
|
619
|
+
} else if (hex.length >= 6) {
|
|
620
|
+
r = parseInt(hex.slice(0, 2), 16);
|
|
621
|
+
g = parseInt(hex.slice(2, 4), 16);
|
|
622
|
+
b = parseInt(hex.slice(4, 6), 16);
|
|
623
|
+
}
|
|
624
|
+
} else {
|
|
625
|
+
const nums = raw.match(/[\d.]+/g)?.map(Number) || [];
|
|
626
|
+
if (nums.length >= 3) [r, g, b] = nums;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (![r, g, b].every((n) => Number.isFinite(n))) return null;
|
|
630
|
+
const hsl = rgbToHsl(r, g, b);
|
|
631
|
+
return { raw, r, g, b, ...hsl };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function contrastRatio(fg, bg) {
|
|
635
|
+
const l1 = relativeLuminance(fg);
|
|
636
|
+
const l2 = relativeLuminance(bg);
|
|
637
|
+
const lighter = Math.max(l1, l2);
|
|
638
|
+
const darker = Math.min(l1, l2);
|
|
639
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function relativeLuminance(color) {
|
|
643
|
+
const values = [color.r, color.g, color.b].map((v) => {
|
|
644
|
+
const channel = v / 255;
|
|
645
|
+
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
646
|
+
});
|
|
647
|
+
return 0.2126 * values[0] + 0.7152 * values[1] + 0.0722 * values[2];
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function rgbToHsl(r, g, b) {
|
|
651
|
+
r /= 255;
|
|
652
|
+
g /= 255;
|
|
653
|
+
b /= 255;
|
|
654
|
+
const max = Math.max(r, g, b);
|
|
655
|
+
const min = Math.min(r, g, b);
|
|
656
|
+
let h = 0;
|
|
657
|
+
let s = 0;
|
|
658
|
+
const l = (max + min) / 2;
|
|
659
|
+
if (max !== min) {
|
|
660
|
+
const d = max - min;
|
|
661
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
662
|
+
switch (max) {
|
|
663
|
+
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
|
664
|
+
case g: h = (b - r) / d + 2; break;
|
|
665
|
+
default: h = (r - g) / d + 4; break;
|
|
666
|
+
}
|
|
667
|
+
h *= 60;
|
|
668
|
+
}
|
|
669
|
+
return { hue: h, saturation: s, lightness: l };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function parseLargestPx(value) {
|
|
673
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- CSS declaration values are short strings from parsed style attributes; numeric unit scan is linear in practice.
|
|
674
|
+
const px = [...String(value || '').matchAll(/(-?\d+(?:\.\d+)?)px/g)].map((m) => Math.abs(Number(m[1])));
|
|
675
|
+
if (px.length) return Math.max(...px);
|
|
676
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- CSS declaration values are short strings from parsed style attributes; numeric unit scan is linear in practice.
|
|
677
|
+
const rem = [...String(value || '').matchAll(/(-?\d+(?:\.\d+)?)rem/g)].map((m) => Math.abs(Number(m[1]) * 16));
|
|
678
|
+
return rem.length ? Math.max(...rem) : 0;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function issue(code, severity, message, details = {}) {
|
|
682
|
+
return { code, severity, message, details };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function severityRank(severity) {
|
|
686
|
+
return { high: 0, medium: 1, low: 2 }[severity] ?? 3;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function countMatches(text, regex) {
|
|
690
|
+
return [...String(text || '').matchAll(regex)].length;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function countBy(values) {
|
|
694
|
+
const counts = {};
|
|
695
|
+
for (const value of values) counts[value] = (counts[value] || 0) + 1;
|
|
696
|
+
return counts;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function unique(values) {
|
|
700
|
+
return [...new Set(values.map((value) => normalizeString(value)).filter(Boolean))];
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function normalizeColorToken(value) {
|
|
704
|
+
return normalizeString(value).toLowerCase();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function normalizeString(value) {
|
|
708
|
+
return String(value ?? '').trim();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function compactWhitespace(value) {
|
|
712
|
+
return normalizeString(value).replace(/\s+/g, ' ');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function cleanMarkdown(value) {
|
|
716
|
+
return compactWhitespace(value).replace(/[*_`]/g, '').replace(/\s+-\s+/g, ' - ');
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function escapeRegex(value) {
|
|
720
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
721
|
+
}
|