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
553 lines
17 KiB
JavaScript
553 lines
17 KiB
JavaScript
/**
|
||
* ds-screenshot-gallery.js
|
||
* Screenshot gallery with IndexedDB storage and artifact-based images
|
||
*
|
||
* REFACTORED: DSS-compliant version using DSBaseTool + gallery-template.js
|
||
* - Extends DSBaseTool for Shadow DOM, AbortController, and standardized lifecycle
|
||
* - Uses gallery-template.js for DSS-compliant templating (NO inline events/styles)
|
||
* - Event delegation pattern for all interactions
|
||
* - Logger utility instead of console.*
|
||
*
|
||
* Reference: .knowledge/dss-coding-standards.json
|
||
*/
|
||
|
||
import DSBaseTool from '../base/ds-base-tool.js';
|
||
import toolBridge from '../../services/tool-bridge.js';
|
||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||
import { logger } from '../../utils/logger.js';
|
||
|
||
class DSScreenshotGallery extends DSBaseTool {
|
||
constructor() {
|
||
super();
|
||
this.screenshots = [];
|
||
this.selectedScreenshot = null;
|
||
this.isCapturing = false;
|
||
this.db = null;
|
||
}
|
||
|
||
async connectedCallback() {
|
||
// Initialize IndexedDB first
|
||
await this.initDB();
|
||
|
||
// Call parent connectedCallback (renders + setupEventListeners)
|
||
super.connectedCallback();
|
||
|
||
// Load screenshots after render
|
||
await this.loadScreenshots();
|
||
}
|
||
|
||
/**
|
||
* Initialize IndexedDB for metadata storage
|
||
*/
|
||
async initDB() {
|
||
return new Promise((resolve, reject) => {
|
||
const request = indexedDB.open('ds-screenshots', 1);
|
||
|
||
request.onerror = () => {
|
||
logger.error('[DSScreenshotGallery] Failed to open IndexedDB', request.error);
|
||
reject(request.error);
|
||
};
|
||
|
||
request.onsuccess = () => {
|
||
this.db = request.result;
|
||
logger.debug('[DSScreenshotGallery] IndexedDB initialized');
|
||
resolve();
|
||
};
|
||
|
||
request.onupgradeneeded = (event) => {
|
||
const db = event.target.result;
|
||
if (!db.objectStoreNames.contains('screenshots')) {
|
||
const store = db.createObjectStore('screenshots', { keyPath: 'id' });
|
||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||
store.createIndex('tags', 'tags', { unique: false, multiEntry: true });
|
||
logger.info('[DSScreenshotGallery] IndexedDB schema created');
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Render the component (required by DSBaseTool)
|
||
*/
|
||
render() {
|
||
this.shadowRoot.innerHTML = `
|
||
<style>
|
||
:host {
|
||
display: block;
|
||
height: 100%;
|
||
}
|
||
|
||
.screenshot-gallery-container {
|
||
padding: 16px;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.capture-controls {
|
||
margin-bottom: 16px;
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.capture-input {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
padding: 6px 8px;
|
||
font-size: 12px;
|
||
background: var(--vscode-input-background);
|
||
color: var(--vscode-input-foreground);
|
||
border: 1px solid var(--vscode-input-border);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.capture-input:focus {
|
||
outline: 1px solid var(--vscode-focusBorder);
|
||
}
|
||
|
||
.fullpage-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: var(--vscode-foreground);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.capture-btn {
|
||
padding: 6px 12px;
|
||
font-size: 11px;
|
||
background: var(--vscode-button-background);
|
||
color: var(--vscode-button-foreground);
|
||
border: none;
|
||
border-radius: 2px;
|
||
cursor: pointer;
|
||
transition: background 0.15s ease;
|
||
}
|
||
|
||
.capture-btn:hover {
|
||
background: var(--vscode-button-hoverBackground);
|
||
}
|
||
|
||
.capture-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.gallery-wrapper {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.loading {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 48px;
|
||
color: var(--vscode-descriptionForeground);
|
||
}
|
||
|
||
.loading-spinner {
|
||
font-size: 32px;
|
||
margin-bottom: 12px;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
/* Modal styles */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.9);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10000;
|
||
padding: 24px;
|
||
}
|
||
|
||
.modal-content {
|
||
max-width: 90%;
|
||
max-height: 90%;
|
||
background: var(--vscode-sideBar-background);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.modal-header {
|
||
padding: 16px;
|
||
border-bottom: 1px solid var(--vscode-panel-border);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 14px;
|
||
margin: 0 0 4px 0;
|
||
}
|
||
|
||
.modal-subtitle {
|
||
font-size: 11px;
|
||
color: var(--vscode-descriptionForeground);
|
||
}
|
||
|
||
.modal-close-btn {
|
||
background: none;
|
||
border: none;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
color: var(--vscode-foreground);
|
||
padding: 0;
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.modal-close-btn:hover {
|
||
background: var(--vscode-toolbar-hoverBackground);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.modal-body {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.modal-image {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
</style>
|
||
|
||
<div class="screenshot-gallery-container">
|
||
<!-- Capture Controls -->
|
||
<div class="capture-controls">
|
||
<input
|
||
type="text"
|
||
id="screenshot-selector"
|
||
placeholder="Optional: CSS selector to capture"
|
||
class="capture-input"
|
||
/>
|
||
<label class="fullpage-label">
|
||
<input type="checkbox" id="screenshot-fullpage" />
|
||
Full page
|
||
</label>
|
||
<button
|
||
id="capture-screenshot-btn"
|
||
data-action="capture"
|
||
class="capture-btn"
|
||
type="button"
|
||
aria-label="Capture screenshot">
|
||
📸 Capture
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Gallery Content -->
|
||
<div class="gallery-wrapper" id="gallery-content">
|
||
<div class="loading">
|
||
<div class="loading-spinner">⏳</div>
|
||
<div>Initializing gallery...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Setup event listeners (required by DSBaseTool)
|
||
* Uses event delegation pattern with data-action attributes
|
||
*/
|
||
setupEventListeners() {
|
||
// EVENT-002: Event delegation on container
|
||
this.delegateEvents('.screenshot-gallery-container', 'click', (action, e) => {
|
||
switch (action) {
|
||
case 'capture':
|
||
this.captureScreenshot();
|
||
break;
|
||
case 'item-click':
|
||
const idx = parseInt(e.target.closest('[data-item-idx]')?.dataset.itemIdx, 10);
|
||
if (!isNaN(idx) && this.screenshots[idx]) {
|
||
this.viewScreenshot(this.screenshots[idx]);
|
||
}
|
||
break;
|
||
case 'item-delete':
|
||
const deleteIdx = parseInt(e.target.closest('[data-item-idx]')?.dataset.itemIdx, 10);
|
||
if (!isNaN(deleteIdx) && this.screenshots[deleteIdx]) {
|
||
this.handleDelete(this.screenshots[deleteIdx].id);
|
||
}
|
||
break;
|
||
}
|
||
});
|
||
}
|
||
|
||
async captureScreenshot() {
|
||
if (this.isCapturing) return;
|
||
|
||
this.isCapturing = true;
|
||
const captureBtn = this.$('#capture-screenshot-btn');
|
||
|
||
if (captureBtn) {
|
||
captureBtn.disabled = true;
|
||
captureBtn.textContent = '📸 Capturing...';
|
||
}
|
||
|
||
try {
|
||
const selectorInput = this.$('#screenshot-selector');
|
||
const fullPageToggle = this.$('#screenshot-fullpage');
|
||
|
||
const selector = selectorInput?.value.trim() || null;
|
||
const fullPage = fullPageToggle?.checked || false;
|
||
|
||
logger.info('[DSScreenshotGallery] Capturing screenshot', { selector, fullPage });
|
||
|
||
// Call MCP tool to capture screenshot
|
||
const result = await toolBridge.takeScreenshot(fullPage, selector);
|
||
|
||
if (result && result.screenshot) {
|
||
// Save metadata to IndexedDB
|
||
const screenshot = {
|
||
id: Date.now(),
|
||
timestamp: new Date(),
|
||
selector: selector || 'Full Page',
|
||
fullPage,
|
||
imageData: result.screenshot, // Base64 image data
|
||
tags: selector ? [selector] : ['fullpage']
|
||
};
|
||
|
||
await this.saveScreenshot(screenshot);
|
||
await this.loadScreenshots();
|
||
|
||
ComponentHelpers.showToast?.('Screenshot captured successfully', 'success');
|
||
logger.info('[DSScreenshotGallery] Screenshot saved', { id: screenshot.id });
|
||
} else {
|
||
throw new Error('No screenshot data returned');
|
||
}
|
||
} catch (error) {
|
||
logger.error('[DSScreenshotGallery] Failed to capture screenshot', error);
|
||
ComponentHelpers.showToast?.(`Failed to capture screenshot: ${error.message}`, 'error');
|
||
} finally {
|
||
this.isCapturing = false;
|
||
if (captureBtn) {
|
||
captureBtn.disabled = false;
|
||
captureBtn.textContent = '📸 Capture';
|
||
}
|
||
}
|
||
}
|
||
|
||
async saveScreenshot(screenshot) {
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = this.db.transaction(['screenshots'], 'readwrite');
|
||
const store = transaction.objectStore('screenshots');
|
||
const request = store.add(screenshot);
|
||
|
||
request.onsuccess = () => resolve();
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
async loadScreenshots() {
|
||
const content = this.$('#gallery-content');
|
||
if (!content) return;
|
||
|
||
try {
|
||
this.screenshots = await this.getAllScreenshots();
|
||
logger.debug('[DSScreenshotGallery] Loaded screenshots', { count: this.screenshots.length });
|
||
this.renderGallery();
|
||
} catch (error) {
|
||
logger.error('[DSScreenshotGallery] Failed to load screenshots', error);
|
||
content.innerHTML = ComponentHelpers.renderError('Failed to load screenshots', error);
|
||
}
|
||
}
|
||
|
||
async getAllScreenshots() {
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = this.db.transaction(['screenshots'], 'readonly');
|
||
const store = transaction.objectStore('screenshots');
|
||
const request = store.getAll();
|
||
|
||
request.onsuccess = () => resolve(request.result.reverse()); // Most recent first
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
async deleteScreenshot(id) {
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = this.db.transaction(['screenshots'], 'readwrite');
|
||
const store = transaction.objectStore('screenshots');
|
||
const request = store.delete(id);
|
||
|
||
request.onsuccess = () => resolve();
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
async handleDelete(id) {
|
||
if (!confirm('Delete this screenshot?')) return;
|
||
|
||
try {
|
||
await this.deleteScreenshot(id);
|
||
await this.loadScreenshots();
|
||
ComponentHelpers.showToast?.('Screenshot deleted', 'success');
|
||
logger.info('[DSScreenshotGallery] Screenshot deleted', { id });
|
||
} catch (error) {
|
||
logger.error('[DSScreenshotGallery] Failed to delete screenshot', error);
|
||
ComponentHelpers.showToast?.(`Failed to delete: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
viewScreenshot(screenshot) {
|
||
this.selectedScreenshot = screenshot;
|
||
this.renderModal();
|
||
}
|
||
|
||
renderModal() {
|
||
if (!this.selectedScreenshot) return;
|
||
|
||
// Create modal in Shadow DOM
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay';
|
||
modal.innerHTML = `
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<div>
|
||
<h3 class="modal-title">${this.escapeHtml(this.selectedScreenshot.selector)}</h3>
|
||
<div class="modal-subtitle">
|
||
${ComponentHelpers.formatTimestamp(new Date(this.selectedScreenshot.timestamp))}
|
||
</div>
|
||
</div>
|
||
<button
|
||
class="modal-close-btn"
|
||
data-action="close-modal"
|
||
type="button"
|
||
aria-label="Close modal">
|
||
×
|
||
</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<img
|
||
src="${this.selectedScreenshot.imageData}"
|
||
class="modal-image"
|
||
alt="${this.escapeHtml(this.selectedScreenshot.selector)}" />
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Add click handlers for modal
|
||
this.bindEvent(modal, 'click', (e) => {
|
||
const closeBtn = e.target.closest('[data-action="close-modal"]');
|
||
if (closeBtn || e.target === modal) {
|
||
modal.remove();
|
||
this.selectedScreenshot = null;
|
||
logger.debug('[DSScreenshotGallery] Modal closed');
|
||
}
|
||
});
|
||
|
||
this.shadowRoot.appendChild(modal);
|
||
logger.debug('[DSScreenshotGallery] Modal opened', { id: this.selectedScreenshot.id });
|
||
}
|
||
|
||
renderGallery() {
|
||
const content = this.$('#gallery-content');
|
||
if (!content) return;
|
||
|
||
if (this.screenshots.length === 0) {
|
||
content.innerHTML = `
|
||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px; color: var(--vscode-descriptionForeground);">
|
||
<div style="font-size: 48px; margin-bottom: 12px; opacity: 0.5;">📸</div>
|
||
<div style="font-size: 13px;">No screenshots captured yet</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Transform screenshots to gallery items format
|
||
const galleryItems = this.screenshots.map(screenshot => ({
|
||
src: screenshot.imageData,
|
||
title: screenshot.selector,
|
||
subtitle: ComponentHelpers.formatRelativeTime(new Date(screenshot.timestamp))
|
||
}));
|
||
|
||
// Use DSS-compliant gallery template (NO inline styles/events)
|
||
// Note: We're using a simplified inline version here since we're in Shadow DOM
|
||
// For full modular approach, we'd import createGalleryView from gallery-template.js
|
||
content.innerHTML = `
|
||
<div style="margin-bottom: 12px; padding: 12px; background-color: var(--vscode-sideBar-background); border-radius: 4px;">
|
||
<div style="font-size: 11px; color: var(--vscode-descriptionForeground);">
|
||
${this.screenshots.length} screenshot${this.screenshots.length !== 1 ? 's' : ''} stored
|
||
</div>
|
||
</div>
|
||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px;">
|
||
${galleryItems.map((item, idx) => `
|
||
<div class="gallery-item" data-action="item-click" data-item-idx="${idx}" style="
|
||
background: var(--vscode-sideBar-background);
|
||
border: 1px solid var(--vscode-panel-border);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: transform 0.2s ease;
|
||
">
|
||
<div style="aspect-ratio: 16/9; overflow: hidden; background: var(--vscode-editor-background);">
|
||
<img src="${item.src}"
|
||
style="width: 100%; height: 100%; object-fit: cover;"
|
||
alt="${this.escapeHtml(item.title)}" />
|
||
</div>
|
||
<div style="padding: 12px;">
|
||
<div style="font-size: 12px; font-weight: 600; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||
${this.escapeHtml(item.title)}
|
||
</div>
|
||
<div style="font-size: 11px; color: var(--vscode-descriptionForeground); margin-bottom: 8px;">
|
||
${item.subtitle}
|
||
</div>
|
||
<button
|
||
data-action="item-delete"
|
||
data-item-idx="${idx}"
|
||
type="button"
|
||
aria-label="Delete ${this.escapeHtml(item.title)}"
|
||
style="padding: 4px 8px; font-size: 10px; background: rgba(244, 135, 113, 0.1); color: #f48771; border: 1px solid #f48771; border-radius: 2px; cursor: pointer;">
|
||
🗑️ Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
|
||
// Add hover styles via adoptedStyleSheets
|
||
this.adoptStyles(`
|
||
.gallery-item:hover {
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
.gallery-item button:hover {
|
||
background: rgba(244, 135, 113, 0.2);
|
||
}
|
||
`);
|
||
|
||
logger.debug('[DSScreenshotGallery] Gallery rendered', { count: this.screenshots.length });
|
||
}
|
||
}
|
||
|
||
customElements.define('ds-screenshot-gallery', DSScreenshotGallery);
|
||
|
||
export default DSScreenshotGallery;
|