Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
383 lines
15 KiB
JavaScript
383 lines
15 KiB
JavaScript
/**
|
|
* ds-visual-diff.js
|
|
* Visual diff tool for comparing design changes using Pixelmatch
|
|
*/
|
|
|
|
import toolBridge from '../../services/tool-bridge.js';
|
|
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
|
|
|
// Load Pixelmatch from CDN
|
|
let pixelmatch = null;
|
|
|
|
class DSVisualDiff extends HTMLElement {
|
|
constructor() {
|
|
super();
|
|
this.screenshots = [];
|
|
this.beforeImage = null;
|
|
this.afterImage = null;
|
|
this.diffResult = null;
|
|
this.isComparing = false;
|
|
this.pixelmatchLoaded = false;
|
|
}
|
|
|
|
async connectedCallback() {
|
|
this.render();
|
|
this.setupEventListeners();
|
|
await this.loadPixelmatch();
|
|
await this.loadScreenshots();
|
|
}
|
|
|
|
/**
|
|
* Load Pixelmatch library from CDN
|
|
*/
|
|
async loadPixelmatch() {
|
|
if (this.pixelmatchLoaded) return;
|
|
|
|
try {
|
|
// Import pixelmatch from esm.sh CDN
|
|
const module = await import('https://esm.sh/pixelmatch@5.3.0');
|
|
pixelmatch = module.default;
|
|
this.pixelmatchLoaded = true;
|
|
console.log('[DSVisualDiff] Pixelmatch loaded successfully');
|
|
} catch (error) {
|
|
console.error('[DSVisualDiff] Failed to load Pixelmatch:', error);
|
|
ComponentHelpers.showToast?.('Failed to load visual diff library', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load screenshots from IndexedDB (shared with ds-screenshot-gallery)
|
|
*/
|
|
async loadScreenshots() {
|
|
try {
|
|
const db = await this.openDB();
|
|
this.screenshots = await this.getAllScreenshots(db);
|
|
this.renderSelectors();
|
|
} catch (error) {
|
|
console.error('Failed to load screenshots:', error);
|
|
}
|
|
}
|
|
|
|
async openDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open('ds-screenshots', 1);
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve(request.result);
|
|
});
|
|
}
|
|
|
|
async getAllScreenshots(db) {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(['screenshots'], 'readonly');
|
|
const store = transaction.objectStore('screenshots');
|
|
const request = store.getAll();
|
|
|
|
request.onsuccess = () => resolve(request.result.reverse());
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
setupEventListeners() {
|
|
const compareBtn = this.querySelector('#visual-diff-compare-btn');
|
|
if (compareBtn) {
|
|
compareBtn.addEventListener('click', () => this.compareImages());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load image data and decode to ImageData
|
|
*/
|
|
async loadImageData(imageDataUrl) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
|
|
img.onload = () => {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = img.width;
|
|
canvas.height = img.height;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0);
|
|
|
|
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
|
resolve(imageData);
|
|
};
|
|
|
|
img.onerror = () => reject(new Error('Failed to load image'));
|
|
img.src = imageDataUrl;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Compare two images using Pixelmatch
|
|
*/
|
|
async compareImages() {
|
|
if (this.isComparing || !this.pixelmatchLoaded) return;
|
|
|
|
const beforeSelect = this.querySelector('#before-image-select');
|
|
const afterSelect = this.querySelector('#after-image-select');
|
|
|
|
if (!beforeSelect || !afterSelect) return;
|
|
|
|
const beforeId = parseInt(beforeSelect.value);
|
|
const afterId = parseInt(afterSelect.value);
|
|
|
|
if (!beforeId || !afterId) {
|
|
ComponentHelpers.showToast?.('Please select both before and after images', 'error');
|
|
return;
|
|
}
|
|
|
|
if (beforeId === afterId) {
|
|
ComponentHelpers.showToast?.('Please select different images to compare', 'error');
|
|
return;
|
|
}
|
|
|
|
this.isComparing = true;
|
|
const compareBtn = this.querySelector('#visual-diff-compare-btn');
|
|
|
|
if (compareBtn) {
|
|
compareBtn.disabled = true;
|
|
compareBtn.textContent = '🔍 Comparing...';
|
|
}
|
|
|
|
try {
|
|
// Get screenshot objects
|
|
this.beforeImage = this.screenshots.find(s => s.id === beforeId);
|
|
this.afterImage = this.screenshots.find(s => s.id === afterId);
|
|
|
|
if (!this.beforeImage || !this.afterImage) {
|
|
throw new Error('Screenshots not found');
|
|
}
|
|
|
|
// Load image data
|
|
const beforeData = await this.loadImageData(this.beforeImage.imageData);
|
|
const afterData = await this.loadImageData(this.afterImage.imageData);
|
|
|
|
// Ensure images are same size
|
|
if (beforeData.width !== afterData.width || beforeData.height !== afterData.height) {
|
|
throw new Error(`Image dimensions don't match: ${beforeData.width}x${beforeData.height} vs ${afterData.width}x${afterData.height}`);
|
|
}
|
|
|
|
// Create diff canvas
|
|
const diffCanvas = document.createElement('canvas');
|
|
diffCanvas.width = beforeData.width;
|
|
diffCanvas.height = beforeData.height;
|
|
const diffCtx = diffCanvas.getContext('2d');
|
|
const diffImageData = diffCtx.createImageData(beforeData.width, beforeData.height);
|
|
|
|
// Run pixelmatch comparison
|
|
const numDiffPixels = pixelmatch(
|
|
beforeData.data,
|
|
afterData.data,
|
|
diffImageData.data,
|
|
beforeData.width,
|
|
beforeData.height,
|
|
{
|
|
threshold: 0.1,
|
|
includeAA: false,
|
|
alpha: 0.1,
|
|
diffColor: [255, 0, 0]
|
|
}
|
|
);
|
|
|
|
// Put diff data on canvas
|
|
diffCtx.putImageData(diffImageData, 0, 0);
|
|
|
|
// Calculate difference percentage
|
|
const totalPixels = beforeData.width * beforeData.height;
|
|
const diffPercentage = ((numDiffPixels / totalPixels) * 100).toFixed(2);
|
|
|
|
this.diffResult = {
|
|
beforeImage: this.beforeImage,
|
|
afterImage: this.afterImage,
|
|
diffImageData: diffCanvas.toDataURL(),
|
|
numDiffPixels,
|
|
totalPixels,
|
|
diffPercentage,
|
|
timestamp: new Date()
|
|
};
|
|
|
|
this.renderDiffResult();
|
|
|
|
ComponentHelpers.showToast?.(
|
|
`Comparison complete: ${diffPercentage}% difference`,
|
|
diffPercentage < 1 ? 'success' : diffPercentage < 10 ? 'warning' : 'error'
|
|
);
|
|
} catch (error) {
|
|
console.error('Failed to compare images:', error);
|
|
ComponentHelpers.showToast?.(`Comparison failed: ${error.message}`, 'error');
|
|
|
|
const diffContent = this.querySelector('#diff-result-content');
|
|
if (diffContent) {
|
|
diffContent.innerHTML = ComponentHelpers.renderError('Comparison failed', error);
|
|
}
|
|
} finally {
|
|
this.isComparing = false;
|
|
if (compareBtn) {
|
|
compareBtn.disabled = false;
|
|
compareBtn.textContent = '🔍 Compare';
|
|
}
|
|
}
|
|
}
|
|
|
|
renderSelectors() {
|
|
const beforeSelect = this.querySelector('#before-image-select');
|
|
const afterSelect = this.querySelector('#after-image-select');
|
|
|
|
if (!beforeSelect || !afterSelect) return;
|
|
|
|
const options = this.screenshots.map(screenshot => {
|
|
const timestamp = ComponentHelpers.formatTimestamp(new Date(screenshot.timestamp));
|
|
return `<option value="${screenshot.id}">${ComponentHelpers.escapeHtml(screenshot.selector)} - ${timestamp}</option>`;
|
|
}).join('');
|
|
|
|
const emptyOption = '<option value="">-- Select Screenshot --</option>';
|
|
|
|
beforeSelect.innerHTML = emptyOption + options;
|
|
afterSelect.innerHTML = emptyOption + options;
|
|
}
|
|
|
|
renderDiffResult() {
|
|
const diffContent = this.querySelector('#diff-result-content');
|
|
if (!diffContent || !this.diffResult) return;
|
|
|
|
const { diffPercentage, numDiffPixels, totalPixels } = this.diffResult;
|
|
|
|
// Determine status badge
|
|
let statusBadge;
|
|
if (diffPercentage < 1) {
|
|
statusBadge = ComponentHelpers.createBadge('Identical', 'success');
|
|
} else if (diffPercentage < 10) {
|
|
statusBadge = ComponentHelpers.createBadge('Minor Changes', 'warning');
|
|
} else {
|
|
statusBadge = ComponentHelpers.createBadge('Significant Changes', 'error');
|
|
}
|
|
|
|
diffContent.innerHTML = `
|
|
<!-- Stats Card -->
|
|
<div style="background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px; margin-bottom: 16px;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
|
<h4 style="font-size: 12px; font-weight: 600;">Comparison Result</h4>
|
|
${statusBadge}
|
|
</div>
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; font-size: 11px;">
|
|
<div style="text-align: center;">
|
|
<div style="font-size: 24px; font-weight: 600; color: var(--vscode-text);">${diffPercentage}%</div>
|
|
<div style="color: var(--vscode-text-dim); margin-top: 4px;">Difference</div>
|
|
</div>
|
|
<div style="text-align: center;">
|
|
<div style="font-size: 24px; font-weight: 600; color: var(--vscode-text);">${numDiffPixels.toLocaleString()}</div>
|
|
<div style="color: var(--vscode-text-dim); margin-top: 4px;">Pixels Changed</div>
|
|
</div>
|
|
<div style="text-align: center;">
|
|
<div style="font-size: 24px; font-weight: 600; color: var(--vscode-text);">${totalPixels.toLocaleString()}</div>
|
|
<div style="color: var(--vscode-text-dim); margin-top: 4px;">Total Pixels</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Image Comparison Grid -->
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px;">
|
|
<!-- Before Image -->
|
|
<div style="background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; overflow: hidden;">
|
|
<div style="padding: 12px; border-bottom: 1px solid var(--vscode-border);">
|
|
<div style="font-size: 11px; font-weight: 600; margin-bottom: 4px;">Before</div>
|
|
<div style="font-size: 10px; color: var(--vscode-text-dim);">${ComponentHelpers.escapeHtml(this.diffResult.beforeImage.selector)}</div>
|
|
<div style="font-size: 10px; color: var(--vscode-text-dim);">${ComponentHelpers.formatRelativeTime(new Date(this.diffResult.beforeImage.timestamp))}</div>
|
|
</div>
|
|
<div style="aspect-ratio: 16/9; overflow: hidden; background: var(--vscode-bg);">
|
|
<img src="${this.diffResult.beforeImage.imageData}" style="width: 100%; height: 100%; object-fit: contain;" alt="Before" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- After Image -->
|
|
<div style="background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; overflow: hidden;">
|
|
<div style="padding: 12px; border-bottom: 1px solid var(--vscode-border);">
|
|
<div style="font-size: 11px; font-weight: 600; margin-bottom: 4px;">After</div>
|
|
<div style="font-size: 10px; color: var(--vscode-text-dim);">${ComponentHelpers.escapeHtml(this.diffResult.afterImage.selector)}</div>
|
|
<div style="font-size: 10px; color: var(--vscode-text-dim);">${ComponentHelpers.formatRelativeTime(new Date(this.diffResult.afterImage.timestamp))}</div>
|
|
</div>
|
|
<div style="aspect-ratio: 16/9; overflow: hidden; background: var(--vscode-bg);">
|
|
<img src="${this.diffResult.afterImage.imageData}" style="width: 100%; height: 100%; object-fit: contain;" alt="After" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Diff Image -->
|
|
<div style="background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; overflow: hidden; grid-column: span 2;">
|
|
<div style="padding: 12px; border-bottom: 1px solid var(--vscode-border);">
|
|
<div style="font-size: 11px; font-weight: 600; margin-bottom: 4px;">Visual Diff</div>
|
|
<div style="font-size: 10px; color: var(--vscode-text-dim);">Red pixels indicate changes</div>
|
|
</div>
|
|
<div style="aspect-ratio: 16/9; overflow: hidden; background: var(--vscode-bg);">
|
|
<img src="${this.diffResult.diffImageData}" style="width: 100%; height: 100%; object-fit: contain;" alt="Diff" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top: 12px; padding: 8px; background-color: var(--vscode-sidebar); border-radius: 4px; font-size: 10px; color: var(--vscode-text-dim);">
|
|
💡 Red pixels show where the images differ. Lower percentages indicate more similarity.
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
render() {
|
|
this.innerHTML = `
|
|
<div style="padding: 16px; height: 100%; display: flex; flex-direction: column;">
|
|
<!-- Selector Controls -->
|
|
<div style="margin-bottom: 16px; background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px;">
|
|
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 12px;">Visual Diff Comparison</h3>
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: end;">
|
|
<div>
|
|
<label style="display: block; font-size: 11px; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
|
Before Image
|
|
</label>
|
|
<select id="before-image-select" class="input" style="width: 100%; font-size: 11px;">
|
|
<option value="">-- Select Screenshot --</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label style="display: block; font-size: 11px; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
|
After Image
|
|
</label>
|
|
<select id="after-image-select" class="input" style="width: 100%; font-size: 11px;">
|
|
<option value="">-- Select Screenshot --</option>
|
|
</select>
|
|
</div>
|
|
|
|
<button id="visual-diff-compare-btn" class="button" style="padding: 4px 12px; font-size: 11px;">
|
|
🔍 Compare
|
|
</button>
|
|
</div>
|
|
|
|
${this.screenshots.length === 0 ? `
|
|
<div style="margin-top: 12px; padding: 12px; background-color: rgba(255, 191, 0, 0.1); border-radius: 4px;">
|
|
<div style="font-size: 11px; color: #ffbf00;">
|
|
⚠️ No screenshots available. Capture screenshots using the Screenshot Gallery tool first.
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
|
|
<!-- Diff Result -->
|
|
<div id="diff-result-content" style="flex: 1; overflow-y: auto;">
|
|
<div style="text-align: center; padding: 48px; color: var(--vscode-text-dim);">
|
|
<div style="font-size: 48px; margin-bottom: 16px;">🔍</div>
|
|
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">Ready to Compare</h3>
|
|
<p style="font-size: 12px;">
|
|
Select two screenshots above and click "Compare" to see the visual differences.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define('ds-visual-diff', DSVisualDiff);
|
|
|
|
export default DSVisualDiff;
|