diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index 0956ece..aef1dbd 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -11,6 +11,8 @@ -webkit-user-select: none; border-radius: 16px; border: 1px solid #dbe2ea; + /* The kind of step as a stripe, as a resource card has: blue automated, amber human. */ + border-left: 5px solid #3b82f6; background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%); box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); overflow: visible; @@ -187,6 +189,7 @@ .llm-node--human { border-color: #f7d7a7; + border-left-color: #f59e0b; background: linear-gradient(180deg, #fffdf9 0%, #fff7ed 100%); box-shadow: 0 10px 24px rgba(124, 45, 18, 0.14); color: #431407; @@ -212,7 +215,7 @@ line-height: 1.1; font-weight: 500; letter-spacing: 0.03em; - color: rgba(255, 255, 255, 0.78); + color: #94a3b8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -224,10 +227,10 @@ gap: 4px; margin-top: 2px; padding: 1px 7px; - border: 1px solid rgba(255, 255, 255, 0.5); + border: 1px solid #cbd5e1; border-radius: 999px; - background: rgba(255, 255, 255, 0.16); - color: rgba(255, 255, 255, 0.92); + background: #f8fafc; + color: #475569; font-size: 9px; font-weight: 700; letter-spacing: 0.02em; @@ -235,46 +238,44 @@ width: fit-content; } +/* + * A light header, the way a resource card is drawn: a tinted badge with the step's icon, its name + * in dark type with its kind under it in the step's colour, and quiet buttons that colour on hover. + */ .llm-header { display: flex; align-items: center; gap: 10px; - padding: 10px 12px; - border-bottom: 1px solid #e8edf4; - background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + padding: 11px 12px; + border-bottom: 1px solid #e8eef6; + background: linear-gradient(135deg, #ffffff 0%, #f5f9ff 100%); border-top-left-radius: 16px; border-top-right-radius: 16px; } .llm-node--human .llm-header { - background: linear-gradient(135deg, #f59e0b 0%, #ea580c 100%); - border-bottom-color: #fdba74; -} - -.llm-node--human .llm-icon { - color: #fffbeb; -} - -.llm-node--human .llm-title { - color: #fffbeb; -} - -.llm-node--human .llm-subtitle { - color: #fff7ed; + background: linear-gradient(135deg, #ffffff 0%, #fff7ed 100%); + border-bottom-color: #fde7c7; } .llm-icon { - width: 32px; - height: 32px; + width: 38px; + height: 38px; border-radius: 10px; - background: rgba(255, 255, 255, 0.18); - border: 1px solid rgba(255, 255, 255, 0.28); + background: #dbeafe; + color: #1d4ed8; + font-size: 19px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } +.llm-node--human .llm-icon { + background: #ffedd5; + color: #c2410c; +} + .llm-icon-image { width: 24px; height: 16px; @@ -282,10 +283,16 @@ } .llm-header-content { + flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; - line-height: 1.2; + line-height: 1.25; +} + +/* The name first, as on a resource card; the kind of block under it. */ +.llm-header-content .llm-subtitle-row { + order: -1; } .llm-subtitle-row { @@ -362,15 +369,23 @@ } .llm-title { - font-size: 13px; - font-weight: 700; - letter-spacing: 0.02em; - color: #eff6ff; + margin-top: 1px; + font-size: 11.5px; + font-weight: 500; + color: #1d4ed8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.llm-node--human .llm-title { + color: #c2410c; } .llm-subtitle { - font-size: 11px; - color: #dbeafe; + font-size: 14px; + font-weight: 600; + color: #0f172a; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; @@ -378,21 +393,28 @@ .llm-name-edit-btn { border: none; - background: rgba(255, 255, 255, 0.2); - color: #eff6ff; - border-radius: 999px; - width: 18px; - height: 18px; + background: transparent; + color: #64748b; + border-radius: 6px; + width: 20px; + height: 20px; + font-size: 11px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; - transition: background-color 0.15s ease; + opacity: 0; + transition: opacity 0.15s ease, background-color 0.15s ease; +} + +.llm-node:hover .llm-name-edit-btn { + opacity: 1; } .llm-name-edit-btn:hover { - background: rgba(255, 255, 255, 0.3); + background: #eff6ff; + color: #1d4ed8; } .llm-warning-wrap { @@ -400,37 +422,38 @@ margin-left: 0; } -.llm-delete-btn { - position: relative; - z-index: 146; - width: 24px; - height: 24px; - border-radius: 999px; - border: 1px solid rgba(185, 28, 28, 0.45); - background: rgba(127, 29, 29, 0.2); - color: #fff1f2; - display: inline-flex; - align-items: center; - justify-content: center; -} - +.llm-delete-btn, .llm-clone-btn { position: relative; z-index: 146; - width: 24px; - height: 24px; - border-radius: 999px; - border: 1px solid rgba(191, 219, 254, 0.7); - background: rgba(30, 64, 175, 0.18); - color: #eff6ff; + width: 26px; + height: 26px; + padding: 0; + border-radius: 8px; + border: none; + background: transparent; + color: #64748b; + font-size: 13px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.llm-clone-btn:hover { + background: #eff6ff; + color: #1d4ed8; +} + +.llm-node--human .llm-clone-btn:hover { + background: #fff7ed; + color: #c2410c; } .llm-delete-btn:hover { - background: rgba(127, 29, 29, 0.35); + background: #fef2f2; + color: #dc2626; } .llm-warning-tooltip { diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index b712005..2de0603 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -31,7 +31,7 @@ import { findCapability, isModelParameterField, providerAppliesParameter } from import { firstValueFrom, take } from 'rxjs'; import { SWIMLANES_ENABLED } from '@shared/feature-flags'; import { ConditionalRequiredField, extractSchemaRequirements, SchemaRequirements } from '../schema-requirements'; -import { evaluateUiConditionRule, flattenPrimitiveValues, readEffectiveUiVisibleConditionRule, formatNodeTitle, getOutputPillClass, getOutputsTitle, getValueByPath, isConditionalByPorts, isHumanInteractiveNode, orderedSchemaPropertyEntries, parentGroupLabel, parentPath, pathToLabel, readUiConditionRule, resolveNodeIcon, resolveSchemaPath, resolveSchemaRef, schemaFieldDescription, schemaFieldLabel, shouldSkipSchemaField, splitTemplatedTextParts, toStringOrNull, type UiConditionRule, validateUniqueByConstraint, valueToDisplayString } from '../node-utility'; +import { evaluateUiConditionRule, flattenPrimitiveValues, readEffectiveUiVisibleConditionRule, formatNodeTitle, getOutputPillClass, getOutputsTitle, getValueByPath, isConditionalByPorts, isHumanInteractiveNode, orderedSchemaPropertyEntries, parentGroupLabel, parentPath, pathToLabel, readUiConditionRule, resolveSchemaPath, resolveStepIcon, resolveSchemaRef, schemaFieldDescription, schemaFieldLabel, shouldSkipSchemaField, splitTemplatedTextParts, toStringOrNull, type UiConditionRule, validateUniqueByConstraint, valueToDisplayString } from '../node-utility'; import { buildSchemaEditableFieldDefinitions, buildSchemaObjectDialog, @@ -628,7 +628,7 @@ export class GenericNodeComponent implements OnDestroy { } nodeIcon(): { type: 'class' | 'img'; value: string } { - return resolveNodeIcon(this.blockDescriptor?.schema, this.isHumanNode()); + return resolveStepIcon(this.blockDescriptor?.schema, this.isHumanNode(), this.blockDescriptor?.capabilities); } hasExecutionDependencyPorts(): boolean { diff --git a/src/app/shared/nodes/node-utility.spec.ts b/src/app/shared/nodes/node-utility.spec.ts index b683fd1..cb26a6e 100644 --- a/src/app/shared/nodes/node-utility.spec.ts +++ b/src/app/shared/nodes/node-utility.spec.ts @@ -15,7 +15,8 @@ import { splitTemplatedTextParts, getValueByPath, toStringOrNull, - valueToDisplayString + valueToDisplayString, + resolveStepIcon } from './node-utility'; describe('node-utility', () => { @@ -240,3 +241,15 @@ describe('node-utility', () => { expect(parentGroupLabel('')).toBeNull(); }); }); + +describe('resolveStepIcon', () => { + it('pictures a step by its kind, as the palette sorts blocks, unless its schema names an icon', () => { + expect(resolveStepIcon(null, false, { visualRole: 'ACTIVITY' }).value).toBe('bi bi-stars'); + expect(resolveStepIcon(null, true, { visualRole: 'ACTIVITY' }).value).toBe('bi bi-hand-index'); + expect(resolveStepIcon(null, true, { visualRole: 'DECISION', routesExclusively: true }).value).toBe('bi bi-signpost-split'); + expect(resolveStepIcon(null, false, { visualRole: 'DECISION', routesExclusively: true }).value).toBe('bi bi-signpost-split'); + expect(resolveStepIcon(null, false, { visualRole: 'END', terminal: true }).value).toBe('bi bi-flag'); + expect(resolveStepIcon(null, false, { visualRole: 'MERGE' }).value).toBe('bi bi-sign-merge-left'); + expect(resolveStepIcon({ 'x-ui-icon': 'bi bi-robot' }, false, { visualRole: 'ACTIVITY' }).value).toBe('bi bi-robot'); + }); +}); diff --git a/src/app/shared/nodes/node-utility.ts b/src/app/shared/nodes/node-utility.ts index ef837f5..8303d60 100644 --- a/src/app/shared/nodes/node-utility.ts +++ b/src/app/shared/nodes/node-utility.ts @@ -412,6 +412,27 @@ export function resolveNodeIcon(schema: Record | null | undefin return { type: 'img', value: 'llm_node.png' }; } +/** + * A step's icon on the canvas, as a Bootstrap glyph: the one its schema declares, or else one for + * what kind of step it is - the same kinds, and the same pictures, the palette sorts blocks by. + */ +export function resolveStepIcon( + schema: Record | null | undefined, + isHuman: boolean, + capabilities: { visualRole?: string; terminal?: boolean; routesExclusively?: boolean } | null | undefined +): { type: 'class'; value: string } | { type: 'img'; value: string } { + const declared = schema?.['x-ui-icon']; + if (typeof declared === 'string' && declared.trim().length > 0) { + return resolveNodeIcon(schema, isHuman); + } + const decides = capabilities?.visualRole === 'DECISION' || capabilities?.routesExclusively === true; + if (isHuman) return { type: 'class', value: decides ? 'bi bi-signpost-split' : 'bi bi-hand-index' }; + if (capabilities?.visualRole === 'END' || capabilities?.terminal === true) return { type: 'class', value: 'bi bi-flag' }; + if (decides) return { type: 'class', value: 'bi bi-signpost-split' }; + if (capabilities?.visualRole === 'MERGE') return { type: 'class', value: 'bi bi-sign-merge-left' }; + return { type: 'class', value: 'bi bi-stars' }; +} + export function splitTemplatedTextParts(text: string | null): TemplatedTextPart[] { if (!text) return [];