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:
249
admin-ui/js/components/tools/ds-accessibility-report.js
Normal file
249
admin-ui/js/components/tools/ds-accessibility-report.js
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* ds-accessibility-report.js
|
||||
* Accessibility audit report using axe-core via MCP browser tools
|
||||
*/
|
||||
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
|
||||
class DSAccessibilityReport extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.auditResult = null;
|
||||
this.selector = null;
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const runBtn = this.querySelector('#a11y-run-btn');
|
||||
if (runBtn) {
|
||||
runBtn.addEventListener('click', () => this.runAudit());
|
||||
}
|
||||
|
||||
const selectorInput = this.querySelector('#a11y-selector');
|
||||
if (selectorInput) {
|
||||
selectorInput.addEventListener('change', (e) => {
|
||||
this.selector = e.target.value.trim() || null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async runAudit() {
|
||||
if (this.isRunning) return;
|
||||
|
||||
this.isRunning = true;
|
||||
const content = this.querySelector('#a11y-content');
|
||||
const runBtn = this.querySelector('#a11y-run-btn');
|
||||
|
||||
if (!content) {
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (runBtn) {
|
||||
runBtn.disabled = true;
|
||||
runBtn.textContent = 'Running Audit...';
|
||||
}
|
||||
|
||||
content.innerHTML = ComponentHelpers.renderLoading('Running accessibility audit with axe-core...');
|
||||
|
||||
try {
|
||||
const result = await toolBridge.runAccessibilityAudit(this.selector);
|
||||
|
||||
if (result) {
|
||||
this.auditResult = result;
|
||||
this.renderResults();
|
||||
} else {
|
||||
content.innerHTML = ComponentHelpers.renderEmpty('No audit results returned', '🔍');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to run accessibility audit:', error);
|
||||
content.innerHTML = ComponentHelpers.renderError('Failed to run accessibility audit', error);
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
if (runBtn) {
|
||||
runBtn.disabled = false;
|
||||
runBtn.textContent = '▶ Run Audit';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityIcon(impact) {
|
||||
const icons = {
|
||||
critical: '🔴',
|
||||
serious: '🟠',
|
||||
moderate: '🟡',
|
||||
minor: '🔵'
|
||||
};
|
||||
return icons[impact] || '⚪';
|
||||
}
|
||||
|
||||
getSeverityBadge(impact) {
|
||||
const types = {
|
||||
critical: 'error',
|
||||
serious: 'error',
|
||||
moderate: 'warning',
|
||||
minor: 'info'
|
||||
};
|
||||
return ComponentHelpers.createBadge(impact.toUpperCase(), types[impact] || 'info');
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const content = this.querySelector('#a11y-content');
|
||||
if (!content || !this.auditResult) return;
|
||||
|
||||
const violations = this.auditResult.violations || [];
|
||||
const passes = this.auditResult.passes || [];
|
||||
const incomplete = this.auditResult.incomplete || [];
|
||||
const inapplicable = this.auditResult.inapplicable || [];
|
||||
|
||||
const totalViolations = violations.length;
|
||||
const totalPasses = passes.length;
|
||||
const totalTests = totalViolations + totalPasses + incomplete.length + inapplicable.length;
|
||||
|
||||
if (totalViolations === 0) {
|
||||
content.innerHTML = `
|
||||
<div style="text-align: center; padding: 48px;">
|
||||
<div style="font-size: 64px; margin-bottom: 16px;">✅</div>
|
||||
<h3 style="font-size: 18px; margin-bottom: 8px; color: #89d185;">No Violations Found!</h3>
|
||||
<p style="font-size: 12px; color: var(--vscode-text-dim);">
|
||||
All ${totalPasses} accessibility tests passed.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const violationCards = violations.map((violation, index) => {
|
||||
const impact = violation.impact || 'unknown';
|
||||
const nodes = violation.nodes || [];
|
||||
const nodeCount = nodes.length;
|
||||
|
||||
return `
|
||||
<div style="background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-left: 3px solid ${this.getImpactColor(impact)}; border-radius: 4px; padding: 16px; margin-bottom: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
|
||||
<div style="flex: 1;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
|
||||
<span style="font-size: 20px;">${this.getSeverityIcon(impact)}</span>
|
||||
<h4 style="font-size: 13px; font-weight: 600;">${ComponentHelpers.escapeHtml(violation.description || violation.id)}</h4>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 8px;">
|
||||
Rule: <span style="font-family: 'Courier New', monospace;">${ComponentHelpers.escapeHtml(violation.id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
${this.getSeverityBadge(impact)}
|
||||
</div>
|
||||
|
||||
<div style="font-size: 12px; margin-bottom: 12px; padding: 12px; background-color: var(--vscode-bg); border-radius: 2px;">
|
||||
${ComponentHelpers.escapeHtml(violation.help || 'No help text available')}
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 4px;">
|
||||
Affected elements: ${nodeCount}
|
||||
</div>
|
||||
${nodes.slice(0, 3).map(node => `
|
||||
<div style="margin-bottom: 6px; padding: 8px; background-color: var(--vscode-bg); border-radius: 2px; font-size: 11px;">
|
||||
<div style="font-family: 'Courier New', monospace; color: var(--vscode-accent); margin-bottom: 4px;">
|
||||
${ComponentHelpers.escapeHtml(ComponentHelpers.truncateText(node.target ? node.target.join(', ') : 'unknown', 80))}
|
||||
</div>
|
||||
${node.failureSummary ? `
|
||||
<div style="color: var(--vscode-text-dim); font-size: 10px;">
|
||||
${ComponentHelpers.escapeHtml(ComponentHelpers.truncateText(node.failureSummary, 150))}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
${nodeCount > 3 ? `<div style="font-size: 10px; color: var(--vscode-text-dim); margin-top: 4px;">... and ${nodeCount - 3} more</div>` : ''}
|
||||
</div>
|
||||
|
||||
${violation.helpUrl ? `
|
||||
<a href="${ComponentHelpers.escapeHtml(violation.helpUrl)}" target="_blank" style="font-size: 11px; color: var(--vscode-accent); text-decoration: none;">
|
||||
Learn more →
|
||||
</a>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
content.innerHTML = `
|
||||
<!-- Summary Card -->
|
||||
<div style="background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px; margin-bottom: 16px;">
|
||||
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 12px;">Audit Summary</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px;">
|
||||
<div style="text-align: center; padding: 12px; background-color: var(--vscode-bg); border-radius: 4px;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: #f48771;">${totalViolations}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-top: 4px;">Violations</div>
|
||||
</div>
|
||||
<div style="text-align: center; padding: 12px; background-color: var(--vscode-bg); border-radius: 4px;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: #89d185;">${totalPasses}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-top: 4px;">Passes</div>
|
||||
</div>
|
||||
<div style="text-align: center; padding: 12px; background-color: var(--vscode-bg); border-radius: 4px;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: var(--vscode-accent);">${totalTests}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-top: 4px;">Total Tests</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Violations List -->
|
||||
<div style="margin-bottom: 12px;">
|
||||
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 12px;">Violations (${totalViolations})</h3>
|
||||
${violationCards}
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); text-align: center; padding-top: 8px; border-top: 1px solid var(--vscode-border);">
|
||||
Audit completed: ${ComponentHelpers.formatTimestamp(new Date())}
|
||||
${this.selector ? ` • Scoped to: ${ComponentHelpers.escapeHtml(this.selector)}` : ' • Full page scan'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
getImpactColor(impact) {
|
||||
const colors = {
|
||||
critical: '#f48771',
|
||||
serious: '#dbb765',
|
||||
moderate: '#dbb765',
|
||||
minor: '#75beff'
|
||||
};
|
||||
return colors[impact] || '#858585';
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="padding: 16px; height: 100%; display: flex; flex-direction: column;">
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center;">
|
||||
<input
|
||||
type="text"
|
||||
id="a11y-selector"
|
||||
placeholder="Optional: CSS selector to scope audit"
|
||||
class="input"
|
||||
style="flex: 1; min-width: 200px;"
|
||||
/>
|
||||
<button id="a11y-run-btn" class="button" style="padding: 4px 12px; font-size: 11px;">
|
||||
▶ Run Audit
|
||||
</button>
|
||||
</div>
|
||||
<div id="a11y-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; margin-bottom: 8px;">Accessibility Audit</h3>
|
||||
<p style="font-size: 12px;">
|
||||
Click "Run Audit" to scan for WCAG violations using axe-core.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-accessibility-report', DSAccessibilityReport);
|
||||
|
||||
export default DSAccessibilityReport;
|
||||
442
admin-ui/js/components/tools/ds-activity-log.js
Normal file
442
admin-ui/js/components/tools/ds-activity-log.js
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* ds-activity-log.js
|
||||
* Activity log showing recent MCP tool executions and user actions
|
||||
*
|
||||
* REFACTORED: DSS-compliant version using DSBaseTool + table-template.js
|
||||
* - Extends DSBaseTool for Shadow DOM, AbortController, and standardized lifecycle
|
||||
* - Uses table-template.js for DSS-compliant table rendering (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';
|
||||
import { createTableView, setupTableEvents, createStatsCard } from '../../templates/table-template.js';
|
||||
|
||||
class DSActivityLog extends DSBaseTool {
|
||||
constructor() {
|
||||
super();
|
||||
this.activities = [];
|
||||
this.maxActivities = 100;
|
||||
this.autoRefresh = false;
|
||||
this.refreshInterval = null;
|
||||
|
||||
// Listen for tool executions
|
||||
this.originalExecuteTool = toolBridge.executeTool.bind(toolBridge);
|
||||
this.setupToolInterceptor();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadActivities();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
setupToolInterceptor() {
|
||||
// Intercept tool executions to log them
|
||||
toolBridge.executeTool = async (toolName, params) => {
|
||||
const startTime = Date.now();
|
||||
const activity = {
|
||||
id: Date.now() + Math.random(),
|
||||
type: 'tool_execution',
|
||||
toolName,
|
||||
params,
|
||||
timestamp: new Date(),
|
||||
status: 'running'
|
||||
};
|
||||
|
||||
this.addActivity(activity);
|
||||
|
||||
try {
|
||||
const result = await this.originalExecuteTool(toolName, params);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
activity.status = 'success';
|
||||
activity.duration = duration;
|
||||
activity.result = result;
|
||||
|
||||
this.updateActivity(activity);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
activity.status = 'error';
|
||||
activity.duration = duration;
|
||||
activity.error = error.message;
|
||||
|
||||
this.updateActivity(activity);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
addActivity(activity) {
|
||||
this.activities.unshift(activity);
|
||||
if (this.activities.length > this.maxActivities) {
|
||||
this.activities.pop();
|
||||
}
|
||||
this.saveActivities();
|
||||
this.renderActivities();
|
||||
}
|
||||
|
||||
updateActivity(activity) {
|
||||
const index = this.activities.findIndex(a => a.id === activity.id);
|
||||
if (index !== -1) {
|
||||
this.activities[index] = activity;
|
||||
this.saveActivities();
|
||||
this.renderActivities();
|
||||
}
|
||||
}
|
||||
|
||||
saveActivities() {
|
||||
try {
|
||||
localStorage.setItem('ds-activity-log', JSON.stringify(this.activities.slice(0, 50)));
|
||||
} catch (e) {
|
||||
logger.warn('[DSActivityLog] Failed to save activities to localStorage', e);
|
||||
}
|
||||
}
|
||||
|
||||
loadActivities() {
|
||||
try {
|
||||
const stored = localStorage.getItem('ds-activity-log');
|
||||
if (stored) {
|
||||
this.activities = JSON.parse(stored).map(a => ({
|
||||
...a,
|
||||
timestamp: new Date(a.timestamp)
|
||||
}));
|
||||
this.renderActivities();
|
||||
logger.debug('[DSActivityLog] Loaded activities from localStorage', { count: this.activities.length });
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('[DSActivityLog] Failed to load activities from localStorage', e);
|
||||
}
|
||||
}
|
||||
|
||||
clearActivities() {
|
||||
this.activities = [];
|
||||
this.saveActivities();
|
||||
this.renderActivities();
|
||||
logger.info('[DSActivityLog] Activities cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the component (required by DSBaseTool)
|
||||
*/
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.activity-log-container {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-controls {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.auto-refresh-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clear-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;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: rgba(75, 181, 211, 0.2);
|
||||
color: #4bb5d3;
|
||||
}
|
||||
|
||||
.badge-running {
|
||||
background: rgba(75, 181, 211, 0.2);
|
||||
color: #4bb5d3;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(137, 209, 133, 0.2);
|
||||
color: #89d185;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: rgba(244, 135, 113, 0.2);
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: 'Courier New', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.icon-cell {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background-color: rgba(244, 135, 113, 0.1);
|
||||
padding: 8px;
|
||||
border-radius: 2px;
|
||||
color: #f48771;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="activity-log-container">
|
||||
<!-- Log Controls -->
|
||||
<div class="log-controls">
|
||||
<label class="auto-refresh-label">
|
||||
<input type="checkbox" id="activity-auto-refresh" />
|
||||
Live updates
|
||||
</label>
|
||||
<button
|
||||
id="activity-clear-btn"
|
||||
data-action="clear"
|
||||
class="clear-btn"
|
||||
type="button"
|
||||
aria-label="Clear activity log">
|
||||
🗑️ Clear Log
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content-wrapper" id="activity-content">
|
||||
<div class="loading">Loading activities...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners (required by DSBaseTool)
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// EVENT-002: Event delegation
|
||||
this.delegateEvents('.activity-log-container', 'click', (action, e) => {
|
||||
if (action === 'clear') {
|
||||
this.clearActivities();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-refresh toggle
|
||||
const autoRefreshToggle = this.$('#activity-auto-refresh');
|
||||
if (autoRefreshToggle) {
|
||||
this.bindEvent(autoRefreshToggle, 'change', (e) => {
|
||||
this.autoRefresh = e.target.checked;
|
||||
if (this.autoRefresh) {
|
||||
this.refreshInterval = setInterval(() => this.renderActivities(), 1000);
|
||||
logger.debug('[DSActivityLog] Auto-refresh enabled');
|
||||
} else {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
logger.debug('[DSActivityLog] Auto-refresh disabled');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getActivityIcon(activity) {
|
||||
if (activity.status === 'running') return '🔄';
|
||||
if (activity.status === 'success') return '✅';
|
||||
if (activity.status === 'error') return '❌';
|
||||
return '⚪';
|
||||
}
|
||||
|
||||
renderActivities() {
|
||||
const content = this.$('#activity-content');
|
||||
if (!content) return;
|
||||
|
||||
if (this.activities.length === 0) {
|
||||
content.innerHTML = '<div class="table-empty"><div class="table-empty-icon">📋</div><div class="table-empty-text">No recent activity</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
Total: this.activities.length,
|
||||
Success: this.activities.filter(a => a.status === 'success').length,
|
||||
Failed: this.activities.filter(a => a.status === 'error').length
|
||||
};
|
||||
|
||||
const running = this.activities.filter(a => a.status === 'running').length;
|
||||
if (running > 0) {
|
||||
stats.Running = running;
|
||||
}
|
||||
|
||||
// Render stats card
|
||||
const statsHtml = createStatsCard(stats);
|
||||
|
||||
// Use table-template.js for DSS-compliant rendering
|
||||
const { html: tableHtml, styles: tableStyles } = createTableView({
|
||||
columns: [
|
||||
{ header: '', key: 'icon', width: '40px', align: 'center' },
|
||||
{ header: 'Status', key: 'status', width: '80px', align: 'left' },
|
||||
{ header: 'Tool', key: 'toolName', align: 'left' },
|
||||
{ header: 'Duration', key: 'duration', width: '100px', align: 'left' },
|
||||
{ header: 'Time', key: 'timestamp', width: '120px', align: 'left' }
|
||||
],
|
||||
rows: this.activities,
|
||||
renderCell: (col, row) => this.renderCell(col, row),
|
||||
renderDetails: (row) => this.renderDetails(row),
|
||||
emptyMessage: 'No recent activity',
|
||||
emptyIcon: '📋'
|
||||
});
|
||||
|
||||
// Adopt table styles
|
||||
this.adoptStyles(tableStyles);
|
||||
|
||||
// Render table
|
||||
content.innerHTML = statsHtml + tableHtml + '<div class="hint">💡 Click any row to view full activity details</div>';
|
||||
|
||||
// Setup table event handlers
|
||||
setupTableEvents(this.shadowRoot);
|
||||
|
||||
logger.debug('[DSActivityLog] Rendered activities', { count: this.activities.length });
|
||||
}
|
||||
|
||||
renderCell(col, row) {
|
||||
const icon = this.getActivityIcon(row);
|
||||
const toolName = row.toolName || 'Unknown';
|
||||
const duration = row.duration ? ComponentHelpers.formatDuration(row.duration) : '-';
|
||||
const timestamp = ComponentHelpers.formatRelativeTime(row.timestamp);
|
||||
|
||||
switch (col.key) {
|
||||
case 'icon':
|
||||
return `<span class="icon-cell">${icon}</span>`;
|
||||
|
||||
case 'status':
|
||||
return `<span class="badge badge-${row.status}">${this.escapeHtml(row.status)}</span>`;
|
||||
|
||||
case 'toolName':
|
||||
return `<span class="tool-name">${this.escapeHtml(toolName)}</span>`;
|
||||
|
||||
case 'duration':
|
||||
return `<span style="color: var(--vscode-descriptionForeground);">${duration}</span>`;
|
||||
|
||||
case 'timestamp':
|
||||
return `<span style="color: var(--vscode-descriptionForeground);">${timestamp}</span>`;
|
||||
|
||||
default:
|
||||
return this.escapeHtml(String(row[col.key] || '-'));
|
||||
}
|
||||
}
|
||||
|
||||
renderDetails(row) {
|
||||
const toolName = row.toolName || 'Unknown';
|
||||
const duration = row.duration ? ComponentHelpers.formatDuration(row.duration) : '-';
|
||||
|
||||
return `
|
||||
<div style="margin-bottom: 12px;">
|
||||
<span class="detail-label">Tool:</span>
|
||||
<span class="detail-value code">${this.escapeHtml(toolName)}</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 12px;">
|
||||
<span class="detail-label">Status:</span>
|
||||
<span class="badge badge-${row.status}">${this.escapeHtml(row.status)}</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 12px;">
|
||||
<span class="detail-label">Timestamp:</span>
|
||||
<span>${ComponentHelpers.formatTimestamp(row.timestamp)}</span>
|
||||
</div>
|
||||
${row.duration ? `
|
||||
<div style="margin-bottom: 12px;">
|
||||
<span class="detail-label">Duration:</span>
|
||||
<span>${duration}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${row.params && Object.keys(row.params).length > 0 ? `
|
||||
<div style="margin-bottom: 12px;">
|
||||
<div class="detail-label" style="display: block; margin-bottom: 4px;">Parameters:</div>
|
||||
<pre class="detail-code">${this.escapeHtml(JSON.stringify(row.params, null, 2))}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
${row.error ? `
|
||||
<div style="margin-bottom: 12px;">
|
||||
<div class="detail-label" style="display: block; margin-bottom: 4px;">Error:</div>
|
||||
<div class="error-box">
|
||||
${this.escapeHtml(row.error)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-activity-log', DSActivityLog);
|
||||
|
||||
export default DSActivityLog;
|
||||
100
admin-ui/js/components/tools/ds-asset-list.js
Normal file
100
admin-ui/js/components/tools/ds-asset-list.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* ds-asset-list.js
|
||||
* List view of design assets (icons, images, etc.)
|
||||
* UX Team Tool #3
|
||||
*/
|
||||
|
||||
import { createGalleryView, setupGalleryHandlers } from '../../utils/tool-templates.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
|
||||
class DSAssetList extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.assets = [];
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
await this.loadAssets();
|
||||
}
|
||||
|
||||
async loadAssets() {
|
||||
this.isLoading = true;
|
||||
const container = this.querySelector('#asset-list-container');
|
||||
if (container) {
|
||||
container.innerHTML = ComponentHelpers.renderLoading('Loading design assets...');
|
||||
}
|
||||
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) {
|
||||
throw new Error('No project selected');
|
||||
}
|
||||
|
||||
// Call assets API
|
||||
const response = await fetch(`/api/assets/list?projectId=${context.project_id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load assets: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this.assets = result.assets || [];
|
||||
|
||||
this.renderAssetGallery();
|
||||
} catch (error) {
|
||||
console.error('[DSAssetList] Failed to load assets:', error);
|
||||
if (container) {
|
||||
container.innerHTML = ComponentHelpers.renderError('Failed to load assets', error);
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
renderAssetGallery() {
|
||||
const container = this.querySelector('#asset-list-container');
|
||||
if (!container) return;
|
||||
|
||||
const config = {
|
||||
title: 'Design Assets',
|
||||
items: this.assets.map(asset => ({
|
||||
id: asset.id,
|
||||
src: asset.url || asset.thumbnailUrl,
|
||||
title: asset.name,
|
||||
subtitle: `${asset.type} • ${asset.size || 'N/A'}`
|
||||
})),
|
||||
onItemClick: (item) => this.viewAsset(item),
|
||||
onDelete: (item) => this.deleteAsset(item)
|
||||
};
|
||||
|
||||
container.innerHTML = createGalleryView(config);
|
||||
setupGalleryHandlers(container, config);
|
||||
}
|
||||
|
||||
viewAsset(item) {
|
||||
// Open asset in new tab or modal
|
||||
if (item.src) {
|
||||
window.open(item.src, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
deleteAsset(item) {
|
||||
ComponentHelpers.showToast?.(`Deleted ${item.title}`, 'success');
|
||||
this.assets = this.assets.filter(a => a.id !== item.id);
|
||||
this.renderAssetGallery();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div id="asset-list-container" style="height: 100%; overflow: hidden;">
|
||||
${ComponentHelpers.renderLoading('Loading assets...')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-asset-list', DSAssetList);
|
||||
|
||||
export default DSAssetList;
|
||||
355
admin-ui/js/components/tools/ds-chat-panel.js
Normal file
355
admin-ui/js/components/tools/ds-chat-panel.js
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* ds-chat-panel.js
|
||||
* AI chatbot panel with team+project context
|
||||
* MVP1: Integrates claude-service with ContextStore for team-aware assistance
|
||||
*/
|
||||
|
||||
import claudeService from '../../services/claude-service.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
|
||||
class DSChatPanel extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.messages = [];
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
// Sync claude-service with ContextStore
|
||||
const context = contextStore.getMCPContext();
|
||||
if (context.project_id) {
|
||||
claudeService.setProject(context.project_id);
|
||||
}
|
||||
|
||||
// Subscribe to project changes
|
||||
this.unsubscribe = contextStore.subscribeToKey('projectId', (newProjectId) => {
|
||||
if (newProjectId) {
|
||||
claudeService.setProject(newProjectId);
|
||||
this.showSystemMessage(`Switched to project: ${newProjectId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize MCP tools in background
|
||||
this.initializeMcpTools();
|
||||
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
this.loadHistory();
|
||||
this.showWelcomeMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize MCP tools to enable AI tool awareness
|
||||
*/
|
||||
async initializeMcpTools() {
|
||||
try {
|
||||
console.log('[DSChatPanel] Initializing MCP tools...');
|
||||
await claudeService.getMcpTools();
|
||||
console.log('[DSChatPanel] MCP tools initialized successfully');
|
||||
} catch (error) {
|
||||
console.warn('[DSChatPanel] Failed to load MCP tools (non-blocking):', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set context from parent component (ds-ai-chat-sidebar)
|
||||
* @param {Object} context - Context object with project, team, page
|
||||
*/
|
||||
setContext(context) {
|
||||
if (!context) return;
|
||||
|
||||
// Handle project context (could be object with id or string id)
|
||||
if (context.project) {
|
||||
const projectId = typeof context.project === 'object'
|
||||
? context.project.id
|
||||
: context.project;
|
||||
|
||||
if (projectId && projectId !== claudeService.currentProjectId) {
|
||||
claudeService.setProject(projectId);
|
||||
console.log('[DSChatPanel] Context updated via setContext:', { projectId });
|
||||
}
|
||||
}
|
||||
|
||||
// Store team and page context for reference
|
||||
if (context.team) {
|
||||
this.currentTeam = context.team;
|
||||
}
|
||||
if (context.page) {
|
||||
this.currentPage = context.page;
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const input = this.querySelector('#chat-input');
|
||||
const sendBtn = this.querySelector('#chat-send-btn');
|
||||
const clearBtn = this.querySelector('#chat-clear-btn');
|
||||
const exportBtn = this.querySelector('#chat-export-btn');
|
||||
|
||||
if (sendBtn && input) {
|
||||
sendBtn.addEventListener('click', () => this.sendMessage());
|
||||
input.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => this.clearChat());
|
||||
}
|
||||
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', () => this.exportChat());
|
||||
}
|
||||
}
|
||||
|
||||
loadHistory() {
|
||||
const history = claudeService.getHistory();
|
||||
if (history && history.length > 0) {
|
||||
this.messages = history;
|
||||
this.renderMessages();
|
||||
}
|
||||
}
|
||||
|
||||
showWelcomeMessage() {
|
||||
if (this.messages.length === 0) {
|
||||
const context = contextStore.getMCPContext();
|
||||
const teamId = context.team_id || 'ui';
|
||||
|
||||
const teamGreetings = {
|
||||
ui: 'I can help with token extraction, component audits, Storybook comparisons, and quick wins analysis.',
|
||||
ux: 'I can assist with Figma syncing, design tokens, asset management, and navigation flows.',
|
||||
qa: 'I can help with visual regression testing, accessibility audits, and ESRE validation.',
|
||||
admin: 'I can help manage projects, configure integrations, and oversee the design system.'
|
||||
};
|
||||
|
||||
const greeting = teamGreetings[teamId] || teamGreetings.admin;
|
||||
|
||||
this.showSystemMessage(
|
||||
`👋 Welcome to the ${teamId.toUpperCase()} team workspace!\n\n${greeting}\n\nI have access to all MCP tools for the active project.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
showSystemMessage(text) {
|
||||
this.messages.push({
|
||||
role: 'system',
|
||||
content: text,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
this.renderMessages();
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const input = this.querySelector('#chat-input');
|
||||
const message = input?.value.trim();
|
||||
|
||||
if (!message || this.isLoading) return;
|
||||
|
||||
// Check project context
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) {
|
||||
ComponentHelpers.showToast?.('Please select a project before chatting', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
this.messages.push({
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Clear input
|
||||
input.value = '';
|
||||
|
||||
// Render and scroll
|
||||
this.renderMessages();
|
||||
this.scrollToBottom();
|
||||
|
||||
// Show loading
|
||||
this.isLoading = true;
|
||||
this.updateLoadingState();
|
||||
|
||||
try {
|
||||
// Add team context to the request
|
||||
const teamContext = {
|
||||
projectId: context.project_id,
|
||||
teamId: context.team_id,
|
||||
userId: context.user_id,
|
||||
page: 'workdesk',
|
||||
capabilities: context.capabilities
|
||||
};
|
||||
|
||||
// Send to Claude with team+project context
|
||||
const response = await claudeService.chat(message, teamContext);
|
||||
|
||||
// Add assistant response
|
||||
this.messages.push({
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
this.renderMessages();
|
||||
this.scrollToBottom();
|
||||
} catch (error) {
|
||||
console.error('[DSChatPanel] Failed to send message:', error);
|
||||
ComponentHelpers.showToast?.(`Chat error: ${error.message}`, 'error');
|
||||
|
||||
this.messages.push({
|
||||
role: 'system',
|
||||
content: `❌ Error: ${error.message}\n\nPlease try again or check your connection.`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
this.renderMessages();
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.updateLoadingState();
|
||||
}
|
||||
}
|
||||
|
||||
clearChat() {
|
||||
if (!confirm('Clear all chat history?')) return;
|
||||
|
||||
claudeService.clearHistory();
|
||||
this.messages = [];
|
||||
this.renderMessages();
|
||||
this.showWelcomeMessage();
|
||||
ComponentHelpers.showToast?.('Chat history cleared', 'success');
|
||||
}
|
||||
|
||||
exportChat() {
|
||||
claudeService.exportConversation();
|
||||
ComponentHelpers.showToast?.('Chat exported successfully', 'success');
|
||||
}
|
||||
|
||||
updateLoadingState() {
|
||||
const sendBtn = this.querySelector('#chat-send-btn');
|
||||
const input = this.querySelector('#chat-input');
|
||||
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = this.isLoading;
|
||||
sendBtn.textContent = this.isLoading ? '⏳ Sending...' : '📤 Send';
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.disabled = this.isLoading;
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
const messagesContainer = this.querySelector('#chat-messages');
|
||||
if (messagesContainer) {
|
||||
setTimeout(() => {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
renderMessages() {
|
||||
const messagesContainer = this.querySelector('#chat-messages');
|
||||
if (!messagesContainer) return;
|
||||
|
||||
if (this.messages.length === 0) {
|
||||
messagesContainer.innerHTML = `
|
||||
<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;">No messages yet</h3>
|
||||
<p style="font-size: 12px;">Start a conversation to get help with your design system.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
messagesContainer.innerHTML = this.messages.map(msg => {
|
||||
const isUser = msg.role === 'user';
|
||||
const isSystem = msg.role === 'system';
|
||||
|
||||
const alignStyle = isUser ? 'flex-end' : 'flex-start';
|
||||
const bgColor = isUser
|
||||
? 'var(--vscode-button-background)'
|
||||
: isSystem
|
||||
? 'rgba(255, 191, 0, 0.1)'
|
||||
: 'var(--vscode-sidebar)';
|
||||
const textColor = isUser ? 'var(--vscode-button-foreground)' : 'var(--vscode-text)';
|
||||
const maxWidth = isSystem ? '100%' : '80%';
|
||||
const icon = isUser ? '👤' : isSystem ? 'ℹ️' : '🤖';
|
||||
|
||||
return `
|
||||
<div style="display: flex; justify-content: ${alignStyle}; margin-bottom: 16px;">
|
||||
<div style="max-width: ${maxWidth}; background: ${bgColor}; padding: 12px; border-radius: 8px; color: ${textColor};">
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim); margin-bottom: 6px; display: flex; align-items: center; gap: 6px;">
|
||||
<span>${icon}</span>
|
||||
<span>${isUser ? 'You' : isSystem ? 'System' : 'AI Assistant'}</span>
|
||||
<span>•</span>
|
||||
<span>${ComponentHelpers.formatRelativeTime(new Date(msg.timestamp))}</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; white-space: pre-wrap; word-wrap: break-word;">
|
||||
${ComponentHelpers.escapeHtml(msg.content)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="height: 100%; display: flex; flex-direction: column; background: var(--vscode-bg);">
|
||||
<!-- Header -->
|
||||
<div style="padding: 12px 16px; border-bottom: 1px solid var(--vscode-border); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<h3 style="font-size: 12px; font-weight: 600; margin-bottom: 4px;">AI Assistant</h3>
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim);">Team-contextualized help with MCP tools</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button id="chat-export-btn" class="button" style="padding: 4px 8px; font-size: 10px;">
|
||||
📥 Export
|
||||
</button>
|
||||
<button id="chat-clear-btn" class="button" style="padding: 4px 8px; font-size: 10px;">
|
||||
🗑️ Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div id="chat-messages" style="flex: 1; overflow-y: auto; padding: 16px;">
|
||||
${ComponentHelpers.renderLoading('Loading chat...')}
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div style="padding: 16px; border-top: 1px solid var(--vscode-border);">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<textarea
|
||||
id="chat-input"
|
||||
placeholder="Ask me anything about your design system..."
|
||||
class="input"
|
||||
style="flex: 1; min-height: 60px; resize: vertical; font-size: 12px;"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button id="chat-send-btn" class="button" style="padding: 8px 16px; font-size: 12px; height: 60px;">
|
||||
📤 Send
|
||||
</button>
|
||||
</div>
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim); margin-top: 8px;">
|
||||
Press Enter to send • Shift+Enter for new line
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-chat-panel', DSChatPanel);
|
||||
|
||||
export default DSChatPanel;
|
||||
170
admin-ui/js/components/tools/ds-component-list.js
Normal file
170
admin-ui/js/components/tools/ds-component-list.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* ds-component-list.js
|
||||
* List view of all design system components
|
||||
* UX Team Tool #4
|
||||
*/
|
||||
|
||||
import { createListView, setupListHandlers } from '../../utils/tool-templates.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
|
||||
class DSComponentList extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.components = [];
|
||||
this.filteredComponents = [];
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
await this.loadComponents();
|
||||
}
|
||||
|
||||
async loadComponents() {
|
||||
this.isLoading = true;
|
||||
const container = this.querySelector('#component-list-container');
|
||||
if (container) {
|
||||
container.innerHTML = ComponentHelpers.renderLoading('Loading components...');
|
||||
}
|
||||
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) {
|
||||
throw new Error('No project selected');
|
||||
}
|
||||
|
||||
// Call component audit to get component list
|
||||
const result = await toolBridge.executeTool('dss_audit_components', {
|
||||
path: `/projects/${context.project_id}`
|
||||
});
|
||||
|
||||
this.components = result.components || [];
|
||||
this.filteredComponents = [...this.components];
|
||||
|
||||
this.renderComponentList();
|
||||
} catch (error) {
|
||||
console.error('[DSComponentList] Failed to load components:', error);
|
||||
if (container) {
|
||||
container.innerHTML = ComponentHelpers.renderError('Failed to load components', error);
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
renderComponentList() {
|
||||
const container = this.querySelector('#component-list-container');
|
||||
if (!container) return;
|
||||
|
||||
const config = {
|
||||
title: 'Design System Components',
|
||||
items: this.filteredComponents,
|
||||
columns: [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Component',
|
||||
render: (comp) => `<span style="font-family: monospace; font-size: 11px; font-weight: 600;">${ComponentHelpers.escapeHtml(comp.name)}</span>`
|
||||
},
|
||||
{
|
||||
key: 'path',
|
||||
label: 'File Path',
|
||||
render: (comp) => `<span style="font-family: monospace; font-size: 10px; color: var(--vscode-text-dim);">${ComponentHelpers.escapeHtml(comp.path)}</span>`
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
render: (comp) => ComponentHelpers.createBadge(comp.type || 'react', 'info')
|
||||
},
|
||||
{
|
||||
key: 'dsAdoption',
|
||||
label: 'DS Adoption',
|
||||
render: (comp) => {
|
||||
const percentage = comp.dsAdoption || 0;
|
||||
let color = '#f48771';
|
||||
if (percentage >= 80) color = '#89d185';
|
||||
else if (percentage >= 50) color = '#ffbf00';
|
||||
return `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="flex: 1; height: 6px; background: var(--vscode-bg); border-radius: 3px; overflow: hidden;">
|
||||
<div style="height: 100%; width: ${percentage}%; background: ${color};"></div>
|
||||
</div>
|
||||
<span style="font-size: 10px; font-weight: 600; min-width: 35px;">${percentage}%</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
label: 'Refresh',
|
||||
icon: '🔄',
|
||||
onClick: () => this.loadComponents()
|
||||
},
|
||||
{
|
||||
label: 'Export Report',
|
||||
icon: '📥',
|
||||
onClick: () => this.exportReport()
|
||||
}
|
||||
],
|
||||
onSearch: (query) => this.handleSearch(query),
|
||||
onFilter: (filterValue) => this.handleFilter(filterValue)
|
||||
};
|
||||
|
||||
container.innerHTML = createListView(config);
|
||||
setupListHandlers(container, config);
|
||||
|
||||
// Update filter dropdown
|
||||
const filterSelect = container.querySelector('#filter-select');
|
||||
if (filterSelect) {
|
||||
const types = [...new Set(this.components.map(c => c.type || 'react'))];
|
||||
filterSelect.innerHTML = `
|
||||
<option value="">All Types</option>
|
||||
${types.map(type => `<option value="${type}">${type}</option>`).join('')}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
handleSearch(query) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
this.filteredComponents = this.components.filter(comp =>
|
||||
comp.name.toLowerCase().includes(lowerQuery) ||
|
||||
comp.path.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
this.renderComponentList();
|
||||
}
|
||||
|
||||
handleFilter(filterValue) {
|
||||
if (!filterValue) {
|
||||
this.filteredComponents = [...this.components];
|
||||
} else {
|
||||
this.filteredComponents = this.components.filter(comp => (comp.type || 'react') === filterValue);
|
||||
}
|
||||
this.renderComponentList();
|
||||
}
|
||||
|
||||
exportReport() {
|
||||
const data = JSON.stringify(this.components, null, 2);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'component-audit.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
ComponentHelpers.showToast?.('Report exported', 'success');
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div id="component-list-container" style="height: 100%; overflow: hidden;">
|
||||
${ComponentHelpers.renderLoading('Loading components...')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-component-list', DSComponentList);
|
||||
|
||||
export default DSComponentList;
|
||||
249
admin-ui/js/components/tools/ds-console-viewer.js
Normal file
249
admin-ui/js/components/tools/ds-console-viewer.js
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* ds-console-viewer.js
|
||||
* Console log viewer with real-time streaming and filtering
|
||||
*/
|
||||
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
|
||||
class DSConsoleViewer extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.logs = [];
|
||||
this.currentFilter = 'all';
|
||||
this.autoScroll = true;
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
|
||||
render() {
|
||||
const filteredLogs = this.currentFilter === 'all'
|
||||
? this.logs
|
||||
: this.logs.filter(log => log.level === this.currentFilter);
|
||||
|
||||
this.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Header Controls -->
|
||||
<div style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--vscode-border);
|
||||
background-color: var(--vscode-sidebar);
|
||||
">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="filter-btn ${this.currentFilter === 'all' ? 'active' : ''}" data-filter="all" style="
|
||||
padding: 4px 12px;
|
||||
background-color: ${this.currentFilter === 'all' ? 'var(--vscode-accent)' : 'transparent'};
|
||||
border: 1px solid var(--vscode-border);
|
||||
color: var(--vscode-text);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
">All</button>
|
||||
<button class="filter-btn ${this.currentFilter === 'log' ? 'active' : ''}" data-filter="log" style="
|
||||
padding: 4px 12px;
|
||||
background-color: ${this.currentFilter === 'log' ? 'var(--vscode-accent)' : 'transparent'};
|
||||
border: 1px solid var(--vscode-border);
|
||||
color: var(--vscode-text);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
">Log</button>
|
||||
<button class="filter-btn ${this.currentFilter === 'warn' ? 'active' : ''}" data-filter="warn" style="
|
||||
padding: 4px 12px;
|
||||
background-color: ${this.currentFilter === 'warn' ? 'var(--vscode-accent)' : 'transparent'};
|
||||
border: 1px solid var(--vscode-border);
|
||||
color: var(--vscode-text);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
">Warn</button>
|
||||
<button class="filter-btn ${this.currentFilter === 'error' ? 'active' : ''}" data-filter="error" style="
|
||||
padding: 4px 12px;
|
||||
background-color: ${this.currentFilter === 'error' ? 'var(--vscode-accent)' : 'transparent'};
|
||||
border: 1px solid var(--vscode-border);
|
||||
color: var(--vscode-text);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
">Error</button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<label style="font-size: 11px; display: flex; align-items: center; gap: 4px; cursor: pointer;">
|
||||
<input type="checkbox" id="auto-scroll-toggle" ${this.autoScroll ? 'checked' : ''}>
|
||||
Auto-scroll
|
||||
</label>
|
||||
<button id="clear-logs-btn" class="button" style="padding: 4px 12px; font-size: 11px;">
|
||||
Clear
|
||||
</button>
|
||||
<button id="refresh-logs-btn" class="button" style="padding: 4px 12px; font-size: 11px;">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Console Output -->
|
||||
<div id="console-output" style="
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
background-color: var(--vscode-bg);
|
||||
">
|
||||
${filteredLogs.length === 0 ? `
|
||||
<div style="padding: 16px; text-align: center; color: var(--vscode-text-dim);">
|
||||
No console logs${this.currentFilter !== 'all' ? ` (${this.currentFilter})` : ''}
|
||||
</div>
|
||||
` : filteredLogs.map(log => this.renderLogEntry(log)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.setupEventListeners();
|
||||
|
||||
if (this.autoScroll) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
renderLogEntry(log) {
|
||||
const levelColors = {
|
||||
log: 'var(--vscode-text)',
|
||||
warn: '#ff9800',
|
||||
error: '#f44336',
|
||||
info: '#2196f3',
|
||||
debug: 'var(--vscode-text-dim)'
|
||||
};
|
||||
|
||||
const color = levelColors[log.level] || 'var(--vscode-text)';
|
||||
|
||||
return `
|
||||
<div style="
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--vscode-border);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
">
|
||||
<span style="color: var(--vscode-text-dim); min-width: 80px; flex-shrink: 0;">
|
||||
${log.timestamp}
|
||||
</span>
|
||||
<span style="color: ${color}; font-weight: 600; min-width: 50px; flex-shrink: 0;">
|
||||
[${log.level.toUpperCase()}]
|
||||
</span>
|
||||
<span style="color: var(--vscode-text); flex: 1; word-break: break-word;">
|
||||
${this.escapeHtml(log.message)}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Filter buttons
|
||||
this.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
this.currentFilter = e.target.dataset.filter;
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-scroll toggle
|
||||
const autoScrollToggle = this.querySelector('#auto-scroll-toggle');
|
||||
if (autoScrollToggle) {
|
||||
autoScrollToggle.addEventListener('change', (e) => {
|
||||
this.autoScroll = e.target.checked;
|
||||
});
|
||||
}
|
||||
|
||||
// Clear button
|
||||
const clearBtn = this.querySelector('#clear-logs-btn');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
this.logs = [];
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh button
|
||||
const refreshBtn = this.querySelector('#refresh-logs-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
this.fetchLogs();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fetchLogs() {
|
||||
try {
|
||||
const result = await toolBridge.getBrowserLogs(this.currentFilter, 100);
|
||||
if (result && result.logs) {
|
||||
this.logs = result.logs.map(log => ({
|
||||
timestamp: new Date(log.timestamp).toLocaleTimeString(),
|
||||
level: log.level || 'log',
|
||||
message: log.message || JSON.stringify(log)
|
||||
}));
|
||||
this.render();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch logs:', error);
|
||||
this.addLog('error', `Failed to fetch logs: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
addLog(level, message) {
|
||||
const now = new Date();
|
||||
this.logs.push({
|
||||
timestamp: now.toLocaleTimeString(),
|
||||
level,
|
||||
message
|
||||
});
|
||||
|
||||
// Keep only last 100 logs
|
||||
if (this.logs.length > 100) {
|
||||
this.logs = this.logs.slice(-100);
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
// Fetch logs every 2 seconds
|
||||
this.fetchLogs();
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.fetchLogs();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
stopAutoRefresh() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
const output = this.querySelector('#console-output');
|
||||
if (output) {
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-console-viewer', DSConsoleViewer);
|
||||
|
||||
export default DSConsoleViewer;
|
||||
233
admin-ui/js/components/tools/ds-esre-editor.js
Normal file
233
admin-ui/js/components/tools/ds-esre-editor.js
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* ds-esre-editor.js
|
||||
* Editor for ESRE (Explicit Style Requirements and Expectations)
|
||||
* QA Team Tool #2
|
||||
*/
|
||||
|
||||
import { createEditorView, setupEditorHandlers } from '../../utils/tool-templates.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
|
||||
class DSESREEditor extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.esreContent = '';
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
await this.loadESRE();
|
||||
}
|
||||
|
||||
async loadESRE() {
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) {
|
||||
throw new Error('No project selected');
|
||||
}
|
||||
|
||||
// Load ESRE from project configuration
|
||||
const response = await fetch(`/api/projects/${context.project_id}/esre`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
this.esreContent = result.content || '';
|
||||
this.renderEditor();
|
||||
} else {
|
||||
// No ESRE yet, start with template
|
||||
this.esreContent = this.getESRETemplate();
|
||||
this.renderEditor();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DSESREEditor] Failed to load ESRE:', error);
|
||||
this.esreContent = this.getESRETemplate();
|
||||
this.renderEditor();
|
||||
}
|
||||
}
|
||||
|
||||
getESRETemplate() {
|
||||
return `# Explicit Style Requirements and Expectations (ESRE)
|
||||
|
||||
## Project: ${contextStore.get('projectId') || 'Design System'}
|
||||
|
||||
### Color Requirements
|
||||
- Primary colors must match Figma specifications exactly
|
||||
- Accessibility: All text must meet WCAG 2.1 AA contrast ratios
|
||||
- Color tokens must be used instead of hardcoded hex values
|
||||
|
||||
### Typography Requirements
|
||||
- Font families: [Specify approved fonts]
|
||||
- Font sizes must use design system scale
|
||||
- Line heights must maintain readability
|
||||
- Letter spacing should follow design specifications
|
||||
|
||||
### Spacing Requirements
|
||||
- All spacing must use design system spacing scale
|
||||
- Margins and padding should be consistent across components
|
||||
- Grid system: [Specify grid specifications]
|
||||
|
||||
### Component Requirements
|
||||
- All components must be built from design system primitives
|
||||
- Component variants must match Figma component variants
|
||||
- Props should follow naming conventions
|
||||
|
||||
### Responsive Requirements
|
||||
- Breakpoints: [Specify breakpoints]
|
||||
- Mobile-first approach required
|
||||
- Touch targets must be at least 44x44px
|
||||
|
||||
### Accessibility Requirements
|
||||
- All interactive elements must be keyboard accessible
|
||||
- ARIA labels required for icon-only buttons
|
||||
- Focus indicators must be visible
|
||||
- Screen reader testing required
|
||||
|
||||
### Performance Requirements
|
||||
- Initial load time: [Specify target]
|
||||
- Time to Interactive: [Specify target]
|
||||
- Bundle size limits: [Specify limits]
|
||||
|
||||
### Browser Support
|
||||
- Chrome: Latest 2 versions
|
||||
- Firefox: Latest 2 versions
|
||||
- Safari: Latest 2 versions
|
||||
- Edge: Latest 2 versions
|
||||
|
||||
---
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
### Pre-Deployment
|
||||
- [ ] All colors match Figma specifications
|
||||
- [ ] Typography follows design system scale
|
||||
- [ ] Spacing uses design tokens
|
||||
- [ ] Components match design system library
|
||||
- [ ] Responsive behavior validated
|
||||
- [ ] Accessibility audit passed
|
||||
- [ ] Performance metrics met
|
||||
- [ ] Cross-browser testing completed
|
||||
|
||||
### QA Testing
|
||||
- [ ] Visual comparison with Figma
|
||||
- [ ] Keyboard navigation tested
|
||||
- [ ] Screen reader compatibility verified
|
||||
- [ ] Mobile devices tested
|
||||
- [ ] Edge cases validated
|
||||
|
||||
---
|
||||
|
||||
Last updated: ${new Date().toISOString().split('T')[0]}
|
||||
`;
|
||||
}
|
||||
|
||||
renderEditor() {
|
||||
const container = this.querySelector('#editor-container');
|
||||
if (!container) return;
|
||||
|
||||
const config = {
|
||||
title: 'ESRE Editor',
|
||||
content: this.esreContent,
|
||||
language: 'markdown',
|
||||
onSave: (content) => this.saveESRE(content),
|
||||
onExport: (content) => this.exportESRE(content)
|
||||
};
|
||||
|
||||
container.innerHTML = createEditorView(config);
|
||||
setupEditorHandlers(container, config);
|
||||
}
|
||||
|
||||
async saveESRE(content) {
|
||||
this.isSaving = true;
|
||||
const saveBtn = document.querySelector('#editor-save-btn');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = '⏳ Saving...';
|
||||
}
|
||||
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) {
|
||||
throw new Error('No project selected');
|
||||
}
|
||||
|
||||
// Save ESRE via API
|
||||
const response = await fetch('/api/esre/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectId: context.project_id,
|
||||
content
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Save failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
this.esreContent = content;
|
||||
ComponentHelpers.showToast?.('ESRE saved successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DSESREEditor] Save failed:', error);
|
||||
ComponentHelpers.showToast?.(`Save failed: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = '💾 Save';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exportESRE(content) {
|
||||
const blob = new Blob([content], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const projectId = contextStore.get('projectId') || 'project';
|
||||
a.href = url;
|
||||
a.download = `${projectId}-esre.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
ComponentHelpers.showToast?.('ESRE exported', 'success');
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Info Banner -->
|
||||
<div style="padding: 12px 16px; background: rgba(255, 191, 0, 0.1); border-bottom: 1px solid var(--vscode-border);">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<div style="font-size: 20px;">📋</div>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 11px; font-weight: 600; margin-bottom: 2px;">
|
||||
ESRE: Explicit Style Requirements and Expectations
|
||||
</div>
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim);">
|
||||
Define clear specifications for design implementation and QA validation
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Container -->
|
||||
<div id="editor-container" style="flex: 1; overflow: hidden;">
|
||||
${createEditorView({
|
||||
title: 'ESRE Editor',
|
||||
content: this.esreContent,
|
||||
language: 'markdown',
|
||||
onSave: (content) => this.saveESRE(content),
|
||||
onExport: (content) => this.exportESRE(content)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- Help Footer -->
|
||||
<div style="padding: 8px 16px; border-top: 1px solid var(--vscode-border); font-size: 10px; color: var(--vscode-text-dim);">
|
||||
💡 Tip: Use Markdown formatting for clear documentation. Save changes before closing.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-esre-editor', DSESREEditor);
|
||||
|
||||
export default DSESREEditor;
|
||||
303
admin-ui/js/components/tools/ds-figma-extract-quick.js
Normal file
303
admin-ui/js/components/tools/ds-figma-extract-quick.js
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* ds-figma-extract-quick.js
|
||||
* One-click Figma token extraction tool
|
||||
* MVP2: Extract design tokens directly from Figma file
|
||||
*/
|
||||
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
|
||||
export default class FigmaExtractQuick extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.figmaUrl = '';
|
||||
this.extractionProgress = 0;
|
||||
this.extractedTokens = [];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="padding: 24px; height: 100%; overflow-y: auto;">
|
||||
<div style="margin-bottom: 24px;">
|
||||
<h1 style="margin: 0 0 8px 0; font-size: 24px;">Figma Token Extraction</h1>
|
||||
<p style="margin: 0; color: var(--vscode-text-dim);">
|
||||
Extract design tokens directly from your Figma file
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Input Section -->
|
||||
<div style="background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px; margin-bottom: 24px;">
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label style="display: block; font-size: 12px; font-weight: 500; margin-bottom: 8px;">
|
||||
Figma File URL or Key
|
||||
</label>
|
||||
<input
|
||||
id="figma-url-input"
|
||||
type="text"
|
||||
placeholder="https://figma.com/file/xxx/Design-Tokens or file-key"
|
||||
style="
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-foreground);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
box-sizing: border-box;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||
<button id="extract-btn" style="
|
||||
padding: 8px 16px;
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
">🚀 Extract Tokens</button>
|
||||
|
||||
<button id="export-btn" style="
|
||||
padding: 8px 16px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
">📥 Import to Project</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Section -->
|
||||
<div id="progress-container" style="display: none; margin-bottom: 24px;">
|
||||
<div style="margin-bottom: 8px; font-size: 12px; color: var(--vscode-text-dim);">
|
||||
Extracting tokens... <span id="progress-percent">0%</span>
|
||||
</div>
|
||||
<div style="
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--vscode-bg);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
">
|
||||
<div id="progress-bar" style="
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: #0066CC;
|
||||
transition: width 0.3s ease;
|
||||
"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div id="results-container" style="display: none; background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px;">
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 14px;">✓ Extraction Complete</h3>
|
||||
<div id="token-summary" style="
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
">
|
||||
<!-- Summary cards will be inserted here -->
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--vscode-border);">
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 8px;">
|
||||
Extracted Tokens:
|
||||
</div>
|
||||
<pre id="token-preview" style="
|
||||
background: var(--vscode-bg);
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
margin: 0;
|
||||
color: #CE9178;
|
||||
">{}</pre>
|
||||
</div>
|
||||
|
||||
<button id="copy-tokens-btn" style="
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
">📋 Copy JSON</button>
|
||||
</div>
|
||||
|
||||
<!-- Instructions Section -->
|
||||
<div style="background: var(--vscode-notificationsErrorIcon); opacity: 0.1; border: 1px solid var(--vscode-border); border-radius: 4px; padding: 12px;">
|
||||
<div style="font-size: 12px; color: var(--vscode-text-dim);">
|
||||
<strong>How to extract:</strong>
|
||||
<ol style="margin: 8px 0 0 20px; padding: 0;">
|
||||
<li>Open your Figma Design Tokens file</li>
|
||||
<li>Copy the file URL or key from browser</li>
|
||||
<li>Paste it above and click "Extract Tokens"</li>
|
||||
<li>Review and import to your project</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const extractBtn = this.querySelector('#extract-btn');
|
||||
const exportBtn = this.querySelector('#export-btn');
|
||||
const copyBtn = this.querySelector('#copy-tokens-btn');
|
||||
const input = this.querySelector('#figma-url-input');
|
||||
|
||||
if (extractBtn) {
|
||||
extractBtn.addEventListener('click', () => this.extractTokens());
|
||||
}
|
||||
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', () => this.importTokens());
|
||||
}
|
||||
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => this.copyTokensToClipboard());
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.addEventListener('change', (e) => {
|
||||
this.figmaUrl = e.target.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async extractTokens() {
|
||||
const url = this.figmaUrl.trim();
|
||||
|
||||
if (!url) {
|
||||
alert('Please enter a Figma file URL or key');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate Figma URL or key format
|
||||
const isFigmaUrl = url.includes('figma.com');
|
||||
const isFigmaKey = /^[a-zA-Z0-9]{20,}$/.test(url);
|
||||
|
||||
if (!isFigmaUrl && !isFigmaKey) {
|
||||
alert('Invalid Figma URL or key format. Please provide a valid Figma file URL or file key.');
|
||||
return;
|
||||
}
|
||||
|
||||
const progressContainer = this.querySelector('#progress-container');
|
||||
const resultsContainer = this.querySelector('#results-container');
|
||||
|
||||
progressContainer.style.display = 'block';
|
||||
resultsContainer.style.display = 'none';
|
||||
|
||||
// Simulate token extraction process
|
||||
this.extractedTokens = this.generateMockTokens();
|
||||
|
||||
for (let i = 0; i <= 100; i += 10) {
|
||||
this.extractionProgress = i;
|
||||
this.querySelector('#progress-percent').textContent = i + '%';
|
||||
this.querySelector('#progress-bar').style.width = i + '%';
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
this.showResults();
|
||||
}
|
||||
|
||||
generateMockTokens() {
|
||||
return {
|
||||
colors: {
|
||||
primary: { value: '#0066CC', description: 'Primary brand color' },
|
||||
secondary: { value: '#4CAF50', description: 'Secondary brand color' },
|
||||
error: { value: '#F44336', description: 'Error/danger color' },
|
||||
warning: { value: '#FF9800', description: 'Warning color' },
|
||||
success: { value: '#4CAF50', description: 'Success color' }
|
||||
},
|
||||
spacing: {
|
||||
xs: { value: '4px', description: 'Extra small spacing' },
|
||||
sm: { value: '8px', description: 'Small spacing' },
|
||||
md: { value: '16px', description: 'Medium spacing' },
|
||||
lg: { value: '24px', description: 'Large spacing' },
|
||||
xl: { value: '32px', description: 'Extra large spacing' }
|
||||
},
|
||||
typography: {
|
||||
heading: { value: 'Poppins, sans-serif', description: 'Heading font' },
|
||||
body: { value: 'Inter, sans-serif', description: 'Body font' },
|
||||
mono: { value: 'Courier New, monospace', description: 'Monospace font' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
showResults() {
|
||||
const progressContainer = this.querySelector('#progress-container');
|
||||
const resultsContainer = this.querySelector('#results-container');
|
||||
progressContainer.style.display = 'none';
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
// Create summary cards
|
||||
const summary = this.querySelector('#token-summary');
|
||||
const categories = Object.keys(this.extractedTokens);
|
||||
summary.innerHTML = categories.map(cat => `
|
||||
<div style="
|
||||
background: var(--vscode-bg);
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
">
|
||||
<div style="font-size: 18px; font-weight: 600; color: #0066CC;">
|
||||
${Object.keys(this.extractedTokens[cat]).length}
|
||||
</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); text-transform: capitalize;">
|
||||
${cat}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Show preview
|
||||
this.querySelector('#token-preview').textContent = JSON.stringify(this.extractedTokens, null, 2);
|
||||
}
|
||||
|
||||
copyTokensToClipboard() {
|
||||
const json = JSON.stringify(this.extractedTokens, null, 2);
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
const btn = this.querySelector('#copy-tokens-btn');
|
||||
const original = btn.textContent;
|
||||
btn.textContent = '✓ Copied to clipboard';
|
||||
setTimeout(() => {
|
||||
btn.textContent = original;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
importTokens() {
|
||||
const json = JSON.stringify(this.extractedTokens, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'figma-tokens.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Also dispatch event for integration with project
|
||||
this.dispatchEvent(new CustomEvent('tokens-extracted', {
|
||||
detail: { tokens: this.extractedTokens },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-figma-extract-quick', FigmaExtractQuick);
|
||||
297
admin-ui/js/components/tools/ds-figma-extraction.js
Normal file
297
admin-ui/js/components/tools/ds-figma-extraction.js
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* ds-figma-extraction.js
|
||||
* Interface for extracting design tokens from Figma files
|
||||
* UI Team Tool #3
|
||||
*/
|
||||
|
||||
import { createFormView, setupFormHandlers } from '../../utils/tool-templates.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
import apiClient from '../../services/api-client.js';
|
||||
|
||||
class DSFigmaExtraction extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.figmaFileKey = '';
|
||||
this.figmaToken = '';
|
||||
this.extractionResults = null;
|
||||
this.isExtracting = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await this.loadProjectConfig();
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
async loadProjectConfig() {
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) return;
|
||||
|
||||
const project = await apiClient.getProject(context.project_id);
|
||||
const figmaUrl = project.figma_ui_file || '';
|
||||
|
||||
// Extract file key from Figma URL
|
||||
const match = figmaUrl.match(/file\/([^/]+)/);
|
||||
if (match) {
|
||||
this.figmaFileKey = match[1];
|
||||
}
|
||||
|
||||
// Check for stored Figma token
|
||||
this.figmaToken = localStorage.getItem('figma_token') || '';
|
||||
} catch (error) {
|
||||
console.error('[DSFigmaExtraction] Failed to load project config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const fileKeyInput = this.querySelector('#figma-file-key');
|
||||
const tokenInput = this.querySelector('#figma-token');
|
||||
const extractBtn = this.querySelector('#extract-btn');
|
||||
const saveTokenCheckbox = this.querySelector('#save-token');
|
||||
|
||||
if (fileKeyInput) {
|
||||
fileKeyInput.value = this.figmaFileKey;
|
||||
}
|
||||
|
||||
if (tokenInput) {
|
||||
tokenInput.value = this.figmaToken;
|
||||
}
|
||||
|
||||
if (extractBtn) {
|
||||
extractBtn.addEventListener('click', () => this.extractTokens());
|
||||
}
|
||||
|
||||
if (saveTokenCheckbox && tokenInput) {
|
||||
tokenInput.addEventListener('change', () => {
|
||||
if (saveTokenCheckbox.checked) {
|
||||
localStorage.setItem('figma_token', tokenInput.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async extractTokens() {
|
||||
const fileKeyInput = this.querySelector('#figma-file-key');
|
||||
const tokenInput = this.querySelector('#figma-token');
|
||||
|
||||
this.figmaFileKey = fileKeyInput?.value.trim() || '';
|
||||
this.figmaToken = tokenInput?.value.trim() || '';
|
||||
|
||||
if (!this.figmaFileKey) {
|
||||
ComponentHelpers.showToast?.('Please enter a Figma file key', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.figmaToken) {
|
||||
ComponentHelpers.showToast?.('Please enter a Figma API token', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isExtracting = true;
|
||||
this.updateLoadingState();
|
||||
|
||||
try {
|
||||
// Set Figma token as environment variable for MCP tool
|
||||
// In real implementation, this would be securely stored
|
||||
process.env.FIGMA_TOKEN = this.figmaToken;
|
||||
|
||||
// Call dss_sync_figma MCP tool
|
||||
const result = await toolBridge.executeTool('dss_sync_figma', {
|
||||
file_key: this.figmaFileKey
|
||||
});
|
||||
|
||||
this.extractionResults = result;
|
||||
this.renderResults();
|
||||
|
||||
ComponentHelpers.showToast?.('Tokens extracted successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DSFigmaExtraction] Extraction failed:', error);
|
||||
ComponentHelpers.showToast?.(`Extraction failed: ${error.message}`, 'error');
|
||||
|
||||
const resultsContainer = this.querySelector('#results-container');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = ComponentHelpers.renderError('Token extraction failed', error);
|
||||
}
|
||||
} finally {
|
||||
this.isExtracting = false;
|
||||
this.updateLoadingState();
|
||||
}
|
||||
}
|
||||
|
||||
updateLoadingState() {
|
||||
const extractBtn = this.querySelector('#extract-btn');
|
||||
if (!extractBtn) return;
|
||||
|
||||
if (this.isExtracting) {
|
||||
extractBtn.disabled = true;
|
||||
extractBtn.textContent = '⏳ Extracting...';
|
||||
} else {
|
||||
extractBtn.disabled = false;
|
||||
extractBtn.textContent = '🎨 Extract Tokens';
|
||||
}
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const resultsContainer = this.querySelector('#results-container');
|
||||
if (!resultsContainer || !this.extractionResults) return;
|
||||
|
||||
const tokenCount = Object.keys(this.extractionResults.tokens || {}).length;
|
||||
|
||||
resultsContainer.innerHTML = `
|
||||
<div style="padding: 16px;">
|
||||
<div style="background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px; margin-bottom: 16px;">
|
||||
<h4 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Extraction Summary</h4>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; font-size: 11px;">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: var(--vscode-text);">${tokenCount}</div>
|
||||
<div style="color: var(--vscode-text-dim); margin-top: 4px;">Tokens Found</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: #89d185;">✓</div>
|
||||
<div style="color: var(--vscode-text-dim); margin-top: 4px;">Success</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<button id="export-json-btn" class="button" style="font-size: 11px;">
|
||||
📥 Export JSON
|
||||
</button>
|
||||
<button id="export-css-btn" class="button" style="font-size: 11px;">
|
||||
📥 Export CSS
|
||||
</button>
|
||||
<button id="view-tokens-btn" class="button" style="font-size: 11px;">
|
||||
👁️ View Tokens
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Setup export handlers
|
||||
const exportJsonBtn = resultsContainer.querySelector('#export-json-btn');
|
||||
const exportCssBtn = resultsContainer.querySelector('#export-css-btn');
|
||||
const viewTokensBtn = resultsContainer.querySelector('#view-tokens-btn');
|
||||
|
||||
if (exportJsonBtn) {
|
||||
exportJsonBtn.addEventListener('click', () => this.exportTokens('json'));
|
||||
}
|
||||
|
||||
if (exportCssBtn) {
|
||||
exportCssBtn.addEventListener('click', () => this.exportTokens('css'));
|
||||
}
|
||||
|
||||
if (viewTokensBtn) {
|
||||
viewTokensBtn.addEventListener('click', () => {
|
||||
// Switch to Token Inspector panel
|
||||
const panel = document.querySelector('ds-panel');
|
||||
if (panel) {
|
||||
panel.switchTab('tokens');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
exportTokens(format) {
|
||||
if (!this.extractionResults) return;
|
||||
|
||||
const filename = `figma-tokens-${this.figmaFileKey}.${format}`;
|
||||
let content = '';
|
||||
|
||||
if (format === 'json') {
|
||||
content = JSON.stringify(this.extractionResults.tokens, null, 2);
|
||||
} else if (format === 'css') {
|
||||
// Convert tokens to CSS custom properties
|
||||
const tokens = this.extractionResults.tokens;
|
||||
content = ':root {\n';
|
||||
for (const [key, value] of Object.entries(tokens)) {
|
||||
content += ` --${key}: ${value};\n`;
|
||||
}
|
||||
content += '}\n';
|
||||
}
|
||||
|
||||
// Create download
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
ComponentHelpers.showToast?.(`Exported as ${filename}`, 'success');
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Configuration Panel -->
|
||||
<div style="padding: 16px; border-bottom: 1px solid var(--vscode-border); background: var(--vscode-sidebar);">
|
||||
<h3 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Figma Token Extraction</h3>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 2fr 3fr auto; gap: 12px; align-items: end;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
||||
Figma File Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="figma-file-key"
|
||||
placeholder="abc123def456..."
|
||||
class="input"
|
||||
style="width: 100%; font-size: 11px; font-family: monospace;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
||||
Figma API Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="figma-token"
|
||||
placeholder="figd_..."
|
||||
class="input"
|
||||
style="width: 100%; font-size: 11px; font-family: monospace;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button id="extract-btn" class="button" style="font-size: 11px; padding: 6px 16px;">
|
||||
🎨 Extract Tokens
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<label style="font-size: 10px; color: var(--vscode-text-dim); display: flex; align-items: center; gap: 6px;">
|
||||
<input type="checkbox" id="save-token" />
|
||||
Remember Figma token (stored locally)
|
||||
</label>
|
||||
<a href="https://www.figma.com/developers/api#authentication" target="_blank" style="font-size: 10px; color: var(--vscode-link);">
|
||||
Get API Token →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Container -->
|
||||
<div id="results-container" style="flex: 1; overflow: auto;">
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100%; text-align: center; padding: 48px;">
|
||||
<div>
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">🎨</div>
|
||||
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">Ready to Extract Tokens</h3>
|
||||
<p style="font-size: 12px; color: var(--vscode-text-dim);">
|
||||
Enter your Figma file key and API token above to extract design tokens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-figma-extraction', DSFigmaExtraction);
|
||||
|
||||
export default DSFigmaExtraction;
|
||||
201
admin-ui/js/components/tools/ds-figma-live-compare.js
Normal file
201
admin-ui/js/components/tools/ds-figma-live-compare.js
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* ds-figma-live-compare.js
|
||||
* Side-by-side Figma and Live Application comparison for QA validation
|
||||
* QA Team Tool #1
|
||||
*/
|
||||
|
||||
import { createComparisonView, setupComparisonHandlers } from '../../utils/tool-templates.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
import apiClient from '../../services/api-client.js';
|
||||
|
||||
class DSFigmaLiveCompare extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.figmaUrl = '';
|
||||
this.liveUrl = '';
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await this.loadProjectConfig();
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
async loadProjectConfig() {
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) {
|
||||
throw new Error('No project selected');
|
||||
}
|
||||
|
||||
const project = await apiClient.getProject(context.project_id);
|
||||
this.figmaUrl = project.figma_qa_file || project.figma_ui_file || '';
|
||||
this.liveUrl = project.live_url || window.location.origin;
|
||||
} catch (error) {
|
||||
console.error('[DSFigmaLiveCompare] Failed to load project config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const figmaInput = this.querySelector('#figma-url-input');
|
||||
const liveInput = this.querySelector('#live-url-input');
|
||||
const loadBtn = this.querySelector('#load-comparison-btn');
|
||||
const screenshotBtn = this.querySelector('#take-screenshot-btn');
|
||||
|
||||
if (figmaInput) {
|
||||
figmaInput.value = this.figmaUrl;
|
||||
}
|
||||
|
||||
if (liveInput) {
|
||||
liveInput.value = this.liveUrl;
|
||||
}
|
||||
|
||||
if (loadBtn) {
|
||||
loadBtn.addEventListener('click', () => this.loadComparison());
|
||||
}
|
||||
|
||||
if (screenshotBtn) {
|
||||
screenshotBtn.addEventListener('click', () => this.takeScreenshots());
|
||||
}
|
||||
|
||||
const comparisonContainer = this.querySelector('#comparison-container');
|
||||
if (comparisonContainer) {
|
||||
setupComparisonHandlers(comparisonContainer, {});
|
||||
}
|
||||
}
|
||||
|
||||
loadComparison() {
|
||||
const figmaInput = this.querySelector('#figma-url-input');
|
||||
const liveInput = this.querySelector('#live-url-input');
|
||||
|
||||
this.figmaUrl = figmaInput?.value || '';
|
||||
this.liveUrl = liveInput?.value || '';
|
||||
|
||||
if (!this.figmaUrl || !this.liveUrl) {
|
||||
ComponentHelpers.showToast?.('Please enter both Figma and Live URLs', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(this.figmaUrl);
|
||||
new URL(this.liveUrl);
|
||||
} catch (error) {
|
||||
ComponentHelpers.showToast?.('Invalid URL format', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const comparisonContainer = this.querySelector('#comparison-container');
|
||||
if (comparisonContainer) {
|
||||
comparisonContainer.innerHTML = createComparisonView({
|
||||
leftTitle: 'Figma Design',
|
||||
rightTitle: 'Live Application',
|
||||
leftSrc: this.figmaUrl,
|
||||
rightSrc: this.liveUrl
|
||||
});
|
||||
|
||||
setupComparisonHandlers(comparisonContainer, {});
|
||||
ComponentHelpers.showToast?.('Comparison loaded', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
async takeScreenshots() {
|
||||
ComponentHelpers.showToast?.('Taking screenshots...', 'info');
|
||||
|
||||
try {
|
||||
// Take screenshot of live application via MCP (using authenticated API client)
|
||||
const context = contextStore.getMCPContext();
|
||||
await apiClient.request('POST', '/qa/screenshot-compare', {
|
||||
projectId: context.project_id,
|
||||
figmaUrl: this.figmaUrl,
|
||||
liveUrl: this.liveUrl
|
||||
});
|
||||
|
||||
ComponentHelpers.showToast?.('Screenshots saved to gallery', 'success');
|
||||
|
||||
// Switch to screenshot gallery
|
||||
const panel = document.querySelector('ds-panel');
|
||||
if (panel) {
|
||||
panel.switchTab('screenshots');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DSFigmaLiveCompare] Screenshot failed:', error);
|
||||
ComponentHelpers.showToast?.(`Screenshot failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Configuration Panel -->
|
||||
<div style="padding: 16px; border-bottom: 1px solid var(--vscode-border); background: var(--vscode-sidebar);">
|
||||
<h3 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Figma vs Live QA Comparison</h3>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr auto auto; gap: 12px; align-items: end;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
||||
Figma Design URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="figma-url-input"
|
||||
placeholder="https://figma.com/file/..."
|
||||
class="input"
|
||||
style="width: 100%; font-size: 11px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
||||
Live Component URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="live-url-input"
|
||||
placeholder="https://app.example.com/..."
|
||||
class="input"
|
||||
style="width: 100%; font-size: 11px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button id="load-comparison-btn" class="button" style="font-size: 11px; padding: 6px 16px;">
|
||||
🔍 Load
|
||||
</button>
|
||||
|
||||
<button id="take-screenshot-btn" class="button" style="font-size: 11px; padding: 6px 16px;">
|
||||
📸 Screenshot
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px; font-size: 10px; color: var(--vscode-text-dim);">
|
||||
💡 Compare design specifications with live implementation for QA validation
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comparison View -->
|
||||
<div id="comparison-container" style="flex: 1; overflow: hidden;">
|
||||
${this.figmaUrl && this.liveUrl ? createComparisonView({
|
||||
leftTitle: 'Figma Design',
|
||||
rightTitle: 'Live Application',
|
||||
leftSrc: this.figmaUrl,
|
||||
rightSrc: this.liveUrl
|
||||
}) : `
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100%; text-align: center; padding: 48px;">
|
||||
<div>
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">✅</div>
|
||||
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">QA Comparison Tool</h3>
|
||||
<p style="font-size: 12px; color: var(--vscode-text-dim);">
|
||||
Enter Figma design and live application URLs to validate implementation against specifications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-figma-live-compare', DSFigmaLiveCompare);
|
||||
|
||||
export default DSFigmaLiveCompare;
|
||||
266
admin-ui/js/components/tools/ds-figma-plugin.js
Normal file
266
admin-ui/js/components/tools/ds-figma-plugin.js
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* ds-figma-plugin.js
|
||||
* Interface for Figma plugin export and token management
|
||||
* UX Team Tool #1
|
||||
*/
|
||||
|
||||
import { createFormView, setupFormHandlers } from '../../utils/tool-templates.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
|
||||
class DSFigmaPlugin extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.exportHistory = [];
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
await this.loadExportHistory();
|
||||
}
|
||||
|
||||
async loadExportHistory() {
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) return;
|
||||
|
||||
const cached = localStorage.getItem(`figma_exports_${context.project_id}`);
|
||||
if (cached) {
|
||||
this.exportHistory = JSON.parse(cached);
|
||||
this.renderHistory();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DSFigmaPlugin] Failed to load history:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const exportBtn = this.querySelector('#export-figma-btn');
|
||||
const fileKeyInput = this.querySelector('#figma-file-key');
|
||||
const exportTypeSelect = this.querySelector('#export-type-select');
|
||||
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', () => this.exportFromFigma());
|
||||
}
|
||||
}
|
||||
|
||||
async exportFromFigma() {
|
||||
const fileKeyInput = this.querySelector('#figma-file-key');
|
||||
const exportTypeSelect = this.querySelector('#export-type-select');
|
||||
const formatSelect = this.querySelector('#export-format-select');
|
||||
|
||||
const fileKey = fileKeyInput?.value.trim() || '';
|
||||
const exportType = exportTypeSelect?.value || 'tokens';
|
||||
const format = formatSelect?.value || 'json';
|
||||
|
||||
if (!fileKey) {
|
||||
ComponentHelpers.showToast?.('Please enter a Figma file key', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const exportBtn = this.querySelector('#export-figma-btn');
|
||||
if (exportBtn) {
|
||||
exportBtn.disabled = true;
|
||||
exportBtn.textContent = '⏳ Exporting...';
|
||||
}
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
if (exportType === 'tokens') {
|
||||
// Export design tokens
|
||||
result = await toolBridge.executeTool('dss_sync_figma', {
|
||||
file_key: fileKey
|
||||
});
|
||||
} else if (exportType === 'assets') {
|
||||
// Export assets (icons, images)
|
||||
const response = await fetch('/api/figma/export-assets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectId: contextStore.get('projectId'),
|
||||
fileKey,
|
||||
format
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Asset export failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
result = await response.json();
|
||||
} else if (exportType === 'components') {
|
||||
// Export component definitions
|
||||
const response = await fetch('/api/figma/export-components', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectId: contextStore.get('projectId'),
|
||||
fileKey,
|
||||
format
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Component export failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
result = await response.json();
|
||||
}
|
||||
|
||||
// Add to history
|
||||
const exportEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
fileKey,
|
||||
type: exportType,
|
||||
format,
|
||||
itemCount: result.count || Object.keys(result.tokens || result.assets || result.components || {}).length
|
||||
};
|
||||
|
||||
this.exportHistory.unshift(exportEntry);
|
||||
this.exportHistory = this.exportHistory.slice(0, 10); // Keep last 10
|
||||
|
||||
// Cache history
|
||||
const context = contextStore.getMCPContext();
|
||||
if (context.project_id) {
|
||||
localStorage.setItem(`figma_exports_${context.project_id}`, JSON.stringify(this.exportHistory));
|
||||
}
|
||||
|
||||
this.renderHistory();
|
||||
ComponentHelpers.showToast?.(`Exported ${exportEntry.itemCount} ${exportType}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('[DSFigmaPlugin] Export failed:', error);
|
||||
ComponentHelpers.showToast?.(`Export failed: ${error.message}`, 'error');
|
||||
} finally {
|
||||
if (exportBtn) {
|
||||
exportBtn.disabled = false;
|
||||
exportBtn.textContent = '📤 Export from Figma';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderHistory() {
|
||||
const historyContainer = this.querySelector('#export-history');
|
||||
if (!historyContainer) return;
|
||||
|
||||
if (this.exportHistory.length === 0) {
|
||||
historyContainer.innerHTML = ComponentHelpers.renderEmpty('No export history', '📋');
|
||||
return;
|
||||
}
|
||||
|
||||
historyContainer.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
${this.exportHistory.map((entry, idx) => `
|
||||
<div style="background: var(--vscode-bg); border: 1px solid var(--vscode-border); border-radius: 2px; padding: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 6px;">
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 11px; font-weight: 600; margin-bottom: 4px;">
|
||||
${ComponentHelpers.escapeHtml(entry.type)} Export
|
||||
</div>
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim); font-family: monospace;">
|
||||
${ComponentHelpers.escapeHtml(entry.fileKey)}
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim);">
|
||||
${ComponentHelpers.formatRelativeTime(new Date(entry.timestamp))}
|
||||
</div>
|
||||
<div style="font-size: 11px; font-weight: 600; margin-top: 2px;">
|
||||
${entry.itemCount} items
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<span style="padding: 2px 6px; background: var(--vscode-sidebar); border-radius: 2px; font-size: 9px;">
|
||||
${entry.format.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; height: 100%;">
|
||||
<!-- Export Panel -->
|
||||
<div style="display: flex; flex-direction: column; height: 100%; border-right: 1px solid var(--vscode-border);">
|
||||
<div style="padding: 16px; border-bottom: 1px solid var(--vscode-border); background: var(--vscode-sidebar);">
|
||||
<h3 style="font-size: 12px; font-weight: 600; margin-bottom: 4px;">Figma Export</h3>
|
||||
<p style="font-size: 10px; color: var(--vscode-text-dim);">
|
||||
Export tokens, assets, or components from Figma files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; overflow: auto; padding: 16px;">
|
||||
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 6px;">
|
||||
Figma File Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="figma-file-key"
|
||||
placeholder="abc123def456..."
|
||||
class="input"
|
||||
style="width: 100%; font-size: 11px; font-family: monospace;"
|
||||
/>
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim); margin-top: 4px;">
|
||||
Find this in your Figma file URL
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 6px;">
|
||||
Export Type
|
||||
</label>
|
||||
<select id="export-type-select" class="input" style="width: 100%; font-size: 11px;">
|
||||
<option value="tokens">Design Tokens</option>
|
||||
<option value="assets">Assets (Icons, Images)</option>
|
||||
<option value="components">Component Definitions</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 6px;">
|
||||
Export Format
|
||||
</label>
|
||||
<select id="export-format-select" class="input" style="width: 100%; font-size: 11px;">
|
||||
<option value="json">JSON</option>
|
||||
<option value="css">CSS</option>
|
||||
<option value="scss">SCSS</option>
|
||||
<option value="js">JavaScript</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button id="export-figma-btn" class="button" style="font-size: 12px; padding: 8px;">
|
||||
📤 Export from Figma
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Panel -->
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div style="padding: 16px; border-bottom: 1px solid var(--vscode-border); background: var(--vscode-sidebar);">
|
||||
<h3 style="font-size: 12px; font-weight: 600; margin-bottom: 4px;">Export History</h3>
|
||||
<p style="font-size: 10px; color: var(--vscode-text-dim);">
|
||||
Recent Figma exports for this project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="export-history" style="flex: 1; overflow: auto; padding: 16px;">
|
||||
${ComponentHelpers.renderLoading('Loading history...')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-figma-plugin', DSFigmaPlugin);
|
||||
|
||||
export default DSFigmaPlugin;
|
||||
411
admin-ui/js/components/tools/ds-figma-status.js
Normal file
411
admin-ui/js/components/tools/ds-figma-status.js
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* ds-figma-status.js
|
||||
* Figma integration status and sync controls
|
||||
*/
|
||||
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
|
||||
class DSFigmaStatus extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.figmaToken = null;
|
||||
this.figmaFileKey = null;
|
||||
this.connectionStatus = 'unknown';
|
||||
this.lastSync = null;
|
||||
this.isConfiguring = false;
|
||||
this.isSyncing = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
await this.checkConfiguration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Figma is configured and test connection
|
||||
*/
|
||||
async checkConfiguration() {
|
||||
const statusContent = this.querySelector('#figma-status-content');
|
||||
if (!statusContent) return;
|
||||
|
||||
try {
|
||||
// Check for stored file key in localStorage (not token - that's server-side)
|
||||
this.figmaFileKey = localStorage.getItem('figma_file_key');
|
||||
|
||||
if (!this.figmaFileKey) {
|
||||
this.connectionStatus = 'not_configured';
|
||||
this.renderStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Test connection by calling sync with dry-run check
|
||||
// Note: Backend checks for FIGMA_TOKEN env variable
|
||||
statusContent.innerHTML = ComponentHelpers.renderLoading('Checking Figma connection...');
|
||||
|
||||
try {
|
||||
// Try to get Figma file info (will fail if token not configured)
|
||||
const result = await toolBridge.syncFigma(this.figmaFileKey);
|
||||
|
||||
if (result && result.tokens) {
|
||||
this.connectionStatus = 'connected';
|
||||
this.lastSync = new Date();
|
||||
} else {
|
||||
this.connectionStatus = 'error';
|
||||
}
|
||||
} catch (error) {
|
||||
// Token not configured on backend
|
||||
if (error.message.includes('FIGMA_TOKEN')) {
|
||||
this.connectionStatus = 'token_missing';
|
||||
} else {
|
||||
this.connectionStatus = 'error';
|
||||
}
|
||||
console.error('Figma connection check failed:', error);
|
||||
}
|
||||
|
||||
this.renderStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to check Figma configuration:', error);
|
||||
statusContent.innerHTML = ComponentHelpers.renderError('Failed to check configuration', error);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Configure button
|
||||
const configureBtn = this.querySelector('#figma-configure-btn');
|
||||
if (configureBtn) {
|
||||
configureBtn.addEventListener('click', () => this.showConfiguration());
|
||||
}
|
||||
|
||||
// Sync button
|
||||
const syncBtn = this.querySelector('#figma-sync-btn');
|
||||
if (syncBtn) {
|
||||
syncBtn.addEventListener('click', () => this.syncFromFigma());
|
||||
}
|
||||
}
|
||||
|
||||
showConfiguration() {
|
||||
this.isConfiguring = true;
|
||||
this.renderStatus();
|
||||
|
||||
// Setup save handler
|
||||
const saveBtn = this.querySelector('#figma-save-config-btn');
|
||||
const cancelBtn = this.querySelector('#figma-cancel-config-btn');
|
||||
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', () => this.saveConfiguration());
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
this.isConfiguring = false;
|
||||
this.renderStatus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async saveConfiguration() {
|
||||
const fileKeyInput = this.querySelector('#figma-file-key-input');
|
||||
const tokenInput = this.querySelector('#figma-token-input');
|
||||
|
||||
if (!fileKeyInput || !tokenInput) return;
|
||||
|
||||
const fileKey = fileKeyInput.value.trim();
|
||||
const token = tokenInput.value.trim();
|
||||
|
||||
if (!fileKey) {
|
||||
ComponentHelpers.showToast?.('Please enter a Figma file key', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
ComponentHelpers.showToast?.('Please enter a Figma access token', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Store file key in localStorage (client-side)
|
||||
localStorage.setItem('figma_file_key', fileKey);
|
||||
this.figmaFileKey = fileKey;
|
||||
|
||||
// Display warning about backend token configuration
|
||||
ComponentHelpers.showToast?.('File key saved. Please configure FIGMA_TOKEN environment variable on the backend.', 'info');
|
||||
|
||||
this.isConfiguring = false;
|
||||
this.connectionStatus = 'token_missing';
|
||||
this.renderStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to save Figma configuration:', error);
|
||||
ComponentHelpers.showToast?.(`Failed to save configuration: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async syncFromFigma() {
|
||||
if (this.isSyncing || !this.figmaFileKey) return;
|
||||
|
||||
this.isSyncing = true;
|
||||
const syncBtn = this.querySelector('#figma-sync-btn');
|
||||
|
||||
if (syncBtn) {
|
||||
syncBtn.disabled = true;
|
||||
syncBtn.textContent = '🔄 Syncing...';
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await toolBridge.syncFigma(this.figmaFileKey);
|
||||
|
||||
if (result && result.tokens) {
|
||||
this.lastSync = new Date();
|
||||
this.connectionStatus = 'connected';
|
||||
|
||||
ComponentHelpers.showToast?.(
|
||||
`Synced ${Object.keys(result.tokens).length} tokens from Figma`,
|
||||
'success'
|
||||
);
|
||||
|
||||
this.renderStatus();
|
||||
} else {
|
||||
throw new Error('No tokens returned from Figma');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync from Figma:', error);
|
||||
ComponentHelpers.showToast?.(`Sync failed: ${error.message}`, 'error');
|
||||
this.connectionStatus = 'error';
|
||||
this.renderStatus();
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
if (syncBtn) {
|
||||
syncBtn.disabled = false;
|
||||
syncBtn.textContent = '🔄 Sync Now';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStatusBadge() {
|
||||
const badges = {
|
||||
connected: ComponentHelpers.createBadge('Connected', 'success'),
|
||||
not_configured: ComponentHelpers.createBadge('Not Configured', 'info'),
|
||||
token_missing: ComponentHelpers.createBadge('Token Required', 'warning'),
|
||||
error: ComponentHelpers.createBadge('Error', 'error'),
|
||||
unknown: ComponentHelpers.createBadge('Unknown', 'info')
|
||||
};
|
||||
|
||||
return badges[this.connectionStatus] || badges.unknown;
|
||||
}
|
||||
|
||||
renderStatus() {
|
||||
const statusContent = this.querySelector('#figma-status-content');
|
||||
if (!statusContent) return;
|
||||
|
||||
// Configuration form
|
||||
if (this.isConfiguring) {
|
||||
statusContent.innerHTML = `
|
||||
<div style="padding: 16px; background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px;">
|
||||
<h4 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Configure Figma Integration</h4>
|
||||
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label style="display: block; font-size: 11px; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
||||
Figma File Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="figma-file-key-input"
|
||||
class="input"
|
||||
placeholder="e.g., abc123xyz456"
|
||||
value="${ComponentHelpers.escapeHtml(this.figmaFileKey || '')}"
|
||||
style="width: 100%; font-size: 11px; font-family: 'Courier New', monospace;"
|
||||
/>
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim); margin-top: 4px;">
|
||||
Find this in your Figma file URL: figma.com/file/<strong>FILE_KEY</strong>/...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; font-size: 11px; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
||||
Figma Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="figma-token-input"
|
||||
class="input"
|
||||
placeholder="figd_..."
|
||||
style="width: 100%; font-size: 11px; font-family: 'Courier New', monospace;"
|
||||
/>
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim); margin-top: 4px;">
|
||||
Generate at: <a href="https://www.figma.com/developers/api#access-tokens" target="_blank" style="color: var(--vscode-accent);">figma.com/developers/api</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 12px; background-color: rgba(255, 191, 0, 0.1); border-radius: 4px; margin-bottom: 16px;">
|
||||
<div style="font-size: 11px; color: #ffbf00;">
|
||||
⚠️ <strong>Security Note:</strong> The Figma token must be configured as the <code>FIGMA_TOKEN</code> environment variable on the backend server. This UI only stores the file key locally.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button id="figma-cancel-config-btn" class="button" style="padding: 6px 12px; font-size: 11px;">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="figma-save-config-btn" class="button" style="padding: 6px 12px; font-size: 11px;">
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Not configured state
|
||||
if (this.connectionStatus === 'not_configured') {
|
||||
statusContent.innerHTML = `
|
||||
<div style="text-align: center; padding: 32px;">
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">🎨</div>
|
||||
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">Figma Not Configured</h3>
|
||||
<p style="font-size: 12px; color: var(--vscode-text-dim); margin-bottom: 16px;">
|
||||
Connect your Figma file to sync design tokens automatically.
|
||||
</p>
|
||||
<button id="figma-configure-btn" class="button" style="padding: 8px 16px; font-size: 12px;">
|
||||
Configure Figma
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const configureBtn = statusContent.querySelector('#figma-configure-btn');
|
||||
if (configureBtn) {
|
||||
configureBtn.addEventListener('click', () => this.showConfiguration());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Token missing state
|
||||
if (this.connectionStatus === 'token_missing') {
|
||||
statusContent.innerHTML = `
|
||||
<div style="padding: 16px; background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h4 style="font-size: 12px; font-weight: 600;">Figma Configuration</h4>
|
||||
${this.getStatusBadge()}
|
||||
</div>
|
||||
|
||||
<div style="padding: 12px; background-color: rgba(255, 191, 0, 0.1); border: 1px solid #ffbf00; border-radius: 4px; margin-bottom: 12px;">
|
||||
<div style="font-size: 11px; color: #ffbf00;">
|
||||
⚠️ <strong>Backend Configuration Required</strong><br/>
|
||||
Please set the <code>FIGMA_TOKEN</code> environment variable on the backend server and restart.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 8px;">
|
||||
<strong>File Key:</strong> <code style="background-color: var(--vscode-bg); padding: 2px 6px; border-radius: 2px;">${ComponentHelpers.escapeHtml(this.figmaFileKey || 'N/A')}</code>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-top: 12px;">
|
||||
<button id="figma-configure-btn" class="button" style="padding: 4px 12px; font-size: 11px; flex: 1;">
|
||||
Reconfigure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const configureBtn = statusContent.querySelector('#figma-configure-btn');
|
||||
if (configureBtn) {
|
||||
configureBtn.addEventListener('click', () => this.showConfiguration());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Connected state
|
||||
if (this.connectionStatus === 'connected') {
|
||||
statusContent.innerHTML = `
|
||||
<div style="padding: 16px; background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h4 style="font-size: 12px; font-weight: 600;">Figma Sync</h4>
|
||||
${this.getStatusBadge()}
|
||||
</div>
|
||||
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 8px;">
|
||||
<strong>File Key:</strong> <code style="background-color: var(--vscode-bg); padding: 2px 6px; border-radius: 2px;">${ComponentHelpers.escapeHtml(this.figmaFileKey || 'N/A')}</code>
|
||||
</div>
|
||||
|
||||
${this.lastSync ? `
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 12px;">
|
||||
<strong>Last Sync:</strong> ${ComponentHelpers.formatRelativeTime(this.lastSync)}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-top: 12px;">
|
||||
<button id="figma-sync-btn" class="button" style="padding: 4px 12px; font-size: 11px; flex: 1;">
|
||||
🔄 Sync Now
|
||||
</button>
|
||||
<button id="figma-configure-btn" class="button" style="padding: 4px 12px; font-size: 11px;">
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const syncBtn = statusContent.querySelector('#figma-sync-btn');
|
||||
const configureBtn = statusContent.querySelector('#figma-configure-btn');
|
||||
|
||||
if (syncBtn) {
|
||||
syncBtn.addEventListener('click', () => this.syncFromFigma());
|
||||
}
|
||||
|
||||
if (configureBtn) {
|
||||
configureBtn.addEventListener('click', () => this.showConfiguration());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (this.connectionStatus === 'error') {
|
||||
statusContent.innerHTML = `
|
||||
<div style="padding: 16px; background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h4 style="font-size: 12px; font-weight: 600;">Figma Sync</h4>
|
||||
${this.getStatusBadge()}
|
||||
</div>
|
||||
|
||||
<div style="padding: 12px; background-color: rgba(244, 135, 113, 0.1); border: 1px solid #f48771; border-radius: 4px; margin-bottom: 12px;">
|
||||
<div style="font-size: 11px; color: #f48771;">
|
||||
❌ Failed to connect to Figma. Please check your configuration.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button id="figma-configure-btn" class="button" style="padding: 4px 12px; font-size: 11px; flex: 1;">
|
||||
Reconfigure
|
||||
</button>
|
||||
<button id="figma-sync-btn" class="button" style="padding: 4px 12px; font-size: 11px;">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const configureBtn = statusContent.querySelector('#figma-configure-btn');
|
||||
const syncBtn = statusContent.querySelector('#figma-sync-btn');
|
||||
|
||||
if (configureBtn) {
|
||||
configureBtn.addEventListener('click', () => this.showConfiguration());
|
||||
}
|
||||
|
||||
if (syncBtn) {
|
||||
syncBtn.addEventListener('click', () => this.checkConfiguration());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="padding: 16px; height: 100%; display: flex; flex-direction: column;">
|
||||
<div id="figma-status-content" style="flex: 1;">
|
||||
${ComponentHelpers.renderLoading('Checking Figma configuration...')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-figma-status', DSFigmaStatus);
|
||||
|
||||
export default DSFigmaStatus;
|
||||
178
admin-ui/js/components/tools/ds-metrics-panel.js
Normal file
178
admin-ui/js/components/tools/ds-metrics-panel.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* ds-metrics-panel.js
|
||||
* Universal metrics panel showing tool execution stats and activity
|
||||
*/
|
||||
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
|
||||
class DSMetricsPanel extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.metrics = {
|
||||
totalExecutions: 0,
|
||||
successCount: 0,
|
||||
errorCount: 0,
|
||||
recentActivity: []
|
||||
};
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
|
||||
render() {
|
||||
const successRate = this.metrics.totalExecutions > 0
|
||||
? Math.round((this.metrics.successCount / this.metrics.totalExecutions) * 100)
|
||||
: 0;
|
||||
|
||||
this.innerHTML = `
|
||||
<div style="padding: 16px;">
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 16px;">
|
||||
<!-- Total Executions Card -->
|
||||
<div style="
|
||||
background-color: var(--vscode-sidebar);
|
||||
border: 1px solid var(--vscode-border);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
">
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 4px;">
|
||||
TOTAL EXECUTIONS
|
||||
</div>
|
||||
<div style="font-size: 24px; font-weight: 600;">
|
||||
${this.metrics.totalExecutions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Rate Card -->
|
||||
<div style="
|
||||
background-color: var(--vscode-sidebar);
|
||||
border: 1px solid var(--vscode-border);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
">
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 4px;">
|
||||
SUCCESS RATE
|
||||
</div>
|
||||
<div style="font-size: 24px; font-weight: 600; color: ${successRate >= 80 ? '#4caf50' : successRate >= 50 ? '#ff9800' : '#f44336'};">
|
||||
${successRate}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Count Card -->
|
||||
<div style="
|
||||
background-color: var(--vscode-sidebar);
|
||||
border: 1px solid var(--vscode-border);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
">
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 4px;">
|
||||
ERRORS
|
||||
</div>
|
||||
<div style="font-size: 24px; font-weight: 600; color: ${this.metrics.errorCount > 0 ? '#f44336' : 'inherit'};">
|
||||
${this.metrics.errorCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Recent Activity
|
||||
</div>
|
||||
<div style="
|
||||
background-color: var(--vscode-bg);
|
||||
border: 1px solid var(--vscode-border);
|
||||
border-radius: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
">
|
||||
${this.metrics.recentActivity.length === 0 ? `
|
||||
<div style="padding: 16px; text-align: center; color: var(--vscode-text-dim); font-size: 12px;">
|
||||
No recent activity
|
||||
</div>
|
||||
` : this.metrics.recentActivity.map(activity => `
|
||||
<div style="
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--vscode-border);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
">
|
||||
<div>
|
||||
<span style="color: ${activity.success ? '#4caf50' : '#f44336'};">
|
||||
${activity.success ? '✓' : '✗'}
|
||||
</span>
|
||||
<span style="margin-left: 8px;">${activity.toolName}</span>
|
||||
</div>
|
||||
<span style="color: var(--vscode-text-dim); font-size: 11px;">
|
||||
${activity.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
recordExecution(toolName, success = true) {
|
||||
this.metrics.totalExecutions++;
|
||||
if (success) {
|
||||
this.metrics.successCount++;
|
||||
} else {
|
||||
this.metrics.errorCount++;
|
||||
}
|
||||
|
||||
// Add to recent activity
|
||||
const now = new Date();
|
||||
const timestamp = now.toLocaleTimeString();
|
||||
|
||||
this.metrics.recentActivity.unshift({
|
||||
toolName,
|
||||
success,
|
||||
timestamp
|
||||
});
|
||||
|
||||
// Keep only last 10 activities
|
||||
if (this.metrics.recentActivity.length > 10) {
|
||||
this.metrics.recentActivity = this.metrics.recentActivity.slice(0, 10);
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
// Refresh metrics every 5 seconds
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.render();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
stopAutoRefresh() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.metrics = {
|
||||
totalExecutions: 0,
|
||||
successCount: 0,
|
||||
errorCount: 0,
|
||||
recentActivity: []
|
||||
};
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-metrics-panel', DSMetricsPanel);
|
||||
|
||||
export default DSMetricsPanel;
|
||||
213
admin-ui/js/components/tools/ds-navigation-demos.js
Normal file
213
admin-ui/js/components/tools/ds-navigation-demos.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* ds-navigation-demos.js
|
||||
* Gallery of generated HTML navigation flow demos
|
||||
* UX Team Tool #5
|
||||
*/
|
||||
|
||||
import { createGalleryView, setupGalleryHandlers } from '../../utils/tool-templates.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
|
||||
class DSNavigationDemos extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.demos = [];
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
await this.loadDemos();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const generateBtn = this.querySelector('#generate-demo-btn');
|
||||
if (generateBtn) {
|
||||
generateBtn.addEventListener('click', () => this.generateDemo());
|
||||
}
|
||||
}
|
||||
|
||||
async loadDemos() {
|
||||
this.isLoading = true;
|
||||
const container = this.querySelector('#demos-container');
|
||||
if (container) {
|
||||
container.innerHTML = ComponentHelpers.renderLoading('Loading navigation demos...');
|
||||
}
|
||||
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) {
|
||||
throw new Error('No project selected');
|
||||
}
|
||||
|
||||
// Load cached demos
|
||||
const cached = localStorage.getItem(`nav_demos_${context.project_id}`);
|
||||
if (cached) {
|
||||
this.demos = JSON.parse(cached);
|
||||
} else {
|
||||
this.demos = [];
|
||||
}
|
||||
|
||||
this.renderDemoGallery();
|
||||
} catch (error) {
|
||||
console.error('[DSNavigationDemos] Failed to load demos:', error);
|
||||
if (container) {
|
||||
container.innerHTML = ComponentHelpers.renderError('Failed to load demos', error);
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async generateDemo() {
|
||||
const flowNameInput = this.querySelector('#flow-name-input');
|
||||
const flowName = flowNameInput?.value.trim() || '';
|
||||
|
||||
if (!flowName) {
|
||||
ComponentHelpers.showToast?.('Please enter a flow name', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const generateBtn = this.querySelector('#generate-demo-btn');
|
||||
if (generateBtn) {
|
||||
generateBtn.disabled = true;
|
||||
generateBtn.textContent = '⏳ Generating...';
|
||||
}
|
||||
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
|
||||
// Call navigation generation API
|
||||
const response = await fetch('/api/navigation/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectId: context.project_id,
|
||||
flowName
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Generation failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Add to demos
|
||||
const demo = {
|
||||
id: Date.now().toString(),
|
||||
name: flowName,
|
||||
url: result.url,
|
||||
thumbnailUrl: result.thumbnailUrl,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.demos.unshift(demo);
|
||||
|
||||
// Cache demos
|
||||
if (context.project_id) {
|
||||
localStorage.setItem(`nav_demos_${context.project_id}`, JSON.stringify(this.demos));
|
||||
}
|
||||
|
||||
this.renderDemoGallery();
|
||||
ComponentHelpers.showToast?.(`Demo generated: ${flowName}`, 'success');
|
||||
|
||||
// Clear input
|
||||
if (flowNameInput) {
|
||||
flowNameInput.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DSNavigationDemos] Generation failed:', error);
|
||||
ComponentHelpers.showToast?.(`Generation failed: ${error.message}`, 'error');
|
||||
} finally {
|
||||
if (generateBtn) {
|
||||
generateBtn.disabled = false;
|
||||
generateBtn.textContent = '✨ Generate Demo';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderDemoGallery() {
|
||||
const container = this.querySelector('#demos-container');
|
||||
if (!container) return;
|
||||
|
||||
const config = {
|
||||
title: 'Navigation Flow Demos',
|
||||
items: this.demos.map(demo => ({
|
||||
id: demo.id,
|
||||
src: demo.thumbnailUrl,
|
||||
title: demo.name,
|
||||
subtitle: ComponentHelpers.formatRelativeTime(new Date(demo.timestamp))
|
||||
})),
|
||||
onItemClick: (item) => this.viewDemo(item),
|
||||
onDelete: (item) => this.deleteDemo(item)
|
||||
};
|
||||
|
||||
container.innerHTML = createGalleryView(config);
|
||||
setupGalleryHandlers(container, config);
|
||||
}
|
||||
|
||||
viewDemo(item) {
|
||||
const demo = this.demos.find(d => d.id === item.id);
|
||||
if (demo && demo.url) {
|
||||
window.open(demo.url, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
deleteDemo(item) {
|
||||
this.demos = this.demos.filter(d => d.id !== item.id);
|
||||
|
||||
// Update cache
|
||||
const context = contextStore.getMCPContext();
|
||||
if (context.project_id) {
|
||||
localStorage.setItem(`nav_demos_${context.project_id}`, JSON.stringify(this.demos));
|
||||
}
|
||||
|
||||
this.renderDemoGallery();
|
||||
ComponentHelpers.showToast?.(`Deleted ${item.title}`, 'success');
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Generator Panel -->
|
||||
<div style="padding: 16px; border-bottom: 1px solid var(--vscode-border); background: var(--vscode-sidebar);">
|
||||
<h3 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Generate Navigation Demo</h3>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: end;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px;">
|
||||
Flow Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="flow-name-input"
|
||||
placeholder="e.g., User Onboarding, Checkout Process"
|
||||
class="input"
|
||||
style="width: 100%; font-size: 11px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button id="generate-demo-btn" class="button" style="font-size: 11px; padding: 6px 16px;">
|
||||
✨ Generate Demo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px; font-size: 10px; color: var(--vscode-text-dim);">
|
||||
💡 Generates interactive HTML demos of navigation flows
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Demos Gallery -->
|
||||
<div id="demos-container" style="flex: 1; overflow: hidden;">
|
||||
${ComponentHelpers.renderLoading('Loading demos...')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-navigation-demos', DSNavigationDemos);
|
||||
|
||||
export default DSNavigationDemos;
|
||||
472
admin-ui/js/components/tools/ds-network-monitor.js
Normal file
472
admin-ui/js/components/tools/ds-network-monitor.js
Normal file
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* ds-network-monitor.js
|
||||
* Network request monitoring and debugging
|
||||
*
|
||||
* REFACTORED: DSS-compliant version using DSBaseTool + table-template.js
|
||||
* - Extends DSBaseTool for Shadow DOM, AbortController, and standardized lifecycle
|
||||
* - Uses table-template.js for DSS-compliant table rendering (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';
|
||||
import { createTableView, setupTableEvents, createStatsCard } from '../../templates/table-template.js';
|
||||
|
||||
class DSNetworkMonitor extends DSBaseTool {
|
||||
constructor() {
|
||||
super();
|
||||
this.requests = [];
|
||||
this.filteredRequests = [];
|
||||
this.filterUrl = '';
|
||||
this.filterType = 'all';
|
||||
this.autoRefresh = false;
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadRequests();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the component (required by DSBaseTool)
|
||||
*/
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.network-monitor-container {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-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;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 150px;
|
||||
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;
|
||||
}
|
||||
|
||||
.auto-refresh-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh-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;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
overflow: 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); }
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: rgba(75, 181, 211, 0.2);
|
||||
color: #4bb5d3;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(137, 209, 133, 0.2);
|
||||
color: #89d185;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: rgba(206, 145, 120, 0.2);
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: rgba(244, 135, 113, 0.2);
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: 'Courier New', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.info-count {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="network-monitor-container">
|
||||
<!-- Filter Controls -->
|
||||
<div class="filter-controls">
|
||||
<input
|
||||
type="text"
|
||||
id="network-filter"
|
||||
placeholder="Filter by URL or method..."
|
||||
class="filter-input"
|
||||
/>
|
||||
<select id="network-type-filter" class="filter-select">
|
||||
<option value="all">All Types</option>
|
||||
</select>
|
||||
<label class="auto-refresh-label">
|
||||
<input type="checkbox" id="auto-refresh-toggle" />
|
||||
Auto-refresh
|
||||
</label>
|
||||
<button
|
||||
id="network-refresh-btn"
|
||||
data-action="refresh"
|
||||
class="refresh-btn"
|
||||
type="button"
|
||||
aria-label="Refresh network requests">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content-wrapper" id="network-content">
|
||||
<div class="loading">
|
||||
<div class="loading-spinner">⏳</div>
|
||||
<div>Initializing...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners (required by DSBaseTool)
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// EVENT-002: Event delegation
|
||||
this.delegateEvents('.network-monitor-container', 'click', (action, e) => {
|
||||
if (action === 'refresh') {
|
||||
this.loadRequests();
|
||||
}
|
||||
});
|
||||
|
||||
// Filter input with debounce
|
||||
const filterInput = this.$('#network-filter');
|
||||
if (filterInput) {
|
||||
const debouncedFilter = ComponentHelpers.debounce((term) => {
|
||||
this.filterUrl = term.toLowerCase();
|
||||
this.applyFilters();
|
||||
}, 300);
|
||||
|
||||
this.bindEvent(filterInput, 'input', (e) => debouncedFilter(e.target.value));
|
||||
}
|
||||
|
||||
// Type filter
|
||||
const typeFilter = this.$('#network-type-filter');
|
||||
if (typeFilter) {
|
||||
this.bindEvent(typeFilter, 'change', (e) => {
|
||||
this.filterType = e.target.value;
|
||||
this.applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-refresh toggle
|
||||
const autoRefreshToggle = this.$('#auto-refresh-toggle');
|
||||
if (autoRefreshToggle) {
|
||||
this.bindEvent(autoRefreshToggle, 'change', (e) => {
|
||||
this.autoRefresh = e.target.checked;
|
||||
if (this.autoRefresh) {
|
||||
this.refreshInterval = setInterval(() => this.loadRequests(), 2000);
|
||||
logger.debug('[DSNetworkMonitor] Auto-refresh enabled');
|
||||
} else {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
logger.debug('[DSNetworkMonitor] Auto-refresh disabled');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async loadRequests() {
|
||||
const content = this.$('#network-content');
|
||||
if (!content) return;
|
||||
|
||||
// Only show loading on first load
|
||||
if (this.requests.length === 0) {
|
||||
content.innerHTML = '<div class="loading"><div class="loading-spinner">⏳</div><div>Loading network requests...</div></div>';
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await toolBridge.getNetworkRequests(null, 100);
|
||||
|
||||
if (result && result.requests) {
|
||||
this.requests = result.requests;
|
||||
this.updateTypeFilter();
|
||||
this.applyFilters();
|
||||
logger.debug('[DSNetworkMonitor] Loaded requests', { count: this.requests.length });
|
||||
} else {
|
||||
this.requests = [];
|
||||
content.innerHTML = '<div class="table-empty"><div class="table-empty-icon">🌐</div><div class="table-empty-text">No network requests captured</div></div>';
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[DSNetworkMonitor] Failed to load network requests', error);
|
||||
content.innerHTML = ComponentHelpers.renderError('Failed to load network requests', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateTypeFilter() {
|
||||
const typeFilter = this.$('#network-type-filter');
|
||||
if (!typeFilter) return;
|
||||
|
||||
const types = this.getResourceTypes();
|
||||
const currentValue = typeFilter.value;
|
||||
|
||||
typeFilter.innerHTML = `
|
||||
<option value="all">All Types</option>
|
||||
${types.map(type => `<option value="${type}" ${type === currentValue ? 'selected' : ''}>${type}</option>`).join('')}
|
||||
`;
|
||||
}
|
||||
|
||||
applyFilters() {
|
||||
let filtered = [...this.requests];
|
||||
|
||||
// Filter by URL
|
||||
if (this.filterUrl) {
|
||||
filtered = filtered.filter(req =>
|
||||
req.url.toLowerCase().includes(this.filterUrl) ||
|
||||
req.method.toLowerCase().includes(this.filterUrl)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if (this.filterType !== 'all') {
|
||||
filtered = filtered.filter(req => req.resourceType === this.filterType);
|
||||
}
|
||||
|
||||
this.filteredRequests = filtered;
|
||||
this.renderRequests();
|
||||
}
|
||||
|
||||
getResourceTypes() {
|
||||
if (!this.requests) return [];
|
||||
const types = new Set(this.requests.map(r => r.resourceType).filter(Boolean));
|
||||
return Array.from(types).sort();
|
||||
}
|
||||
|
||||
getStatusColor(status) {
|
||||
if (status >= 200 && status < 300) return 'success';
|
||||
if (status >= 300 && status < 400) return 'info';
|
||||
if (status >= 400 && status < 500) return 'warning';
|
||||
if (status >= 500) return 'error';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
renderRequests() {
|
||||
const content = this.$('#network-content');
|
||||
if (!content) return;
|
||||
|
||||
if (!this.filteredRequests || this.filteredRequests.length === 0) {
|
||||
content.innerHTML = `
|
||||
<div class="table-empty">
|
||||
<div class="table-empty-icon">🔍</div>
|
||||
<div class="table-empty-text">${this.filterUrl ? 'No requests match your filter' : 'No network requests captured yet'}</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render info count
|
||||
const infoHtml = `
|
||||
<div class="info-count">
|
||||
Showing ${this.filteredRequests.length} of ${this.requests.length} requests
|
||||
${this.autoRefresh ? '• Auto-refreshing every 2s' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Use table-template.js for DSS-compliant rendering
|
||||
const { html: tableHtml, styles: tableStyles } = createTableView({
|
||||
columns: [
|
||||
{ header: 'Method', key: 'method', width: '80px', align: 'left' },
|
||||
{ header: 'Status', key: 'status', width: '80px', align: 'left' },
|
||||
{ header: 'URL', key: 'url', align: 'left' },
|
||||
{ header: 'Type', key: 'resourceType', width: '100px', align: 'left' },
|
||||
{ header: 'Time', key: 'timing', width: '80px', align: 'left' }
|
||||
],
|
||||
rows: this.filteredRequests,
|
||||
renderCell: (col, row) => this.renderCell(col, row),
|
||||
renderDetails: (row) => this.renderDetails(row),
|
||||
emptyMessage: 'No network requests',
|
||||
emptyIcon: '🌐'
|
||||
});
|
||||
|
||||
// Adopt table styles
|
||||
this.adoptStyles(tableStyles);
|
||||
|
||||
// Render table
|
||||
content.innerHTML = infoHtml + tableHtml + '<div class="hint">💡 Click any row to view full request details</div>';
|
||||
|
||||
// Setup table event handlers
|
||||
setupTableEvents(this.shadowRoot);
|
||||
|
||||
logger.debug('[DSNetworkMonitor] Rendered requests', { count: this.filteredRequests.length });
|
||||
}
|
||||
|
||||
renderCell(col, row) {
|
||||
const method = row.method || 'GET';
|
||||
const status = row.status || '-';
|
||||
const statusColor = this.getStatusColor(status);
|
||||
const resourceType = row.resourceType || 'other';
|
||||
const url = row.url || 'Unknown URL';
|
||||
const timing = row.timing ? `${Math.round(row.timing)}ms` : '-';
|
||||
|
||||
switch (col.key) {
|
||||
case 'method':
|
||||
const methodColor = method === 'GET' ? 'info' : method === 'POST' ? 'success' : 'warning';
|
||||
return `<span class="badge badge-${methodColor}">${this.escapeHtml(method)}</span>`;
|
||||
|
||||
case 'status':
|
||||
return `<span class="badge badge-${statusColor}">${this.escapeHtml(String(status))}</span>`;
|
||||
|
||||
case 'url':
|
||||
return `<span class="code" style="max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block;">${this.escapeHtml(url)}</span>`;
|
||||
|
||||
case 'resourceType':
|
||||
return `<span class="badge badge-info">${this.escapeHtml(resourceType)}</span>`;
|
||||
|
||||
case 'timing':
|
||||
return `<span style="color: var(--vscode-descriptionForeground);">${timing}</span>`;
|
||||
|
||||
default:
|
||||
return this.escapeHtml(String(row[col.key] || '-'));
|
||||
}
|
||||
}
|
||||
|
||||
renderDetails(row) {
|
||||
const method = row.method || 'GET';
|
||||
const status = row.status || '-';
|
||||
const url = row.url || 'Unknown URL';
|
||||
const resourceType = row.resourceType || 'other';
|
||||
|
||||
return `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span class="detail-label">URL:</span>
|
||||
<span class="detail-value code">${this.escapeHtml(url)}</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span class="detail-label">Method:</span>
|
||||
<span class="detail-value code">${this.escapeHtml(method)}</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span class="detail-label">Status:</span>
|
||||
<span class="detail-value code">${this.escapeHtml(String(status))}</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span class="detail-label">Type:</span>
|
||||
<span class="detail-value code">${this.escapeHtml(resourceType)}</span>
|
||||
</div>
|
||||
${row.headers ? `
|
||||
<div style="margin-top: 12px;">
|
||||
<div class="detail-label" style="display: block; margin-bottom: 4px;">Headers:</div>
|
||||
<pre class="detail-code">${this.escapeHtml(JSON.stringify(row.headers, null, 2))}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-network-monitor', DSNetworkMonitor);
|
||||
|
||||
export default DSNetworkMonitor;
|
||||
268
admin-ui/js/components/tools/ds-project-analysis.js
Normal file
268
admin-ui/js/components/tools/ds-project-analysis.js
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* ds-project-analysis.js
|
||||
* Project analysis results viewer showing token usage, component adoption, etc.
|
||||
* UI Team Tool #4
|
||||
*/
|
||||
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
|
||||
class DSProjectAnalysis extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.analysisResults = null;
|
||||
this.isAnalyzing = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
await this.loadCachedResults();
|
||||
}
|
||||
|
||||
async loadCachedResults() {
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) return;
|
||||
|
||||
const cached = localStorage.getItem(`analysis_${context.project_id}`);
|
||||
if (cached) {
|
||||
this.analysisResults = JSON.parse(cached);
|
||||
this.renderResults();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DSProjectAnalysis] Failed to load cached results:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const analyzeBtn = this.querySelector('#analyze-project-btn');
|
||||
const pathInput = this.querySelector('#project-path-input');
|
||||
|
||||
if (analyzeBtn) {
|
||||
analyzeBtn.addEventListener('click', () => this.analyzeProject());
|
||||
}
|
||||
|
||||
if (pathInput) {
|
||||
pathInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.analyzeProject();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async analyzeProject() {
|
||||
const pathInput = this.querySelector('#project-path-input');
|
||||
const projectPath = pathInput?.value.trim() || '';
|
||||
|
||||
if (!projectPath) {
|
||||
ComponentHelpers.showToast?.('Please enter a project path', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isAnalyzing = true;
|
||||
this.updateLoadingState();
|
||||
|
||||
try {
|
||||
// Call dss_analyze_project MCP tool
|
||||
const result = await toolBridge.executeTool('dss_analyze_project', {
|
||||
path: projectPath
|
||||
});
|
||||
|
||||
this.analysisResults = result;
|
||||
|
||||
// Cache results
|
||||
const context = contextStore.getMCPContext();
|
||||
if (context.project_id) {
|
||||
localStorage.setItem(`analysis_${context.project_id}`, JSON.stringify(result));
|
||||
}
|
||||
|
||||
this.renderResults();
|
||||
ComponentHelpers.showToast?.('Project analysis complete', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DSProjectAnalysis] Analysis failed:', error);
|
||||
ComponentHelpers.showToast?.(`Analysis failed: ${error.message}`, 'error');
|
||||
|
||||
const resultsContainer = this.querySelector('#results-container');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = ComponentHelpers.renderError('Project analysis failed', error);
|
||||
}
|
||||
} finally {
|
||||
this.isAnalyzing = false;
|
||||
this.updateLoadingState();
|
||||
}
|
||||
}
|
||||
|
||||
updateLoadingState() {
|
||||
const analyzeBtn = this.querySelector('#analyze-project-btn');
|
||||
const resultsContainer = this.querySelector('#results-container');
|
||||
|
||||
if (!analyzeBtn || !resultsContainer) return;
|
||||
|
||||
if (this.isAnalyzing) {
|
||||
analyzeBtn.disabled = true;
|
||||
analyzeBtn.textContent = '⏳ Analyzing...';
|
||||
resultsContainer.innerHTML = ComponentHelpers.renderLoading('Analyzing project structure and token usage...');
|
||||
} else {
|
||||
analyzeBtn.disabled = false;
|
||||
analyzeBtn.textContent = '🔍 Analyze Project';
|
||||
}
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const resultsContainer = this.querySelector('#results-container');
|
||||
if (!resultsContainer || !this.analysisResults) return;
|
||||
|
||||
const { patterns, components, tokens, dependencies } = this.analysisResults;
|
||||
|
||||
resultsContainer.innerHTML = `
|
||||
<div style="padding: 16px; overflow: auto; height: 100%;">
|
||||
<!-- Summary Cards -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px;">
|
||||
${this.createStatCard('Components Found', components?.length || 0, '🧩')}
|
||||
${this.createStatCard('Patterns Detected', patterns?.length || 0, '🎨')}
|
||||
${this.createStatCard('Tokens Used', Object.keys(tokens || {}).length, '🎯')}
|
||||
${this.createStatCard('Dependencies', dependencies?.length || 0, '📦')}
|
||||
</div>
|
||||
|
||||
<!-- Patterns Section -->
|
||||
${patterns && patterns.length > 0 ? `
|
||||
<div style="background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px; margin-bottom: 16px;">
|
||||
<h4 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Design Patterns</h4>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
${patterns.map(pattern => `
|
||||
<div style="padding: 8px; background: var(--vscode-bg); border-radius: 2px; font-size: 11px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">${ComponentHelpers.escapeHtml(pattern.name)}</div>
|
||||
<div style="color: var(--vscode-text-dim);">
|
||||
${ComponentHelpers.escapeHtml(pattern.description)} • Used ${pattern.count} times
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Components Section -->
|
||||
${components && components.length > 0 ? `
|
||||
<div style="background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px; margin-bottom: 16px;">
|
||||
<h4 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">React Components</h4>
|
||||
<div style="max-height: 300px; overflow-y: auto;">
|
||||
<table style="width: 100%; font-size: 11px; border-collapse: collapse;">
|
||||
<thead style="position: sticky; top: 0; background: var(--vscode-sidebar);">
|
||||
<tr>
|
||||
<th style="text-align: left; padding: 6px; border-bottom: 1px solid var(--vscode-border);">Component</th>
|
||||
<th style="text-align: left; padding: 6px; border-bottom: 1px solid var(--vscode-border);">Path</th>
|
||||
<th style="text-align: right; padding: 6px; border-bottom: 1px solid var(--vscode-border);">DS Adoption</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${components.slice(0, 20).map(comp => `
|
||||
<tr style="border-bottom: 1px solid var(--vscode-border);">
|
||||
<td style="padding: 6px; font-family: monospace;">${ComponentHelpers.escapeHtml(comp.name)}</td>
|
||||
<td style="padding: 6px; color: var(--vscode-text-dim);">${ComponentHelpers.escapeHtml(comp.path)}</td>
|
||||
<td style="padding: 6px; text-align: right;">
|
||||
${this.renderAdoptionBadge(comp.dsAdoption || 0)}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Token Usage Section -->
|
||||
${tokens && Object.keys(tokens).length > 0 ? `
|
||||
<div style="background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px;">
|
||||
<h4 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Token Usage</h4>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||
${Object.entries(tokens).slice(0, 30).map(([key, count]) => `
|
||||
<div style="padding: 4px 8px; background: var(--vscode-bg); border-radius: 2px; font-size: 10px; font-family: monospace;">
|
||||
${ComponentHelpers.escapeHtml(key)} <span style="color: var(--vscode-text-dim);">(${count})</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
createStatCard(label, value, icon) {
|
||||
return `
|
||||
<div style="background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px; text-align: center;">
|
||||
<div style="font-size: 32px; margin-bottom: 8px;">${icon}</div>
|
||||
<div style="font-size: 24px; font-weight: 600; margin-bottom: 4px;">${value}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim);">${label}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderAdoptionBadge(percentage) {
|
||||
let color = '#f48771';
|
||||
let label = 'Low';
|
||||
|
||||
if (percentage >= 80) {
|
||||
color = '#89d185';
|
||||
label = 'High';
|
||||
} else if (percentage >= 50) {
|
||||
color = '#ffbf00';
|
||||
label = 'Medium';
|
||||
}
|
||||
|
||||
return `<span style="padding: 2px 6px; background: ${color}; border-radius: 2px; font-size: 10px; font-weight: 600;">${label}</span>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Header -->
|
||||
<div style="padding: 16px; border-bottom: 1px solid var(--vscode-border); background: var(--vscode-sidebar);">
|
||||
<h3 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Project Analysis</h3>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: end;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
||||
Project Path
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="project-path-input"
|
||||
placeholder="/path/to/your/project"
|
||||
class="input"
|
||||
style="width: 100%; font-size: 11px; font-family: monospace;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button id="analyze-project-btn" class="button" style="font-size: 11px; padding: 6px 16px;">
|
||||
🔍 Analyze Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px; font-size: 10px; color: var(--vscode-text-dim);">
|
||||
💡 Analyzes components, patterns, token usage, and design system adoption
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Container -->
|
||||
<div id="results-container" style="flex: 1; overflow: hidden;">
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100%; text-align: center; padding: 48px;">
|
||||
<div>
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">🔍</div>
|
||||
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">Ready to Analyze</h3>
|
||||
<p style="font-size: 12px; color: var(--vscode-text-dim);">
|
||||
Enter your project path above to analyze component usage and design system adoption
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-project-analysis', DSProjectAnalysis);
|
||||
|
||||
export default DSProjectAnalysis;
|
||||
278
admin-ui/js/components/tools/ds-quick-wins-script.js
Normal file
278
admin-ui/js/components/tools/ds-quick-wins-script.js
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* ds-quick-wins-script.js
|
||||
* Quick Wins analyzer - finds low-effort, high-impact design system improvements
|
||||
* MVP2: Identifies inconsistencies and suggests standardization opportunities
|
||||
*/
|
||||
|
||||
export default class QuickWinsScript extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.analysisResults = null;
|
||||
this.isAnalyzing = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="padding: 24px; height: 100%; overflow-y: auto;">
|
||||
<div style="margin-bottom: 24px;">
|
||||
<h1 style="margin: 0 0 8px 0; font-size: 24px;">Design System Quick Wins</h1>
|
||||
<p style="margin: 0; color: var(--vscode-text-dim);">
|
||||
Identify low-effort, high-impact improvements to your design system
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Analysis Controls -->
|
||||
<div style="background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px; margin-bottom: 24px;">
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label style="display: block; font-size: 12px; font-weight: 500; margin-bottom: 8px;">
|
||||
What to analyze
|
||||
</label>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px;">
|
||||
<input type="checkbox" id="check-tokens" checked />
|
||||
Design Tokens
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px;">
|
||||
<input type="checkbox" id="check-colors" checked />
|
||||
Color Usage
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px;">
|
||||
<input type="checkbox" id="check-spacing" checked />
|
||||
Spacing Values
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px;">
|
||||
<input type="checkbox" id="check-typography" checked />
|
||||
Typography
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="analyze-btn" aria-label="Analyze design system for improvement opportunities" style="
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
">Analyze Design System</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div id="loading-container" style="display: none; text-align: center; padding: 48px; background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px;">
|
||||
<div style="font-size: 24px; margin-bottom: 12px;">⏳</div>
|
||||
<div style="font-size: 12px; color: var(--vscode-text-dim);">
|
||||
Analyzing design system...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Container -->
|
||||
<div id="results-container" style="display: none;">
|
||||
<!-- Results will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const analyzeBtn = this.querySelector('#analyze-btn');
|
||||
if (analyzeBtn) {
|
||||
analyzeBtn.addEventListener('click', () => this.analyzeDesignSystem());
|
||||
}
|
||||
}
|
||||
|
||||
async analyzeDesignSystem() {
|
||||
this.isAnalyzing = true;
|
||||
const loadingContainer = this.querySelector('#loading-container');
|
||||
const resultsContainer = this.querySelector('#results-container');
|
||||
|
||||
loadingContainer.style.display = 'block';
|
||||
resultsContainer.style.display = 'none';
|
||||
|
||||
// Simulate analysis
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
this.analysisResults = this.generateAnalysisResults();
|
||||
this.renderResults();
|
||||
|
||||
loadingContainer.style.display = 'none';
|
||||
resultsContainer.style.display = 'block';
|
||||
this.isAnalyzing = false;
|
||||
}
|
||||
|
||||
generateAnalysisResults() {
|
||||
return [
|
||||
{
|
||||
title: 'Consolidate Color Palette',
|
||||
impact: 'high',
|
||||
effort: 'low',
|
||||
description: 'Found 23 unique colors in codebase, but only 8 are documented tokens. Consolidate to reduce cognitive load.',
|
||||
recommendation: 'Extract 15 undocumented colors and add to token library',
|
||||
estimate: '2 hours',
|
||||
files_affected: 34
|
||||
},
|
||||
{
|
||||
title: 'Standardize Spacing Scale',
|
||||
impact: 'high',
|
||||
effort: 'low',
|
||||
description: 'Spacing values are inconsistent (4px, 6px, 8px, 12px, 16px, 20px, 24px, 32px). Reduce to 6-8 standard values.',
|
||||
recommendation: 'Use 4px, 8px, 12px, 16px, 24px, 32px as standard spacing scale',
|
||||
estimate: '3 hours',
|
||||
files_affected: 67
|
||||
},
|
||||
{
|
||||
title: 'Create Typography System',
|
||||
impact: 'high',
|
||||
effort: 'medium',
|
||||
description: 'Typography scales vary across components. Establish consistent type hierarchy.',
|
||||
recommendation: 'Define 5 font sizes (12px, 14px, 16px, 18px, 24px) with line-height ratios',
|
||||
estimate: '4 hours',
|
||||
files_affected: 45
|
||||
},
|
||||
{
|
||||
title: 'Document Component Variants',
|
||||
impact: 'medium',
|
||||
effort: 'low',
|
||||
description: 'Button component has 7 undocumented variants in use. Update documentation.',
|
||||
recommendation: 'Add variant definitions and usage guidelines to Storybook',
|
||||
estimate: '1 hour',
|
||||
files_affected: 12
|
||||
},
|
||||
{
|
||||
title: 'Establish Naming Convention',
|
||||
impact: 'medium',
|
||||
effort: 'low',
|
||||
description: 'Token names are inconsistent (color-primary vs primaryColor vs primary-color).',
|
||||
recommendation: 'Adopt kebab-case convention: color-primary, spacing-sm, font-body',
|
||||
estimate: '2 hours',
|
||||
files_affected: 89
|
||||
},
|
||||
{
|
||||
title: 'Create Shadow System',
|
||||
impact: 'medium',
|
||||
effort: 'medium',
|
||||
description: 'Shadow values are hardcoded throughout. Create reusable shadow tokens.',
|
||||
recommendation: 'Define 3-4 elevation levels: shadow-sm, shadow-md, shadow-lg, shadow-xl',
|
||||
estimate: '2 hours',
|
||||
files_affected: 23
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const container = this.querySelector('#results-container');
|
||||
|
||||
const results = this.analysisResults;
|
||||
const highImpact = results.filter(r => r.impact === 'high');
|
||||
const mediumImpact = results.filter(r => r.impact === 'medium');
|
||||
const totalFiles = results.reduce((sum, r) => sum + r.files_affected, 0);
|
||||
|
||||
// Build stats efficiently
|
||||
const statsHtml = this.buildStatsCards(results.length, highImpact.length, totalFiles);
|
||||
|
||||
// Build cards with memoization
|
||||
const highImpactHtml = highImpact.map(win => this.renderWinCard(win)).join('');
|
||||
const mediumImpactHtml = mediumImpact.map(win => this.renderWinCard(win)).join('');
|
||||
|
||||
let html = `
|
||||
<div style="margin-bottom: 24px;">
|
||||
${statsHtml}
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 24px;">
|
||||
<h2 style="margin: 0 0 12px 0; font-size: 14px; color: #FF9800;">High Impact Opportunities</h2>
|
||||
${highImpactHtml}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 style="margin: 0 0 12px 0; font-size: 14px; color: #0066CC;">Medium Impact Opportunities</h2>
|
||||
${mediumImpactHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
buildStatsCards(total, highCount, fileCount) {
|
||||
return `
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 24px;">
|
||||
<div style="background: var(--vscode-sidebar); padding: 12px; border-radius: 4px; text-align: center;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: #4CAF50;">${total}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim);">Total Opportunities</div>
|
||||
</div>
|
||||
<div style="background: var(--vscode-sidebar); padding: 12px; border-radius: 4px; text-align: center;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: #FF9800;">${highCount}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim);">High Impact</div>
|
||||
</div>
|
||||
<div style="background: var(--vscode-sidebar); padding: 12px; border-radius: 4px; text-align: center;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: #0066CC;">${fileCount}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim);">Files Affected</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderWinCard(win) {
|
||||
const impactColor = win.impact === 'high' ? '#FF9800' : '#0066CC';
|
||||
const effortColor = win.effort === 'low' ? '#4CAF50' : win.effort === 'medium' ? '#FF9800' : '#F44336';
|
||||
|
||||
return `
|
||||
<div style="
|
||||
background: var(--vscode-sidebar);
|
||||
border: 1px solid var(--vscode-border);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
|
||||
<div style="font-weight: 500; font-size: 13px;">${win.title}</div>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<span style="
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
background: ${impactColor};
|
||||
color: white;
|
||||
">${win.impact} impact</span>
|
||||
<span style="
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
background: ${effortColor};
|
||||
color: white;
|
||||
">${win.effort} effort</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="margin: 0 0 8px 0; font-size: 12px; color: var(--vscode-text-dim);">
|
||||
${win.description}
|
||||
</p>
|
||||
|
||||
<div style="
|
||||
background: var(--vscode-bg);
|
||||
padding: 8px;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
color: #CE9178;
|
||||
">
|
||||
<strong>Recommendation:</strong> ${win.recommendation}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; font-size: 10px; color: var(--vscode-text-dim);">
|
||||
<span>⏱️ ${win.estimate}</span>
|
||||
<span>📁 ${win.files_affected} files</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-quick-wins-script', QuickWinsScript);
|
||||
305
admin-ui/js/components/tools/ds-quick-wins.js
Normal file
305
admin-ui/js/components/tools/ds-quick-wins.js
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* ds-quick-wins.js
|
||||
* Identifies low-effort, high-impact opportunities for design system adoption
|
||||
* UI Team Tool #5
|
||||
*/
|
||||
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
|
||||
class DSQuickWins extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.quickWins = null;
|
||||
this.isAnalyzing = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
await this.loadCachedResults();
|
||||
}
|
||||
|
||||
async loadCachedResults() {
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) return;
|
||||
|
||||
const cached = localStorage.getItem(`quickwins_${context.project_id}`);
|
||||
if (cached) {
|
||||
this.quickWins = JSON.parse(cached);
|
||||
this.renderResults();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DSQuickWins] Failed to load cached results:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const analyzeBtn = this.querySelector('#analyze-quick-wins-btn');
|
||||
const pathInput = this.querySelector('#project-path-input');
|
||||
|
||||
if (analyzeBtn) {
|
||||
analyzeBtn.addEventListener('click', () => this.analyzeQuickWins());
|
||||
}
|
||||
|
||||
if (pathInput) {
|
||||
pathInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.analyzeQuickWins();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async analyzeQuickWins() {
|
||||
const pathInput = this.querySelector('#project-path-input');
|
||||
const projectPath = pathInput?.value.trim() || '';
|
||||
|
||||
if (!projectPath) {
|
||||
ComponentHelpers.showToast?.('Please enter a project path', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isAnalyzing = true;
|
||||
this.updateLoadingState();
|
||||
|
||||
try {
|
||||
// Call dss_find_quick_wins MCP tool
|
||||
const result = await toolBridge.executeTool('dss_find_quick_wins', {
|
||||
path: projectPath
|
||||
});
|
||||
|
||||
this.quickWins = result;
|
||||
|
||||
// Cache results
|
||||
const context = contextStore.getMCPContext();
|
||||
if (context.project_id) {
|
||||
localStorage.setItem(`quickwins_${context.project_id}`, JSON.stringify(result));
|
||||
}
|
||||
|
||||
this.renderResults();
|
||||
ComponentHelpers.showToast?.('Quick wins analysis complete', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DSQuickWins] Analysis failed:', error);
|
||||
ComponentHelpers.showToast?.(`Analysis failed: ${error.message}`, 'error');
|
||||
|
||||
const resultsContainer = this.querySelector('#results-container');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = ComponentHelpers.renderError('Quick wins analysis failed', error);
|
||||
}
|
||||
} finally {
|
||||
this.isAnalyzing = false;
|
||||
this.updateLoadingState();
|
||||
}
|
||||
}
|
||||
|
||||
updateLoadingState() {
|
||||
const analyzeBtn = this.querySelector('#analyze-quick-wins-btn');
|
||||
const resultsContainer = this.querySelector('#results-container');
|
||||
|
||||
if (!analyzeBtn || !resultsContainer) return;
|
||||
|
||||
if (this.isAnalyzing) {
|
||||
analyzeBtn.disabled = true;
|
||||
analyzeBtn.textContent = '⏳ Analyzing...';
|
||||
resultsContainer.innerHTML = ComponentHelpers.renderLoading('Identifying quick win opportunities...');
|
||||
} else {
|
||||
analyzeBtn.disabled = false;
|
||||
analyzeBtn.textContent = '⚡ Find Quick Wins';
|
||||
}
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const resultsContainer = this.querySelector('#results-container');
|
||||
if (!resultsContainer || !this.quickWins) return;
|
||||
|
||||
const opportunities = this.quickWins.opportunities || [];
|
||||
const totalImpact = opportunities.reduce((sum, opp) => sum + (opp.impact || 0), 0);
|
||||
const avgEffort = opportunities.length > 0
|
||||
? (opportunities.reduce((sum, opp) => sum + (opp.effort || 0), 0) / opportunities.length).toFixed(1)
|
||||
: 0;
|
||||
|
||||
resultsContainer.innerHTML = `
|
||||
<div style="padding: 16px; overflow: auto; height: 100%;">
|
||||
<!-- Summary -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 24px;">
|
||||
${this.createStatCard('Opportunities', opportunities.length, '⚡')}
|
||||
${this.createStatCard('Total Impact', `${totalImpact}%`, '📈')}
|
||||
${this.createStatCard('Avg Effort', `${avgEffort}h`, '⏱️')}
|
||||
</div>
|
||||
|
||||
<!-- Opportunities List -->
|
||||
${opportunities.length === 0 ? ComponentHelpers.renderEmpty('No quick wins found', '✨') : `
|
||||
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||
${opportunities.sort((a, b) => (b.impact || 0) - (a.impact || 0)).map((opp, idx) => `
|
||||
<div style="background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
|
||||
<div style="flex: 1;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
|
||||
<h4 style="font-size: 12px; font-weight: 600;">${ComponentHelpers.escapeHtml(opp.title)}</h4>
|
||||
${this.renderPriorityBadge(opp.priority || 'medium')}
|
||||
</div>
|
||||
<p style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 8px;">
|
||||
${ComponentHelpers.escapeHtml(opp.description)}
|
||||
</p>
|
||||
</div>
|
||||
<div style="text-align: right; margin-left: 16px;">
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim); margin-bottom: 4px;">Impact</div>
|
||||
<div style="font-size: 20px; font-weight: 600; color: #89d185;">${opp.impact || 0}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; font-size: 11px; margin-bottom: 12px;">
|
||||
<div>
|
||||
<div style="color: var(--vscode-text-dim); margin-bottom: 2px;">Effort</div>
|
||||
<div style="font-weight: 600;">${opp.effort || 0} hours</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="color: var(--vscode-text-dim); margin-bottom: 2px;">Files Affected</div>
|
||||
<div style="font-weight: 600;">${opp.filesAffected || 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="color: var(--vscode-text-dim); margin-bottom: 2px;">Type</div>
|
||||
<div style="font-weight: 600;">${ComponentHelpers.escapeHtml(opp.type || 'refactor')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="button apply-quick-win-btn" data-idx="${idx}" style="font-size: 10px; padding: 4px 12px;">
|
||||
✨ Apply Fix
|
||||
</button>
|
||||
<button class="button view-files-btn" data-idx="${idx}" style="font-size: 10px; padding: 4px 12px;">
|
||||
📁 View Files
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${opp.files && opp.files.length > 0 ? `
|
||||
<details style="margin-top: 12px;">
|
||||
<summary style="font-size: 10px; color: var(--vscode-text-dim); cursor: pointer;">
|
||||
Affected Files (${opp.files.length})
|
||||
</summary>
|
||||
<div style="margin-top: 8px; padding: 8px; background: var(--vscode-bg); border-radius: 2px; font-family: monospace; font-size: 10px;">
|
||||
${opp.files.slice(0, 10).map(file => `<div style="padding: 2px 0;">${ComponentHelpers.escapeHtml(file)}</div>`).join('')}
|
||||
${opp.files.length > 10 ? `<div style="padding: 2px 0; color: var(--vscode-text-dim);">...and ${opp.files.length - 10} more</div>` : ''}
|
||||
</div>
|
||||
</details>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Setup button handlers
|
||||
const applyBtns = resultsContainer.querySelectorAll('.apply-quick-win-btn');
|
||||
const viewBtns = resultsContainer.querySelectorAll('.view-files-btn');
|
||||
|
||||
applyBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const idx = parseInt(btn.dataset.idx);
|
||||
this.applyQuickWin(opportunities[idx]);
|
||||
});
|
||||
});
|
||||
|
||||
viewBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const idx = parseInt(btn.dataset.idx);
|
||||
this.viewFiles(opportunities[idx]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createStatCard(label, value, icon) {
|
||||
return `
|
||||
<div style="background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px; text-align: center;">
|
||||
<div style="font-size: 32px; margin-bottom: 8px;">${icon}</div>
|
||||
<div style="font-size: 20px; font-weight: 600; margin-bottom: 4px;">${value}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim);">${label}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderPriorityBadge(priority) {
|
||||
const config = {
|
||||
high: { color: '#f48771', label: 'High Priority' },
|
||||
medium: { color: '#ffbf00', label: 'Medium Priority' },
|
||||
low: { color: '#89d185', label: 'Low Priority' }
|
||||
};
|
||||
|
||||
const { color, label } = config[priority] || config.medium;
|
||||
|
||||
return `<span style="padding: 2px 8px; background: ${color}; border-radius: 2px; font-size: 10px; font-weight: 600;">${label}</span>`;
|
||||
}
|
||||
|
||||
applyQuickWin(opportunity) {
|
||||
ComponentHelpers.showToast?.(`Applying: ${opportunity.title}`, 'info');
|
||||
// In real implementation, this would trigger automated refactoring
|
||||
console.log('Apply quick win:', opportunity);
|
||||
}
|
||||
|
||||
viewFiles(opportunity) {
|
||||
if (!opportunity.files || opportunity.files.length === 0) {
|
||||
ComponentHelpers.showToast?.('No files associated with this opportunity', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('View files:', opportunity.files);
|
||||
ComponentHelpers.showToast?.(`${opportunity.files.length} files affected`, 'info');
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Header -->
|
||||
<div style="padding: 16px; border-bottom: 1px solid var(--vscode-border); background: var(--vscode-sidebar);">
|
||||
<h3 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Quick Wins Identification</h3>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: end;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
||||
Project Path
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="project-path-input"
|
||||
placeholder="/path/to/your/project"
|
||||
class="input"
|
||||
style="width: 100%; font-size: 11px; font-family: monospace;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button id="analyze-quick-wins-btn" class="button" style="font-size: 11px; padding: 6px 16px;">
|
||||
⚡ Find Quick Wins
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px; font-size: 10px; color: var(--vscode-text-dim);">
|
||||
💡 Identifies low-effort, high-impact opportunities for design system adoption
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Container -->
|
||||
<div id="results-container" style="flex: 1; overflow: hidden;">
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100%; text-align: center; padding: 48px;">
|
||||
<div>
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">⚡</div>
|
||||
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">Ready to Find Quick Wins</h3>
|
||||
<p style="font-size: 12px; color: var(--vscode-text-dim);">
|
||||
Enter your project path above to identify low-effort, high-impact improvements
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-quick-wins', DSQuickWins);
|
||||
|
||||
export default DSQuickWins;
|
||||
115
admin-ui/js/components/tools/ds-regression-testing.js
Normal file
115
admin-ui/js/components/tools/ds-regression-testing.js
Normal file
@@ -0,0 +1,115 @@
|
||||
export default class RegressionTesting extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.regressions = [];
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="padding: 24px; height: 100%; overflow-y: auto;">
|
||||
<h1 style="margin: 0 0 8px 0; font-size: 24px;">Visual Regression Testing</h1>
|
||||
<p style="margin: 0 0 24px 0; color: var(--vscode-text-dim);">Detect visual changes in design system components</p>
|
||||
|
||||
<div style="background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px; margin-bottom: 24px;">
|
||||
<label style="display: block; font-size: 12px; font-weight: 500; margin-bottom: 8px;">Components to Test</label>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px;">
|
||||
<input type="checkbox" checked /> Buttons
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px;">
|
||||
<input type="checkbox" checked /> Inputs
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px;">
|
||||
<input type="checkbox" checked /> Cards
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px;">
|
||||
<input type="checkbox" checked /> Modals
|
||||
</label>
|
||||
</div>
|
||||
<button id="run-tests-btn" style="width: 100%; padding: 8px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 12px;">Run Tests</button>
|
||||
</div>
|
||||
|
||||
<div id="progress-container" style="display: none; margin-bottom: 24px;">
|
||||
<div style="margin-bottom: 8px; font-size: 12px; color: var(--vscode-text-dim);">Testing... <span id="progress-count">0/4</span></div>
|
||||
<div style="width: 100%; height: 6px; background: var(--vscode-bg); border-radius: 3px; overflow: hidden;">
|
||||
<div id="progress-bar" style="width: 0%; height: 100%; background: #0066CC; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="results-container" style="display: none;"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.querySelector('#run-tests-btn').addEventListener('click', () => this.runTests());
|
||||
}
|
||||
|
||||
async runTests() {
|
||||
this.isRunning = true;
|
||||
this.querySelector('#progress-container').style.display = 'block';
|
||||
this.querySelector('#results-container').style.display = 'none';
|
||||
|
||||
const components = ['Buttons', 'Inputs', 'Cards', 'Modals'];
|
||||
this.regressions = [];
|
||||
|
||||
for (let i = 0; i < components.length; i++) {
|
||||
this.querySelector('#progress-count').textContent = (i + 1) + '/4';
|
||||
this.querySelector('#progress-bar').style.width = ((i + 1) / 4 * 100) + '%';
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
|
||||
if (Math.random() > 0.7) {
|
||||
this.regressions.push({
|
||||
component: components[i],
|
||||
severity: Math.random() > 0.5 ? 'critical' : 'minor'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.renderResults();
|
||||
this.querySelector('#progress-container').style.display = 'none';
|
||||
this.querySelector('#results-container').style.display = 'block';
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const container = this.querySelector('#results-container');
|
||||
const passed = 4 - this.regressions.length;
|
||||
|
||||
let html = `<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px;">
|
||||
<div style="background: var(--vscode-sidebar); padding: 12px; border-radius: 4px; text-align: center; border: 2px solid #4CAF50;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: #4CAF50;">${passed}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim);">Passed</div>
|
||||
</div>
|
||||
<div style="background: var(--vscode-sidebar); padding: 12px; border-radius: 4px; text-align: center; border: 2px solid ${this.regressions.length > 0 ? '#F44336' : '#4CAF50'};">
|
||||
<div style="font-size: 24px; font-weight: 600; color: ${this.regressions.length > 0 ? '#F44336' : '#4CAF50'};">${this.regressions.length}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim);">Regressions</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (this.regressions.length === 0) {
|
||||
html += `<div style="background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 24px; text-align: center;"><div style="font-size: 20px; margin-bottom: 8px;">All Tests Passed</div></div>`;
|
||||
} else {
|
||||
html += `<h2 style="margin: 0 0 12px 0; font-size: 14px;">Regressions Found</h2>`;
|
||||
for (let reg of this.regressions) {
|
||||
const color = reg.severity === 'critical' ? '#F44336' : '#FF9800';
|
||||
html += `<div style="background: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 12px; margin-bottom: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<div style="font-weight: 500;">${reg.component}</div>
|
||||
<span style="padding: 2px 8px; background: ${color}; color: white; border-radius: 2px; font-size: 10px;">${reg.severity}</span>
|
||||
</div>
|
||||
<button style="width: 100%; padding: 6px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; border-radius: 3px; cursor: pointer; font-size: 10px;">Approve as Baseline</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-regression-testing', RegressionTesting);
|
||||
552
admin-ui/js/components/tools/ds-screenshot-gallery.js
Normal file
552
admin-ui/js/components/tools/ds-screenshot-gallery.js
Normal file
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* 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;
|
||||
174
admin-ui/js/components/tools/ds-storybook-figma-compare.js
Normal file
174
admin-ui/js/components/tools/ds-storybook-figma-compare.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* ds-storybook-figma-compare.js
|
||||
* Side-by-side Storybook and Figma component comparison
|
||||
* UI Team Tool #1
|
||||
*/
|
||||
|
||||
import { createComparisonView, setupComparisonHandlers } from '../../utils/tool-templates.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
import apiClient from '../../services/api-client.js';
|
||||
|
||||
class DSStorybookFigmaCompare extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.storybookUrl = '';
|
||||
this.figmaUrl = '';
|
||||
this.selectedComponent = null;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await this.loadProjectConfig();
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
async loadProjectConfig() {
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) {
|
||||
throw new Error('No project selected');
|
||||
}
|
||||
|
||||
// Fetch project configuration to get Storybook URL and Figma file
|
||||
const project = await apiClient.getProject(context.project_id);
|
||||
this.storybookUrl = project.storybook_url || '';
|
||||
this.figmaUrl = project.figma_ui_file || '';
|
||||
} catch (error) {
|
||||
console.error('[DSStorybookFigmaCompare] Failed to load project config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const storybookInput = this.querySelector('#storybook-url-input');
|
||||
const figmaInput = this.querySelector('#figma-url-input');
|
||||
const loadBtn = this.querySelector('#load-comparison-btn');
|
||||
|
||||
if (storybookInput) {
|
||||
storybookInput.value = this.storybookUrl;
|
||||
}
|
||||
|
||||
if (figmaInput) {
|
||||
figmaInput.value = this.figmaUrl;
|
||||
}
|
||||
|
||||
if (loadBtn) {
|
||||
loadBtn.addEventListener('click', () => this.loadComparison());
|
||||
}
|
||||
|
||||
// Setup comparison handlers (sync scroll, zoom, etc.)
|
||||
const comparisonContainer = this.querySelector('#comparison-container');
|
||||
if (comparisonContainer) {
|
||||
setupComparisonHandlers(comparisonContainer, {});
|
||||
}
|
||||
}
|
||||
|
||||
loadComparison() {
|
||||
const storybookInput = this.querySelector('#storybook-url-input');
|
||||
const figmaInput = this.querySelector('#figma-url-input');
|
||||
|
||||
this.storybookUrl = storybookInput?.value || '';
|
||||
this.figmaUrl = figmaInput?.value || '';
|
||||
|
||||
if (!this.storybookUrl || !this.figmaUrl) {
|
||||
ComponentHelpers.showToast?.('Please enter both Storybook and Figma URLs', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate URLs
|
||||
try {
|
||||
new URL(this.storybookUrl);
|
||||
new URL(this.figmaUrl);
|
||||
} catch (error) {
|
||||
ComponentHelpers.showToast?.('Invalid URL format', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update comparison view
|
||||
const comparisonContainer = this.querySelector('#comparison-container');
|
||||
if (comparisonContainer) {
|
||||
comparisonContainer.innerHTML = createComparisonView({
|
||||
leftTitle: 'Storybook',
|
||||
rightTitle: 'Figma',
|
||||
leftSrc: this.storybookUrl,
|
||||
rightSrc: this.figmaUrl
|
||||
});
|
||||
|
||||
// Re-setup handlers after re-render
|
||||
setupComparisonHandlers(comparisonContainer, {});
|
||||
|
||||
ComponentHelpers.showToast?.('Comparison loaded', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Configuration Panel -->
|
||||
<div style="padding: 16px; border-bottom: 1px solid var(--vscode-border); background: var(--vscode-sidebar);">
|
||||
<h3 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Component Comparison Configuration</h3>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: end;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
||||
Storybook URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="storybook-url-input"
|
||||
placeholder="https://storybook.example.com/..."
|
||||
class="input"
|
||||
style="width: 100%; font-size: 11px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
||||
Figma URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="figma-url-input"
|
||||
placeholder="https://figma.com/file/..."
|
||||
class="input"
|
||||
style="width: 100%; font-size: 11px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button id="load-comparison-btn" class="button" style="font-size: 11px; padding: 6px 16px;">
|
||||
🔍 Load Comparison
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px; font-size: 10px; color: var(--vscode-text-dim);">
|
||||
💡 Tip: Navigate to the same component in both Storybook and Figma for accurate comparison
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comparison View -->
|
||||
<div id="comparison-container" style="flex: 1; overflow: hidden;">
|
||||
${this.storybookUrl && this.figmaUrl ? createComparisonView({
|
||||
leftTitle: 'Storybook',
|
||||
rightTitle: 'Figma',
|
||||
leftSrc: this.storybookUrl,
|
||||
rightSrc: this.figmaUrl
|
||||
}) : `
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100%; text-align: center; padding: 48px;">
|
||||
<div>
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">🔍</div>
|
||||
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">No Comparison Loaded</h3>
|
||||
<p style="font-size: 12px; color: var(--vscode-text-dim);">
|
||||
Enter Storybook and Figma URLs above to start comparing components
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-storybook-figma-compare', DSStorybookFigmaCompare);
|
||||
|
||||
export default DSStorybookFigmaCompare;
|
||||
167
admin-ui/js/components/tools/ds-storybook-live-compare.js
Normal file
167
admin-ui/js/components/tools/ds-storybook-live-compare.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* ds-storybook-live-compare.js
|
||||
* Side-by-side Storybook and Live Application comparison
|
||||
* UI Team Tool #2
|
||||
*/
|
||||
|
||||
import { createComparisonView, setupComparisonHandlers } from '../../utils/tool-templates.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
import apiClient from '../../services/api-client.js';
|
||||
|
||||
class DSStorybookLiveCompare extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.storybookUrl = '';
|
||||
this.liveUrl = '';
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await this.loadProjectConfig();
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
async loadProjectConfig() {
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) {
|
||||
throw new Error('No project selected');
|
||||
}
|
||||
|
||||
const project = await apiClient.getProject(context.project_id);
|
||||
this.storybookUrl = project.storybook_url || '';
|
||||
this.liveUrl = project.live_url || window.location.origin;
|
||||
} catch (error) {
|
||||
console.error('[DSStorybookLiveCompare] Failed to load project config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const storybookInput = this.querySelector('#storybook-url-input');
|
||||
const liveInput = this.querySelector('#live-url-input');
|
||||
const loadBtn = this.querySelector('#load-comparison-btn');
|
||||
|
||||
if (storybookInput) {
|
||||
storybookInput.value = this.storybookUrl;
|
||||
}
|
||||
|
||||
if (liveInput) {
|
||||
liveInput.value = this.liveUrl;
|
||||
}
|
||||
|
||||
if (loadBtn) {
|
||||
loadBtn.addEventListener('click', () => this.loadComparison());
|
||||
}
|
||||
|
||||
const comparisonContainer = this.querySelector('#comparison-container');
|
||||
if (comparisonContainer) {
|
||||
setupComparisonHandlers(comparisonContainer, {});
|
||||
}
|
||||
}
|
||||
|
||||
loadComparison() {
|
||||
const storybookInput = this.querySelector('#storybook-url-input');
|
||||
const liveInput = this.querySelector('#live-url-input');
|
||||
|
||||
this.storybookUrl = storybookInput?.value || '';
|
||||
this.liveUrl = liveInput?.value || '';
|
||||
|
||||
if (!this.storybookUrl || !this.liveUrl) {
|
||||
ComponentHelpers.showToast?.('Please enter both Storybook and Live application URLs', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(this.storybookUrl);
|
||||
new URL(this.liveUrl);
|
||||
} catch (error) {
|
||||
ComponentHelpers.showToast?.('Invalid URL format', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const comparisonContainer = this.querySelector('#comparison-container');
|
||||
if (comparisonContainer) {
|
||||
comparisonContainer.innerHTML = createComparisonView({
|
||||
leftTitle: 'Storybook (Design System)',
|
||||
rightTitle: 'Live Application',
|
||||
leftSrc: this.storybookUrl,
|
||||
rightSrc: this.liveUrl
|
||||
});
|
||||
|
||||
setupComparisonHandlers(comparisonContainer, {});
|
||||
ComponentHelpers.showToast?.('Comparison loaded', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<!-- Configuration Panel -->
|
||||
<div style="padding: 16px; border-bottom: 1px solid var(--vscode-border); background: var(--vscode-sidebar);">
|
||||
<h3 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Storybook vs Live 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; font-weight: 600; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
||||
Storybook Component URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="storybook-url-input"
|
||||
placeholder="https://storybook.example.com/?path=/story/..."
|
||||
class="input"
|
||||
style="width: 100%; font-size: 11px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px; color: var(--vscode-text-dim);">
|
||||
Live Application URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="live-url-input"
|
||||
placeholder="https://app.example.com/..."
|
||||
class="input"
|
||||
style="width: 100%; font-size: 11px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button id="load-comparison-btn" class="button" style="font-size: 11px; padding: 6px 16px;">
|
||||
🔍 Load Comparison
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px; font-size: 10px; color: var(--vscode-text-dim);">
|
||||
💡 Tip: Compare the same component in design system vs production to identify drift
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comparison View -->
|
||||
<div id="comparison-container" style="flex: 1; overflow: hidden;">
|
||||
${this.storybookUrl && this.liveUrl ? createComparisonView({
|
||||
leftTitle: 'Storybook (Design System)',
|
||||
rightTitle: 'Live Application',
|
||||
leftSrc: this.storybookUrl,
|
||||
rightSrc: this.liveUrl
|
||||
}) : `
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100%; text-align: center; padding: 48px;">
|
||||
<div>
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">⚖️</div>
|
||||
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">No Comparison Loaded</h3>
|
||||
<p style="font-size: 12px; color: var(--vscode-text-dim);">
|
||||
Enter Storybook and Live application URLs to compare design system vs implementation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-storybook-live-compare', DSStorybookLiveCompare);
|
||||
|
||||
export default DSStorybookLiveCompare;
|
||||
219
admin-ui/js/components/tools/ds-system-log.js
Normal file
219
admin-ui/js/components/tools/ds-system-log.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* ds-system-log.js
|
||||
* System health dashboard with DSS status, MCP health, and compiler metrics
|
||||
*/
|
||||
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
|
||||
class DSSystemLog extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.status = null;
|
||||
this.autoRefresh = false;
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
this.loadStatus();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const refreshBtn = this.querySelector('#system-refresh-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => this.loadStatus());
|
||||
}
|
||||
|
||||
const autoRefreshToggle = this.querySelector('#system-auto-refresh');
|
||||
if (autoRefreshToggle) {
|
||||
autoRefreshToggle.addEventListener('change', (e) => {
|
||||
this.autoRefresh = e.target.checked;
|
||||
if (this.autoRefresh) {
|
||||
this.refreshInterval = setInterval(() => this.loadStatus(), 5000);
|
||||
} else {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async loadStatus() {
|
||||
const content = this.querySelector('#system-content');
|
||||
if (!content) return;
|
||||
|
||||
// Only show loading on first load
|
||||
if (!this.status) {
|
||||
content.innerHTML = ComponentHelpers.renderLoading('Loading system status...');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await toolBridge.getDSSStatus('json');
|
||||
|
||||
if (result) {
|
||||
this.status = result;
|
||||
this.renderStatus();
|
||||
} else {
|
||||
content.innerHTML = ComponentHelpers.renderEmpty('No status data available', '📊');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load system status:', error);
|
||||
content.innerHTML = ComponentHelpers.renderError('Failed to load system status', error);
|
||||
}
|
||||
}
|
||||
|
||||
getHealthBadge(isHealthy) {
|
||||
return isHealthy
|
||||
? ComponentHelpers.createBadge('Healthy', 'success')
|
||||
: ComponentHelpers.createBadge('Degraded', 'error');
|
||||
}
|
||||
|
||||
renderStatus() {
|
||||
const content = this.querySelector('#system-content');
|
||||
if (!content || !this.status) return;
|
||||
|
||||
const health = this.status.health || {};
|
||||
const config = this.status.configuration || {};
|
||||
const metrics = this.status.metrics || {};
|
||||
const recommendations = this.status.recommendations || [];
|
||||
|
||||
content.innerHTML = `
|
||||
<div style="display: grid; gap: 16px;">
|
||||
<!-- Overall Health Card -->
|
||||
<div style="background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h3 style="font-size: 14px; font-weight: 600;">System Health</h3>
|
||||
${this.getHealthBadge(health.overall)}
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">
|
||||
<div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 4px;">MCP Server</div>
|
||||
<div style="font-size: 12px;">${this.getHealthBadge(health.mcp_server)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 4px;">Context Compiler</div>
|
||||
<div style="font-size: 12px;">${this.getHealthBadge(health.context_compiler)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 4px;">Browser Connection</div>
|
||||
<div style="font-size: 12px;">${this.getHealthBadge(health.browser_connection)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-bottom: 4px;">Dependencies</div>
|
||||
<div style="font-size: 12px;">${this.getHealthBadge(health.dependencies)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Card -->
|
||||
<div style="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;">Configuration</h3>
|
||||
<div style="display: grid; gap: 8px; font-size: 12px;">
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--vscode-text-dim);">Base Theme:</span>
|
||||
<span style="font-family: 'Courier New', monospace;">${ComponentHelpers.escapeHtml(config.base_theme || 'N/A')}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--vscode-text-dim);">Active Skin:</span>
|
||||
<span style="font-family: 'Courier New', monospace;">${ComponentHelpers.escapeHtml(config.skin || 'None')}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--vscode-text-dim);">Project Name:</span>
|
||||
<span style="font-family: 'Courier New', monospace;">${ComponentHelpers.escapeHtml(config.project_name || 'N/A')}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--vscode-text-dim);">Cache Enabled:</span>
|
||||
<span>${config.cache_enabled ? '✓ Yes' : '✗ No'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Card -->
|
||||
${metrics.token_count !== undefined ? `
|
||||
<div style="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;">Metrics</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px;">
|
||||
<div style="text-align: center; padding: 12px; background-color: var(--vscode-bg); border-radius: 4px;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: var(--vscode-accent);">${metrics.token_count || 0}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-top: 4px;">Design Tokens</div>
|
||||
</div>
|
||||
<div style="text-align: center; padding: 12px; background-color: var(--vscode-bg); border-radius: 4px;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: var(--vscode-accent);">${metrics.component_count || 0}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-top: 4px;">Components</div>
|
||||
</div>
|
||||
<div style="text-align: center; padding: 12px; background-color: var(--vscode-bg); border-radius: 4px;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: var(--vscode-accent);">${metrics.theme_count || 0}</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-top: 4px;">Themes</div>
|
||||
</div>
|
||||
${metrics.compilation_time ? `
|
||||
<div style="text-align: center; padding: 12px; background-color: var(--vscode-bg); border-radius: 4px;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: var(--vscode-accent);">${Math.round(metrics.compilation_time)}ms</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); margin-top: 4px;">Compilation Time</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Recommendations Card -->
|
||||
${recommendations.length > 0 ? `
|
||||
<div style="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;">Recommendations</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
${recommendations.map(rec => `
|
||||
<div style="display: flex; align-items: start; gap: 8px; padding: 8px; background-color: var(--vscode-bg); border-radius: 2px;">
|
||||
<span style="font-size: 16px;">💡</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 12px; margin-bottom: 2px;">${ComponentHelpers.escapeHtml(rec.title || rec)}</div>
|
||||
${rec.description ? `
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim);">${ComponentHelpers.escapeHtml(rec.description)}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Last Updated -->
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim); text-align: center; padding-top: 8px;">
|
||||
Last updated: ${ComponentHelpers.formatTimestamp(new Date())}
|
||||
${this.autoRefresh ? '• Auto-refreshing every 5s' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="padding: 16px; height: 100%; display: flex; flex-direction: column;">
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center; justify-content: flex-end;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--vscode-text);">
|
||||
<input type="checkbox" id="system-auto-refresh" />
|
||||
Auto-refresh
|
||||
</label>
|
||||
<button id="system-refresh-btn" class="button" style="padding: 4px 12px; font-size: 11px;">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="system-content" style="flex: 1; overflow-y: auto;">
|
||||
${ComponentHelpers.renderLoading('Initializing...')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-system-log', DSSystemLog);
|
||||
|
||||
export default DSSystemLog;
|
||||
352
admin-ui/js/components/tools/ds-test-results.js
Normal file
352
admin-ui/js/components/tools/ds-test-results.js
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* ds-test-results.js
|
||||
* Test results viewer with polling for Jest/test runner output
|
||||
*/
|
||||
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
|
||||
class DSTestResults extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.testResults = null;
|
||||
this.isRunning = false;
|
||||
this.pollInterval = null;
|
||||
this.autoRefresh = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
await this.loadTestResults();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const runBtn = this.querySelector('#run-tests-btn');
|
||||
if (runBtn) {
|
||||
runBtn.addEventListener('click', () => this.runTests());
|
||||
}
|
||||
|
||||
const refreshBtn = this.querySelector('#refresh-tests-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => this.loadTestResults());
|
||||
}
|
||||
|
||||
const autoRefreshToggle = this.querySelector('#auto-refresh-tests');
|
||||
if (autoRefreshToggle) {
|
||||
autoRefreshToggle.addEventListener('change', (e) => {
|
||||
this.autoRefresh = e.target.checked;
|
||||
if (this.autoRefresh) {
|
||||
this.pollInterval = setInterval(() => this.loadTestResults(), 3000);
|
||||
} else {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load test results from localStorage or file system
|
||||
* In a real implementation, this would call an MCP tool to read test output files
|
||||
*/
|
||||
async loadTestResults() {
|
||||
const content = this.querySelector('#test-results-content');
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
// Try to load from localStorage (mock data for now)
|
||||
const stored = localStorage.getItem('ds-test-results');
|
||||
if (stored) {
|
||||
this.testResults = JSON.parse(stored);
|
||||
this.renderResults();
|
||||
} else {
|
||||
// No results yet
|
||||
content.innerHTML = ComponentHelpers.renderEmpty(
|
||||
'No test results available. Run tests to see results.',
|
||||
'🧪'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load test results:', error);
|
||||
content.innerHTML = ComponentHelpers.renderError('Failed to load test results', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run tests (would call npm test or similar via MCP)
|
||||
*/
|
||||
async runTests() {
|
||||
if (this.isRunning) return;
|
||||
|
||||
this.isRunning = true;
|
||||
const runBtn = this.querySelector('#run-tests-btn');
|
||||
|
||||
if (runBtn) {
|
||||
runBtn.disabled = true;
|
||||
runBtn.textContent = '🧪 Running Tests...';
|
||||
}
|
||||
|
||||
const content = this.querySelector('#test-results-content');
|
||||
if (content) {
|
||||
content.innerHTML = ComponentHelpers.renderLoading('Running tests...');
|
||||
}
|
||||
|
||||
try {
|
||||
// MVP1: Execute real npm test command via MCP
|
||||
// Note: This requires project configuration with test scripts
|
||||
const context = toolBridge.getContext();
|
||||
|
||||
// Call backend API to run tests
|
||||
// The backend will execute `npm test` and return parsed results
|
||||
const response = await fetch('/api/test/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: context.projectId,
|
||||
testCommand: 'npm test'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Test execution failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const testResults = await response.json();
|
||||
|
||||
// Validate results structure
|
||||
if (!testResults || !testResults.summary) {
|
||||
throw new Error('Invalid test results format');
|
||||
}
|
||||
|
||||
this.testResults = {
|
||||
...testResults,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Save to localStorage for offline viewing
|
||||
localStorage.setItem('ds-test-results', JSON.stringify(this.testResults));
|
||||
|
||||
this.renderResults();
|
||||
|
||||
ComponentHelpers.showToast?.(
|
||||
`Tests completed: ${this.testResults.summary.passed}/${this.testResults.summary.total} passed`,
|
||||
this.testResults.summary.failed > 0 ? 'error' : 'success'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to run tests:', error);
|
||||
ComponentHelpers.showToast?.(`Test execution failed: ${error.message}`, 'error');
|
||||
|
||||
if (content) {
|
||||
content.innerHTML = ComponentHelpers.renderError('Test execution failed', error);
|
||||
}
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
if (runBtn) {
|
||||
runBtn.disabled = false;
|
||||
runBtn.textContent = '🧪 Run Tests';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStatusIcon(status) {
|
||||
const icons = {
|
||||
passed: '✅',
|
||||
failed: '❌',
|
||||
skipped: '⏭️'
|
||||
};
|
||||
return icons[status] || '⚪';
|
||||
}
|
||||
|
||||
getStatusBadge(status) {
|
||||
const types = {
|
||||
passed: 'success',
|
||||
failed: 'error',
|
||||
skipped: 'warning'
|
||||
};
|
||||
return ComponentHelpers.createBadge(status, types[status] || 'info');
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const content = this.querySelector('#test-results-content');
|
||||
if (!content || !this.testResults) return;
|
||||
|
||||
const { summary, suites, coverage, timestamp } = this.testResults;
|
||||
|
||||
// Calculate pass rate
|
||||
const passRate = ((summary.passed / summary.total) * 100).toFixed(1);
|
||||
|
||||
content.innerHTML = `
|
||||
<!-- Summary Stats -->
|
||||
<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;">Test Summary</h4>
|
||||
${summary.failed === 0 ? ComponentHelpers.createBadge('All Tests Passed', 'success') : ComponentHelpers.createBadge(`${summary.failed} Failed`, 'error')}
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; font-size: 11px; margin-bottom: 12px;">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: var(--vscode-text);">${summary.total}</div>
|
||||
<div style="color: var(--vscode-text-dim); margin-top: 4px;">Total Tests</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: #89d185;">${summary.passed}</div>
|
||||
<div style="color: var(--vscode-text-dim); margin-top: 4px;">Passed</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: #f48771;">${summary.failed}</div>
|
||||
<div style="color: var(--vscode-text-dim); margin-top: 4px;">Failed</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: #ffbf00;">${summary.skipped}</div>
|
||||
<div style="color: var(--vscode-text-dim); margin-top: 4px;">Skipped</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: var(--vscode-text);">${passRate}%</div>
|
||||
<div style="color: var(--vscode-text-dim); margin-top: 4px;">Pass Rate</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 24px; font-weight: 600; color: var(--vscode-text);">${summary.duration}s</div>
|
||||
<div style="color: var(--vscode-text-dim); margin-top: 4px;">Duration</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim);">
|
||||
Last run: ${ComponentHelpers.formatRelativeTime(new Date(timestamp))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${coverage ? `
|
||||
<!-- Coverage Stats -->
|
||||
<div style="background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px; margin-bottom: 16px;">
|
||||
<h4 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Code Coverage</h4>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px;">
|
||||
${this.renderCoverageBar('Lines', coverage.lines)}
|
||||
${this.renderCoverageBar('Functions', coverage.functions)}
|
||||
${this.renderCoverageBar('Branches', coverage.branches)}
|
||||
${this.renderCoverageBar('Statements', coverage.statements)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Test Suites -->
|
||||
<div style="background-color: var(--vscode-sidebar); border: 1px solid var(--vscode-border); border-radius: 4px; padding: 16px;">
|
||||
<h4 style="font-size: 12px; font-weight: 600; margin-bottom: 12px;">Test Suites</h4>
|
||||
|
||||
${suites.map(suite => this.renderSuite(suite)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderCoverageBar(label, percentage) {
|
||||
let color = '#f48771'; // Red
|
||||
if (percentage >= 80) color = '#89d185'; // Green
|
||||
else if (percentage >= 60) color = '#ffbf00'; // Yellow
|
||||
|
||||
return `
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px; font-size: 11px;">
|
||||
<span>${label}</span>
|
||||
<span style="font-weight: 600;">${percentage}%</span>
|
||||
</div>
|
||||
<div style="height: 8px; background-color: var(--vscode-bg); border-radius: 4px; overflow: hidden;">
|
||||
<div style="height: 100%; width: ${percentage}%; background-color: ${color}; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderSuite(suite) {
|
||||
const suiteId = `suite-${suite.name.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
const passedCount = suite.tests.filter(t => t.status === 'passed').length;
|
||||
const failedCount = suite.tests.filter(t => t.status === 'failed').length;
|
||||
|
||||
return `
|
||||
<div style="margin-bottom: 16px; border: 1px solid var(--vscode-border); border-radius: 4px; overflow: hidden;">
|
||||
<div
|
||||
style="padding: 12px; background-color: var(--vscode-bg); cursor: pointer; display: flex; justify-content: space-between; align-items: center;"
|
||||
onclick="document.getElementById('${suiteId}').style.display = document.getElementById('${suiteId}').style.display === 'none' ? 'block' : 'none'"
|
||||
>
|
||||
<div>
|
||||
<div style="font-size: 12px; font-weight: 600; margin-bottom: 4px;">${ComponentHelpers.escapeHtml(suite.name)}</div>
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim);">
|
||||
${passedCount} passed, ${failedCount} failed of ${suite.tests.length} tests
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 18px;">▼</div>
|
||||
</div>
|
||||
|
||||
<div id="${suiteId}" style="display: none;">
|
||||
${suite.tests.map(test => this.renderTest(test)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderTest(test) {
|
||||
const icon = this.getStatusIcon(test.status);
|
||||
const badge = this.getStatusBadge(test.status);
|
||||
|
||||
return `
|
||||
<div style="padding: 12px; border-top: 1px solid var(--vscode-border); display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
||||
<span style="font-size: 14px;">${icon}</span>
|
||||
<span style="font-size: 11px; font-family: 'Courier New', monospace;">${ComponentHelpers.escapeHtml(test.name)}</span>
|
||||
${badge}
|
||||
</div>
|
||||
|
||||
${test.error ? `
|
||||
<div style="margin-top: 8px; padding: 8px; background-color: rgba(244, 135, 113, 0.1); border-left: 3px solid #f48771; border-radius: 2px;">
|
||||
<div style="font-size: 10px; font-family: 'Courier New', monospace; color: #f48771;">
|
||||
${ComponentHelpers.escapeHtml(test.error)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div style="font-size: 10px; color: var(--vscode-text-dim); white-space: nowrap; margin-left: 12px;">
|
||||
${test.duration}s
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="padding: 16px; height: 100%; display: flex; flex-direction: column;">
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center; justify-content: flex-end;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--vscode-text);">
|
||||
<input type="checkbox" id="auto-refresh-tests" />
|
||||
Auto-refresh
|
||||
</label>
|
||||
<button id="refresh-tests-btn" class="button" style="padding: 4px 12px; font-size: 11px;">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
<button id="run-tests-btn" class="button" style="padding: 4px 12px; font-size: 11px;">
|
||||
🧪 Run Tests
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="test-results-content" style="flex: 1; overflow-y: auto;">
|
||||
${ComponentHelpers.renderLoading('Loading test results...')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-test-results', DSTestResults);
|
||||
|
||||
export default DSTestResults;
|
||||
249
admin-ui/js/components/tools/ds-token-inspector.js
Normal file
249
admin-ui/js/components/tools/ds-token-inspector.js
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* ds-token-inspector.js
|
||||
* Token inspector for viewing and searching design tokens
|
||||
*/
|
||||
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
|
||||
class DSTokenInspector extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.tokens = null;
|
||||
this.filteredTokens = null;
|
||||
this.searchTerm = '';
|
||||
this.currentCategory = 'all';
|
||||
this.manifestPath = '/home/overbits/dss/admin-ui/ds.config.json';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
this.loadTokens();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const refreshBtn = this.querySelector('#token-refresh-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => this.loadTokens(true));
|
||||
}
|
||||
|
||||
const searchInput = this.querySelector('#token-search');
|
||||
if (searchInput) {
|
||||
const debouncedSearch = ComponentHelpers.debounce((term) => {
|
||||
this.searchTerm = term.toLowerCase();
|
||||
this.filterTokens();
|
||||
}, 300);
|
||||
|
||||
searchInput.addEventListener('input', (e) => debouncedSearch(e.target.value));
|
||||
}
|
||||
|
||||
const categoryFilter = this.querySelector('#token-category');
|
||||
if (categoryFilter) {
|
||||
categoryFilter.addEventListener('change', (e) => {
|
||||
this.currentCategory = e.target.value;
|
||||
this.filterTokens();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async loadTokens(forceRefresh = false) {
|
||||
const content = this.querySelector('#token-content');
|
||||
if (!content) return;
|
||||
|
||||
content.innerHTML = ComponentHelpers.renderLoading('Loading tokens from Context Compiler...');
|
||||
|
||||
try {
|
||||
const result = await toolBridge.getTokens(this.manifestPath);
|
||||
|
||||
if (result && result.tokens) {
|
||||
this.tokens = this.flattenTokens(result.tokens);
|
||||
this.filterTokens();
|
||||
} else {
|
||||
content.innerHTML = ComponentHelpers.renderEmpty('No tokens found', '🎨');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tokens:', error);
|
||||
content.innerHTML = ComponentHelpers.renderError('Failed to load tokens', error);
|
||||
}
|
||||
}
|
||||
|
||||
flattenTokens(tokens, prefix = '') {
|
||||
const flattened = [];
|
||||
|
||||
for (const [key, value] of Object.entries(tokens)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (value && typeof value === 'object' && !value.$value) {
|
||||
// Nested object - recurse
|
||||
flattened.push(...this.flattenTokens(value, path));
|
||||
} else {
|
||||
// Token leaf node
|
||||
flattened.push({
|
||||
path,
|
||||
value: value.$value || value,
|
||||
type: value.$type || this.inferType(value.$value || value),
|
||||
description: value.$description || '',
|
||||
category: this.extractCategory(path)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return flattened;
|
||||
}
|
||||
|
||||
extractCategory(path) {
|
||||
const parts = path.split('.');
|
||||
return parts[0] || 'other';
|
||||
}
|
||||
|
||||
inferType(value) {
|
||||
if (typeof value === 'string') {
|
||||
if (value.startsWith('#') || value.startsWith('rgb')) return 'color';
|
||||
if (value.endsWith('px') || value.endsWith('rem') || value.endsWith('em')) return 'dimension';
|
||||
return 'string';
|
||||
}
|
||||
if (typeof value === 'number') return 'number';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
filterTokens() {
|
||||
if (!this.tokens) return;
|
||||
|
||||
let filtered = [...this.tokens];
|
||||
|
||||
// Filter by category
|
||||
if (this.currentCategory !== 'all') {
|
||||
filtered = filtered.filter(token => token.category === this.currentCategory);
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (this.searchTerm) {
|
||||
filtered = filtered.filter(token =>
|
||||
token.path.toLowerCase().includes(this.searchTerm) ||
|
||||
String(token.value).toLowerCase().includes(this.searchTerm) ||
|
||||
token.description.toLowerCase().includes(this.searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
this.filteredTokens = filtered;
|
||||
this.renderTokens();
|
||||
}
|
||||
|
||||
getCategories() {
|
||||
if (!this.tokens) return [];
|
||||
const categories = new Set(this.tokens.map(t => t.category));
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
|
||||
renderTokens() {
|
||||
const content = this.querySelector('#token-content');
|
||||
if (!content) return;
|
||||
|
||||
if (!this.filteredTokens || this.filteredTokens.length === 0) {
|
||||
content.innerHTML = ComponentHelpers.renderEmpty(
|
||||
this.searchTerm ? 'No tokens match your search' : 'No tokens available',
|
||||
'🔍'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenRows = this.filteredTokens.map(token => {
|
||||
const colorPreview = token.type === 'color' ? `
|
||||
<div style="
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: ${token.value};
|
||||
border: 1px solid var(--vscode-border);
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
"></div>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid var(--vscode-border);">
|
||||
<td style="padding: 12px 16px; font-family: 'Courier New', monospace; font-size: 11px; color: var(--vscode-accent);">
|
||||
${ComponentHelpers.escapeHtml(token.path)}
|
||||
</td>
|
||||
<td style="padding: 12px 16px;">
|
||||
<div style="display: flex; align-items: center;">
|
||||
${colorPreview}
|
||||
<span style="font-size: 12px; font-family: 'Courier New', monospace;">
|
||||
${ComponentHelpers.escapeHtml(String(token.value))}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding: 12px 16px;">
|
||||
${ComponentHelpers.createBadge(token.type, 'info')}
|
||||
</td>
|
||||
<td style="padding: 12px 16px; font-size: 11px; color: var(--vscode-text-dim);">
|
||||
${ComponentHelpers.escapeHtml(token.description || '-')}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
content.innerHTML = `
|
||||
<div style="margin-bottom: 12px; padding: 12px; background-color: var(--vscode-sidebar); border-radius: 4px;">
|
||||
<div style="font-size: 11px; color: var(--vscode-text-dim);">
|
||||
Showing ${this.filteredTokens.length} of ${this.tokens.length} tokens
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; background-color: var(--vscode-sidebar);">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid var(--vscode-border);">
|
||||
<th style="padding: 12px 16px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--vscode-text-dim);">
|
||||
Token Path
|
||||
</th>
|
||||
<th style="padding: 12px 16px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--vscode-text-dim);">
|
||||
Value
|
||||
</th>
|
||||
<th style="padding: 12px 16px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--vscode-text-dim);">
|
||||
Type
|
||||
</th>
|
||||
<th style="padding: 12px 16px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--vscode-text-dim);">
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tokenRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div style="padding: 16px; height: 100%; display: flex; flex-direction: column;">
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center;">
|
||||
<input
|
||||
type="text"
|
||||
id="token-search"
|
||||
placeholder="Search tokens..."
|
||||
class="input"
|
||||
style="flex: 1; min-width: 200px;"
|
||||
/>
|
||||
<select id="token-category" class="input" style="width: 150px;">
|
||||
<option value="all">All Categories</option>
|
||||
${this.getCategories().map(cat =>
|
||||
`<option value="${cat}">${cat}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<button id="token-refresh-btn" class="button" style="padding: 4px 12px; font-size: 11px;">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="token-content" style="flex: 1; overflow-y: auto;">
|
||||
${ComponentHelpers.renderLoading('Initializing...')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-token-inspector', DSTokenInspector);
|
||||
|
||||
export default DSTokenInspector;
|
||||
201
admin-ui/js/components/tools/ds-token-list.js
Normal file
201
admin-ui/js/components/tools/ds-token-list.js
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* ds-token-list.js
|
||||
* List view of all design tokens in the project
|
||||
* UX Team Tool #2
|
||||
*/
|
||||
|
||||
import { createListView, setupListHandlers } from '../../utils/tool-templates.js';
|
||||
import { ComponentHelpers } from '../../utils/component-helpers.js';
|
||||
import contextStore from '../../stores/context-store.js';
|
||||
import toolBridge from '../../services/tool-bridge.js';
|
||||
|
||||
class DSTokenList extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.tokens = [];
|
||||
this.filteredTokens = [];
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
await this.loadTokens();
|
||||
}
|
||||
|
||||
async loadTokens() {
|
||||
this.isLoading = true;
|
||||
const container = this.querySelector('#token-list-container');
|
||||
if (container) {
|
||||
container.innerHTML = ComponentHelpers.renderLoading('Loading design tokens...');
|
||||
}
|
||||
|
||||
try {
|
||||
const context = contextStore.getMCPContext();
|
||||
if (!context.project_id) {
|
||||
throw new Error('No project selected');
|
||||
}
|
||||
|
||||
// Try to get resolved context which includes all tokens
|
||||
const result = await toolBridge.executeTool('dss_get_resolved_context', {
|
||||
manifest_path: `/projects/${context.project_id}/ds.config.json`
|
||||
});
|
||||
|
||||
// Extract tokens from result
|
||||
this.tokens = this.extractTokensFromContext(result);
|
||||
this.filteredTokens = [...this.tokens];
|
||||
|
||||
this.renderTokenList();
|
||||
} catch (error) {
|
||||
console.error('[DSTokenList] Failed to load tokens:', error);
|
||||
if (container) {
|
||||
container.innerHTML = ComponentHelpers.renderError('Failed to load tokens', error);
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
extractTokensFromContext(context) {
|
||||
const tokens = [];
|
||||
|
||||
// Extract from colors, typography, spacing, etc.
|
||||
const categories = ['colors', 'typography', 'spacing', 'shadows', 'borders', 'radii'];
|
||||
|
||||
for (const category of categories) {
|
||||
if (context[category]) {
|
||||
for (const [key, value] of Object.entries(context[category])) {
|
||||
tokens.push({
|
||||
category,
|
||||
name: key,
|
||||
value: typeof value === 'object' ? JSON.stringify(value) : String(value),
|
||||
type: this.inferTokenType(category, key, value)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
inferTokenType(category, key, value) {
|
||||
if (category === 'colors') return 'color';
|
||||
if (category === 'typography') return 'font';
|
||||
if (category === 'spacing') return 'size';
|
||||
if (category === 'shadows') return 'shadow';
|
||||
if (category === 'borders') return 'border';
|
||||
if (category === 'radii') return 'radius';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
renderTokenList() {
|
||||
const container = this.querySelector('#token-list-container');
|
||||
if (!container) return;
|
||||
|
||||
const config = {
|
||||
title: 'Design Tokens',
|
||||
items: this.filteredTokens,
|
||||
columns: [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Token Name',
|
||||
render: (token) => `<span style="font-family: monospace; font-size: 11px;">${ComponentHelpers.escapeHtml(token.name)}</span>`
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
render: (token) => ComponentHelpers.createBadge(token.category, 'info')
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
label: 'Value',
|
||||
render: (token) => {
|
||||
if (token.type === 'color') {
|
||||
return `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="width: 20px; height: 20px; background: ${ComponentHelpers.escapeHtml(token.value)}; border: 1px solid var(--vscode-border); border-radius: 2px;"></div>
|
||||
<span style="font-family: monospace; font-size: 10px;">${ComponentHelpers.escapeHtml(token.value)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return `<span style="font-family: monospace; font-size: 10px;">${ComponentHelpers.escapeHtml(token.value)}</span>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
render: (token) => `<span style="font-size: 10px; color: var(--vscode-text-dim);">${ComponentHelpers.escapeHtml(token.type)}</span>`
|
||||
}
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
label: 'Export All',
|
||||
icon: '📥',
|
||||
onClick: () => this.exportTokens()
|
||||
},
|
||||
{
|
||||
label: 'Refresh',
|
||||
icon: '🔄',
|
||||
onClick: () => this.loadTokens()
|
||||
}
|
||||
],
|
||||
onSearch: (query) => this.handleSearch(query),
|
||||
onFilter: (filterValue) => this.handleFilter(filterValue)
|
||||
};
|
||||
|
||||
container.innerHTML = createListView(config);
|
||||
setupListHandlers(container, config);
|
||||
|
||||
// Update filter dropdown with categories
|
||||
const filterSelect = container.querySelector('#filter-select');
|
||||
if (filterSelect) {
|
||||
const categories = [...new Set(this.tokens.map(t => t.category))];
|
||||
filterSelect.innerHTML = `
|
||||
<option value="">All Categories</option>
|
||||
${categories.map(cat => `<option value="${cat}">${cat}</option>`).join('')}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
handleSearch(query) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
this.filteredTokens = this.tokens.filter(token =>
|
||||
token.name.toLowerCase().includes(lowerQuery) ||
|
||||
token.value.toLowerCase().includes(lowerQuery) ||
|
||||
token.category.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
this.renderTokenList();
|
||||
}
|
||||
|
||||
handleFilter(filterValue) {
|
||||
if (!filterValue) {
|
||||
this.filteredTokens = [...this.tokens];
|
||||
} else {
|
||||
this.filteredTokens = this.tokens.filter(token => token.category === filterValue);
|
||||
}
|
||||
this.renderTokenList();
|
||||
}
|
||||
|
||||
exportTokens() {
|
||||
const data = JSON.stringify(this.tokens, null, 2);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'design-tokens.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
ComponentHelpers.showToast?.('Tokens exported', 'success');
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div id="token-list-container" style="height: 100%; overflow: hidden;">
|
||||
${ComponentHelpers.renderLoading('Loading tokens...')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('ds-token-list', DSTokenList);
|
||||
|
||||
export default DSTokenList;
|
||||
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