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 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-29 09:52:46 +02:00
parent 3f4fb97626
commit b6d61c2b8e
4 changed files with 120 additions and 63 deletions

View File

@ -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 {

View File

@ -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 {

View File

@ -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');
});
});

View File

@ -412,6 +412,27 @@ export function resolveNodeIcon(schema: Record<string, unknown> | 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<string, unknown> | 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 [];