bigpowers 2.42.0 → 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 +14 -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,206 @@
|
|
|
1
|
+
// BrowserExtractor — wraps Puppeteer behind a project-owned interface.
|
|
2
|
+
// Uses collect-styles.js for the browser-side sensor logic.
|
|
3
|
+
|
|
4
|
+
import { log } from './logging.js';
|
|
5
|
+
import { withRetry, withTimeout } from './retry.js';
|
|
6
|
+
import { collect, collectPseudoStates } from '../collect-styles.js';
|
|
7
|
+
import { CHROME_CI_FLAGS, TIMEOUTS } from './constants.js';
|
|
8
|
+
|
|
9
|
+
export class BrowserExtractor {
|
|
10
|
+
constructor({ puppeteer, chromePath, chromeFlags = [] }) {
|
|
11
|
+
this._puppeteer = puppeteer;
|
|
12
|
+
this._chromePath = chromePath;
|
|
13
|
+
this._chromeFlags = [...CHROME_CI_FLAGS, ...chromeFlags];
|
|
14
|
+
this._browser = null;
|
|
15
|
+
this._page = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Launch Chrome and extract styles with dual-pass (light + dark).
|
|
20
|
+
* Also collects pseudo-state variants and font declarations.
|
|
21
|
+
* @param {string} source — file path or URL
|
|
22
|
+
* @returns {Promise<Object>} — { light, dark (nullable), pseudoVariants, fontWarnings }
|
|
23
|
+
*/
|
|
24
|
+
async extract(source) {
|
|
25
|
+
log.info('extraction-start', { source, timestamp: new Date().toISOString() });
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
await this._launch();
|
|
29
|
+
const page = await this._openPage(source);
|
|
30
|
+
this._page = page;
|
|
31
|
+
|
|
32
|
+
// Light mode pass
|
|
33
|
+
const light = await collect(page);
|
|
34
|
+
log.info('page-loaded', {
|
|
35
|
+
colorCount: Object.keys(light.colors || {}).length,
|
|
36
|
+
styleCount: (light.styles || []).length,
|
|
37
|
+
declaredFonts: light.declaredFonts?.length || 0,
|
|
38
|
+
fontFaceFamilies: light.fontFaceFamilies?.length || 0,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Font declared-vs-computed check
|
|
42
|
+
const fontWarnings = this._checkFontDeclarations(light);
|
|
43
|
+
|
|
44
|
+
// Dark mode pass
|
|
45
|
+
await this._setColorScheme(page, 'dark');
|
|
46
|
+
const dark = await collect(page);
|
|
47
|
+
const darkDiffers = this._colorsDiffer(light.styles, dark.styles);
|
|
48
|
+
log.info('dark-mode-check', { darkDiffers, darkStyleCount: (dark.styles || []).length });
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
light,
|
|
52
|
+
dark: darkDiffers ? dark : null,
|
|
53
|
+
fontWarnings,
|
|
54
|
+
};
|
|
55
|
+
} finally {
|
|
56
|
+
await this.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Detect pseudo-state variants (:hover) for detected components.
|
|
62
|
+
* Must be called while the page is still open (before close()).
|
|
63
|
+
* @param {Array<Object>} components — component objects with backgroundColor, textColor, width, height, componentName
|
|
64
|
+
* @returns {Promise<Record<string, Object>>}
|
|
65
|
+
*/
|
|
66
|
+
async detectPseudoStates(components) {
|
|
67
|
+
if (!this._page) return {};
|
|
68
|
+
log.info('pseudo-states', { componentCount: components.length });
|
|
69
|
+
return collectPseudoStates(this._page, components);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async _launch() {
|
|
73
|
+
const opts = { headless: true, args: this._chromeFlags };
|
|
74
|
+
if (this._chromePath) opts.executablePath = this._chromePath;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
this._browser = await this._puppeteer.launch(opts);
|
|
78
|
+
log.info('browser-launched');
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err.message.includes('chrome') || err.message.includes('Chromium') || err.message.includes('executable')) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Chrome/Chromium not found. Install Puppeteer with: npm install puppeteer\n` +
|
|
83
|
+
`Or set CHROME_PATH env var to your Chrome binary. Error: ${err.message}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async _openPage(source) {
|
|
91
|
+
const page = await this._browser.newPage();
|
|
92
|
+
await page.setViewport({ width: 1440, height: 900 });
|
|
93
|
+
|
|
94
|
+
const goto = () => {
|
|
95
|
+
// Determine if source is a URL or file path
|
|
96
|
+
const isUrl = source.startsWith('http://') || source.startsWith('https://');
|
|
97
|
+
const target = isUrl ? source : `file://${source}`;
|
|
98
|
+
return page.goto(target, {
|
|
99
|
+
waitUntil: 'networkidle0',
|
|
100
|
+
timeout: TIMEOUTS.PAGE_LOAD_MS,
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await withTimeout(() => withRetry(goto), TIMEOUTS.PAGE_LOAD_MS + 5000);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (err.message.includes('Timeout') || err.message.includes('timed out')) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Page load timed out after ${TIMEOUTS.PAGE_LOAD_MS}ms.\n` +
|
|
110
|
+
`The prototype may be a SPA or have slow network requests. Error: ${err.message}`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return page;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async _setColorScheme(page, mode) {
|
|
120
|
+
await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: mode }]);
|
|
121
|
+
await page.evaluate(() => new Promise(r => requestAnimationFrame(r)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Compare declared fonts (from <link> + @font-face) against computed fontFamily.
|
|
126
|
+
* Returns warnings for fonts that were declared but not rendered.
|
|
127
|
+
*/
|
|
128
|
+
_checkFontDeclarations(extraction) {
|
|
129
|
+
const warnings = [];
|
|
130
|
+
const declared = new Set();
|
|
131
|
+
|
|
132
|
+
// Parse font names from Google Fonts URLs
|
|
133
|
+
for (const url of (extraction.declaredFonts || [])) {
|
|
134
|
+
const match = url.match(/family=([^:&]+)/);
|
|
135
|
+
if (match) {
|
|
136
|
+
for (const fam of decodeURIComponent(match[1]).split('|')) {
|
|
137
|
+
declared.add(fam.trim());
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Add @font-face families
|
|
143
|
+
for (const fam of (extraction.fontFaceFamilies || [])) {
|
|
144
|
+
declared.add(fam);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (declared.size === 0) return warnings;
|
|
148
|
+
|
|
149
|
+
// Get all computed font families
|
|
150
|
+
const computedFamilies = new Set();
|
|
151
|
+
for (const style of (extraction.styles || [])) {
|
|
152
|
+
if (style.fontFamily) {
|
|
153
|
+
// fontFamily may be a stack like "Inter, sans-serif" — take first
|
|
154
|
+
computedFamilies.add(style.fontFamily.split(',')[0].replace(/['"]/g, '').trim());
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check each declared font
|
|
159
|
+
for (const font of declared) {
|
|
160
|
+
const found = [...computedFamilies].some(cf =>
|
|
161
|
+
cf.toLowerCase() === font.toLowerCase() ||
|
|
162
|
+
cf.toLowerCase().includes(font.toLowerCase())
|
|
163
|
+
);
|
|
164
|
+
if (!found) {
|
|
165
|
+
warnings.push(
|
|
166
|
+
`Font "${font}" was declared in <link>/@font-face but not rendered. ` +
|
|
167
|
+
`Check font loading (CDN may be blocked, or font name mismatch). ` +
|
|
168
|
+
`Computed fonts: ${[...computedFamilies].join(', ')}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (warnings.length > 0) {
|
|
174
|
+
log.warn('font-mismatch', { declaredCount: declared.size, computedCount: computedFamilies.size, warnings });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return warnings;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_colorsDiffer(lightStyles, darkStyles) {
|
|
181
|
+
const lightColors = new Set();
|
|
182
|
+
const darkColors = new Set();
|
|
183
|
+
|
|
184
|
+
for (const s of (lightStyles || [])) {
|
|
185
|
+
if (s.backgroundColor && s.backgroundColor !== 'rgba(0, 0, 0, 0)') lightColors.add(s.backgroundColor);
|
|
186
|
+
if (s.color && s.color !== 'rgba(0, 0, 0, 0)') lightColors.add(s.color);
|
|
187
|
+
}
|
|
188
|
+
for (const s of (darkStyles || [])) {
|
|
189
|
+
if (s.backgroundColor && s.backgroundColor !== 'rgba(0, 0, 0, 0)') darkColors.add(s.backgroundColor);
|
|
190
|
+
if (s.color && s.color !== 'rgba(0, 0, 0, 0)') darkColors.add(s.color);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return JSON.stringify([...lightColors].sort()) !== JSON.stringify([...darkColors].sort());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async close() {
|
|
197
|
+
if (this._page) {
|
|
198
|
+
this._page = null;
|
|
199
|
+
}
|
|
200
|
+
if (this._browser) {
|
|
201
|
+
await this._browser.close();
|
|
202
|
+
this._browser = null;
|
|
203
|
+
log.info('browser-closed');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const SURFACE_LEVELS = ['surface','surface-dim','surface-bright','surface-container-lowest','surface-container-low','surface-container','surface-container-high','surface-container-highest'];
|
|
2
|
+
export const COLOR_ROLES = ['primary','on-primary','primary-container','on-primary-container','secondary','on-secondary','secondary-container','on-secondary-container','tertiary','on-tertiary','tertiary-container','on-tertiary-container','error','on-error','error-container','on-error-container','surface','on-surface','surface-variant','on-surface-variant','outline','outline-variant','inverse-surface','inverse-on-surface','inverse-primary','background','on-background'];
|
|
3
|
+
export const TYPOGRAPHY_LEVELS = ['display-lg','display-md','display-sm','headline-lg','headline-md','headline-sm','title-lg','title-md','title-sm','body-lg','body-md','body-sm','label-lg','label-md','label-sm'];
|
|
4
|
+
export const CHROME_CI_FLAGS = ['--headless=new','--no-sandbox','--disable-gpu','--disable-dbus','--use-gl=angle','--use-angle=swiftshader'];
|
|
5
|
+
export const TIMEOUTS = { PAGE_LOAD_MS: 30_000, PSEUDO_STATE_MS: 10_000 };
|
|
6
|
+
export const RETRY_CONFIG = { MAX_ATTEMPTS: 3, BASE_DELAY_MS: 1_000 };
|
|
7
|
+
export const COVERAGE_MINIMUMS = { MIN_COLORS: 2, MIN_TYPOGRAPHY_LEVELS: 1 };
|
|
8
|
+
export const OUTPUT_PATH = 'specs/tech-architecture/DESIGN_LATEST.md';
|
|
9
|
+
export const AGENT_NOTE_LOW_CONFIDENCE = '<!-- AGENT NOTE: Generated from visual analysis. Grill-me should validate. -->';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { writeSync } from 'node:fs';
|
|
2
|
+
function fmt(l,e,d={}){return JSON.stringify({level:l,event:e,timestamp:new Date().toISOString(),...d})+'\n'}
|
|
3
|
+
export const log = {
|
|
4
|
+
info(e,d={}){writeSync(2,fmt('info',e,d))},
|
|
5
|
+
warn(e,d={}){writeSync(2,fmt('warn',e,d))},
|
|
6
|
+
error(e,d={}){writeSync(2,fmt('error',e,d))},
|
|
7
|
+
user(m){writeSync(1,m+'\n')},
|
|
8
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { log } from './logging.js';
|
|
2
|
+
import { RETRY_CONFIG } from './constants.js';
|
|
3
|
+
export async function withRetry(fn, opts = {}) {
|
|
4
|
+
const max = opts.maxAttempts ?? RETRY_CONFIG.MAX_ATTEMPTS;
|
|
5
|
+
const base = opts.baseDelayMs ?? RETRY_CONFIG.BASE_DELAY_MS;
|
|
6
|
+
for (let a = 1; a <= max; a++) {
|
|
7
|
+
try { return await fn(); }
|
|
8
|
+
catch (e) { if (a === max) throw e; const d = base * Math.pow(2, a - 1); log.warn('retry', { attempt: a, max, delayMs: d, error: e.message }); await new Promise(r => setTimeout(r, d)); }
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export async function withTimeout(fn, ms) {
|
|
12
|
+
return Promise.race([fn(), new Promise((_, r) => setTimeout(() => r(new Error(`Timed out after ${ms}ms`)), ms))]);
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { log } from './logging.js';
|
|
4
|
+
const STATE_PATH = 'specs/state.yaml';
|
|
5
|
+
|
|
6
|
+
export function writeGrillMeHandoff(ctx = {}) {
|
|
7
|
+
const ul = (ctx.uncertainDecisions || []).map(d => ` - ${d}`).join('\n');
|
|
8
|
+
const hb = ['handoff:',' last_step_completed: >',` extract-design completed. DESIGN_LATEST.md written with ${ctx.tokenCount || '?'} tokens, ${ctx.componentCount || '?'} components.`,` ${ctx.uncertainCount || 0} uncertain decisions flagged.`,' required_reading:',' - specs/tech-architecture/DESIGN_LATEST.md',' next_skill: grill-me'];
|
|
9
|
+
if (ul) { hb.push(' uncertain_decisions:'); hb.push(ul); }
|
|
10
|
+
try {
|
|
11
|
+
const d = dirname(STATE_PATH); if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
12
|
+
let c = existsSync(STATE_PATH) ? readFileSync(STATE_PATH, 'utf8') : '';
|
|
13
|
+
if (!c.includes('next_skill:')) { c = c.trimEnd() + '\n' + hb.join('\n') + '\n'; writeFileSync(STATE_PATH, c, 'utf8'); log.info('handoff-written', { nextSkill: 'grill-me' }); }
|
|
14
|
+
} catch (e) { log.warn('handoff-failed', { error: e.message }); }
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { log } from './logging.js';
|
|
4
|
+
const BIN = 'npx @google/design.md';
|
|
5
|
+
|
|
6
|
+
export class DesignValidator {
|
|
7
|
+
constructor({ runCommand } = {}) { this._run = runCommand || ((c) => execSync(c, { encoding: 'utf8', timeout: 30_000 })); this._avail = null; }
|
|
8
|
+
isAvailable() { if (this._avail !== null) return this._avail; try { this._run('npx -y @google/design.md --help'); this._avail = true; } catch { this._avail = false; } return this._avail; }
|
|
9
|
+
lint(fp) { if (!this.isAvailable()) return { summary: { errors: 0, warnings: 0, info: 0 }, findings: [], skipped: true }; try { return JSON.parse(this._run(`${BIN} lint --format json ${fp}`)); } catch(e) { if (e.stdout) try { return JSON.parse(e.stdout); } catch {} throw e; } }
|
|
10
|
+
diff(a, b) { if (!this.isAvailable()) return { skipped: true }; return JSON.parse(this._run(`${BIN} diff --format json ${a} ${b}`)); }
|
|
11
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { OUTPUT_PATH } from './lib/constants.js';
|
|
4
|
+
|
|
5
|
+
export function writeDESIGNmd({ name, version = 'alpha', colors, typography, spacing, rounded, components, prose, colorsDark, outputPath = OUTPUT_PATH }) {
|
|
6
|
+
const y = []; y.push(`version: ${version}`, `name: ${name}`);
|
|
7
|
+
if (colors && Object.keys(colors).length) { y.push('colors:'); for (const [k,v] of Object.entries(colors)) y.push(` ${k}: "${v}"`); }
|
|
8
|
+
if (colorsDark && Object.keys(colorsDark).length) { y.push('colors-dark:'); for (const [k,v] of Object.entries(colorsDark)) y.push(` ${k}: "${v}"`); }
|
|
9
|
+
if (typography && Object.keys(typography).length) { y.push('typography:'); for (const [k,v] of Object.entries(typography)) { y.push(` ${k}:`); for (const [p,val] of Object.entries(v)) { if (val !== undefined && val !== null && val !== '') { const q = String(val).startsWith('#')||String(val).startsWith('rgba')?`"${val}"`:val; y.push(` ${p}: ${q}`); } } } }
|
|
10
|
+
if (spacing && Object.keys(spacing).length) { y.push('spacing:'); for (const [k,v] of Object.entries(spacing)) y.push(` ${k}: "${v}"`); }
|
|
11
|
+
if (rounded && Object.keys(rounded).length) { y.push('rounded:'); for (const [k,v] of Object.entries(rounded)) y.push(` ${k}: "${v}"`); }
|
|
12
|
+
if (components && Object.keys(components).length) { y.push('components:'); for (const [k,v] of Object.entries(components)) { y.push(` ${k}:`); for (const [p,val] of Object.entries(v)) { if (val !== undefined && val !== null && val !== '') { const q = String(val).startsWith('#')||String(val).startsWith('rgba')?`"${val}"`:val; y.push(` ${p}: ${q}`); } } } }
|
|
13
|
+
const content = `---\n${y.join('\n')}\n---\n\n${prose}\n`;
|
|
14
|
+
const d = dirname(outputPath); if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
15
|
+
writeFileSync(outputPath, content, 'utf8'); return outputPath;
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"Atmospheric Glass","colors":["rgba(255, 255, 255, 0.1)","rgba(255, 255, 255, 0.2)","#FFFFFF","#DAE2FD","#2F3131"],"typography":["Inter"],"minSpacingValues":3,"hasComponents":true}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Atmospheric Glass</title>
|
|
2
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700" rel="stylesheet">
|
|
3
|
+
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:'Inter',sans-serif;background:linear-gradient(135deg,#1E3A8A,#7E22CE,#DB2777);color:#DAE2FD;min-height:100vh;padding:40px}.card{background:rgba(255,255,255,0.1);backdrop-filter:blur(20px);border-radius:16px;padding:24px;margin-bottom:16px;border:1px solid rgba(255,255,255,0.2)}.card-elevated{background:rgba(255,255,255,0.2);backdrop-filter:blur(40px);border-radius:24px;padding:24px}.btn-primary{background:#FFF;color:#2F3131;border:none;border-radius:24px;padding:0 24px;height:48px;font-family:Inter,sans-serif;cursor:pointer}input{background:rgba(255,255,255,0.1);color:#FFF;border-radius:24px;padding:20px;height:48px;font-family:Inter,sans-serif}</style></head><body>
|
|
4
|
+
<div class="card"><span>Temperature</span><div style="font-size:84px;font-weight:700;color:#FFF">24°</div><p>Partly cloudy</p></div>
|
|
5
|
+
<div class="card-elevated"><h2>Forecast</h2><p>Clear skies</p><button class="btn-primary">View</button></div>
|
|
6
|
+
<div class="card"><h2>Search</h2><input placeholder="Search..." /></div>
|
|
7
|
+
</body></html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"Untitled Design System","degraded":true}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"Swiss Grid","colors":["#FFFFFF","#111111","#E4002B","#F7F5F2","#6C7278"],"typography":["Inter","Space Mono"],"hasComponents":true}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Swiss Grid</title>
|
|
2
|
+
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:'Inter','Helvetica Neue',sans-serif;background:#FFF;color:#111;max-width:1200px;margin:0 auto;padding:48px}h1{font-size:48px;font-weight:700;line-height:1.1;letter-spacing:-0.02em;color:#111;margin-bottom:24px}h2{font-size:32px;font-weight:600;color:#111;margin-bottom:16px}p{font-size:16px;font-weight:400;line-height:1.5;color:#333}.caption{font-size:12px;font-family:'Space Mono',monospace;color:#6C7278;letter-spacing:0.05em}.btn{display:inline-block;background:#E4002B;color:#FFF;border:none;border-radius:4px;padding:8px 24px;font-size:14px;font-weight:600;cursor:pointer}.card{background:#F7F5F2;padding:32px;margin-bottom:24px}</style></head><body>
|
|
3
|
+
<h1>Swiss Design Principles</h1><p>The grid system is an aid, not a guarantee.</p>
|
|
4
|
+
<div class="card"><h2>Typography</h2><p>Flush-left, ragged-right.</p><span class="caption">Brockmann, 1981</span></div>
|
|
5
|
+
<div class="card"><h2>Palette</h2><p>White paper, near-black ink, one accent.</p><button class="btn">Learn</button></div>
|
|
6
|
+
</body></html>
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// test-extraction.js — verify command for extract-design
|
|
3
|
+
// Unit tests (all classifiers) + integration tests (Puppeteer, if available)
|
|
4
|
+
|
|
5
|
+
import { readFileSync, existsSync, unlinkSync } from 'node:fs';
|
|
6
|
+
import { dirname, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const FIXTURES = resolve(__dirname, 'fixtures');
|
|
11
|
+
|
|
12
|
+
let p = 0, f = 0, s = 0;
|
|
13
|
+
|
|
14
|
+
function t(name, fn) { try { fn(); p++; console.log(` ✓ ${name}`); } catch (e) { f++; console.log(` ✗ ${name}: ${e.message}`); } }
|
|
15
|
+
function skip(name, reason) { s++; console.log(` - ${name} (skipped: ${reason})`); }
|
|
16
|
+
function assert(c, m) { if (!c) throw new Error(m || 'Assertion failed'); }
|
|
17
|
+
function includes(text, sub, m) { assert(text.includes(sub), m || `Expected "${sub}" in text`); }
|
|
18
|
+
|
|
19
|
+
// --- Check Puppeteer availability ---
|
|
20
|
+
function checkPuppeteer() {
|
|
21
|
+
try { import.meta.resolve('puppeteer'); return 'puppeteer'; }
|
|
22
|
+
catch { try { import.meta.resolve('puppeteer-core'); return 'puppeteer-core'; } catch { return null; } }
|
|
23
|
+
}
|
|
24
|
+
const puppeteerPkg = checkPuppeteer();
|
|
25
|
+
|
|
26
|
+
// ================================================================
|
|
27
|
+
// Unit tests
|
|
28
|
+
// ================================================================
|
|
29
|
+
console.log('\nUnit tests\n');
|
|
30
|
+
|
|
31
|
+
t('classifyColors returns valid structure', async () => {
|
|
32
|
+
const { classifyColors } = await import('../scripts/classify-colors.js');
|
|
33
|
+
const r = classifyColors([], 'light');
|
|
34
|
+
assert(r.colors && typeof r.colors === 'object');
|
|
35
|
+
assert(Array.isArray(r.uncertain));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
t('classifyTypography returns valid structure', async () => {
|
|
39
|
+
const { classifyTypography } = await import('../scripts/classify-typography.js');
|
|
40
|
+
const r = classifyTypography([]);
|
|
41
|
+
assert(r.typography && typeof r.typography === 'object');
|
|
42
|
+
assert(Array.isArray(r.uncertain));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
t('classifySpacing returns valid structure', async () => {
|
|
46
|
+
const { classifySpacing } = await import('../scripts/classify-spacing.js');
|
|
47
|
+
assert((classifySpacing([])).spacing);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
t('classifyRounded returns valid structure', async () => {
|
|
51
|
+
const { classifyRounded } = await import('../scripts/classify-rounded.js');
|
|
52
|
+
assert((classifyRounded([])).rounded);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
t('colors cluster neutrals vs accents', async () => {
|
|
56
|
+
const { classifyColors } = await import('../scripts/classify-colors.js');
|
|
57
|
+
const m = [
|
|
58
|
+
{ backgroundColor: 'rgba(255,255,255,1)', color: 'rgba(0,0,0,1)', borderColor: 'rgba(0,0,0,0)', width: 1440, height: 900, text: '' },
|
|
59
|
+
{ backgroundColor: 'rgba(255,0,0,1)', color: 'rgba(255,255,255,1)', borderColor: 'rgba(0,0,0,0)', width: 100, height: 48, text: 'Click' },
|
|
60
|
+
];
|
|
61
|
+
const r = classifyColors(m, 'light');
|
|
62
|
+
assert(Object.keys(r.colors).length >= 2, `Expected >=2 colors, got ${Object.keys(r.colors).length}`);
|
|
63
|
+
assert(r.colors.surface, 'Expected surface color');
|
|
64
|
+
assert(r.colors.tertiary, 'Expected accent color (tertiary)');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
t('dark mode classification differs from light', async () => {
|
|
68
|
+
const { classifyColors } = await import('../scripts/classify-colors.js');
|
|
69
|
+
// Dark mode: dark bg = surface, light text = on-surface, bright accent
|
|
70
|
+
const m = [
|
|
71
|
+
{ backgroundColor: 'rgba(10,10,30,1)', color: 'rgba(220,220,240,1)', borderColor: 'rgba(0,0,0,0)', width: 1440, height: 900, text: '' },
|
|
72
|
+
{ backgroundColor: 'rgba(200,200,210,1)', color: 'rgba(10,10,30,1)', borderColor: 'rgba(0,0,0,0)', width: 300, height: 200, text: 'Card' },
|
|
73
|
+
{ backgroundColor: 'rgba(100,150,255,1)', color: 'rgba(255,255,255,1)', borderColor: 'rgba(0,0,0,0)', width: 120, height: 48, text: 'Click' },
|
|
74
|
+
];
|
|
75
|
+
const light = classifyColors(m, 'light');
|
|
76
|
+
const dark = classifyColors(m, 'dark');
|
|
77
|
+
assert(light.colors.surface !== dark.colors.surface || Object.keys(light.colors).length > 1,
|
|
78
|
+
'Dark mode should produce different or equivalent color assignments');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
t('typography detects Inter from mock styles', async () => {
|
|
82
|
+
const { classifyTypography } = await import('../scripts/classify-typography.js');
|
|
83
|
+
const m = [
|
|
84
|
+
{ text: 'Heading', fontFamily: 'Inter', fontSize: '48px', fontWeight: '700', lineHeight: '1.1', letterSpacing: '-0.02em', tag: 'h1' },
|
|
85
|
+
{ text: 'Body', fontFamily: 'Inter', fontSize: '16px', fontWeight: '400', lineHeight: '1.5', letterSpacing: 'normal', tag: 'p' },
|
|
86
|
+
{ text: 'Caption', fontFamily: 'Inter', fontSize: '12px', fontWeight: '600', lineHeight: '1.33', letterSpacing: '0.05em', tag: 'span' },
|
|
87
|
+
];
|
|
88
|
+
const r = classifyTypography(m);
|
|
89
|
+
assert(Object.keys(r.typography).length >= 2, `Expected >=2 levels, got ${Object.keys(r.typography).length}`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
t('spacing computes GCD correctly', async () => {
|
|
93
|
+
const { classifySpacing } = await import('../scripts/classify-spacing.js');
|
|
94
|
+
const m = [];
|
|
95
|
+
for (let i = 0; i < 4; i++) m.push({ padding: '16px', margin: '8px', gap: '8px' });
|
|
96
|
+
const r = classifySpacing(m);
|
|
97
|
+
assert(r.spacing.unit === '8px', `Expected 8px, got ${r.spacing.unit}`);
|
|
98
|
+
assert(r.spacing.sm === '16px', `Expected 16px sm, got ${r.spacing.sm}`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
t('spacing detects half-step', async () => {
|
|
102
|
+
const { classifySpacing } = await import('../scripts/classify-spacing.js');
|
|
103
|
+
const m = [];
|
|
104
|
+
for (let i = 0; i < 4; i++) m.push({ padding: '16px', margin: '4px', gap: '4px' });
|
|
105
|
+
const r = classifySpacing(m);
|
|
106
|
+
assert(r.spacing.half || r.spacing.unit, 'Should detect unit or half-step');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
t('rounded detects full pill value', async () => {
|
|
110
|
+
const { classifyRounded } = await import('../scripts/classify-rounded.js');
|
|
111
|
+
const m = [{ borderRadius: '16px' },{ borderRadius: '16px' },{ borderRadius: '9999px' },{ borderRadius: '9999px' }];
|
|
112
|
+
assert(classifyRounded(m).rounded.full);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
t('rounded detects none for zero-radius', async () => {
|
|
116
|
+
const { classifyRounded } = await import('../scripts/classify-rounded.js');
|
|
117
|
+
assert(classifyRounded([{ borderRadius: '0px' },{ borderRadius: '0px' }]).rounded.none);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
t('detectComponents finds buttons and cards', async () => {
|
|
121
|
+
const { detectComponents } = await import('../scripts/detect-components.js');
|
|
122
|
+
const m = [
|
|
123
|
+
{ isButton: true, width: 120, height: 48, backgroundColor: 'rgba(255,255,255,1)', color: 'rgba(0,0,0,1)', borderRadius: '24px', padding: '0 24px', text: 'Click' },
|
|
124
|
+
{ isButton: false, width: 400, height: 200, backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: '16px', padding: '24px', text: '' },
|
|
125
|
+
{ isInput: true, width: 300, height: 48, backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: '24px', padding: '20px', text: '' },
|
|
126
|
+
];
|
|
127
|
+
const r = await detectComponents(m);
|
|
128
|
+
assert(Object.keys(r.components).length >= 2, `Expected >=2 components, got ${Object.keys(r.components).length}`);
|
|
129
|
+
assert(r.detectedPatterns.includes('button-primary') || r.detectedPatterns.includes('input-field'));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
t('detectComponents returns empty on unstyleable input', async () => {
|
|
133
|
+
const { detectComponents } = await import('../scripts/detect-components.js');
|
|
134
|
+
const r = await detectComponents([]);
|
|
135
|
+
assert(Object.keys(r.components).length === 0);
|
|
136
|
+
assert(r.uncertain.length > 0, 'Should flag no components detected');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
t('generateProse produces all 8 sections', async () => {
|
|
140
|
+
const { generateProse } = await import('../scripts/generate-prose.js');
|
|
141
|
+
const prose = generateProse({
|
|
142
|
+
name: 'Test', colors: { surface: '#FFF', 'on-surface': '#000', tertiary: '#E4002B' },
|
|
143
|
+
typography: { 'body-md': { fontFamily: 'Inter', fontSize: '16px', fontWeight: '400' } },
|
|
144
|
+
spacing: { unit: '8px' }, rounded: { sm: '4px' }, components: {},
|
|
145
|
+
hints: { dominantColorFamily: 'light', styleSignals: ['swiss'], fontCount: 1, glassDetected: false },
|
|
146
|
+
detectedPatterns: [],
|
|
147
|
+
});
|
|
148
|
+
['## Overview','## Colors','## Typography','## Layout','## Elevation','## Shapes','## Components',"## Do's and Don'ts"]
|
|
149
|
+
.forEach(sec => assert(prose.includes(sec), `Missing: ${sec}`));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
t('generateProse handles glassmorphism style', async () => {
|
|
153
|
+
const { generateProse } = await import('../scripts/generate-prose.js');
|
|
154
|
+
const prose = generateProse({
|
|
155
|
+
name: 'Glass', colors: { surface: '#0B1326' },
|
|
156
|
+
typography: {}, spacing: {}, rounded: {}, components: {},
|
|
157
|
+
hints: { dominantColorFamily: 'dark', styleSignals: ['glassmorphism'], fontCount: 1, glassDetected: true },
|
|
158
|
+
detectedPatterns: [],
|
|
159
|
+
});
|
|
160
|
+
assert(prose.includes('glassmorphism') || prose.includes('frosted'), 'Should mention glass/glassmorphism');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
t('writeDESIGNmd produces valid DESIGN.md file', async () => {
|
|
164
|
+
const { writeDESIGNmd } = await import('../scripts/write-designd.js');
|
|
165
|
+
const tmp = '/tmp/ed-test-output.md';
|
|
166
|
+
writeDESIGNmd({
|
|
167
|
+
name: 'Test System', version: 'alpha',
|
|
168
|
+
colors: { primary: '#111', surface: '#FFF' },
|
|
169
|
+
typography: { 'body-md': { fontFamily: 'Inter', fontSize: '16px', fontWeight: '400' } },
|
|
170
|
+
spacing: { unit: '8px' }, rounded: { sm: '4px' },
|
|
171
|
+
components: { 'button-primary': { backgroundColor: '#E4002B', textColor: '#FFF', rounded: '4px' } },
|
|
172
|
+
prose: '## Overview\n\nTest.\n',
|
|
173
|
+
outputPath: tmp,
|
|
174
|
+
});
|
|
175
|
+
const c = readFileSync(tmp, 'utf8');
|
|
176
|
+
assert(c.startsWith('---\n'), 'Should start with YAML');
|
|
177
|
+
assert(c.includes('name: Test System'));
|
|
178
|
+
assert(c.includes('version: alpha'));
|
|
179
|
+
assert(c.includes('primary: "#111"'));
|
|
180
|
+
assert(c.includes('## Overview'));
|
|
181
|
+
assert(c.includes('button-primary:'));
|
|
182
|
+
unlinkSync(tmp);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
t('writeDESIGNmd handles dark mode colors', async () => {
|
|
186
|
+
const { writeDESIGNmd } = await import('../scripts/write-designd.js');
|
|
187
|
+
const tmp = '/tmp/ed-test-dark.md';
|
|
188
|
+
writeDESIGNmd({
|
|
189
|
+
name: 'Dark', colors: { surface: '#111' }, typography: {}, spacing: {}, rounded: {}, components: {},
|
|
190
|
+
colorsDark: { surface: '#222', 'on-surface': '#EEE' },
|
|
191
|
+
prose: '## Overview\n\nDark.\n', outputPath: tmp,
|
|
192
|
+
});
|
|
193
|
+
const c = readFileSync(tmp, 'utf8');
|
|
194
|
+
assert(c.includes('colors-dark:'));
|
|
195
|
+
assert(c.includes('surface: "#222"'));
|
|
196
|
+
unlinkSync(tmp);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ================================================================
|
|
200
|
+
// Integration tests (requires Puppeteer)
|
|
201
|
+
// ================================================================
|
|
202
|
+
if (puppeteerPkg) {
|
|
203
|
+
console.log('\nIntegration tests (Puppeteer)\n');
|
|
204
|
+
|
|
205
|
+
t('BrowserExtractor launches and extracts', async () => {
|
|
206
|
+
const pm = await import(puppeteerPkg);
|
|
207
|
+
const { BrowserExtractor } = await import('../scripts/lib/browser.js');
|
|
208
|
+
const ex = new BrowserExtractor({ puppeteer: pm.default || pm });
|
|
209
|
+
const fixture = resolve(FIXTURES, 'swiss-grid/prototype.html');
|
|
210
|
+
const result = await ex.extract(fixture);
|
|
211
|
+
assert(result.light, 'Should have light mode extraction');
|
|
212
|
+
assert(result.light.styles && result.light.styles.length > 0, 'Should have styled elements');
|
|
213
|
+
assert(result.light.declaredFonts !== undefined, 'Should have declaredFonts');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
t('BrowserExtractor extracts glassmorphism fixture', async () => {
|
|
217
|
+
const pm = await import(puppeteerPkg);
|
|
218
|
+
const { BrowserExtractor } = await import('../scripts/lib/browser.js');
|
|
219
|
+
const ex = new BrowserExtractor({ puppeteer: pm.default || pm });
|
|
220
|
+
const fixture = resolve(FIXTURES, 'glassmorphism-dark/prototype.html');
|
|
221
|
+
const result = await ex.extract(fixture);
|
|
222
|
+
const styles = result.light.styles;
|
|
223
|
+
assert(styles.length > 0);
|
|
224
|
+
const hasGlass = styles.some(s => s.backdropFilter && s.backdropFilter !== 'none');
|
|
225
|
+
// Note: file:// may not load Google Fonts CDN — expected
|
|
226
|
+
assert(result.light.declaredFonts !== undefined, 'Should collect <link> declarations even if unloaded');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
t('BrowserExtractor handles minimal-no-styles fixture (degraded)', async () => {
|
|
230
|
+
const pm = await import(puppeteerPkg);
|
|
231
|
+
const { BrowserExtractor } = await import('../scripts/lib/browser.js');
|
|
232
|
+
const ex = new BrowserExtractor({ puppeteer: pm.default || pm });
|
|
233
|
+
const fixture = resolve(FIXTURES, 'minimal-no-styles/prototype.html');
|
|
234
|
+
const result = await ex.extract(fixture);
|
|
235
|
+
const styles = result.light.styles;
|
|
236
|
+
// Browser still computes defaults (transparent bg, inherit fonts)
|
|
237
|
+
assert(styles.length > 0, 'Should have DOM elements even without explicit styles');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
t('Dark mode pass runs without error', async () => {
|
|
241
|
+
const pm = await import(puppeteerPkg);
|
|
242
|
+
const { BrowserExtractor } = await import('../scripts/lib/browser.js');
|
|
243
|
+
const ex = new BrowserExtractor({ puppeteer: pm.default || pm });
|
|
244
|
+
const fixture = resolve(FIXTURES, 'swiss-grid/prototype.html');
|
|
245
|
+
const result = await ex.extract(fixture);
|
|
246
|
+
// Swiss grid is light-only — dark should be null or have styles
|
|
247
|
+
assert(result.dark === null || result.dark.styles !== undefined, 'Dark pass should complete');
|
|
248
|
+
});
|
|
249
|
+
} else {
|
|
250
|
+
console.log('\nIntegration tests (Puppeteer)\n');
|
|
251
|
+
skip('Puppeteer tests', 'Puppeteer not installed. Install with: npm install puppeteer');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ================================================================
|
|
255
|
+
// Hardening tests
|
|
256
|
+
// ================================================================
|
|
257
|
+
console.log('\nHardening tests\n');
|
|
258
|
+
|
|
259
|
+
t('all 15 script modules resolve imports correctly', async () => {
|
|
260
|
+
const scripts = [
|
|
261
|
+
'../scripts/lib/constants.js',
|
|
262
|
+
'../scripts/lib/logging.js',
|
|
263
|
+
'../scripts/lib/retry.js',
|
|
264
|
+
'../scripts/lib/state.js',
|
|
265
|
+
'../scripts/lib/browser.js',
|
|
266
|
+
'../scripts/lib/validator.js',
|
|
267
|
+
'../scripts/collect-styles.js',
|
|
268
|
+
'../scripts/classify-colors.js',
|
|
269
|
+
'../scripts/classify-typography.js',
|
|
270
|
+
'../scripts/classify-spacing.js',
|
|
271
|
+
'../scripts/classify-rounded.js',
|
|
272
|
+
'../scripts/detect-components.js',
|
|
273
|
+
'../scripts/generate-prose.js',
|
|
274
|
+
'../scripts/write-designd.js',
|
|
275
|
+
'../scripts/extract.js',
|
|
276
|
+
];
|
|
277
|
+
for (const script of scripts) {
|
|
278
|
+
try { await import(script); }
|
|
279
|
+
catch (e) {
|
|
280
|
+
if (e.code === 'ERR_MODULE_NOT_FOUND') throw new Error(`Broken import in ${script}: ${e.message}`);
|
|
281
|
+
if (!e.message.includes('puppeteer') && !e.message.includes('Cannot find package')) throw e;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
t('classifyColors handles empty styles gracefully', async () => {
|
|
287
|
+
const { classifyColors } = await import('../scripts/classify-colors.js');
|
|
288
|
+
const r = classifyColors([], 'light');
|
|
289
|
+
assert(Object.keys(r.colors).length === 0);
|
|
290
|
+
assert(r.uncertain.length > 0 || typeof r.uncertain === 'object');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
t('classifySpacing handles all-zero values', async () => {
|
|
294
|
+
const { classifySpacing } = await import('../scripts/classify-spacing.js');
|
|
295
|
+
const m = [];
|
|
296
|
+
for (let i = 0; i < 4; i++) m.push({ padding: '0px', margin: '0px', gap: '0px' });
|
|
297
|
+
assert(classifySpacing(m).spacing.unit);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
t('classifyRounded handles mixed px+rem units', async () => {
|
|
301
|
+
const { classifyRounded } = await import('../scripts/classify-rounded.js');
|
|
302
|
+
const m = [{ borderRadius: '16px' },{ borderRadius: '1rem' },{ borderRadius: '16px' },{ borderRadius: '1rem' }];
|
|
303
|
+
assert(classifyRounded(m).rounded.sm || classifyRounded(m).rounded.md);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ================================================================
|
|
307
|
+
// Summary
|
|
308
|
+
// ================================================================
|
|
309
|
+
console.log(`\n${'='.repeat(40)}`);
|
|
310
|
+
console.log(`${p} passed, ${f} failed, ${s} skipped`);
|
|
311
|
+
if (p === 0 && f === 0 && s > 0) {
|
|
312
|
+
console.log('All tests skipped. Install Puppeteer for full coverage.');
|
|
313
|
+
process.exit(0);
|
|
314
|
+
}
|
|
315
|
+
process.exit(f > 0 ? 1 : 0);
|