Initial commit: Clean DSS implementation
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
This commit is contained in:
382
admin-ui/js/components/tools/ds-visual-diff.js
Normal file
382
admin-ui/js/components/tools/ds-visual-diff.js
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user