From fc3a4d2e271211f29465d7b15214f900ae409751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Mon, 23 Feb 2026 11:47:23 -0300 Subject: [PATCH 01/24] Update DS submodule and refresh workflow editor icons --- apps/web/index.html | 2 +- apps/web/src/app/workflow-editor.ts | 42 ++++++++++++++++--------- apps/web/src/workflow-editor.css | 49 +++++++---------------------- design-system | 2 +- 4 files changed, 40 insertions(+), 55 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index b7959c1..8c57b65 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -34,7 +34,7 @@

Nodes

- Agent + Agent
If / Else diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index 39032b4..eae14af 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -149,7 +149,7 @@ export class WorkflowEditor { break; case 'idle': default: - this.runButton.textContent = 'Run Workflow'; + this.runButton.innerHTML = 'Run Workflow '; this.runButton.disabled = false; break; } @@ -615,7 +615,7 @@ export class WorkflowEditor { delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.className = 'button button-tertiary button-small icon-btn delete'; - delBtn.innerHTML = ''; + delBtn.innerHTML = ''; delBtn.title = 'Delete Node'; delBtn.addEventListener('mousedown', async (e) => { e.stopPropagation(); @@ -695,7 +695,7 @@ export class WorkflowEditor { getNodeLabel(node) { if (node.type === 'agent') { const name = (node.data.agentName || 'Agent').trim() || 'Agent'; - return `${name}`; + return `${name}`; } if (node.type === 'start') return 'Start'; if (node.type === 'end') return 'End'; @@ -808,24 +808,36 @@ export class WorkflowEditor { toolsList.className = 'tool-list'; const toolItems = [ - { key: 'web_search', label: 'Web Search' } + { key: 'web_search', label: 'Web Search', iconClass: 'icon-globe' } ]; toolItems.forEach(tool => { - const row = document.createElement('label'); - row.className = 'tool-item'; - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.className = 'tool-checkbox'; - checkbox.checked = node.data.tools?.[tool.key] || false; - checkbox.addEventListener('change', (e) => { - if (!node.data.tools) node.data.tools = {}; - node.data.tools[tool.key] = e.target.checked; - }); - row.appendChild(checkbox); + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'tool-item tool-tag tag secondary'; + + if (tool.iconClass) { + const icon = document.createElement('span'); + icon.className = `icon ${tool.iconClass} icon-small tool-item-icon`; + row.appendChild(icon); + } const labelText = document.createElement('span'); labelText.textContent = tool.label; row.appendChild(labelText); + + const setSelected = (selected) => { + if (!node.data.tools) node.data.tools = {}; + node.data.tools[tool.key] = selected; + row.classList.toggle('selected', selected); + row.classList.toggle('secondary', !selected); + row.setAttribute('aria-pressed', String(selected)); + }; + + setSelected(Boolean(node.data.tools?.[tool.key])); + row.addEventListener('click', () => { + setSelected(!Boolean(node.data.tools?.[tool.key])); + }); + toolsList.appendChild(row); }); diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index fb82443..2425217 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -480,49 +480,18 @@ path.connection-line.active { color: var(--Colors-Text-Body-Default); } -.tool-list .tool-item:hover { - color: var(--Colors-Text-Body-Strong); -} - -/* Custom checkbox styling */ -.tool-list .tool-checkbox { - appearance: none; - -webkit-appearance: none; - width: 18px; - height: 18px; - border: 2px solid var(--Colors-Stroke-Strong); - border-radius: var(--UI-Radius-radius-xxs); - background: var(--Colors-Backgrounds-Main-Top); +.tool-list .tool-tag { + width: fit-content; + border: none; cursor: pointer; - position: relative; - flex-shrink: 0; - transition: all 0.15s ease; -} - -.tool-list .tool-checkbox:hover { - border-color: var(--Colors-Primary-Medium); } -.tool-list .tool-checkbox:checked { - background: var(--Colors-Primary-Default); - border-color: var(--Colors-Primary-Default); +.tool-list .tool-item:hover { + color: var(--Colors-Text-Body-Strong); } -.tool-list .tool-checkbox:checked::after { - content: ''; - position: absolute; - left: 5px; - top: 2px; - width: 4px; - height: 8px; - border: solid var(--Colors-Text-Body-White); - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} - -.tool-list .tool-checkbox:focus { - outline: none; - box-shadow: 0 0 0 3px var(--Colors-Primary-Lightest); +.tool-list .tool-item.selected .tool-item-icon { + color: var(--Colors-Primary-Medium); } /* Right Sidebar - Chat Panel */ @@ -786,6 +755,10 @@ path.connection-line.active { .run-button { width: 100%; margin-top: var(--UI-Spacing-spacing-mxs); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--UI-Spacing-spacing-s); } /* Confirmation Modal Adjustments */ diff --git a/design-system b/design-system index 30ab87b..450e146 160000 --- a/design-system +++ b/design-system @@ -1 +1 @@ -Subproject commit 30ab87bae891f0b621bcb4081e8d53b19557653c +Subproject commit 450e146b6ce81363abb1a5fdab26868e51b7ab72 From 7a4b57c0a292b6587cbd0139af734e95766038fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Mon, 23 Feb 2026 12:05:09 -0300 Subject: [PATCH 02/24] Adopt DS split panel and modal components in web app --- apps/web/index.html | 19 +-- apps/web/src/app/workflow-editor.ts | 168 ++++++++++++++------------ apps/web/src/components/help-modal.ts | 111 ----------------- apps/web/src/main.ts | 23 +++- apps/web/src/workflow-editor.css | 117 ++++-------------- 5 files changed, 132 insertions(+), 306 deletions(-) delete mode 100644 apps/web/src/components/help-modal.ts diff --git a/apps/web/index.html b/apps/web/index.html index 8c57b65..1dd3a89 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -24,6 +24,8 @@ + + @@ -61,7 +63,6 @@

Nodes

-
- - diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index eae14af..d27ad13 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -41,22 +41,19 @@ export class WorkflowEditor { this.runButton = document.getElementById('btn-run'); this.workflowState = 'idle'; // 'idle' | 'running' | 'paused' this.rightPanel = document.getElementById('right-panel'); - this.rightResizer = document.getElementById('right-resizer'); this.pendingAgentMessage = null; this.currentPrompt = ''; this.pendingApprovalRequest = null; - this.confirmModal = document.getElementById('confirm-modal'); - this.confirmTitle = document.getElementById('confirm-modal-title'); - this.confirmMessage = document.getElementById('confirm-modal-message'); - this.confirmConfirmBtn = document.getElementById('confirm-modal-confirm'); - this.confirmCancelBtn = document.getElementById('confirm-modal-cancel'); - this.confirmBackdrop = this.confirmModal ? this.confirmModal.querySelector('.modal-backdrop') : null; + + this.splitPanelCtorPromise = null; + this.dropdownCtorPromise = null; + this.modalCtorPromise = null; + this.initSplitPanelLayout(); // Bindings this.initDragAndDrop(); this.initCanvasInteractions(); this.initButtons(); - this.initPanelControls(); // WebSocket for Logs this.initWebSocket(); @@ -65,8 +62,6 @@ export class WorkflowEditor { this.updateRunButton(); this.addDefaultStartNode(); this.upgradeLegacyNodes(true); - - this.dropdownCtorPromise = null; } async getDropdownCtor() { @@ -78,6 +73,50 @@ export class WorkflowEditor { return this.dropdownCtorPromise; } + async getSplitPanelCtor() { + if (!this.splitPanelCtorPromise) { + const origin = window.location.origin; + const splitPanelModulePath = `${origin}/design-system/components/split-panel/split-panel.js`; + this.splitPanelCtorPromise = import(/* @vite-ignore */ splitPanelModulePath).then((mod) => mod.default); + } + return this.splitPanelCtorPromise; + } + + async getModalCtor() { + if (!this.modalCtorPromise) { + const origin = window.location.origin; + const modalModulePath = `${origin}/design-system/components/modal/modal.js`; + this.modalCtorPromise = import(/* @vite-ignore */ modalModulePath).then((mod) => mod.default); + } + return this.modalCtorPromise; + } + + async initSplitPanelLayout() { + const mainLayout = document.querySelector('.main-layout'); + if (!mainLayout || !this.canvas || !this.rightPanel) return; + + const rightWidthVar = getComputedStyle(document.documentElement) + .getPropertyValue('--right-sidebar-width') + .trim(); + const rightWidth = Number.parseFloat(rightWidthVar) || 320; + const containerWidth = mainLayout.getBoundingClientRect().width || window.innerWidth || 1280; + const initialSplit = ((containerWidth - rightWidth) / containerWidth) * 100; + const clampedSplit = Math.max(40, Math.min(80, initialSplit)); + + try { + const SplitPanelCtor = await this.getSplitPanelCtor(); + this.splitPanel = new SplitPanelCtor(mainLayout, { + initialSplit: clampedSplit, + minLeft: 40, + minRight: 20 + }); + this.splitPanel.getLeftPanel().appendChild(this.canvas); + this.splitPanel.getRightPanel().appendChild(this.rightPanel); + } catch (error) { + console.warn('Failed to initialize split panel layout', error); + } + } + async setupDropdown(container, items, selectedValue, placeholder, onSelect) { const DropdownCtor = await this.getDropdownCtor(); const dropdown = new DropdownCtor(container, { @@ -376,34 +415,6 @@ export class WorkflowEditor { if (zoomResetBtn) zoomResetBtn.addEventListener('click', () => this.resetViewport()); } - initPanelControls() { - if (this.rightResizer && this.rightPanel) { - let isDragging = false; - - const onMouseMove = (e) => { - if (!isDragging) return; - const newWidth = Math.min(600, Math.max(240, window.innerWidth - e.clientX)); - document.documentElement.style.setProperty('--right-sidebar-width', `${newWidth}px`); - }; - - const onMouseUp = () => { - if (!isDragging) return; - isDragging = false; - this.rightResizer.classList.remove('dragging'); - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - }; - - this.rightResizer.addEventListener('mousedown', (e) => { - e.preventDefault(); - isDragging = true; - this.rightResizer.classList.add('dragging'); - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }); - } - } - initWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${protocol}//${window.location.host}`); @@ -412,7 +423,7 @@ export class WorkflowEditor { }; } - openConfirmModal(options = {}) { + async openConfirmModal(options = {}) { const { title = 'Confirm', message = 'Are you sure?', @@ -420,48 +431,45 @@ export class WorkflowEditor { cancelLabel = 'Cancel' } = options; - if (!this.confirmModal || !this.confirmConfirmBtn || !this.confirmCancelBtn) { - return Promise.resolve(window.confirm(message)); - } - - if (this.confirmTitle) this.confirmTitle.textContent = title; - if (this.confirmMessage) this.confirmMessage.textContent = message; - this.confirmConfirmBtn.textContent = confirmLabel; - this.confirmCancelBtn.textContent = cancelLabel; - - return new Promise((resolve) => { - const cleanup = () => { - this.confirmModal.style.display = 'none'; - this.confirmConfirmBtn.removeEventListener('click', onConfirm); - this.confirmCancelBtn.removeEventListener('click', onCancel); - if (this.confirmBackdrop) { - this.confirmBackdrop.removeEventListener('click', onCancel); - } - document.removeEventListener('keydown', onKeydown); - }; - - const onConfirm = () => { - cleanup(); - resolve(true); - }; - - const onCancel = () => { - cleanup(); - resolve(false); - }; - - const onKeydown = (event) => { - if (event.key === 'Escape') onCancel(); - }; + try { + const ModalCtor = await this.getModalCtor(); + const content = document.createElement('p'); + content.textContent = message; + + return await new Promise((resolve) => { + let confirmed = false; + + const modal = new ModalCtor({ + size: 'small', + title, + content, + footerButtons: [ + { + label: cancelLabel, + type: 'secondary', + onClick: (_event, instance) => instance.close() + }, + { + label: confirmLabel, + type: 'primary', + onClick: (_event, instance) => { + confirmed = true; + instance.close(); + } + } + ], + onClose: () => { + modal.destroy(); + resolve(confirmed); + } + }); - this.confirmModal.style.display = 'flex'; - document.addEventListener('keydown', onKeydown); - this.confirmConfirmBtn.addEventListener('click', onConfirm); - this.confirmCancelBtn.addEventListener('click', onCancel); - if (this.confirmBackdrop) { - this.confirmBackdrop.addEventListener('click', onCancel); - } - }); + modal.open(); + }); + } catch (error) { + console.warn('Failed to initialize DS confirm modal', error); + return window.confirm(message); + } } // --- NODE MANAGEMENT --- diff --git a/apps/web/src/components/help-modal.ts b/apps/web/src/components/help-modal.ts deleted file mode 100644 index 0679a49..0000000 --- a/apps/web/src/components/help-modal.ts +++ /dev/null @@ -1,111 +0,0 @@ -export interface HelpModalOptions { - triggerSelector?: string; - content?: string; - theme?: 'auto' | 'light' | 'dark'; -} - -export class HelpModal { - private options: Required; - - private isOpen = false; - - private modal: HTMLDivElement; - - private trigger: HTMLElement | null = null; - - constructor(options: HelpModalOptions = {}) { - this.options = { - triggerSelector: '#btn-help', - content: '', - theme: 'auto', - ...options - }; - - this.modal = this.createModal(); - this.bindEvents(); - } - - static init(options: HelpModalOptions) { - return new HelpModal(options); - } - - open() { - if (this.isOpen) return; - this.isOpen = true; - this.modal.style.display = 'flex'; - document.body.style.overflow = 'hidden'; - const closeBtn = this.modal.querySelector('.modal-close'); - closeBtn?.focus(); - this.trigger?.dispatchEvent(new CustomEvent('helpModal:open', { detail: this })); - } - - close() { - if (!this.isOpen) return; - this.isOpen = false; - this.modal.style.display = 'none'; - document.body.style.overflow = ''; - this.trigger?.focus(); - this.trigger?.dispatchEvent(new CustomEvent('helpModal:close', { detail: this })); - } - - updateContent(content: string) { - const body = this.modal.querySelector('.modal-body'); - if (body) { - body.innerHTML = content; - } - } - - private createModal(): HTMLDivElement { - const modal = document.createElement('div'); - modal.className = 'modal'; - modal.style.display = 'none'; - modal.innerHTML = ` - - - `; - document.body.appendChild(modal); - return modal; - } - - private bindEvents() { - this.trigger = document.querySelector(this.options.triggerSelector); - if (!this.trigger) { - console.warn(`Help trigger "${this.options.triggerSelector}" not found`); - return; - } - - this.trigger.addEventListener('click', (event) => { - event.preventDefault(); - this.open(); - }); - - const closeBtn = this.modal.querySelector('.modal-close'); - closeBtn?.addEventListener('click', () => this.close()); - - const backdrop = this.modal.querySelector('.modal-backdrop'); - backdrop?.addEventListener('click', () => this.close()); - - document.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && this.isOpen) { - this.close(); - } - }); - - this.modal.addEventListener('click', (event) => { - const target = event.target as HTMLElement; - if (target.matches('a[href^="#"]')) { - event.preventDefault(); - const id = target.getAttribute('href')?.substring(1); - const section = id ? this.modal.querySelector(`#${id}`) : null; - section?.scrollIntoView({ behavior: 'smooth' }); - } - }); - } -} - diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index b879f7e..d9d4a54 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,7 +1,6 @@ import './workflow-editor.css'; import WorkflowEditor from './app/workflow-editor'; -import { HelpModal } from './components/help-modal'; import { helpContent } from './data/help-content'; declare global { @@ -12,5 +11,25 @@ declare global { document.addEventListener('DOMContentLoaded', () => { window.editor = new WorkflowEditor(); - HelpModal.init({ content: helpContent }); + const helpTrigger = document.getElementById('btn-help'); + if (!helpTrigger) return; + + const origin = window.location.origin; + const modalModulePath = `${origin}/design-system/components/modal/modal.js`; + + import(/* @vite-ignore */ modalModulePath) + .then(({ default: Modal }) => { + const helpModal = Modal.createHelpModal({ + title: 'Help / User Guide', + content: helpContent + }); + + helpTrigger.addEventListener('click', (event) => { + event.preventDefault(); + helpModal.open(); + }); + }) + .catch((error) => { + console.warn('Failed to initialize DS help modal', error); + }); }); diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 2425217..5846ed3 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -28,7 +28,7 @@ body { /* Main Layout Override for 2-column structure */ .main-layout { display: grid; - grid-template-columns: 1fr 6px var(--right-sidebar-width); + grid-template-columns: 1fr var(--right-sidebar-width); height: 100vh; overflow: hidden; } @@ -40,12 +40,22 @@ body { display: flex; flex-direction: column; gap: var(--UI-Spacing-spacing-mxs); - overflow-y: auto; - width: var(--right-sidebar-width); - min-width: var(--right-sidebar-width); + height: 100%; + min-height: 0; + overflow: hidden; box-sizing: border-box; } +.split-panel-container { + height: 100vh; +} + +.split-panel-container .split-panel-left, +.split-panel-container .split-panel-right { + overflow: hidden; + min-height: 0; +} + .palette-floating { position: absolute; top: var(--UI-Spacing-spacing-ms); @@ -115,6 +125,9 @@ body { /* Canvas Area */ #canvas-container { position: relative; + width: 100%; + height: 100%; + min-height: 100%; overflow: hidden; background-image: radial-gradient(var(--Colors-Stroke-Default) 1px, transparent 1px); background-size: 20px 20px; @@ -496,10 +509,12 @@ path.connection-line.active { /* Right Sidebar - Chat Panel */ .chat-panel { - height: calc(100vh - var(--UI-Spacing-spacing-ms)); + height: 100%; + min-height: 0; display: flex; flex-direction: column; padding: var(--UI-Spacing-spacing-mxs); + box-sizing: border-box; } .chat-header { @@ -514,6 +529,7 @@ path.connection-line.active { } .chat-messages { flex: 1; + min-height: 0; background: var(--Colors-Backgrounds-Main-Light); border: 1px solid var(--Colors-Stroke-Default); border-radius: var(--UI-Radius-radius-s); @@ -760,94 +776,3 @@ path.connection-line.active { justify-content: center; gap: var(--UI-Spacing-spacing-s); } - -/* Confirmation Modal Adjustments */ -#confirm-modal .modal-content { - max-width: 420px; - width: min(420px, calc(100% - 2rem)); -} - -#confirm-modal .modal-header, -#confirm-modal .modal-body, -#confirm-modal .modal-footer { - padding: var(--UI-Spacing-spacing-ms); -} - -#confirm-modal .modal-body { - font-size: var(--Fonts-Body-Default-md); - color: var(--Colors-Text-Body-Strong); -} - -#confirm-modal .modal-footer { - border-top: 1px solid var(--Colors-Stroke-Default); - display: flex; - justify-content: flex-end; - gap: var(--UI-Spacing-spacing-s); -} - -/* Modal Base Styles */ -.modal { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; -} - -.modal-backdrop { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: var(--Colors-Base-Neutral-Alphas-1400-50); - backdrop-filter: blur(2px); -} - -.modal-content { - position: relative; - background: var(--Colors-Backgrounds-Main-Top); - border-radius: var(--UI-Radius-radius-m); - box-shadow: 0 12px 38px 0 var(--Colors-Shadow-Float, rgba(22, 44, 96, 0.12)); - max-width: 600px; - width: min(600px, calc(100% - 2rem)); - max-height: 90vh; - overflow-y: auto; -} - -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--UI-Spacing-spacing-ms); - border-bottom: 1px solid var(--Colors-Stroke-Default); -} - -.modal-header h2 { - margin: 0; - font-size: var(--Fonts-Headlines-xsm); -} - -.modal-close { - background: none; - border: none; - font-size: var(--Fonts-Body-Default-xxl); - cursor: pointer; - color: var(--Colors-Text-Body-Medium); - padding: var(--UI-Spacing-spacing-xxs); - line-height: 1; -} - -.modal-close:hover { - color: var(--Colors-Text-Body-Strong); -} - -.modal-body { - padding: var(--UI-Spacing-spacing-ms); - max-height: 60vh; - overflow-y: auto; -} From 03b76f4869b5de8fdd53926a9e21f21411508b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Mon, 23 Feb 2026 12:09:21 -0300 Subject: [PATCH 03/24] Fix editor layout and align header icon styling --- apps/web/src/workflow-editor.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 5846ed3..f6deadb 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -305,6 +305,8 @@ path.connection-line.active { } .node-header > span:first-child { + display: inline-flex; + align-items: center; flex: 1; min-width: 0; overflow: hidden; From c41b0f866e9bd53d4ed26552818d516817cb91ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Mon, 23 Feb 2026 14:12:39 -0300 Subject: [PATCH 04/24] Refactor zoom controls and clean up DS UI styles --- apps/web/index.html | 20 ++++--- apps/web/src/app/workflow-editor.ts | 25 +++++---- apps/web/src/workflow-editor.css | 84 ++++++++++++++--------------- 3 files changed, 64 insertions(+), 65 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 1dd3a89..a6106a1 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -11,11 +11,6 @@ href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" /> - - @@ -50,12 +45,15 @@

Nodes

- - +
+
+ + Zoom +
+ + 100% + +
diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index d27ad13..0319382 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -39,6 +39,7 @@ export class WorkflowEditor { this.chatMessages = document.getElementById('chat-messages'); this.initialPrompt = document.getElementById('initial-prompt'); this.runButton = document.getElementById('btn-run'); + this.zoomValue = document.getElementById('zoom-value'); this.workflowState = 'idle'; // 'idle' | 'running' | 'paused' this.rightPanel = document.getElementById('right-panel'); this.pendingAgentMessage = null; @@ -133,6 +134,12 @@ export class WorkflowEditor { if (this.canvasStage) { this.canvasStage.style.transform = `translate(${this.viewport.x}px, ${this.viewport.y}px) scale(${this.viewport.scale})`; } + this.updateZoomValue(); + } + + updateZoomValue() { + if (!this.zoomValue) return; + this.zoomValue.textContent = `${Math.round(this.viewport.scale * 100)}%`; } screenToWorld(clientX, clientY) { @@ -262,9 +269,12 @@ export class WorkflowEditor { return select; } - zoomCanvas(factor) { + zoomCanvas(stepPercent) { if (!this.canvas) return; - const newScale = Math.min(2, Math.max(0.5, this.viewport.scale * factor)); + const snappedScale = Math.round(this.viewport.scale * 10) / 10; + const delta = stepPercent / 100; + const newScale = Math.min(2, Math.max(0.5, snappedScale + delta)); + if (newScale === this.viewport.scale) return; const rect = this.canvas.getBoundingClientRect(); const screenX = rect.width / 2; const screenY = rect.height / 2; @@ -276,11 +286,6 @@ export class WorkflowEditor { this.applyViewport(); } - resetViewport() { - this.viewport = { x: 0, y: 0, scale: 1 }; - this.applyViewport(); - } - // --- INITIALIZATION --- initDragAndDrop() { @@ -409,10 +414,8 @@ export class WorkflowEditor { const zoomInBtn = document.getElementById('btn-zoom-in'); const zoomOutBtn = document.getElementById('btn-zoom-out'); - const zoomResetBtn = document.getElementById('btn-zoom-reset'); - if (zoomInBtn) zoomInBtn.addEventListener('click', () => this.zoomCanvas(1.2)); - if (zoomOutBtn) zoomOutBtn.addEventListener('click', () => this.zoomCanvas(0.8)); - if (zoomResetBtn) zoomResetBtn.addEventListener('click', () => this.resetViewport()); + if (zoomInBtn) zoomInBtn.addEventListener('click', () => this.zoomCanvas(10)); + if (zoomOutBtn) zoomOutBtn.addEventListener('click', () => this.zoomCanvas(-10)); } initWebSocket() { diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index f6deadb..7969f6c 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -2,7 +2,6 @@ --node-width-min: 120px; --node-width-max: 280px; --node-width-expanded: 420px; - --sidebar-width: 260px; --right-sidebar-width: 320px; } @@ -25,10 +24,8 @@ body { } -/* Main Layout Override for 2-column structure */ +/* Split panel host */ .main-layout { - display: grid; - grid-template-columns: 1fr var(--right-sidebar-width); height: 100vh; overflow: hidden; } @@ -75,33 +72,6 @@ body { font-size: var(--Fonts-Body-Default-md); } -.panel-resizer { - width: 6px; - cursor: col-resize; - background: transparent; - position: relative; - z-index: 4; - height: 100%; -} - -.panel-resizer::after { - content: ''; - position: absolute; - left: 50%; - top: 0; - bottom: 0; - width: 2px; - background: var(--Colors-Stroke-Default); - transform: translateX(-50%); - opacity: 0; - transition: opacity 0.2s ease; -} - -.panel-resizer:hover::after, -.panel-resizer.dragging::after { - opacity: 1; -} - /* Palette Items */ .draggable-node { padding: var(--UI-Spacing-spacing-mxs); @@ -167,7 +137,6 @@ body { right: var(--UI-Spacing-spacing-ms); z-index: 5; display: flex; - gap: var(--UI-Spacing-spacing-xs); } .canvas-clear { @@ -183,28 +152,57 @@ body { opacity: 0.9; } -.canvas-controls button { - width: 48px; +.zoom-control { height: 44px; border-radius: var(--UI-Radius-radius-mxl); - border: none; - font-size: var(--Fonts-Body-Default-xl); - color: var(--Colors-Text-Body-Strong); - background: var(--Colors-Backgrounds-Main-Top); + padding: 0 var(--UI-Spacing-spacing-mxs); + background: color-mix(in srgb, var(--Colors-Backgrounds-Main-Strong) 82%, transparent); box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card); display: flex; align-items: center; - justify-content: center; - padding: 0; + gap: var(--UI-Spacing-spacing-s); + border: 1px solid var(--Colors-Stroke-Default); } -.canvas-controls button:hover { - background: var(--Colors-Backgrounds-Main-Medium); +.zoom-label { + display: inline-flex; + align-items: center; + gap: var(--UI-Spacing-spacing-xs); + color: var(--Colors-Text-Body-Default); + padding: 0 var(--UI-Spacing-spacing-xxs); + font-size: var(--Fonts-Body-Default-md); + font-weight: 600; +} + +.zoom-label .icon { + color: var(--Colors-Text-Body-Secondary); } -.canvas-controls button span { +.zoom-stepper { + width: 28px; + height: 28px; + min-width: 28px; + border-radius: 50%; + font-size: var(--Fonts-Body-Default-xl); line-height: 1; + padding: 0; +} + +.zoom-value { + font-size: var(--Fonts-Body-Default-md); font-weight: 600; + letter-spacing: 0.01em; + width: 54px; + min-width: 54px; + text-align: center; + padding: 0; + font-variant-numeric: tabular-nums; +} + +.zoom-value:focus-visible, +.zoom-stepper:focus-visible { + outline: 2px solid var(--Colors-Primary-Medium); + outline-offset: 1px; } /* SVG Connections */ From b4560962373bcfa14e860badc97f968a3793d5e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Mon, 23 Feb 2026 14:17:33 -0300 Subject: [PATCH 05/24] Remove dotenv usage and require exported OpenAI key --- .gitignore | 5 ----- README.md | 5 ++++- apps/server/package.json | 2 -- apps/server/src/config.ts | 4 ---- package-lock.json | 18 +++++------------- 5 files changed, 9 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index cc81089..ca2af74 100644 --- a/.gitignore +++ b/.gitignore @@ -18,11 +18,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -# Env files -.env -.env.local -.env.*.local - # OS .DS_Store diff --git a/README.md b/README.md index 53b2bc8..2526499 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,10 @@ data/ ```bash npm install ``` -2. (Optional) create `.env` with `OPENAI_API_KEY=sk-...`. Without it the engine falls back to deterministic mock responses. +2. Export your OpenAI API key in the shell: + ```bash + export OPENAI_API_KEY="sk-..." + ``` 3. Start the integrated dev server (Express + embedded Vite middleware on one port): ```bash npm run dev diff --git a/apps/server/package.json b/apps/server/package.json index 83adbd6..8c80ccf 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,7 +15,6 @@ "@agentic/types": "file:../../packages/types", "@agentic/workflow-engine": "file:../../packages/workflow-engine", "cors": "^2.8.5", - "dotenv": "^17.2.3", "express": "^4.19.2", "openai": "^6.9.1" }, @@ -24,4 +23,3 @@ "@types/express": "^4.17.21" } } - diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 1097607..d3a3331 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -1,8 +1,5 @@ import fs from 'node:fs'; import path from 'node:path'; -import dotenv from 'dotenv'; - -dotenv.config(); const FALLBACK_ROOT = path.resolve(__dirname, '..', '..', '..'); const PROJECT_ROOT = process.env.PROJECT_ROOT || FALLBACK_ROOT; @@ -16,4 +13,3 @@ export const config = { projectRoot: PROJECT_ROOT, openAiApiKey: process.env.OPENAI_API_KEY ?? '' }; - diff --git a/package-lock.json b/package-lock.json index 952c51f..f0acb14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,6 @@ "@agentic/types": "file:../../packages/types", "@agentic/workflow-engine": "file:../../packages/workflow-engine", "cors": "^2.8.5", - "dotenv": "^17.2.3", "express": "^4.19.2", "openai": "^6.9.1" }, @@ -1280,6 +1279,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -1611,6 +1611,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2063,18 +2064,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2225,6 +2214,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4129,6 +4119,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4187,6 +4178,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", From 2c56ecc029f60dd0aa39679afc9b326677d301ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Mon, 23 Feb 2026 14:32:13 -0300 Subject: [PATCH 06/24] Fail agent runs cleanly and improve error UX --- README.md | 2 +- apps/server/src/index.ts | 3 +-- apps/server/src/routes/workflows.ts | 9 ++++++++- apps/web/src/app/workflow-editor.ts | 24 ++++++++++++++++++------ apps/web/src/services/api.ts | 25 +++++++++++++++++++++++-- apps/web/src/workflow-editor.css | 4 ++++ packages/workflow-engine/src/index.js | 20 +++++++++++++------- packages/workflow-engine/src/index.ts | 22 ++++++++++++++-------- 8 files changed, 82 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 2526499..7e99cb3 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ data/ ## Architecture Notes - **`@agentic/workflow-engine`**: Pure TypeScript package that normalizes graphs, manages state, pauses for approvals, and calls an injected `WorkflowLLM`. It now exposes `getGraph()` so callers can persist what actually ran. -- **Server (`apps/server`)**: Express routes `/api/run` + `/api/resume` hydrate `WorkflowEngine` instances, fallback to mock LLMs when no OpenAI key is present, and persist run records through `saveRunRecord()` into `data/runs/`. +- **Server (`apps/server`)**: Express routes `/api/run` + `/api/resume` hydrate `WorkflowEngine` instances, require an OpenAI-backed LLM for Agent nodes, and persist run records through `saveRunRecord()` into `data/runs/`. - **Web (`apps/web`)**: Vite SPA using the CodeSignal design system. Core UI logic lives in `src/app/workflow-editor.ts`; shared helpers (help modal, API client, etc.) live under `src/`. - **Shared contracts**: `packages/types` keeps node shapes, graph schemas, log formats, and run-record definitions in sync across the stack. diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index bbcc84d..bf3a815 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -23,7 +23,7 @@ async function bootstrap() { const client = new OpenAI({ apiKey: config.openAiApiKey }); llmService = new OpenAILLMService(client); } else { - logger.warn('OPENAI_API_KEY missing. Falling back to mock LLM responses.'); + logger.warn('OPENAI_API_KEY missing. Agent workflows will be rejected.'); } app.use('/api', createWorkflowRouter(llmService)); @@ -82,4 +82,3 @@ bootstrap().catch((error) => { logger.error('Failed to start server', error); process.exitCode = 1; }); - diff --git a/apps/server/src/routes/workflows.ts b/apps/server/src/routes/workflows.ts index fa081e6..762ac0a 100644 --- a/apps/server/src/routes/workflows.ts +++ b/apps/server/src/routes/workflows.ts @@ -50,6 +50,14 @@ export function createWorkflowRouter(llm?: WorkflowLLM): Router { return; } + const hasAgentNode = graph.nodes.some((node) => node.type === 'agent'); + if (hasAgentNode && !llm) { + res.status(503).json({ + error: 'OPENAI_API_KEY is required to run workflows with Agent nodes.' + }); + return; + } + try { const runId = Date.now().toString(); const engine = new WorkflowEngine(graph, { runId, llm }); @@ -97,4 +105,3 @@ export function createWorkflowRouter(llm?: WorkflowLLM): Router { return router; } - diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index 0319382..6cfaaf1 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -1178,6 +1178,10 @@ export class WorkflowEditor { if (!this.chatMessages) return; const message = document.createElement('div'); message.className = `chat-message ${role}`; + const normalizedText = + role === 'error' && !String(text).trim().toLowerCase().startsWith('error:') + ? `Error: ${text}` + : text; if (role === 'agent') { const label = document.createElement('span'); label.className = 'chat-message-label'; @@ -1185,7 +1189,7 @@ export class WorkflowEditor { message.appendChild(label); } const body = document.createElement('div'); - body.textContent = text; + body.textContent = normalizedText; message.appendChild(body); this.chatMessages.appendChild(message); this.chatMessages.scrollTop = this.chatMessages.scrollHeight; @@ -1203,6 +1207,7 @@ export class WorkflowEditor { mapLogEntryToRole(entry) { const type = entry.type || ''; if (type.includes('llm_response')) return 'agent'; + if (type.includes('llm_error') || type === 'error') return 'error'; if (type.includes('input_received') || type.includes('start_prompt')) return 'user'; return null; } @@ -1215,19 +1220,19 @@ export class WorkflowEditor { renderChatFromLogs(logs = []) { if (!this.chatMessages) return; this.chatMessages.innerHTML = ''; - let agentMessageShown = false; + let messageShown = false; logs.forEach(entry => { const role = this.mapLogEntryToRole(entry); if (!role) return; - if (role === 'agent' && !agentMessageShown) { + if ((role === 'agent' || role === 'error') && !messageShown) { this.hideAgentSpinner(); - agentMessageShown = true; + messageShown = true; } const text = this.formatLogContent(entry); if (!text) return; this.appendChatMessage(text, role); }); - if (!agentMessageShown) { + if (!messageShown) { this.showAgentSpinner(); } } @@ -1272,6 +1277,9 @@ export class WorkflowEditor { if (result.logs) { this.renderChatFromLogs(result.logs); } + const hasLlmError = Array.isArray(result.logs) + ? result.logs.some(entry => (entry?.type || '').includes('llm_error')) + : false; if (result.status === 'paused' && result.waitingForInput) { this.currentRunId = result.runId; @@ -1280,7 +1288,11 @@ export class WorkflowEditor { this.setWorkflowState('paused'); } else if (result.status === 'completed') { this.clearApprovalMessage(); - this.appendStatusMessage('Completed', 'completed'); + if (hasLlmError) { + this.appendStatusMessage('Failed', 'failed'); + } else { + this.appendStatusMessage('Completed', 'completed'); + } this.hideAgentSpinner(); this.setWorkflowState('idle'); this.currentRunId = null; diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts index eb4cf2c..5c4e16c 100644 --- a/apps/web/src/services/api.ts +++ b/apps/web/src/services/api.ts @@ -8,8 +8,30 @@ async function request(url: string, body: unknown): Promise { }); if (!res.ok) { + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + let payload: { error?: string; details?: string; message?: string } | null = null; + try { + payload = (await res.json()) as { error?: string; details?: string; message?: string }; + } catch { + // Fall through to generic message + } + const message = payload?.error || payload?.details || payload?.message; + throw new Error(message || 'Request failed'); + } + const text = await res.text(); - throw new Error(text || 'Request failed'); + if (text) { + try { + const payload = JSON.parse(text) as { error?: string; details?: string; message?: string }; + const message = payload.error || payload.details || payload.message; + throw new Error(message || text.trim()); + } catch { + throw new Error(text.trim()); + } + } + + throw new Error('Request failed'); } return res.json() as Promise; @@ -22,4 +44,3 @@ export function runWorkflow(graph: WorkflowGraph): Promise { export function resumeWorkflow(runId: string, input: ApprovalInput): Promise { return request('/api/resume', { runId, input }); } - diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 7969f6c..7183ef0 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -545,6 +545,8 @@ path.connection-line.active { padding: var(--UI-Spacing-spacing-mxs) var(--UI-Spacing-spacing-ms); line-height: 1.4; font-size: var(--Fonts-Body-Default-xs); + max-width: 100%; + min-width: 0; } .chat-message.user { @@ -607,6 +609,8 @@ path.connection-line.active { .chat-message > div:last-child { white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; } .chat-message .chat-message-label { diff --git a/packages/workflow-engine/src/index.js b/packages/workflow-engine/src/index.js index f14ea3d..cab6477 100644 --- a/packages/workflow-engine/src/index.js +++ b/packages/workflow-engine/src/index.js @@ -2,10 +2,9 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.WorkflowEngine = void 0; const DEFAULT_REASONING = 'low'; -class MockLLM { - async respond(input) { - const toolSuffix = input.tools?.web_search ? ' (web search enabled)' : ''; - return `Mock response for "${input.userContent || 'empty prompt'}" using ${input.model}${toolSuffix}.`; +class MissingLLM { + async respond() { + throw new Error('No LLM service configured. Set OPENAI_API_KEY in the environment.'); } } class WorkflowEngine { @@ -17,7 +16,7 @@ class WorkflowEngine { this.waitingForInput = false; this.graph = this.normalizeGraph(graph); this.runId = options.runId ?? Date.now().toString(); - this.llm = options.llm ?? new MockLLM(); + this.llm = options.llm ?? new MissingLLM(); this.timestampFn = options.timestampFn ?? (() => new Date().toISOString()); this.onLog = options.onLog; } @@ -180,7 +179,14 @@ class WorkflowEngine { } catch (error) { const message = error instanceof Error ? error.message : String(error); - this.log(node.id, 'error', message); + const lastLog = this.logs[this.logs.length - 1]; + const isDuplicateLlmError = lastLog && + lastLog.nodeId === node.id && + lastLog.type === 'llm_error' && + lastLog.content === message; + if (!isDuplicateLlmError) { + this.log(node.id, 'error', message); + } this.status = 'failed'; } } @@ -250,7 +256,7 @@ class WorkflowEngine { catch (error) { const message = error instanceof Error ? error.message : String(error); this.log(node.id, 'llm_error', message); - return `LLM error: ${message}`; + throw error instanceof Error ? error : new Error(message); } } findLastNonApprovalOutput() { diff --git a/packages/workflow-engine/src/index.ts b/packages/workflow-engine/src/index.ts index b8ea0e1..c42e050 100644 --- a/packages/workflow-engine/src/index.ts +++ b/packages/workflow-engine/src/index.ts @@ -33,10 +33,9 @@ export interface WorkflowEngineInitOptions { const DEFAULT_REASONING = 'low'; -class MockLLM implements WorkflowLLM { - async respond(input: AgentInvocation): Promise { - const toolSuffix = input.tools?.web_search ? ' (web search enabled)' : ''; - return `Mock response for "${input.userContent || 'empty prompt'}" using ${input.model}${toolSuffix}.`; +class MissingLLM implements WorkflowLLM { + async respond(): Promise { + throw new Error('No LLM service configured. Set OPENAI_API_KEY in the environment.'); } } @@ -64,7 +63,7 @@ export class WorkflowEngine { constructor(graph: WorkflowGraph, options: WorkflowEngineInitOptions = {}) { this.graph = this.normalizeGraph(graph); this.runId = options.runId ?? Date.now().toString(); - this.llm = options.llm ?? new MockLLM(); + this.llm = options.llm ?? new MissingLLM(); this.timestampFn = options.timestampFn ?? (() => new Date().toISOString()); this.onLog = options.onLog; } @@ -247,7 +246,15 @@ export class WorkflowEngine { } } catch (error) { const message = error instanceof Error ? error.message : String(error); - this.log(node.id, 'error', message); + const lastLog = this.logs[this.logs.length - 1]; + const isDuplicateLlmError = + lastLog && + lastLog.nodeId === node.id && + lastLog.type === 'llm_error' && + lastLog.content === message; + if (!isDuplicateLlmError) { + this.log(node.id, 'error', message); + } this.status = 'failed'; } } @@ -331,7 +338,7 @@ export class WorkflowEngine { } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log(node.id, 'llm_error', message); - return `LLM error: ${message}`; + throw error instanceof Error ? error : new Error(message); } } @@ -380,4 +387,3 @@ export class WorkflowEngine { } export default WorkflowEngine; - From 635ed4e6415c77181e6f0b4153f977c4cebd5d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Mon, 23 Feb 2026 14:55:51 -0300 Subject: [PATCH 07/24] Add run preflight rules and cancel-run UI --- apps/web/docs/run-readiness.md | 41 ++++++++ apps/web/index.html | 13 ++- apps/web/src/app/workflow-editor.ts | 146 +++++++++++++++++++++++++++- apps/web/src/services/api.ts | 21 ++-- apps/web/src/workflow-editor.css | 61 +++++++++++- 5 files changed, 269 insertions(+), 13 deletions(-) create mode 100644 apps/web/docs/run-readiness.md diff --git a/apps/web/docs/run-readiness.md b/apps/web/docs/run-readiness.md new file mode 100644 index 0000000..78ede4b --- /dev/null +++ b/apps/web/docs/run-readiness.md @@ -0,0 +1,41 @@ +# Run Readiness Rules (Web UI) + +This document defines when the **Run Workflow** button is enabled or disabled in the web editor. + +Source of truth: +- `apps/web/src/app/workflow-editor.ts` +- `getRunDisableReason()` +- `updateRunButton()` + +## UI State Rules + +The run button is disabled when the editor is not idle: +- `running`: button label is `Running...` +- `paused`: button label is `Paused` + +The cancel button is shown only while state is `running`. + +## Graph Validation Rules (Idle State) + +The run button is disabled if any of these are true: +- No `Start` node exists. +- More than one `Start` node exists. +- Any connection references a missing source or target node. +- The `Start` node has no outgoing connection. +- Nothing is reachable after `Start` (for example only `Start`, or `Start` not connected to any executable node). +- A reachable `If / Else` node has neither a `true` nor a `false` outgoing branch. +- A reachable `Approval` node has neither an `approve` nor a `reject` outgoing branch. + +## Explicitly Allowed + +These cases are currently allowed and do not block run: +- Circular connections (loops). +- Unreachable/disconnected nodes not on the reachable path from `Start`. +- `If / Else` with only one branch connected (at least one is required). +- `Approval` with only one branch connected (at least one is required). + +## Backend Runtime Constraint (Not a UI Preflight Rule) + +Even if UI preflight passes, backend can still reject a run: +- Workflows containing `Agent` nodes require an OpenAI-backed LLM configuration (`OPENAI_API_KEY` in environment). +- If unavailable, the backend returns an error and the UI shows it in chat. diff --git a/apps/web/index.html b/apps/web/index.html index a6106a1..cd0850f 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -80,7 +80,18 @@

Run Console

placeholder="Describe what the workflow should accomplish..." >
- +
+ + +
diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index 6cfaaf1..36bedf5 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -39,12 +39,14 @@ export class WorkflowEditor { this.chatMessages = document.getElementById('chat-messages'); this.initialPrompt = document.getElementById('initial-prompt'); this.runButton = document.getElementById('btn-run'); + this.cancelRunButton = document.getElementById('btn-cancel-run'); this.zoomValue = document.getElementById('zoom-value'); this.workflowState = 'idle'; // 'idle' | 'running' | 'paused' this.rightPanel = document.getElementById('right-panel'); this.pendingAgentMessage = null; this.currentPrompt = ''; this.pendingApprovalRequest = null; + this.activeRunController = null; this.splitPanelCtorPromise = null; this.dropdownCtorPromise = null; @@ -181,22 +183,132 @@ export class WorkflowEditor { this.updateRunButton(); } + setRunButtonHint(reason) { + if (!this.runButton) return; + if (reason) { + this.runButton.title = reason; + this.runButton.setAttribute('data-disabled-hint', reason); + } else { + this.runButton.removeAttribute('title'); + this.runButton.removeAttribute('data-disabled-hint'); + } + } + + isAbortError(error) { + if (!error) return false; + if (error.name === 'AbortError') return true; + const message = typeof error.message === 'string' ? error.message : ''; + return message.toLowerCase().includes('aborted'); + } + + cancelRunningWorkflow() { + if (this.activeRunController) { + this.activeRunController.abort(); + this.activeRunController = null; + } + if (this.workflowState === 'running') { + this.hideAgentSpinner(); + this.clearApprovalMessage(); + this.appendStatusMessage('Cancelled'); + this.currentRunId = null; + this.setWorkflowState('idle'); + } + } + + getRunDisableReason() { + const startNodes = this.nodes.filter(node => node.type === 'start'); + if (startNodes.length === 0) { + return 'Add a Start node to run the workflow.'; + } + if (startNodes.length > 1) { + return 'Use only one Start node before running.'; + } + + const nodeIdSet = new Set(this.nodes.map(node => node.id)); + const hasBrokenConnection = this.connections.some( + (conn) => !nodeIdSet.has(conn.source) || !nodeIdSet.has(conn.target) + ); + if (hasBrokenConnection) { + return 'Fix broken connections before running.'; + } + + const startNode = startNodes[0]; + const adjacency = new Map(); + this.connections.forEach((conn) => { + if (!adjacency.has(conn.source)) adjacency.set(conn.source, []); + adjacency.get(conn.source).push(conn); + }); + + const startConnections = adjacency.get(startNode.id) || []; + if (startConnections.length === 0) { + return 'Connect Start to another node before running.'; + } + + const reachable = new Set([startNode.id]); + const queue = [startNode.id]; + while (queue.length > 0) { + const nodeId = queue.shift(); + const next = adjacency.get(nodeId) || []; + next.forEach((conn) => { + if (!reachable.has(conn.target)) { + reachable.add(conn.target); + queue.push(conn.target); + } + }); + } + + if (reachable.size <= 1) { + return 'Add and connect at least one node after Start.'; + } + + for (const node of this.nodes) { + if (!reachable.has(node.id)) continue; + if (node.type === 'if') { + const outgoing = adjacency.get(node.id) || []; + const hasTrue = outgoing.some((conn) => conn.sourceHandle === 'true'); + const hasFalse = outgoing.some((conn) => conn.sourceHandle === 'false'); + if (!hasTrue && !hasFalse) { + return 'Connect at least one branch for each If / Else node.'; + } + } + if (node.type === 'approval' || node.type === 'input') { + const outgoing = adjacency.get(node.id) || []; + const hasApprove = outgoing.some((conn) => conn.sourceHandle === 'approve'); + const hasReject = outgoing.some((conn) => conn.sourceHandle === 'reject'); + if (!hasApprove && !hasReject) { + return 'Connect at least one branch for each approval node.'; + } + } + } + + return null; + } + updateRunButton() { if (!this.runButton) return; + if (this.cancelRunButton) { + const showCancel = this.workflowState === 'running'; + this.cancelRunButton.style.display = showCancel ? 'inline-flex' : 'none'; + this.cancelRunButton.disabled = !showCancel; + } switch (this.workflowState) { case 'running': this.runButton.textContent = 'Running...'; this.runButton.disabled = true; + this.setRunButtonHint('Workflow is currently running.'); break; case 'paused': this.runButton.textContent = 'Paused'; this.runButton.disabled = true; + this.setRunButtonHint('Workflow is paused waiting for approval.'); break; case 'idle': default: + const disabledReason = this.getRunDisableReason(); this.runButton.innerHTML = 'Run Workflow '; - this.runButton.disabled = false; + this.runButton.disabled = Boolean(disabledReason); + this.setRunButtonHint(disabledReason); break; } } @@ -385,6 +497,10 @@ export class WorkflowEditor { initButtons() { document.getElementById('btn-run').addEventListener('click', () => this.runWorkflow()); + const cancelRunBtn = document.getElementById('btn-cancel-run'); + if (cancelRunBtn) { + cancelRunBtn.addEventListener('click', () => this.cancelRunningWorkflow()); + } document.getElementById('btn-clear').addEventListener('click', async () => { const confirmed = await this.openConfirmModal({ title: 'Clear Canvas', @@ -488,6 +604,7 @@ export class WorkflowEditor { }; this.nodes.push(node); this.renderNode(node); + this.updateRunButton(); } upgradeLegacyNodes(shouldRender = false) { @@ -503,6 +620,8 @@ export class WorkflowEditor { }); if (updated && shouldRender) { this.render(); + } else if (updated) { + this.updateRunButton(); } } @@ -561,6 +680,7 @@ export class WorkflowEditor { this.nodes = this.nodes.filter(n => n.id !== id); this.connections = this.connections.filter(c => c.source !== id && c.target !== id); this.render(); + this.updateRunButton(); } // --- RENDERING --- @@ -570,6 +690,7 @@ export class WorkflowEditor { this.connectionsLayer.innerHTML = ''; this.nodes.forEach(n => this.renderNode(n)); this.renderConnections(); + this.updateRunButton(); } renderNode(node) { @@ -973,6 +1094,7 @@ export class WorkflowEditor { if(this.tempConnection) this.tempConnection.remove(); this.connectionStart = null; this.tempConnection = null; + this.updateRunButton(); } else if (this.reconnectingConnection !== null) { // Released without connecting to anything - connection already deleted, just clean up this.reconnectingConnection = null; @@ -980,6 +1102,7 @@ export class WorkflowEditor { if(this.tempConnection) this.tempConnection.remove(); this.connectionStart = null; this.tempConnection = null; + this.updateRunButton(); } } @@ -1013,6 +1136,7 @@ export class WorkflowEditor { // Remove the original connection temporarily this.connections.splice(connIndex, 1); this.renderConnections(); + this.updateRunButton(); // Create temp connection for dragging this.tempConnection = document.createElementNS('http://www.w3.org/2000/svg', 'path'); @@ -1260,16 +1384,23 @@ export class WorkflowEditor { nodes: this.nodes, connections: this.connections }; + const controller = new AbortController(); + this.activeRunController = controller; try { - const result = await runWorkflow(graph); + const result = await runWorkflow(graph, { signal: controller.signal }); this.handleRunResult(result); } catch (e) { - this.appendChatMessage('Error: ' + e.message, 'error'); + if (this.isAbortError(e)) return; + this.appendChatMessage(e.message, 'error'); this.appendStatusMessage('Failed', 'failed'); this.hideAgentSpinner(); this.setWorkflowState('idle'); + } finally { + if (this.activeRunController === controller) { + this.activeRunController = null; + } } } @@ -1318,15 +1449,22 @@ export class WorkflowEditor { this.replaceApprovalWithResult(decision, note); this.setWorkflowState('running'); this.showAgentSpinner(); + const controller = new AbortController(); + this.activeRunController = controller; try { - const result = await resumeWorkflow(this.currentRunId, { decision, note }); + const result = await resumeWorkflow(this.currentRunId, { decision, note }, { signal: controller.signal }); this.handleRunResult(result); } catch (e) { + if (this.isAbortError(e)) return; this.appendChatMessage(e.message, 'error'); this.appendStatusMessage('Failed', 'failed'); this.hideAgentSpinner(); this.setWorkflowState('idle'); + } finally { + if (this.activeRunController === controller) { + this.activeRunController = null; + } } } } diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts index 5c4e16c..78d0980 100644 --- a/apps/web/src/services/api.ts +++ b/apps/web/src/services/api.ts @@ -1,10 +1,15 @@ import type { ApprovalInput, WorkflowGraph, WorkflowRunResult } from '@agentic/types'; -async function request(url: string, body: unknown): Promise { +type RequestOptions = { + signal?: AbortSignal; +}; + +async function request(url: string, body: unknown, options: RequestOptions = {}): Promise { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) + body: JSON.stringify(body), + signal: options.signal }); if (!res.ok) { @@ -37,10 +42,14 @@ async function request(url: string, body: unknown): Promise { return res.json() as Promise; } -export function runWorkflow(graph: WorkflowGraph): Promise { - return request('/api/run', { graph }); +export function runWorkflow(graph: WorkflowGraph, options: RequestOptions = {}): Promise { + return request('/api/run', { graph }, options); } -export function resumeWorkflow(runId: string, input: ApprovalInput): Promise { - return request('/api/resume', { runId, input }); +export function resumeWorkflow( + runId: string, + input: ApprovalInput, + options: RequestOptions = {} +): Promise { + return request('/api/resume', { runId, input }, options); } diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index 7183ef0..6fa55d0 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -772,11 +772,68 @@ path.connection-line.active { box-sizing: border-box; } -.run-button { - width: 100%; +.run-actions { margin-top: var(--UI-Spacing-spacing-mxs); + display: flex; + align-items: center; + gap: var(--UI-Spacing-spacing-s); +} + +.run-button { + flex: 1; + width: auto; display: inline-flex; align-items: center; justify-content: center; gap: var(--UI-Spacing-spacing-s); + position: relative; + overflow: visible; +} + +.cancel-run-button { + width: var(--UI-Input-md); + min-width: var(--UI-Input-md); + height: var(--UI-Input-md); + padding: 0; + display: none; + align-items: center; + justify-content: center; +} + +.run-button:disabled { + pointer-events: auto; + cursor: not-allowed; +} + +.run-button:disabled[data-disabled-hint]:hover::after { + content: attr(data-disabled-hint); + position: absolute; + left: 50%; + bottom: calc(100% + var(--UI-Spacing-spacing-s)); + transform: translateX(-50%); + width: max-content; + max-width: min(320px, calc(100vw - 32px)); + padding: var(--UI-Spacing-spacing-s) var(--UI-Spacing-spacing-ms); + border-radius: var(--UI-Radius-radius-xs); + border: 1px solid var(--Colors-Stroke-Strong); + background: var(--Colors-Backgrounds-Main-Top); + color: var(--Colors-Text-Body-Strong); + font-size: var(--Fonts-Body-Default-xxs); + line-height: 1.4; + white-space: normal; + text-align: left; + z-index: 30; + box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card); +} + +.run-button:disabled[data-disabled-hint]:hover::before { + content: ''; + position: absolute; + left: 50%; + bottom: calc(100% + var(--UI-Spacing-spacing-xxs)); + transform: translateX(-50%); + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid var(--Colors-Backgrounds-Main-Top); + z-index: 31; } From d36c34d0e7d0f7ebd92c14b5433f254ce1e4720e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Mon, 23 Feb 2026 15:03:22 -0300 Subject: [PATCH 08/24] Adding run-readiness docs --- README.md | 4 ++++ apps/web/src/data/help-content.ts | 9 --------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7e99cb3..387950e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ data/ | `npm run lint` | ESLint via the repo-level config. | | `npm run typecheck` | TypeScript in both apps. | +## Run Readiness + +Workflow run preflight rules and blocking conditions are documented in `apps/web/docs/run-readiness.md`. + ## Architecture Notes - **`@agentic/workflow-engine`**: Pure TypeScript package that normalizes graphs, manages state, pauses for approvals, and calls an injected `WorkflowLLM`. It now exposes `getGraph()` so callers can persist what actually ran. diff --git a/apps/web/src/data/help-content.ts b/apps/web/src/data/help-content.ts index 707ba09..0b14136 100644 --- a/apps/web/src/data/help-content.ts +++ b/apps/web/src/data/help-content.ts @@ -51,14 +51,5 @@ export const helpContent = ` Why does a workflow pause?

Approval nodes intentionally pause execution until you make a decision in the Run Console.

-
- Where are run logs stored? -

Each execution is saved under data/runs/run_*.json for later auditing.

-
-
- Do I need an OpenAI key? -

No. Without OPENAI_API_KEY, the engine returns mock responses. Provide the key for live model calls.

-
`; - From 1cfca465e2441a57747454d36947280519c0a011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Mon, 23 Feb 2026 15:04:04 -0300 Subject: [PATCH 09/24] Falling back to create data folder at runtime if deleted or missing --- apps/server/src/services/persistence.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/services/persistence.ts b/apps/server/src/services/persistence.ts index a466f4b..d0cf904 100644 --- a/apps/server/src/services/persistence.ts +++ b/apps/server/src/services/persistence.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import type { WorkflowRunRecord } from '@agentic/types'; export async function saveRunRecord(runsDir: string, record: WorkflowRunRecord): Promise { + await fs.mkdir(runsDir, { recursive: true }); const filePath = path.join(runsDir, `run_${record.runId}.json`); await fs.writeFile(filePath, JSON.stringify(record, null, 2), 'utf-8'); } - From b608c19324031372d800ac5f60a72804256f2062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Galv=C3=A3o?= Date: Mon, 23 Feb 2026 15:08:45 -0300 Subject: [PATCH 10/24] Updating AGENTS.md --- AGENTS.md | 734 ++++-------------------------------------------------- 1 file changed, 47 insertions(+), 687 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 59ccf64..3e3f5cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,687 +1,47 @@ -# CodeSignal Design System - Agent Reference Guide - -This document provides comprehensive information for agentic code generators to effectively use the CodeSignal Design System. - -## Table of Contents - -1. [System Overview](#system-overview) -2. [Installation & Setup](#installation--setup) -3. [Design Tokens](#design-tokens) -4. [Components](#components) -5. [Usage Patterns](#usage-patterns) -6. [Best Practices](#best-practices) -7. [File Structure](#file-structure) - ---- - -## System Overview - -The CodeSignal Design System is a CSS-based design system organized into **Foundations** (design tokens) and **Components** (reusable UI elements). All components are built using CSS custom properties (CSS variables) for theming and consistency. - -### Key Principles - -- **Semantic over Primitive**: Always prefer semantic tokens (e.g., `--Colors-Text-Body-Default`) over base scale tokens -- **Dark Mode Support**: All components automatically adapt to dark mode via `@media (prefers-color-scheme: dark)` -- **CSS-First**: Components are primarily CSS-based with minimal JavaScript (only Dropdown requires JS) -- **Accessibility**: Components follow WCAG guidelines and support keyboard navigation - ---- - -## Installation & Setup - -### Required CSS Files (Load in Order) - -```html - - - - - - - - - - - - - - - - - -``` - -### Alternative: CSS Import - -```css -@import url('/design-system/colors/colors.css'); -@import url('/design-system/spacing/spacing.css'); -@import url('/design-system/typography/typography.css'); -@import url('/design-system/components/button/button.css'); -``` - -### JavaScript (Only for Dropdown) - -```html - -``` - ---- - -## Design Tokens - -### Colors - -#### Base Scales (Primitive Tokens) -**Avoid using these directly** - use semantic tokens instead for theming support. - -Pattern: `--Colors-Base-[Family]-[Step]` - -**Families:** -- `Primary`: Brand blue colors (20-1400 scale) -- `Neutral`: Grays, white, black (00-1400 scale) -- `Accent-Green`: Success states -- `Accent-Sky-Blue`: Info states -- `Accent-Yellow`: Warning states -- `Accent-Orange`: Warning states -- `Accent-Red`: Error/Danger states - -**Example:** -```css ---Colors-Base-Primary-700: #1062FB; ---Colors-Base-Neutral-600: #ACB4C7; ---Colors-Base-Accent-Green-600: #10B981; -``` - -#### Semantic Tokens (Preferred) -**Always use these** for automatic dark mode support and consistency. - -**Categories:** - -1. **Primary Colors** - - `--Colors-Primary-Default` - - `--Colors-Primary-Medium` - - `--Colors-Primary-Strong` - -2. **Backgrounds** - - `--Colors-Backgrounds-Main-Default` - - `--Colors-Backgrounds-Main-Top` - - `--Colors-Backgrounds-Main-Medium` - - `--Colors-Backgrounds-Main-Strong` - -3. **Text Colors** - - `--Colors-Text-Body-Default` - - `--Colors-Text-Body-Secondary` - - `--Colors-Text-Body-Medium` - - `--Colors-Text-Body-Strong` - - `--Colors-Text-Body-Strongest` - -4. **Icon Colors** - - `--Colors-Icon-Default` - - `--Colors-Icon-Primary` - - `--Colors-Icon-Secondary` - -5. **Stroke/Border Colors** - - `--Colors-Stroke-Default` - - `--Colors-Stroke-Strong` - - `--Colors-Stroke-Strongest` - -6. **Alert Colors** - - `--Colors-Alert-Success-Default`, `--Colors-Alert-Success-Medium` - - `--Colors-Alert-Error-Default`, `--Colors-Alert-Error-Medium` - - `--Colors-Alert-Warning-Default`, `--Colors-Alert-Warning-Medium` - - `--Colors-Alert-Info-Default`, `--Colors-Alert-Info-Medium` - -### Spacing - -Pattern: `--UI-Spacing-spacing-[size]` - -**Available Sizes:** -- `none`: 0 -- `min`: 2px -- `xxs`: 4px -- `xs`: 6px -- `s`: 8px -- `mxs`: 12px -- `ms`: 16px -- `m`: 18px -- `ml`: 20px -- `mxl`: 24px -- `l`: 28px -- `xl`: 32px -- `xxl`: 36px -- `xxxl`: 48px -- `4xl`: 60px -- `max`: 90px - -**Usage:** -```css -padding: var(--UI-Spacing-spacing-m); -margin: var(--UI-Spacing-spacing-s); -gap: var(--UI-Spacing-spacing-mxl); -``` - -### Border Radius - -Pattern: `--UI-Radius-radius-[size]` - -**Available Sizes:** -- `none`: 0 -- `min`: 2px -- `xxs`: 4px -- `xs`: 6px -- `s`: 8px -- `m`: 12px -- `ml`: 16px -- `mxl`: 20px -- `l`: 24px -- `xl`: 32px - -**Usage:** -```css -border-radius: var(--UI-Radius-radius-m); -``` - -### Input Heights - -Pattern: `--UI-Input-[size]` - -**Available Sizes:** -- `min`: 26px -- `xs`: 32px -- `sm`: 40px -- `md`: 48px (default) -- `lg`: 60px - -**Usage:** -```css -height: var(--UI-Input-md); -``` - -### Typography - -#### Font Families -- **Body & Labels**: `Work Sans` (sans-serif) - Must be loaded from Google Fonts -- **Headings**: `Founders Grotesk` (sans-serif) - Included via `@font-face` -- **Code**: `JetBrains Mono` (monospace) - Included via `@font-face` - -#### Typography Classes - -**Body Text** (Work Sans): -- `.body-xxsmall` (13px) -- `.body-xsmall` (14px) -- `.body-small` (15px) -- `.body-medium` (16px) -- `.body-large` (17px) -- `.body-xlarge` (19px) -- `.body-xxlarge` (21px) -- `.body-xxxlarge` (24px) - -**Body Elegant** (Founders Grotesk): -- `.body-elegant-xxsmall` (22px) -- `.body-elegant-xsmall` (26px) -- `.body-elegant-small` (32px) -- `.body-elegant-medium` (38px) - -**Headings** (Founders Grotesk, 500 weight): -- `.heading-xxxsmall` (16px) -- `.heading-xxsmall` (22px) -- `.heading-xsmall` (22px) -- `.heading-small` (24px) -- `.heading-medium` (32px) -- `.heading-large` (38px) -- `.heading-xlarge` (48px) -- `.heading-xxlarge` (64px) - -**Labels** (Work Sans, 600 weight, uppercase): -- `.label-small` (10px) -- `.label-medium` (11px) -- `.label-large` (14px) - -**Label Numbers** (Work Sans, 500 weight): -- `.label-number-xsmall` (11px) -- `.label-number-small` (12px) -- `.label-number-medium` (14px) -- `.label-number-large` (15px) - ---- - -## Components - -### Button - -**Base Class:** `.button` (required) - -**Variants:** -- `.button-primary`: Primary action (Brand Blue background) -- `.button-secondary`: Secondary action (Outlined style) -- `.button-tertiary`: Tertiary/Ghost (Subtle background) -- `.button-danger`: Destructive action (Red) -- `.button-success`: Positive action (Green) -- `.button-text`: Text button (Neutral text, no background) -- `.button-text-primary`: Primary text button (Brand color text) - -**Sizes:** -- `.button-xsmall`: 32px height -- `.button-small`: 40px height -- Default: 48px height (medium) -- `.button-large`: 60px height - -**States:** -- Standard pseudo-classes: `:hover`, `:focus`, `:active`, `:disabled` -- Utility classes: `.hover`, `.focus`, `.active`, `.disabled` - -**Example:** -```html - - - -``` - -**Dependencies:** colors.css, spacing.css, typography.css - ---- - -### Box - -**Base Class:** `.box` (required) - -**Variants:** -- `.box.selected`: Selected state (Primary border) -- `.box.emphasized`: Emphasized state (Neutral border) -- `.box.shadowed`: Soft shadow -- `.box.card`: Card-style shadow - -**States:** -- Standard pseudo-classes: `:hover`, `:focus`, `:active` -- Utility classes: `.hover`, `.focus`, `.selected` - -**Example:** -```html -
Default content
-
Selected content
-
Card content
-``` - -**Dependencies:** colors.css, spacing.css - ---- - -### Input - -**Base Class:** `.input` (required) - -**Input Types:** -- `type="text"`: Standard text input (default) -- `type="number"`: Numeric input with styled spinner buttons - -**States:** -- Standard pseudo-classes: `:hover`, `:focus`, `:disabled` -- Utility classes: `.hover`, `.focus` - -**Features:** -- Automatic focus ring (primary color with reduced opacity) -- Styled number input spinners -- Dark mode support - -**Example:** -```html - - - -``` - -**Dependencies:** colors.css, spacing.css, typography.css - ---- - -### Tag - -**Base Class:** `.tag` or `.tag.default` (required) - -**Variants:** -- `.tag` / `.tag.default`: Primary tag (Brand Blue background) -- `.tag.secondary`: Secondary tag (Neutral gray background) -- `.tag.outline`: Outline tag (Transparent with border) -- `.tag.success`: Success tag (Green background) -- `.tag.error`: Error tag (Red background) -- `.tag.warning`: Warning tag (Yellow background) -- `.tag.info`: Info tag (Sky Blue background) - -**States:** -- Standard pseudo-classes: `:hover`, `:focus`, `:active` -- Utility classes: `.hover`, `.focus`, `.active` - -**Example:** -```html -
Default
-
Completed
-
Failed
-
Filter
-``` - -**Dependencies:** colors.css, spacing.css, typography.css - ---- - -### Icon - -**Base Class:** `.icon` (required) - -**Icon Names:** -Use `.icon-[name]` where `[name]` is derived from SVG filename (e.g., `Icon=Academy.svg` → `.icon-academy`) - -**Available Icons** (80+ icons): -- `.icon-academy` -- `.icon-assessment` -- `.icon-interview` -- `.icon-jobs` -- `.icon-course` -- ... (see `icons.css` for full list) - -**Sizes:** -- `.icon-small`: 16px -- `.icon-medium`: 24px (default) -- `.icon-large`: 32px -- `.icon-xlarge`: 48px - -**Colors:** -- Default: Uses `currentColor` (inherits text color) -- `.icon-primary`: Primary brand color -- `.icon-secondary`: Secondary neutral color -- `.icon-success`: Success green color -- `.icon-danger`: Danger red color -- `.icon-warning`: Warning yellow color - -**Implementation Note:** -Icons use `mask-image` with `background-color` for color control. SVGs in data URIs use black fills (black = visible in mask). - -**Example:** -```html - - - -``` - -**Dependencies:** colors.css, spacing.css - ---- - -### Dropdown (JavaScript Component) - -**Import:** -```javascript -import Dropdown from '/design-system/components/dropdown/dropdown.js'; -``` - -**Initialization:** -```javascript -const dropdown = new Dropdown(selector, options); -``` - -**Configuration Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `items` | Array | `[]` | Array of `{value, label}` objects | -| `placeholder` | String | `'Select option'` | Placeholder text | -| `selectedValue` | String | `null` | Initial selected value | -| `width` | String/Number | `'auto'` | Fixed width (ignored if `growToFit` is true) | -| `growToFit` | Boolean | `false` | Auto-resize to fit content | -| `onSelect` | Function | `null` | Callback `(value, item)` on selection | - -**API Methods:** -- `getValue()`: Returns current selected value -- `setValue(value)`: Sets selected value programmatically -- `open()`: Opens dropdown menu -- `close()`: Closes dropdown menu -- `toggleOpen()`: Toggles open state -- `destroy()`: Removes event listeners and clears container - -**Example:** -```javascript -const dropdown = new Dropdown('#my-dropdown', { - placeholder: 'Choose an option', - items: [ - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' } - ], - onSelect: (value, item) => { - console.log('Selected:', value, item); - } -}); - -// Later... -dropdown.setValue('2'); -const currentValue = dropdown.getValue(); -``` - -**Dependencies:** colors.css, spacing.css, typography.css - ---- - -## Usage Patterns - -### Component Composition - -Components can be combined and nested: - -```html - - - - -
- - Completed -
- - -
- - -
-``` - -### Custom Styling - -You can extend components using CSS custom properties: - -```css -.my-custom-button { - /* Inherit button styles */ - composes: button button-primary; - - /* Override with custom properties */ - --Colors-Base-Primary-700: #custom-color; -} -``` - -### Responsive Design - -Use standard CSS media queries with design tokens: - -```css -@media (max-width: 768px) { - .responsive-box { - padding: var(--UI-Spacing-spacing-s); - } -} -``` - ---- - -## Best Practices - -### 1. Token Usage - -✅ **DO:** -```css -color: var(--Colors-Text-Body-Default); -padding: var(--UI-Spacing-spacing-m); -border-radius: var(--UI-Radius-radius-m); -``` - -❌ **DON'T:** -```css -color: #333; -padding: 16px; -border-radius: 8px; -``` - -### 2. Component Classes - -✅ **DO:** -```html - -``` - -❌ **DON'T:** -```html - -``` - -### 3. Dark Mode - -✅ **DO:** Use semantic tokens (automatic dark mode) -```css -background: var(--Colors-Backgrounds-Main-Default); -``` - -❌ **DON'T:** Use hardcoded colors -```css -background: #ffffff; -``` - -### 4. File Loading Order - -✅ **DO:** Load foundations before components -```html - - - - - - - -``` - -### 5. Icon Usage - -✅ **DO:** -```html - -``` - -❌ **DON'T:** Use inline SVG or img tags for icons -```html -icon -``` - -### 6. Accessibility - -- Always include proper `alt` text for images -- Use semantic HTML (`