Files
tolaria/scripts/design-diff-analyzer.js
lucaronin 1a627877db feat: detect layout/structural changes in design-diff-analyzer
- Detect new components added (frames with names)
- Detect components removed
- Detect height/structural changes
- Add high-priority layout tasks

Now catches additions like macOS title bar that were previously missed.
2026-02-17 16:36:02 +01:00

360 lines
10 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Design Diff Analyzer
* Analyzes changes to ui-design.pen and generates implementation tasks for Claude Code
*/
import { execSync } from 'child_process';
import fs from 'fs';
function getDiff() {
try {
return execSync('git diff HEAD~1 ui-design.pen', { encoding: 'utf8' });
} catch (e) {
return '';
}
}
function analyzeDiff(diff) {
const changes = {
colors: [],
typography: [],
spacing: [],
layout: [],
content: []
};
const lines = diff.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Color changes
if (line.includes('"fill":') || line.includes('"backgroundColor":')) {
const oldMatch = lines[i-1]?.match(/"(fill|backgroundColor)":\s*"([^"]+)"/);
const newMatch = line.match(/"(fill|backgroundColor)":\s*"([^"]+)"/);
if (oldMatch && newMatch && oldMatch[2] !== newMatch[2]) {
changes.colors.push({
type: oldMatch[1],
from: oldMatch[2],
to: newMatch[2]
});
}
}
// Font size changes
if (line.includes('"fontSize":')) {
const oldMatch = lines[i-1]?.match(/"fontSize":\s*(\d+)/);
const newMatch = line.match(/"fontSize":\s*(\d+)/);
if (oldMatch && newMatch && oldMatch[1] !== newMatch[1]) {
changes.typography.push({
type: 'fontSize',
from: parseInt(oldMatch[1]),
to: parseInt(newMatch[1])
});
}
}
// Spacing/padding changes
if (line.includes('"padding":') || line.includes('"margin":') || line.includes('"gap":')) {
const oldMatch = lines[i-1]?.match(/"(padding|margin|gap)":\s*(\d+)/);
const newMatch = line.match(/"(padding|margin|gap)":\s*(\d+)/);
if (oldMatch && newMatch && oldMatch[2] !== newMatch[2]) {
changes.spacing.push({
type: oldMatch[1],
from: parseInt(oldMatch[2]),
to: parseInt(newMatch[2])
});
}
}
// Content changes (text only - no implementation needed)
if (line.includes('"content":')) {
const oldMatch = lines[i-1]?.match(/"content":\s*"([^"]+)"/);
const newMatch = line.match(/"content":\s*"([^"]+)"/);
if (oldMatch && newMatch && oldMatch[1] !== newMatch[1]) {
changes.content.push({
from: oldMatch[1],
to: newMatch[1]
});
}
}
// Layout changes - new components added
if (line.startsWith('+') && line.includes('"name":')) {
// Look back for "type": "frame" in recent lines
const frameTypeMatch = lines.slice(Math.max(0, i-5), i+1).find(l =>
l.startsWith('+') && l.includes('"type": "frame"')
);
if (frameTypeMatch) {
const nameMatch = line.match(/"name":\s*"([^"]+)"/);
if (nameMatch) {
changes.layout.push({
action: 'added',
component: nameMatch[1],
type: 'frame'
});
}
}
}
// Layout changes - components removed
if (line.startsWith('-') && line.includes('"name":')) {
const frameTypeMatch = lines.slice(Math.max(0, i-5), i+1).find(l =>
l.startsWith('-') && l.includes('"type": "frame"')
);
if (frameTypeMatch) {
const nameMatch = line.match(/"name":\s*"([^"]+)"/);
if (nameMatch) {
changes.layout.push({
action: 'removed',
component: nameMatch[1],
type: 'frame'
});
}
}
}
// Height changes (structural)
if (line.includes('"height":')) {
const oldMatch = lines[i-1]?.match(/"height":\s*(\d+)/);
const newMatch = line.match(/"height":\s*(\d+)/);
if (oldMatch && newMatch && oldMatch[1] !== newMatch[1]) {
changes.layout.push({
action: 'resized',
property: 'height',
from: parseInt(oldMatch[1]),
to: parseInt(newMatch[1])
});
}
}
}
return changes;
}
function analyzeIntent(changes) {
const intent = {
patterns: [],
systems: [],
recommendations: []
};
// Detect color-coding system for types
const accentColorIntro = changes.colors.filter(c =>
c.to.includes('accent-') && !c.from.includes('accent-')
);
if (accentColorIntro.length >= 4) {
const colors = [...new Set(accentColorIntro.map(c => c.to))];
intent.systems.push({
type: 'color-coding',
description: 'Type-based color system',
colors: colors,
explanation: 'Each entity type (Project, Procedure, Person, Topic) has its own accent color. This color should be used consistently for: icons, active states, badges, and any UI element representing that type.',
critical: '⚠️ IMPORTANT: Active states must use the color **dynamically** based on the note type, not hardcoded. Example: if a Project is selected, use accent-red; if a Procedure is selected, use accent-purple.'
});
}
// Detect contrast improvements
const contrastChanges = changes.colors.filter(c =>
c.from === '$--muted-foreground' && c.to === '$--foreground'
);
if (contrastChanges.length > 3) {
intent.patterns.push({
type: 'contrast-improvement',
description: 'Increased text contrast for better readability',
count: contrastChanges.length
});
}
// Detect spacing improvements
if (changes.spacing.length > 0) {
const avgIncrease = changes.spacing.reduce((sum, s) => sum + (s.to - s.from), 0) / changes.spacing.length;
if (avgIncrease > 0) {
intent.patterns.push({
type: 'spacing-increase',
description: 'Increased spacing for less cluttered UI',
avgIncrease: Math.round(avgIncrease)
});
}
}
return intent;
}
function generateTasks(changes, intent) {
const tasks = [];
// Color changes → update theme.json or CSS variables
if (changes.colors.length > 0) {
const colorList = changes.colors.map(c =>
` - ${c.type}: ${c.from}${c.to}`
).join('\n');
let details = `Color changes detected:\n${colorList}\n\nUpdate src/theme.json or CSS variables to match the design.`;
// Add intent explanation if color-coding system detected
const colorSystem = intent.systems.find(s => s.type === 'color-coding');
if (colorSystem) {
details += `\n\n${colorSystem.explanation}\n\n${colorSystem.critical}`;
}
tasks.push({
priority: 'high',
description: 'Update color palette',
details: details
});
}
// Typography changes → update theme.json
if (changes.typography.length > 0) {
const typoList = changes.typography.map(t =>
` - ${t.type}: ${t.from}px → ${t.to}px`
).join('\n');
tasks.push({
priority: 'medium',
description: 'Update typography',
details: `Typography changes detected:\n${typoList}\n\nUpdate src/theme.json typography settings.`
});
}
// Spacing changes → update theme.json
if (changes.spacing.length > 0) {
const spacingList = changes.spacing.map(s =>
` - ${s.type}: ${s.from}px → ${s.to}px`
).join('\n');
tasks.push({
priority: 'medium',
description: 'Update spacing',
details: `Spacing changes detected:\n${spacingList}\n\nUpdate src/theme.json spacing values.`
});
}
// Layout changes → structural modifications
if (changes.layout.length > 0) {
const added = changes.layout.filter(l => l.action === 'added');
const removed = changes.layout.filter(l => l.action === 'removed');
const resized = changes.layout.filter(l => l.action === 'resized');
let layoutDetails = 'Layout changes detected:\n\n';
if (added.length > 0) {
layoutDetails += 'Components added:\n';
added.forEach(c => layoutDetails += ` + ${c.component} (${c.type})\n`);
layoutDetails += '\n';
}
if (removed.length > 0) {
layoutDetails += 'Components removed:\n';
removed.forEach(c => layoutDetails += ` - ${c.component} (${c.type})\n`);
layoutDetails += '\n';
}
if (resized.length > 0) {
layoutDetails += 'Components resized:\n';
resized.forEach(c => layoutDetails += `${c.property}: ${c.from}px → ${c.to}px\n`);
layoutDetails += '\n';
}
layoutDetails += 'Review ui-design.pen diff and update components accordingly.';
tasks.push({
priority: 'high',
description: 'Update layout structure',
details: layoutDetails
});
}
return tasks;
}
function formatOutput(tasks, changes, intent) {
if (tasks.length === 0 && changes.content.length === 0) {
return null;
}
let output = '🎨 Design changes detected:\n\n';
// Add design intent analysis
if (intent.systems.length > 0 || intent.patterns.length > 0) {
output += '🧠 Design Intent Analysis:\n\n';
intent.systems.forEach(system => {
output += `📐 ${system.description}\n`;
output += `${system.explanation}\n`;
if (system.critical) {
output += `\n${system.critical}\n`;
}
output += '\n';
});
intent.patterns.forEach(pattern => {
output += `${pattern.description}`;
if (pattern.count) output += ` (${pattern.count} changes)`;
if (pattern.avgIncrease) output += ` (+${pattern.avgIncrease}px avg)`;
output += '\n';
});
output += '\n';
}
if (tasks.length > 0) {
output += '📋 Implementation tasks:\n\n';
tasks.forEach((task, i) => {
output += `${i + 1}. [${task.priority.toUpperCase()}] ${task.description}\n`;
output += `${task.details}\n\n`;
});
}
if (changes.content.length > 0) {
output += '📝 Content changes (mockup text only, no code changes needed):\n';
changes.content.forEach(c => {
output += ` - "${c.from}" → "${c.to}"\n`;
});
output += '\n';
}
return { output, tasks, intent };
}
function main() {
const diff = getDiff();
if (!diff) {
console.log('No design changes detected');
process.exit(0);
}
const changes = analyzeDiff(diff);
const intent = analyzeIntent(changes);
const tasks = generateTasks(changes, intent);
const result = formatOutput(tasks, changes, intent);
if (!result) {
console.log('No significant design changes detected');
process.exit(0);
}
console.log(result.output);
// Output JSON for automation
if (process.argv.includes('--json')) {
console.log('\n---JSON---');
console.log(JSON.stringify({ tasks, changes, intent }, null, 2));
}
process.exit(tasks.length > 0 ? 1 : 0); // Exit 1 if there are tasks to implement
}
main();