bigpowers 2.42.1 → 2.43.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/.pi/package.json +2 -2
- package/.pi/prompts/extract-design.md +131 -0
- package/.pi/skills/extract-design/SKILL.md +133 -0
- package/CHANGELOG.md +7 -0
- package/SKILL-INDEX.md +2 -2
- package/extract-design/REFERENCE.md +52 -0
- package/extract-design/SKILL.md +77 -0
- package/extract-design/extract-design.feature +23 -0
- package/extract-design/scripts/classify-colors.js +21 -0
- package/extract-design/scripts/classify-rounded.js +10 -0
- package/extract-design/scripts/classify-spacing.js +14 -0
- package/extract-design/scripts/classify-typography.js +9 -0
- package/extract-design/scripts/collect-styles.js +149 -0
- package/extract-design/scripts/detect-components.js +12 -0
- package/extract-design/scripts/extract.js +306 -0
- package/extract-design/scripts/generate-prose.js +38 -0
- package/extract-design/scripts/lib/browser.js +206 -0
- package/extract-design/scripts/lib/constants.js +9 -0
- package/extract-design/scripts/lib/logging.js +8 -0
- package/extract-design/scripts/lib/retry.js +13 -0
- package/extract-design/scripts/lib/state.js +15 -0
- package/extract-design/scripts/lib/validator.js +11 -0
- package/extract-design/scripts/write-designd.js +16 -0
- package/extract-design/tests/fixtures/glassmorphism-dark/expected-tokens.json +1 -0
- package/extract-design/tests/fixtures/glassmorphism-dark/prototype.html +7 -0
- package/extract-design/tests/fixtures/minimal-no-styles/expected-tokens.json +1 -0
- package/extract-design/tests/fixtures/minimal-no-styles/prototype.html +2 -0
- package/extract-design/tests/fixtures/swiss-grid/expected-tokens.json +1 -0
- package/extract-design/tests/fixtures/swiss-grid/prototype.html +6 -0
- package/extract-design/tests/test-extraction.js +315 -0
- package/package.json +1 -1
- package/skills-lock.json +5 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Browser sensor — collects computed styles inside Puppeteer page.
|
|
2
|
+
// Returns raw JSON. No classification logic here.
|
|
3
|
+
|
|
4
|
+
export function collect(page) {
|
|
5
|
+
return page.evaluate(() => {
|
|
6
|
+
const styles = [];
|
|
7
|
+
const title = document.title || '';
|
|
8
|
+
|
|
9
|
+
// Collect computed styles from every visible element
|
|
10
|
+
document.querySelectorAll('body, body *').forEach(el => {
|
|
11
|
+
const cs = getComputedStyle(el);
|
|
12
|
+
const tag = el.tagName.toLowerCase();
|
|
13
|
+
const rect = el.getBoundingClientRect();
|
|
14
|
+
|
|
15
|
+
if (rect.width === 0 && rect.height === 0 && tag !== 'body') return;
|
|
16
|
+
|
|
17
|
+
styles.push({
|
|
18
|
+
tag,
|
|
19
|
+
text: (el.textContent || '').trim().slice(0, 100),
|
|
20
|
+
backgroundColor: cs.backgroundColor,
|
|
21
|
+
color: cs.color,
|
|
22
|
+
borderColor: cs.borderColor,
|
|
23
|
+
fontFamily: cs.fontFamily,
|
|
24
|
+
fontSize: cs.fontSize,
|
|
25
|
+
fontWeight: cs.fontWeight,
|
|
26
|
+
lineHeight: cs.lineHeight,
|
|
27
|
+
letterSpacing: cs.letterSpacing,
|
|
28
|
+
padding: cs.padding,
|
|
29
|
+
margin: cs.margin,
|
|
30
|
+
gap: cs.gap,
|
|
31
|
+
borderRadius: cs.borderRadius,
|
|
32
|
+
boxShadow: cs.boxShadow,
|
|
33
|
+
backdropFilter: cs.backdropFilter,
|
|
34
|
+
cursor: cs.cursor,
|
|
35
|
+
zIndex: cs.zIndex,
|
|
36
|
+
width: rect.width,
|
|
37
|
+
height: rect.height,
|
|
38
|
+
isButton: tag === 'button' || el.getAttribute('role') === 'button',
|
|
39
|
+
isInput: tag === 'input' || tag === 'textarea' || tag === 'select',
|
|
40
|
+
isHeading: /^h[1-6]$/.test(tag),
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Collect declared fonts from <link> tags
|
|
45
|
+
const declaredFonts = [];
|
|
46
|
+
document.querySelectorAll('link[rel="stylesheet"]').forEach(l => {
|
|
47
|
+
declaredFonts.push(l.href);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Collect @font-face declarations from stylesheets
|
|
51
|
+
const fontFaceFamilies = new Set();
|
|
52
|
+
try {
|
|
53
|
+
for (const sheet of document.styleSheets) {
|
|
54
|
+
try {
|
|
55
|
+
for (const rule of sheet.cssRules || []) {
|
|
56
|
+
if (rule.constructor.name === 'CSSFontFaceRule' && rule.style) {
|
|
57
|
+
const family = rule.style.getPropertyValue('font-family');
|
|
58
|
+
if (family) fontFaceFamilies.add(family.replace(/['"]/g, '').trim());
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Cross-origin stylesheets can't be inspected — skip
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// Some browsers throw on styleSheets access — skip
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { title, styles, declaredFonts, fontFaceFamilies: [...fontFaceFamilies] };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Collect pseudo-state variants for detected component elements.
|
|
75
|
+
* Forces :hover state and reads computed styles.
|
|
76
|
+
* @param {Object} page — Puppeteer page
|
|
77
|
+
* @param {Array<Object>} componentStyles — array of { styleSignature, componentName }
|
|
78
|
+
* @returns {Promise<Record<string, Object>>} — variant component tokens
|
|
79
|
+
*/
|
|
80
|
+
export async function collectPseudoStates(page, componentStyles) {
|
|
81
|
+
const variants = {};
|
|
82
|
+
|
|
83
|
+
for (const comp of componentStyles) {
|
|
84
|
+
// Hover state
|
|
85
|
+
try {
|
|
86
|
+
const hoverStyles = await page.evaluate((signature) => {
|
|
87
|
+
const elements = document.querySelectorAll('body, body *');
|
|
88
|
+
for (const el of elements) {
|
|
89
|
+
const cs = getComputedStyle(el);
|
|
90
|
+
const bg = cs.backgroundColor;
|
|
91
|
+
const rect = el.getBoundingClientRect();
|
|
92
|
+
const w = Math.round(rect.width);
|
|
93
|
+
const h = Math.round(rect.height);
|
|
94
|
+
|
|
95
|
+
if (bg === signature.bg &&
|
|
96
|
+
Math.abs(w - signature.width) < 10 &&
|
|
97
|
+
Math.abs(h - signature.height) < 10) {
|
|
98
|
+
return {
|
|
99
|
+
index: Array.from(document.querySelectorAll('body, body *')).indexOf(el),
|
|
100
|
+
exists: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { exists: false };
|
|
105
|
+
}, { bg: comp.backgroundColor, width: comp.width || 0, height: comp.height || 0 });
|
|
106
|
+
|
|
107
|
+
if (hoverStyles.exists) {
|
|
108
|
+
// Use Puppeteer's hover API (not page.evaluate for pseudo-classes)
|
|
109
|
+
const elements = await page.$$('body, body *');
|
|
110
|
+
const el = elements[hoverStyles.index];
|
|
111
|
+
if (el) {
|
|
112
|
+
await el.hover();
|
|
113
|
+
await page.waitForTimeout(100); // Wait for transition
|
|
114
|
+
|
|
115
|
+
const hoverComputed = await page.evaluate((idx) => {
|
|
116
|
+
const els = document.querySelectorAll('body, body *');
|
|
117
|
+
const target = els[idx];
|
|
118
|
+
if (!target) return null;
|
|
119
|
+
const cs = getComputedStyle(target);
|
|
120
|
+
return { backgroundColor: cs.backgroundColor, color: cs.color, borderColor: cs.borderColor, boxShadow: cs.boxShadow };
|
|
121
|
+
}, hoverStyles.index);
|
|
122
|
+
|
|
123
|
+
if (hoverComputed) {
|
|
124
|
+
const changed = {};
|
|
125
|
+
if (hoverComputed.backgroundColor !== comp.backgroundColor && hoverComputed.backgroundColor !== 'rgba(0, 0, 0, 0)') {
|
|
126
|
+
changed.backgroundColor = hoverComputed.backgroundColor;
|
|
127
|
+
}
|
|
128
|
+
if (hoverComputed.color !== comp.textColor) {
|
|
129
|
+
changed.textColor = hoverComputed.color;
|
|
130
|
+
}
|
|
131
|
+
if (hoverComputed.borderColor !== comp.borderColor) {
|
|
132
|
+
changed.borderColor = hoverComputed.borderColor;
|
|
133
|
+
}
|
|
134
|
+
if (Object.keys(changed).length > 0) {
|
|
135
|
+
variants[`${comp.componentName}-hover`] = changed;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Move mouse away to reset
|
|
140
|
+
await page.mouse.move(0, 0);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Pseudo-state detection is best-effort. Skip on failure.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return variants;
|
|
149
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
function extract(st) { const t = {}; if (st.backgroundColor && st.backgroundColor !== 'rgba(0, 0, 0, 0)') t.backgroundColor = st.backgroundColor; if (st.color && st.color !== 'rgba(0, 0, 0, 0)') t.textColor = st.color; if (st.borderRadius && st.borderRadius !== '0px') t.rounded = st.borderRadius; if (st.padding && st.padding !== '0px') { const p = st.padding.split(/\s+/); t.padding = p.length === 2 && p[0] === p[1] ? p[0] : st.padding; } if (st.height > 0) t.height = `${Math.round(st.height)}px`; return t; }
|
|
2
|
+
export async function detectComponents(styles, page = null) { const u = []; const comp = {}; const pat = [];
|
|
3
|
+
const btnCands = styles.filter(s => { if (!s.width && !s.height) return false; return s.isButton || (s.width < 300 && s.height < 64 && s.height > 20 && s.backgroundColor && s.backgroundColor !== 'rgba(0, 0, 0, 0)' && s.borderRadius && s.borderRadius !== '0px' && s.text.length > 0 && s.text.length < 50); });
|
|
4
|
+
const uqBtns = new Map(); for (const c of btnCands) { const k = `${c.backgroundColor}|${c.color}|${c.borderRadius}`; if (!uqBtns.has(k)) uqBtns.set(k, c); }
|
|
5
|
+
const btns = [...uqBtns.values()]; if (btns.length) { comp['button-primary'] = extract(btns[0]); pat.push('button-primary'); if (btns.length > 1) { comp['button-secondary'] = extract(btns[1]); pat.push('button-secondary'); } }
|
|
6
|
+
const cardCands = styles.filter(s => s.backgroundColor && s.backgroundColor !== 'rgba(0, 0, 0, 0)' && s.borderRadius && s.borderRadius !== '0px' && s.padding && s.padding !== '0px' && (s.width > 200 || s.height > 100));
|
|
7
|
+
const uqCards = new Map(); for (const c of cardCands) { const k = `${c.backgroundColor}|${c.borderRadius}|${c.padding}`; if (!uqCards.has(k)) uqCards.set(k, c); }
|
|
8
|
+
const cards = [...uqCards.values()]; if (cards.length) { comp['card-standard'] = extract(cards[0]); pat.push('card-standard'); if (cards.length > 1) { comp['card-elevated'] = extract(cards[1]); pat.push('card-elevated'); } }
|
|
9
|
+
const inputs = styles.filter(s => s.isInput); if (inputs.length) { comp['input-field'] = extract(inputs[0]); pat.push('input-field'); }
|
|
10
|
+
if (!Object.keys(comp).length) u.push('No components detected.');
|
|
11
|
+
return { components: comp, detectedPatterns: pat, uncertain: u };
|
|
12
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// extract-design — entry point
|
|
3
|
+
// Orchestrates: Puppeteer → token classification → prose → DESIGN.md → lint → handoff
|
|
4
|
+
|
|
5
|
+
import { existsSync, renameSync, copyFileSync, unlinkSync } from 'node:fs';
|
|
6
|
+
import { basename, resolve } from 'node:path';
|
|
7
|
+
import { log } from './lib/logging.js';
|
|
8
|
+
import { BrowserExtractor } from './lib/browser.js';
|
|
9
|
+
import { DesignValidator } from './lib/validator.js';
|
|
10
|
+
import { classifyColors } from './classify-colors.js';
|
|
11
|
+
import { classifyTypography } from './classify-typography.js';
|
|
12
|
+
import { classifySpacing } from './classify-spacing.js';
|
|
13
|
+
import { classifyRounded } from './classify-rounded.js';
|
|
14
|
+
import { detectComponents } from './detect-components.js';
|
|
15
|
+
import { generateProse } from './generate-prose.js';
|
|
16
|
+
import { writeDESIGNmd } from './write-designd.js';
|
|
17
|
+
import { writeGrillMeHandoff } from './lib/state.js';
|
|
18
|
+
import { COVERAGE_MINIMUMS, OUTPUT_PATH } from './lib/constants.js';
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv) {
|
|
21
|
+
const a = { lintOnly: false };
|
|
22
|
+
for (let i = 2; i < argv.length; i++) {
|
|
23
|
+
if (argv[i] === '--source' && argv[i + 1]) a.source = argv[++i];
|
|
24
|
+
else if (argv[i] === '--name' && argv[i + 1]) a.name = argv[++i];
|
|
25
|
+
else if (argv[i] === '--lint-only') a.lintOnly = true;
|
|
26
|
+
}
|
|
27
|
+
return a;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildHints(styles, colors, components) {
|
|
31
|
+
const glass = styles.some(s => s.backdropFilter && s.backdropFilter !== 'none');
|
|
32
|
+
const darkBg = styles.some(s => {
|
|
33
|
+
if (s.tag !== 'body') return false;
|
|
34
|
+
const m = s.backgroundColor?.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
35
|
+
return m && parseInt(m[1]) + parseInt(m[2]) + parseInt(m[3]) < 100;
|
|
36
|
+
});
|
|
37
|
+
const ss = [];
|
|
38
|
+
if (glass) ss.push('glassmorphism');
|
|
39
|
+
if (darkBg) ss.push('dark');
|
|
40
|
+
const hasGrot = styles.some(s => {
|
|
41
|
+
const ff = (s.fontFamily || '').toLowerCase();
|
|
42
|
+
return ff.includes('inter') || ff.includes('helvetica') || ff.includes('grotesk') || ff.includes('archivo');
|
|
43
|
+
});
|
|
44
|
+
if (hasGrot && Object.keys(colors).length <= 8 && !glass) ss.push('swiss');
|
|
45
|
+
const ff = new Set(styles.map(s => s.fontFamily).filter(Boolean));
|
|
46
|
+
return {
|
|
47
|
+
dominantColorFamily: darkBg ? 'dark' : 'light',
|
|
48
|
+
styleSignals: ss.length ? ss : ['minimalist'],
|
|
49
|
+
fontCount: ff.size,
|
|
50
|
+
glassDetected: glass,
|
|
51
|
+
elevationStrategy: glass ? 'glass' : 'flat',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if the extraction returned usable data.
|
|
57
|
+
*/
|
|
58
|
+
function validateExtraction(light) {
|
|
59
|
+
const styles = light?.styles || [];
|
|
60
|
+
const hasStyledElements = styles.filter(s => {
|
|
61
|
+
const hasBg = s.backgroundColor && s.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
|
62
|
+
const hasText = s.fontFamily && s.fontSize;
|
|
63
|
+
return (s.width > 0 || s.height > 0) && (hasBg || hasText);
|
|
64
|
+
}).length;
|
|
65
|
+
|
|
66
|
+
if (styles.length === 0) {
|
|
67
|
+
return {
|
|
68
|
+
tier: 'Tier 2 — Degraded',
|
|
69
|
+
message: 'No styled elements found. The prototype may be empty, JS-rendered (SPA shell), or contain no CSS.',
|
|
70
|
+
fix: 'Verify the HTML file contains visible styled elements. For SPAs, use a published URL with server-side rendering.',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (hasStyledElements === 0) {
|
|
75
|
+
return {
|
|
76
|
+
tier: 'Tier 2 — Degraded',
|
|
77
|
+
message: `${styles.length} elements found but none have visible styles (all inherit/browser defaults).`,
|
|
78
|
+
fix: 'Add explicit CSS rules to the prototype before extraction.',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null; // OK
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function main() {
|
|
86
|
+
const args = parseArgs(process.argv);
|
|
87
|
+
const start = Date.now();
|
|
88
|
+
|
|
89
|
+
// --- Lint-only mode ---
|
|
90
|
+
if (args.lintOnly) {
|
|
91
|
+
if (!existsSync(OUTPUT_PATH)) {
|
|
92
|
+
log.user(`Error: DESIGN_LATEST.md not found at ${OUTPUT_PATH}. Run without --lint-only first.`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
const v = new DesignValidator();
|
|
96
|
+
const r = v.lint(OUTPUT_PATH);
|
|
97
|
+
log.user(formatLint(r));
|
|
98
|
+
process.exit(r.summary.errors > 0 ? 1 : 0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Validate inputs ---
|
|
102
|
+
if (!args.source) {
|
|
103
|
+
log.user('Usage: node extract-design/scripts/extract.js --source <file|url> [--name "Name"] [--lint-only]');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const name = args.name || basename(args.source, '.html');
|
|
108
|
+
|
|
109
|
+
// --- Check if this is an update (existing DESIGN_LATEST.md) ---
|
|
110
|
+
const isUpdate = existsSync(OUTPUT_PATH);
|
|
111
|
+
const oldSnapshotPath = isUpdate ? `${OUTPUT_PATH}.pre-update` : null;
|
|
112
|
+
|
|
113
|
+
if (isUpdate) {
|
|
114
|
+
copyFileSync(OUTPUT_PATH, oldSnapshotPath);
|
|
115
|
+
log.info('update-detected', { existingPath: OUTPUT_PATH, snapshot: oldSnapshotPath });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- Launch Puppeteer ---
|
|
119
|
+
let pm;
|
|
120
|
+
try { pm = await import('puppeteer-core'); } catch { try { pm = await import('puppeteer'); } catch {} }
|
|
121
|
+
|
|
122
|
+
if (!pm) {
|
|
123
|
+
log.user('Error: Puppeteer not found. Install with: npm install puppeteer');
|
|
124
|
+
log.user('Or if you have Chrome installed: npm install puppeteer-core');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const extractor = new BrowserExtractor({ puppeteer: pm.default || pm });
|
|
129
|
+
let extraction;
|
|
130
|
+
try {
|
|
131
|
+
extraction = await extractor.extract(args.source);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
log.user(`\nFATAL — Extraction failed:\n ${err.message}`);
|
|
134
|
+
log.user('\nTroubleshooting:');
|
|
135
|
+
log.user(' • Verify the source is accessible (URL reachable, file exists)');
|
|
136
|
+
log.user(' • For SPAs, try a server-rendered or published URL');
|
|
137
|
+
log.user(' • Check that Chrome/Chromium is installed (npm install puppeteer)');
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const styles = extraction.light.styles || [];
|
|
142
|
+
|
|
143
|
+
// --- Validate extraction quality ---
|
|
144
|
+
const validation = validateExtraction(extraction.light);
|
|
145
|
+
if (validation) {
|
|
146
|
+
log.user(`\n${validation.tier}`);
|
|
147
|
+
log.user(` ${validation.message}`);
|
|
148
|
+
log.user(` Fix: ${validation.fix}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Classify tokens ---
|
|
152
|
+
const cr = classifyColors(styles, 'light');
|
|
153
|
+
const tr = classifyTypography(styles);
|
|
154
|
+
const sr = classifySpacing(styles);
|
|
155
|
+
const rr = classifyRounded(styles);
|
|
156
|
+
const compR = await detectComponents(styles, null);
|
|
157
|
+
|
|
158
|
+
// --- Detect pseudo-state variants (while browser is open) ---
|
|
159
|
+
let pseudoVariants = {};
|
|
160
|
+
try {
|
|
161
|
+
const compArr = Object.entries(compR.components).map(([name, tokens]) => ({
|
|
162
|
+
componentName: name,
|
|
163
|
+
backgroundColor: tokens.backgroundColor,
|
|
164
|
+
textColor: tokens.textColor,
|
|
165
|
+
width: parseInt(tokens.width) || 0,
|
|
166
|
+
height: parseInt(tokens.height) || 0,
|
|
167
|
+
})).filter(c => c.backgroundColor);
|
|
168
|
+
|
|
169
|
+
if (compArr.length > 0) {
|
|
170
|
+
pseudoVariants = await extractor.detectPseudoStates(compArr);
|
|
171
|
+
Object.assign(compR.components, pseudoVariants);
|
|
172
|
+
if (Object.keys(pseudoVariants).length > 0) {
|
|
173
|
+
log.info('pseudo-variants-detected', { variantCount: Object.keys(pseudoVariants).length });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
log.warn('pseudo-states-skipped', { error: err.message });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- Font warnings ---
|
|
181
|
+
const fontWarnings = extraction.fontWarnings || [];
|
|
182
|
+
|
|
183
|
+
// --- Aggregate uncertain decisions ---
|
|
184
|
+
const allU = [
|
|
185
|
+
...cr.uncertain, ...tr.uncertain, ...sr.uncertain, ...rr.uncertain, ...compR.uncertain, ...fontWarnings,
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
const degraded = Object.keys(cr.colors).length < COVERAGE_MINIMUMS.MIN_COLORS ||
|
|
189
|
+
Object.keys(tr.typography).length < COVERAGE_MINIMUMS.MIN_TYPOGRAPHY_LEVELS;
|
|
190
|
+
|
|
191
|
+
const hints = buildHints(styles, cr.colors, compR.components);
|
|
192
|
+
|
|
193
|
+
const prose = generateProse({
|
|
194
|
+
name, colors: cr.colors, typography: tr.typography,
|
|
195
|
+
spacing: sr.spacing, rounded: rr.rounded,
|
|
196
|
+
components: compR.components, hints,
|
|
197
|
+
detectedPatterns: compR.detectedPatterns,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// --- Dark mode ---
|
|
201
|
+
let cd;
|
|
202
|
+
if (extraction.dark?.styles) {
|
|
203
|
+
const dr = classifyColors(extraction.dark.styles, 'dark');
|
|
204
|
+
if (Object.keys(dr.colors).length) cd = dr.colors;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- Write DESIGN.md ---
|
|
208
|
+
writeDESIGNmd({
|
|
209
|
+
name, colors: cr.colors, typography: tr.typography,
|
|
210
|
+
spacing: sr.spacing, rounded: rr.rounded,
|
|
211
|
+
components: compR.components, prose, colorsDark: cd,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// --- Diff on update ---
|
|
215
|
+
let diffResult = null;
|
|
216
|
+
if (isUpdate && oldSnapshotPath && existsSync(oldSnapshotPath)) {
|
|
217
|
+
const validator = new DesignValidator();
|
|
218
|
+
try {
|
|
219
|
+
diffResult = validator.diff(oldSnapshotPath, OUTPUT_PATH);
|
|
220
|
+
} catch {
|
|
221
|
+
// Diff unavailable — skip
|
|
222
|
+
}
|
|
223
|
+
// Clean up snapshot
|
|
224
|
+
try { unlinkSync(oldSnapshotPath); } catch {}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- Lint ---
|
|
228
|
+
const validator = new DesignValidator();
|
|
229
|
+
let lr;
|
|
230
|
+
try { lr = validator.lint(OUTPUT_PATH); } catch {}
|
|
231
|
+
|
|
232
|
+
// --- Terminal summary ---
|
|
233
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
234
|
+
log.user(`\n=== extract-design complete ===`);
|
|
235
|
+
log.user(`Design: ${name} | Output: ${OUTPUT_PATH}`);
|
|
236
|
+
log.user(`Colors: ${Object.keys(cr.colors).length} Typo: ${Object.keys(tr.typography).length} Spacing: ${Object.keys(sr.spacing).length} Rounded: ${Object.keys(rr.rounded).length} Components: ${Object.keys(compR.components).length}`);
|
|
237
|
+
if (Object.keys(pseudoVariants).length > 0) log.user(`Pseudo-states: ${Object.keys(pseudoVariants).length} variants detected`);
|
|
238
|
+
if (cd) log.user(`Dark mode: ${Object.keys(cd).length} colors (differs from light)`);
|
|
239
|
+
|
|
240
|
+
if (isUpdate && diffResult && !diffResult.skipped) {
|
|
241
|
+
log.user(`\n--- Diff (update) ---`);
|
|
242
|
+
log.user(formatDiff(diffResult));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (validation) log.user(`\n⚠️ ${validation.tier}: ${validation.message}`);
|
|
246
|
+
|
|
247
|
+
if (fontWarnings.length) {
|
|
248
|
+
log.user(`\n⚠️ Font declaration mismatches:`);
|
|
249
|
+
fontWarnings.forEach(w => log.user(` • ${w}`));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (allU.length) {
|
|
253
|
+
log.user(`\n⚠️ ${allU.length} uncertain decisions — run grill-me to validate:`);
|
|
254
|
+
allU.slice(0, 5).forEach(d => log.user(` • ${d}`));
|
|
255
|
+
if (allU.length > 5) log.user(` ... and ${allU.length - 5} more`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (lr && !lr.skipped) log.user(`\n${formatLint(lr)}`);
|
|
259
|
+
else log.user('\nValidation skipped (@google/design.md not available).');
|
|
260
|
+
|
|
261
|
+
log.user(`\nDuration: ${elapsed}s | Next: grill-me`);
|
|
262
|
+
|
|
263
|
+
// --- Handoff ---
|
|
264
|
+
writeGrillMeHandoff({
|
|
265
|
+
tokenCount: Object.keys(cr.colors).length + Object.keys(tr.typography).length,
|
|
266
|
+
componentCount: Object.keys(compR.components).length,
|
|
267
|
+
uncertainCount: allU.length,
|
|
268
|
+
uncertainDecisions: allU.slice(0, 10),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
log.info('extraction-complete', {
|
|
272
|
+
totalDurationMs: Date.now() - start,
|
|
273
|
+
outputPath: OUTPUT_PATH,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
process.exit(degraded ? 2 : 0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function formatLint(r) {
|
|
280
|
+
if (r.skipped) return 'Lint skipped.';
|
|
281
|
+
return `Lint: ${r.summary.errors} errors, ${r.summary.warnings} warnings, ${r.summary.info} info`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function formatDiff(dr) {
|
|
285
|
+
const lines = [];
|
|
286
|
+
if (dr.tokens?.colors) {
|
|
287
|
+
const c = dr.tokens.colors;
|
|
288
|
+
if (c.added?.length) lines.push(` Colors added: ${c.added.join(', ')}`);
|
|
289
|
+
if (c.removed?.length) lines.push(` Colors removed: ${c.removed.join(', ')}`);
|
|
290
|
+
if (c.modified?.length) lines.push(` Colors modified: ${c.modified.join(', ')}`);
|
|
291
|
+
}
|
|
292
|
+
if (dr.tokens?.typography) {
|
|
293
|
+
const t = dr.tokens.typography;
|
|
294
|
+
if (t.added?.length) lines.push(` Typography added: ${t.added.join(', ')}`);
|
|
295
|
+
if (t.removed?.length) lines.push(` Typography removed: ${t.removed.join(', ')}`);
|
|
296
|
+
if (t.modified?.length) lines.push(` Typography modified: ${t.modified.join(', ')}`);
|
|
297
|
+
}
|
|
298
|
+
if (dr.regression) lines.push(' ⚠️ REGRESSION detected — more errors in new version.');
|
|
299
|
+
return lines.length ? lines.join('\n') : ' No token-level changes detected.';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
main().catch(err => {
|
|
303
|
+
log.user(`\nFATAL — Unexpected error:\n ${err.message}`);
|
|
304
|
+
log.error('unhandled', { error: err.message, stack: err.stack });
|
|
305
|
+
process.exit(1);
|
|
306
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AGENT_NOTE_LOW_CONFIDENCE } from './lib/constants.js';
|
|
2
|
+
export function generateProse(ctx) {
|
|
3
|
+
const { name, colors, typography, spacing, rounded, components, hints, detectedPatterns } = ctx;
|
|
4
|
+
const glass = hints?.glassDetected, dark = hints?.dominantColorFamily === 'dark', swiss = (hints?.styleSignals||[]).includes('swiss');
|
|
5
|
+
let personality = 'modern and functional';
|
|
6
|
+
if (glass && dark) personality = 'ethereal and depth-driven, using glassmorphism for layered space';
|
|
7
|
+
else if (swiss) personality = 'architectural and editorial, rooted in International Typographic Style';
|
|
8
|
+
else if (dark) personality = 'dark, sophisticated, and focused';
|
|
9
|
+
|
|
10
|
+
const secs = [];
|
|
11
|
+
secs.push(`## Overview\n\n${AGENT_NOTE_LOW_CONFIDENCE}\n\n${name} is a ${personality} design system.${glass ? ' The UI relies on frosted glass surfaces and blurred backgrounds to create physical depth through transparency.' : ''}${hints?.fontCount===1?' It uses a single type family.':''}`);
|
|
12
|
+
|
|
13
|
+
let cl = ['## Colors',''];
|
|
14
|
+
for (const [k,v] of Object.entries(colors||{})) { if (k==='surface') cl.push(`**Surface (${v}):** Foundational background.`); else if (k==='on-surface') cl.push(`**On Surface (${v}):** Primary text.`); else if (k==='tertiary') cl.push(`**Tertiary (${v}):** Primary accent for CTAs.`); else if (k==='error') cl.push(`**Error (${v}):** Destructive actions.`); else if (k.includes('container')) cl.push(`**${k} (${v}):** Elevated surface.`); else cl.push(`**${k} (${v}):** Used throughout.`); }
|
|
15
|
+
cl.push('','Maintain WCAG AA contrast (4.5:1).'); secs.push(cl.join('\n'));
|
|
16
|
+
|
|
17
|
+
let tl = ['## Typography',''];
|
|
18
|
+
for (const [k,v] of Object.entries(typography||{})) tl.push(`**${k}:** ${v.fontFamily} at ${v.fontSize}${v.fontWeight!=='400'?`, weight ${v.fontWeight}`:''}.`);
|
|
19
|
+
secs.push(tl.join('\n'));
|
|
20
|
+
|
|
21
|
+
if (spacing?.unit) secs.push(`## Layout\n\nLayout follows a ${spacing.unit} base grid${spacing.half?` with a ${spacing.half} half-step`:''}. Components use containment principles.`);
|
|
22
|
+
secs.push(`## Elevation & Depth\n\n${glass?'Depth is achieved through glassmorphism — frosted surfaces with backdrop blur.':'A flat design with hierarchy via typography, spacing, and color contrast.'}`);
|
|
23
|
+
const re = Object.entries(rounded||{}).filter(([k])=>k!=='none');
|
|
24
|
+
secs.push(`## Shapes\n\n${re.length?`Elements use rounded corners (${re.map(([,v])=>v).join(', ')}).`:'Sharp corners — engineered aesthetic.'}`);
|
|
25
|
+
|
|
26
|
+
let cpl = ['## Components','',AGENT_NOTE_LOW_CONFIDENCE,''];
|
|
27
|
+
if (detectedPatterns?.includes('button-primary')) cpl.push('**Buttons:** Primary buttons use accent color.','');
|
|
28
|
+
if (detectedPatterns?.includes('card-standard')) cpl.push('**Cards:** Containers with rounded corners group content.','');
|
|
29
|
+
if (detectedPatterns?.includes('input-field')) cpl.push('**Inputs:** Distinct background separates editable areas.','');
|
|
30
|
+
secs.push(cpl.join('\n'));
|
|
31
|
+
|
|
32
|
+
const dos = ['Maintain WCAG AA contrast','Use consistent spacing','Keep typography hierarchy clear','Treat interactive elements consistently'];
|
|
33
|
+
const donts = ["Don't mix rounded and sharp corners","Don't use more than two font weights per screen"];
|
|
34
|
+
if (glass) { dos.push('Use backdrop-filter on elevated surfaces'); donts.push("Don't use solid backgrounds on cards"); }
|
|
35
|
+
if (dark) donts.push("Don't introduce light backgrounds");
|
|
36
|
+
secs.push(`## Do's and Don'ts\n\n${AGENT_NOTE_LOW_CONFIDENCE}\n\n${dos.map(d=>'- '+d).join('\n')}\n${donts.map(d=>'- '+d).join('\n')}`);
|
|
37
|
+
return secs.join('\n\n');
|
|
38
|
+
}
|