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:
399
admin-ui/js/components/ds-workflow.js
Normal file
399
admin-ui/js/components/ds-workflow.js
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* @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);
|
||||
Reference in New Issue
Block a user