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
400 lines
11 KiB
JavaScript
400 lines
11 KiB
JavaScript
/**
|
|
* @fileoverview A reusable stepper component for guided workflows.
|
|
* Supports step dependencies, persistence, and event-driven actions.
|
|
*/
|
|
|
|
const ICONS = {
|
|
pending: '',
|
|
active: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<path d="M12 6v6l4 2"/>
|
|
</svg>`,
|
|
completed: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>`,
|
|
error: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>`,
|
|
skipped: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
|
</svg>`
|
|
};
|
|
|
|
class DsWorkflow extends HTMLElement {
|
|
constructor() {
|
|
super();
|
|
this.attachShadow({ mode: 'open' });
|
|
this._steps = [];
|
|
}
|
|
|
|
static get observedAttributes() {
|
|
return ['workflow-id'];
|
|
}
|
|
|
|
get workflowId() {
|
|
return this.getAttribute('workflow-id');
|
|
}
|
|
|
|
set steps(stepsArray) {
|
|
this._steps = stepsArray.map(s => ({
|
|
status: 'pending',
|
|
optional: false,
|
|
dependsOn: [],
|
|
...s
|
|
}));
|
|
this._loadState();
|
|
this._render();
|
|
}
|
|
|
|
get steps() {
|
|
return this._steps;
|
|
}
|
|
|
|
connectedCallback() {
|
|
this._renderBase();
|
|
}
|
|
|
|
_loadState() {
|
|
if (!this.workflowId) return;
|
|
try {
|
|
const savedState = JSON.parse(localStorage.getItem(`dss_workflow_${this.workflowId}`));
|
|
if (savedState) {
|
|
this._steps.forEach(step => {
|
|
if (savedState[step.id]) {
|
|
step.status = savedState[step.id].status;
|
|
if (savedState[step.id].message) {
|
|
step.message = savedState[step.id].message;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load workflow state:', e);
|
|
}
|
|
}
|
|
|
|
_saveState() {
|
|
if (!this.workflowId) return;
|
|
const stateToSave = this._steps.reduce((acc, step) => {
|
|
acc[step.id] = {
|
|
status: step.status,
|
|
message: step.message || null
|
|
};
|
|
return acc;
|
|
}, {});
|
|
localStorage.setItem(`dss_workflow_${this.workflowId}`, JSON.stringify(stateToSave));
|
|
}
|
|
|
|
/**
|
|
* Update a step's status
|
|
* @param {string} stepId - The step ID
|
|
* @param {string} status - 'pending', 'active', 'completed', 'error', 'skipped'
|
|
* @param {string} [message] - Optional message (for error states)
|
|
*/
|
|
updateStepStatus(stepId, status, message = '') {
|
|
const step = this._steps.find(s => s.id === stepId);
|
|
if (step) {
|
|
step.status = status;
|
|
step.message = message;
|
|
this._saveState();
|
|
this._render();
|
|
|
|
this.dispatchEvent(new CustomEvent('workflow-step-change', {
|
|
bubbles: true,
|
|
composed: true,
|
|
detail: { ...step }
|
|
}));
|
|
|
|
// Check if workflow is complete
|
|
const requiredSteps = this._steps.filter(s => !s.optional);
|
|
const completedRequired = requiredSteps.filter(s => s.status === 'completed').length;
|
|
|
|
if (completedRequired === requiredSteps.length && requiredSteps.length > 0) {
|
|
this.dispatchEvent(new CustomEvent('workflow-complete', {
|
|
bubbles: true,
|
|
composed: true
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset the workflow to initial state
|
|
*/
|
|
reset() {
|
|
this._steps.forEach(step => {
|
|
step.status = 'pending';
|
|
step.message = '';
|
|
});
|
|
this._saveState();
|
|
this._render();
|
|
}
|
|
|
|
/**
|
|
* Skip a step
|
|
* @param {string} stepId - The step ID to skip
|
|
*/
|
|
skipStep(stepId) {
|
|
const step = this._steps.find(s => s.id === stepId);
|
|
if (step && step.optional) {
|
|
this.updateStepStatus(stepId, 'skipped');
|
|
}
|
|
}
|
|
|
|
_determineActiveStep() {
|
|
const completedIds = new Set(
|
|
this._steps
|
|
.filter(s => s.status === 'completed' || s.status === 'skipped')
|
|
.map(s => s.id)
|
|
);
|
|
|
|
let foundActive = false;
|
|
|
|
this._steps.forEach(step => {
|
|
if (step.status === 'pending' && !foundActive) {
|
|
const depsMet = (step.dependsOn || []).every(depId => completedIds.has(depId));
|
|
if (depsMet) {
|
|
step.status = 'active';
|
|
foundActive = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
_getProgress() {
|
|
const total = this._steps.filter(s => !s.optional).length;
|
|
const completed = this._steps.filter(s =>
|
|
!s.optional && (s.status === 'completed' || s.status === 'skipped')
|
|
).length;
|
|
return total > 0 ? (completed / total) * 100 : 0;
|
|
}
|
|
|
|
_renderBase() {
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
:host {
|
|
display: block;
|
|
}
|
|
.workflow-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.progress-bar {
|
|
height: 4px;
|
|
background: var(--muted);
|
|
border-radius: 2px;
|
|
margin-bottom: var(--space-4);
|
|
overflow: hidden;
|
|
}
|
|
.progress-bar__indicator {
|
|
height: 100%;
|
|
background: var(--success);
|
|
width: 0%;
|
|
transition: width 0.3s ease;
|
|
}
|
|
.steps-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.step {
|
|
display: flex;
|
|
gap: var(--space-3);
|
|
}
|
|
.step__indicator {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
.step__icon {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 2px solid var(--border);
|
|
background: var(--card);
|
|
transition: all 0.2s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
.step__icon svg {
|
|
color: white;
|
|
}
|
|
.step--pending .step__icon {
|
|
border-color: var(--muted-foreground);
|
|
}
|
|
.step--active .step__icon {
|
|
border-color: var(--primary);
|
|
background: var(--primary);
|
|
}
|
|
.step--completed .step__icon {
|
|
border-color: var(--success);
|
|
background: var(--success);
|
|
}
|
|
.step--error .step__icon {
|
|
border-color: var(--destructive);
|
|
background: var(--destructive);
|
|
}
|
|
.step--skipped .step__icon {
|
|
border-color: var(--muted-foreground);
|
|
background: var(--muted-foreground);
|
|
}
|
|
.step__line {
|
|
width: 2px;
|
|
flex-grow: 1;
|
|
min-height: var(--space-4);
|
|
background: var(--border);
|
|
margin: var(--space-1) 0;
|
|
}
|
|
.step:last-child .step__line {
|
|
display: none;
|
|
}
|
|
.step--completed .step__line,
|
|
.step--skipped .step__line {
|
|
background: var(--success);
|
|
}
|
|
.step__content {
|
|
flex: 1;
|
|
padding-bottom: var(--space-4);
|
|
min-width: 0;
|
|
}
|
|
.step__header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: var(--space-2);
|
|
}
|
|
.step__title {
|
|
font-weight: var(--font-medium);
|
|
color: var(--foreground);
|
|
font-size: var(--text-sm);
|
|
}
|
|
.step--pending .step__title,
|
|
.step--pending .step__description {
|
|
color: var(--muted-foreground);
|
|
}
|
|
.step__optional {
|
|
font-size: var(--text-xs);
|
|
color: var(--muted-foreground);
|
|
background: var(--muted);
|
|
padding: 0 var(--space-1);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.step__description {
|
|
font-size: var(--text-xs);
|
|
color: var(--muted-foreground);
|
|
margin-top: var(--space-1);
|
|
line-height: 1.4;
|
|
}
|
|
.step__actions {
|
|
margin-top: var(--space-3);
|
|
display: flex;
|
|
gap: var(--space-2);
|
|
}
|
|
.error-message {
|
|
color: var(--destructive);
|
|
font-size: var(--text-xs);
|
|
margin-top: var(--space-2);
|
|
padding: var(--space-2);
|
|
background: oklch(from var(--destructive) l c h / 0.1);
|
|
border-radius: var(--radius);
|
|
}
|
|
</style>
|
|
<div class="workflow-container">
|
|
<div class="progress-bar">
|
|
<div class="progress-bar__indicator"></div>
|
|
</div>
|
|
<div class="steps-wrapper" id="steps-wrapper"></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_render() {
|
|
const wrapper = this.shadowRoot.getElementById('steps-wrapper');
|
|
if (!wrapper || !this._steps || this._steps.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Determine which step should be active
|
|
this._determineActiveStep();
|
|
|
|
// Render steps
|
|
wrapper.innerHTML = this._steps.map(step => this._renderStep(step)).join('');
|
|
|
|
// Update progress bar
|
|
const progress = this._getProgress();
|
|
const indicator = this.shadowRoot.querySelector('.progress-bar__indicator');
|
|
if (indicator) {
|
|
indicator.style.width = `${progress}%`;
|
|
}
|
|
|
|
// Add event listeners for action buttons
|
|
wrapper.querySelectorAll('[data-action-event]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
this.dispatchEvent(new CustomEvent(button.dataset.actionEvent, {
|
|
bubbles: true,
|
|
composed: true,
|
|
detail: { stepId: button.dataset.stepId }
|
|
}));
|
|
});
|
|
});
|
|
|
|
// Add skip button listeners
|
|
wrapper.querySelectorAll('[data-skip]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
this.skipStep(button.dataset.skip);
|
|
});
|
|
});
|
|
}
|
|
|
|
_renderStep(step) {
|
|
const isActionable = step.status === 'active' && step.action;
|
|
const canSkip = step.status === 'active' && step.optional;
|
|
|
|
return `
|
|
<div class="step step--${step.status}" data-step-id="${step.id}">
|
|
<div class="step__indicator">
|
|
<div class="step__icon">${ICONS[step.status] || ''}</div>
|
|
<div class="step__line"></div>
|
|
</div>
|
|
<div class="step__content">
|
|
<div class="step__header">
|
|
<div class="step__title">${this._escapeHtml(step.title)}</div>
|
|
${step.optional ? '<span class="step__optional">Optional</span>' : ''}
|
|
</div>
|
|
${step.description ? `<div class="step__description">${this._escapeHtml(step.description)}</div>` : ''}
|
|
${step.status === 'error' && step.message ? `<div class="error-message">${this._escapeHtml(step.message)}</div>` : ''}
|
|
${isActionable || canSkip ? `
|
|
<div class="step__actions">
|
|
${isActionable ? `
|
|
<ds-button
|
|
variant="primary"
|
|
size="sm"
|
|
data-step-id="${step.id}"
|
|
data-action-event="${step.action.event}"
|
|
>${step.action.label}</ds-button>
|
|
` : ''}
|
|
${canSkip ? `
|
|
<ds-button
|
|
variant="ghost"
|
|
size="sm"
|
|
data-skip="${step.id}"
|
|
>Skip</ds-button>
|
|
` : ''}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
}
|
|
|
|
customElements.define('ds-workflow', DsWorkflow);
|