real-prototypes-skill 0.1.1 → 0.1.2

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.
@@ -0,0 +1,496 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Color Validator Module
5
+ *
6
+ * Validates that all colors used in generated prototype files
7
+ * match the design tokens extracted from the captured platform.
8
+ *
9
+ * Features:
10
+ * - Scans TSX/JSX/CSS files for color values
11
+ * - Extracts hex colors from inline styles, CSS, and Tailwind arbitrary values
12
+ * - Finds closest matching color in design tokens
13
+ * - Reports violations with line numbers and suggestions
14
+ *
15
+ * Usage:
16
+ * node color-validator.js --project <name>
17
+ * node color-validator.js --proto ./prototype --tokens ./references/design-tokens.json
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ // Patterns to find colors
24
+ const COLOR_PATTERNS = {
25
+ // Hex colors (#fff, #ffffff, #ffffffff)
26
+ hex: /#([0-9a-fA-F]{3,8})\b/g,
27
+
28
+ // RGB/RGBA
29
+ rgb: /rgba?\s*\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+)?\s*\)/g,
30
+
31
+ // HSL/HSLA
32
+ hsl: /hsla?\s*\(\s*(\d{1,3})\s*,\s*(\d{1,3})%?\s*,\s*(\d{1,3})%?\s*(?:,\s*[\d.]+)?\s*\)/g,
33
+
34
+ // Tailwind default colors (not allowed)
35
+ tailwindDefault: /\b(bg|text|border|ring|fill|stroke)-(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(\d{2,3})\b/g,
36
+
37
+ // Tailwind arbitrary values
38
+ tailwindArbitrary: /\[(#[0-9a-fA-F]{3,8})\]/g
39
+ };
40
+
41
+ // Named colors to avoid
42
+ const NAMED_COLORS = [
43
+ 'red', 'blue', 'green', 'yellow', 'orange', 'purple', 'pink',
44
+ 'gray', 'grey', 'black', 'white', 'cyan', 'magenta', 'lime',
45
+ 'maroon', 'navy', 'olive', 'teal', 'aqua', 'fuchsia', 'silver'
46
+ ];
47
+
48
+ class ColorValidator {
49
+ constructor(options = {}) {
50
+ this.designTokens = options.designTokens || null;
51
+ this.prototypeDir = options.prototypeDir || './prototype';
52
+ this.violations = [];
53
+ this.validColors = new Set();
54
+ this.colorMap = new Map(); // hex -> token name mapping
55
+ }
56
+
57
+ /**
58
+ * Load design tokens from file
59
+ */
60
+ loadDesignTokens(tokensPath) {
61
+ if (!fs.existsSync(tokensPath)) {
62
+ throw new Error(`Design tokens file not found: ${tokensPath}`);
63
+ }
64
+
65
+ this.designTokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
66
+ this.buildColorMap();
67
+ }
68
+
69
+ /**
70
+ * Build a map of valid colors from design tokens
71
+ */
72
+ buildColorMap() {
73
+ if (!this.designTokens) return;
74
+
75
+ const extractColors = (obj, prefix = '') => {
76
+ if (!obj || typeof obj !== 'object') return;
77
+
78
+ for (const [key, value] of Object.entries(obj)) {
79
+ const tokenName = prefix ? `${prefix}.${key}` : key;
80
+
81
+ if (typeof value === 'string' && value.startsWith('#')) {
82
+ const normalizedHex = this.normalizeHex(value);
83
+ this.validColors.add(normalizedHex);
84
+ this.colorMap.set(normalizedHex, tokenName);
85
+ } else if (typeof value === 'object') {
86
+ extractColors(value, tokenName);
87
+ }
88
+ }
89
+ };
90
+
91
+ // Extract from colors section
92
+ if (this.designTokens.colors) {
93
+ extractColors(this.designTokens.colors);
94
+ }
95
+
96
+ // Extract from raw colors if available
97
+ if (this.designTokens.rawColors) {
98
+ for (const color of this.designTokens.rawColors) {
99
+ const normalizedHex = this.normalizeHex(color.hex || color);
100
+ this.validColors.add(normalizedHex);
101
+ if (!this.colorMap.has(normalizedHex)) {
102
+ this.colorMap.set(normalizedHex, `raw.${normalizedHex}`);
103
+ }
104
+ }
105
+ }
106
+
107
+ // Also add black and white as they're always valid
108
+ this.validColors.add('#000000');
109
+ this.validColors.add('#ffffff');
110
+ this.colorMap.set('#000000', 'black');
111
+ this.colorMap.set('#ffffff', 'white');
112
+ }
113
+
114
+ /**
115
+ * Normalize hex color to 6-digit lowercase format
116
+ */
117
+ normalizeHex(hex) {
118
+ if (!hex) return null;
119
+
120
+ let cleaned = hex.toLowerCase().replace('#', '');
121
+
122
+ // Convert 3-digit to 6-digit
123
+ if (cleaned.length === 3) {
124
+ cleaned = cleaned.split('').map(c => c + c).join('');
125
+ }
126
+
127
+ // Handle 8-digit (with alpha)
128
+ if (cleaned.length === 8) {
129
+ cleaned = cleaned.substring(0, 6);
130
+ }
131
+
132
+ return '#' + cleaned;
133
+ }
134
+
135
+ /**
136
+ * Convert RGB to hex
137
+ */
138
+ rgbToHex(r, g, b) {
139
+ return '#' + [r, g, b].map(x => {
140
+ const hex = parseInt(x, 10).toString(16);
141
+ return hex.length === 1 ? '0' + hex : hex;
142
+ }).join('');
143
+ }
144
+
145
+ /**
146
+ * Calculate color distance (simple Euclidean in RGB space)
147
+ */
148
+ colorDistance(hex1, hex2) {
149
+ const rgb1 = this.hexToRgb(hex1);
150
+ const rgb2 = this.hexToRgb(hex2);
151
+
152
+ if (!rgb1 || !rgb2) return Infinity;
153
+
154
+ return Math.sqrt(
155
+ Math.pow(rgb1.r - rgb2.r, 2) +
156
+ Math.pow(rgb1.g - rgb2.g, 2) +
157
+ Math.pow(rgb1.b - rgb2.b, 2)
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Convert hex to RGB object
163
+ */
164
+ hexToRgb(hex) {
165
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
166
+ return result ? {
167
+ r: parseInt(result[1], 16),
168
+ g: parseInt(result[2], 16),
169
+ b: parseInt(result[3], 16)
170
+ } : null;
171
+ }
172
+
173
+ /**
174
+ * Find closest matching color from design tokens
175
+ */
176
+ findClosestColor(hex) {
177
+ const normalizedHex = this.normalizeHex(hex);
178
+ let closestColor = null;
179
+ let closestDistance = Infinity;
180
+ let closestName = null;
181
+
182
+ for (const [validHex, tokenName] of this.colorMap.entries()) {
183
+ const distance = this.colorDistance(normalizedHex, validHex);
184
+ if (distance < closestDistance) {
185
+ closestDistance = distance;
186
+ closestColor = validHex;
187
+ closestName = tokenName;
188
+ }
189
+ }
190
+
191
+ return {
192
+ hex: closestColor,
193
+ name: closestName,
194
+ distance: closestDistance,
195
+ isExact: closestDistance === 0
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Validate a single file
201
+ */
202
+ validateFile(filePath) {
203
+ const content = fs.readFileSync(filePath, 'utf-8');
204
+ const lines = content.split('\n');
205
+ const relativePath = path.relative(this.prototypeDir, filePath);
206
+ const fileViolations = [];
207
+
208
+ lines.forEach((line, index) => {
209
+ const lineNumber = index + 1;
210
+
211
+ // Check for hex colors
212
+ const hexMatches = [...line.matchAll(COLOR_PATTERNS.hex)];
213
+ for (const match of hexMatches) {
214
+ const hex = this.normalizeHex(match[0]);
215
+ if (!this.validColors.has(hex)) {
216
+ const closest = this.findClosestColor(hex);
217
+ fileViolations.push({
218
+ file: relativePath,
219
+ line: lineNumber,
220
+ column: match.index + 1,
221
+ type: 'invalid-hex',
222
+ value: match[0],
223
+ normalized: hex,
224
+ suggestion: closest.hex,
225
+ suggestionName: closest.name,
226
+ distance: closest.distance,
227
+ context: line.trim().substring(0, 80)
228
+ });
229
+ }
230
+ }
231
+
232
+ // Check for RGB colors
233
+ const rgbMatches = [...line.matchAll(COLOR_PATTERNS.rgb)];
234
+ for (const match of rgbMatches) {
235
+ const hex = this.rgbToHex(match[1], match[2], match[3]);
236
+ const normalized = this.normalizeHex(hex);
237
+ if (!this.validColors.has(normalized)) {
238
+ const closest = this.findClosestColor(hex);
239
+ fileViolations.push({
240
+ file: relativePath,
241
+ line: lineNumber,
242
+ column: match.index + 1,
243
+ type: 'invalid-rgb',
244
+ value: match[0],
245
+ normalized: normalized,
246
+ suggestion: closest.hex,
247
+ suggestionName: closest.name,
248
+ distance: closest.distance,
249
+ context: line.trim().substring(0, 80)
250
+ });
251
+ }
252
+ }
253
+
254
+ // Check for Tailwind default colors (always a violation)
255
+ const tailwindMatches = [...line.matchAll(COLOR_PATTERNS.tailwindDefault)];
256
+ for (const match of tailwindMatches) {
257
+ fileViolations.push({
258
+ file: relativePath,
259
+ line: lineNumber,
260
+ column: match.index + 1,
261
+ type: 'tailwind-default',
262
+ value: match[0],
263
+ suggestion: 'Use inline style={{ }} with design token hex color',
264
+ context: line.trim().substring(0, 80)
265
+ });
266
+ }
267
+
268
+ // Check for Tailwind arbitrary values
269
+ const arbitraryMatches = [...line.matchAll(COLOR_PATTERNS.tailwindArbitrary)];
270
+ for (const match of arbitraryMatches) {
271
+ const hex = this.normalizeHex(match[1]);
272
+ if (!this.validColors.has(hex)) {
273
+ const closest = this.findClosestColor(hex);
274
+ fileViolations.push({
275
+ file: relativePath,
276
+ line: lineNumber,
277
+ column: match.index + 1,
278
+ type: 'invalid-arbitrary',
279
+ value: match[0],
280
+ normalized: hex,
281
+ suggestion: `[${closest.hex}]`,
282
+ suggestionName: closest.name,
283
+ distance: closest.distance,
284
+ context: line.trim().substring(0, 80)
285
+ });
286
+ }
287
+ }
288
+ });
289
+
290
+ this.violations.push(...fileViolations);
291
+ return fileViolations;
292
+ }
293
+
294
+ /**
295
+ * Recursively scan directory for files
296
+ */
297
+ scanDirectory(dir, extensions = ['.tsx', '.jsx', '.ts', '.js', '.css', '.scss']) {
298
+ if (!fs.existsSync(dir)) return;
299
+
300
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
301
+
302
+ for (const entry of entries) {
303
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
304
+
305
+ const fullPath = path.join(dir, entry.name);
306
+
307
+ if (entry.isDirectory()) {
308
+ this.scanDirectory(fullPath, extensions);
309
+ } else if (entry.isFile()) {
310
+ const ext = path.extname(entry.name).toLowerCase();
311
+ if (extensions.includes(ext)) {
312
+ this.validateFile(fullPath);
313
+ }
314
+ }
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Run validation on prototype directory
320
+ */
321
+ validate() {
322
+ this.violations = [];
323
+ this.scanDirectory(this.prototypeDir);
324
+ return this.violations;
325
+ }
326
+
327
+ /**
328
+ * Get validation summary
329
+ */
330
+ getSummary() {
331
+ const byType = {};
332
+ const byFile = {};
333
+
334
+ for (const violation of this.violations) {
335
+ byType[violation.type] = (byType[violation.type] || 0) + 1;
336
+ byFile[violation.file] = (byFile[violation.file] || 0) + 1;
337
+ }
338
+
339
+ return {
340
+ total: this.violations.length,
341
+ byType,
342
+ byFile,
343
+ validColorsCount: this.validColors.size,
344
+ passed: this.violations.length === 0
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Format violations for CLI output
350
+ */
351
+ formatViolations() {
352
+ if (this.violations.length === 0) {
353
+ return '\x1b[32m✓ No color violations found\x1b[0m';
354
+ }
355
+
356
+ const lines = [
357
+ `\x1b[31m✗ Found ${this.violations.length} color violation(s)\x1b[0m\n`
358
+ ];
359
+
360
+ // Group by file
361
+ const byFile = {};
362
+ for (const v of this.violations) {
363
+ if (!byFile[v.file]) byFile[v.file] = [];
364
+ byFile[v.file].push(v);
365
+ }
366
+
367
+ for (const [file, violations] of Object.entries(byFile)) {
368
+ lines.push(`\x1b[1m${file}\x1b[0m`);
369
+
370
+ for (const v of violations) {
371
+ lines.push(` Line ${v.line}: \x1b[33m${v.value}\x1b[0m`);
372
+
373
+ if (v.type === 'tailwind-default') {
374
+ lines.push(` \x1b[31mError:\x1b[0m Tailwind default colors are not allowed`);
375
+ lines.push(` \x1b[32mFix:\x1b[0m Use style={{ backgroundColor: "#hexcolor" }} with design token`);
376
+ } else {
377
+ lines.push(` \x1b[31mError:\x1b[0m Color ${v.normalized} not found in design-tokens.json`);
378
+ if (v.suggestion && v.suggestionName) {
379
+ lines.push(` \x1b[32mSuggestion:\x1b[0m Use ${v.suggestion} (${v.suggestionName})`);
380
+ }
381
+ }
382
+
383
+ if (v.context) {
384
+ lines.push(` \x1b[90mContext: ${v.context}\x1b[0m`);
385
+ }
386
+ lines.push('');
387
+ }
388
+ }
389
+
390
+ return lines.join('\n');
391
+ }
392
+
393
+ /**
394
+ * Generate JSON report
395
+ */
396
+ toJSON() {
397
+ return {
398
+ timestamp: new Date().toISOString(),
399
+ prototypeDir: this.prototypeDir,
400
+ summary: this.getSummary(),
401
+ validColors: Array.from(this.validColors),
402
+ violations: this.violations
403
+ };
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Validate colors in prototype against design tokens
409
+ */
410
+ function validateColors(prototypeDir, tokensPath) {
411
+ const validator = new ColorValidator({ prototypeDir });
412
+ validator.loadDesignTokens(tokensPath);
413
+ validator.validate();
414
+ return validator;
415
+ }
416
+
417
+ // CLI execution
418
+ if (require.main === module) {
419
+ const args = process.argv.slice(2);
420
+ let prototypeDir = './prototype';
421
+ let tokensPath = './references/design-tokens.json';
422
+ let jsonOutput = false;
423
+
424
+ for (let i = 0; i < args.length; i++) {
425
+ switch (args[i]) {
426
+ case '--proto':
427
+ case '-p':
428
+ prototypeDir = args[++i];
429
+ break;
430
+ case '--tokens':
431
+ case '-t':
432
+ tokensPath = args[++i];
433
+ break;
434
+ case '--project':
435
+ const projectName = args[++i];
436
+ const SKILL_DIR = path.dirname(__dirname);
437
+ const PROJECTS_DIR = path.resolve(SKILL_DIR, '../../../projects');
438
+ prototypeDir = path.join(PROJECTS_DIR, projectName, 'prototype');
439
+ tokensPath = path.join(PROJECTS_DIR, projectName, 'references', 'design-tokens.json');
440
+ break;
441
+ case '--json':
442
+ jsonOutput = true;
443
+ break;
444
+ case '--help':
445
+ case '-h':
446
+ console.log(`
447
+ Usage: node color-validator.js [options]
448
+
449
+ Options:
450
+ --proto, -p <path> Path to prototype directory
451
+ --tokens, -t <path> Path to design-tokens.json
452
+ --project <name> Project name (sets proto and tokens paths)
453
+ --json Output as JSON
454
+ --help, -h Show this help
455
+
456
+ Examples:
457
+ node color-validator.js --project my-app
458
+ node color-validator.js --proto ./prototype --tokens ./references/design-tokens.json
459
+ `);
460
+ process.exit(0);
461
+ }
462
+ }
463
+
464
+ try {
465
+ const validator = validateColors(prototypeDir, tokensPath);
466
+
467
+ if (jsonOutput) {
468
+ console.log(JSON.stringify(validator.toJSON(), null, 2));
469
+ } else {
470
+ console.log(`\n\x1b[1mColor Validation Report\x1b[0m`);
471
+ console.log(`Prototype: ${prototypeDir}`);
472
+ console.log(`Design Tokens: ${tokensPath}`);
473
+ console.log(`Valid Colors: ${validator.validColors.size}`);
474
+ console.log('');
475
+ console.log(validator.formatViolations());
476
+
477
+ const summary = validator.getSummary();
478
+ if (!summary.passed) {
479
+ console.log(`\n\x1b[1mSummary by Type:\x1b[0m`);
480
+ for (const [type, count] of Object.entries(summary.byType)) {
481
+ console.log(` ${type}: ${count}`);
482
+ }
483
+ }
484
+ }
485
+
486
+ process.exit(validator.violations.length === 0 ? 0 : 1);
487
+ } catch (error) {
488
+ console.error(`\x1b[31mError:\x1b[0m ${error.message}`);
489
+ process.exit(1);
490
+ }
491
+ }
492
+
493
+ module.exports = {
494
+ ColorValidator,
495
+ validateColors
496
+ };
package/bin/cli.js CHANGED
@@ -14,7 +14,7 @@ const fs = require('fs');
14
14
  const path = require('path');
15
15
  const os = require('os');
16
16
 
17
- const VERSION = '2.0.0';
17
+ const VERSION = require('../package.json').version;
18
18
  const SKILL_NAME = 'real-prototypes-skill';
19
19
 
20
20
  function log(message, type = 'info') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "real-prototypes-skill",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Capture any web platform and generate pixel-perfect prototypes that match its design. A Claude Code skill for rapid feature prototyping.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -48,6 +48,9 @@
48
48
  "pngjs": "^7.0.0"
49
49
  },
50
50
  "dependencies": {
51
+ "@babel/generator": "^7.29.0",
52
+ "@babel/parser": "^7.29.0",
53
+ "@babel/traverse": "^7.29.0",
51
54
  "class-variance-authority": "^0.7.1",
52
55
  "clsx": "^2.1.1",
53
56
  "jsdom": "^27.4.0",