From b6d61c2b8ee9da685bd085f49938111d89ca2923 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 29 Sep 2026 09:52:46 +0200 Subject: [PATCH] Draw a step's header the way a resource card is drawn The saturated blue (or amber) header bar gives way to a light one: 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 borderless buttons that colour on hover. The kind of step stays in a stripe on the left, as on a resource card. A step's icon now follows its kind - automated, human, decision, end, branches rejoin - with the pictures the palette already uses for them, instead of one raster image for every automated block. A schema that names an icon still wins. Co-Authored-By: Claude Opus 5.5 --- .../nodes/generic-node/generic-node.css | 143 ++++++++++-------- .../shared/nodes/generic-node/generic-node.ts | 4 +- src/app/shared/nodes/node-utility.spec.ts | 15 +- src/app/shared/nodes/node-utility.ts | 21 +++ 4 files changed, 120 insertions(+), 63 deletions(-) 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 [];