Quieten the blocks and containers palettes, description on demand

Each card carried an avatar, a title, a subtitle, the full description as body
copy and a chip that repeated the subtitle - five elements to say what is really
a name and a kind. In a narrow sidebar that reads as a wall of text you have to
scroll past to find the block you want to drag.

A card is now one line plus a kind icon, with the description behind the info
button. It expands in place rather than in a tooltip or a modal: these
descriptions run to several hundred characters, which a tooltip cannot render
readably, and a modal is heavy for a hint you read while reaching for a drag.
Only one description is open at a time, so the list cannot unfold into the same
wall of text by another route.

Dragging is untouched and now says so: the whole card shows a grab cursor and a
delayed tooltip naming the gesture, and a test pins the draggable attribute so a
future restyle cannot quietly break the palette's only real job.

The chip removal also drops MatChipsModule from both components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 10:52:12 +02:00
parent 6d1efc4f69
commit 5476324d8c
7 changed files with 297 additions and 326 deletions

View File

@ -1,8 +1,12 @@
:host {
/*
* Palette lives here rather than in the markup so the blocks and containers lists, which share
* these class names, stay identical apart from their accent.
*/
.blocks-list-root {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
height: 100%;
min-height: 0;
}
.blocks-list-loading {
@ -12,178 +16,138 @@
height: 8rem;
}
.blocks-list-root {
display: flex;
flex: 1 1 auto;
flex-direction: column;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
overflow-x: hidden;
}
.blocks-list-search {
width: 100%;
padding: 8px 0;
}
.blocks-list-search mat-form-field {
width: 100%;
}
.blocks-list-empty {
display: flex;
flex: 1;
flex-direction: column;
padding-top: 2.5rem;
margin-top: 2.5rem;
color: #94a3b8;
text-align: center;
}
.blocks-list-items {
display: flex;
flex: 1 1 auto;
flex: 1;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: 100%;
min-width: 0;
gap: 4px;
min-height: 0;
box-sizing: border-box;
overflow-x: hidden;
padding-bottom: 12px;
overflow-y: auto;
padding: 8px 0 20px;
}
/* Flat by default: a palette you drag from should not compete with the canvas. */
.blocks-list-card {
display: block;
flex-shrink: 0;
width: 100%;
min-width: 0;
overflow: hidden;
padding: 7px 8px;
border: 1px solid #e2e8f0;
border-left: 3px solid #cbd5e1;
border-radius: 6px;
background: #ffffff;
box-shadow: none;
/* The whole card is the drag source, so say so. */
cursor: grab;
border: 1px solid #dbe7f5;
border-radius: 16px;
background:
radial-gradient(circle at top right, rgba(56, 189, 248, 0.12), transparent 34%),
linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
box-shadow:
0 12px 26px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
transition: background-color 0.12s ease, border-color 0.12s ease;
}
.blocks-list-card:hover {
border-color: #cbd5e1;
background: #f8fafc;
}
.blocks-list-card:active {
cursor: grabbing;
}
.blocks-list-card:hover {
transform: translateY(-2px);
border-color: #7dd3fc;
box-shadow: 0 18px 30px rgba(14, 116, 144, 0.14);
.blocks-list-card--container {
border-left-color: #a78bfa;
}
.blocks-list-card-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 14px 0;
}
.blocks-list-card-heading {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
}
.blocks-list-card-title-row {
.blocks-list-card-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.blocks-list-card-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
margin: 0;
border-radius: 12px;
color: #075985;
background: linear-gradient(180deg, #e0f2fe 0%, #bae6fd 100%);
.blocks-list-card-kind {
flex: 0 0 auto;
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
color: #94a3b8;
}
.blocks-list-card-avatar .mat-icon {
width: 22px;
height: 22px;
font-size: 22px;
.blocks-list-card-kind--interactive {
color: #0ea5e9;
}
.blocks-list-card-kind--container {
color: #7c3aed;
}
.blocks-list-card-meta {
flex: 1 1 auto;
min-width: 0;
}
.blocks-list-card-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 700;
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
.blocks-list-card-info {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(224, 242, 254, 0.95);
color: #0369a1;
cursor: default;
flex-shrink: 0;
}
.blocks-list-card-info .mat-icon {
width: 14px;
height: 14px;
font-size: 14px;
}
.blocks-list-card-subtitle {
color: #475569;
font-size: 12px;
}
.blocks-list-card-content {
padding: 0 14px 8px;
}
.blocks-list-card-copy {
margin: 0;
font-size: 12px;
line-height: 1.45;
.blocks-list-card-sub {
font-size: 11px;
color: #64748b;
}
.blocks-list-card-actions {
padding: 0 14px 14px !important;
margin: 0;
.blocks-list-card-info {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: #cbd5e1;
cursor: help;
}
.blocks-list-card-chip {
min-height: 28px;
font-size: 12px;
font-weight: 600;
color: #0369a1 !important;
background: rgba(224, 242, 254, 0.95) !important;
border: 1px solid rgba(125, 211, 252, 0.7);
.blocks-list-card:hover .blocks-list-card-info {
color: #64748b;
}
.blocks-list-card-chip-interactive {
color: #b45309 !important;
background: rgba(254, 243, 199, 0.98) !important;
border-color: rgba(252, 211, 77, 0.72);
.blocks-list-card-info--open {
background: #e2e8f0;
color: #334155;
}
.blocks-list-card-info .mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
}
/* Expanded in place: these descriptions run to several hundred characters. */
.blocks-list-card-copy {
margin: 6px 0 2px;
padding-top: 6px;
border-top: 1px dashed #e2e8f0;
font-size: 11.5px;
line-height: 1.45;
color: #475569;
cursor: text;
}

View File

@ -12,12 +12,6 @@
</mat-form-field>
</div>
<!--
<div class="flex py-2 text-sm w-full min-w-0 items-center text-center justify-center h-18 overflow-x-hidden">
<app-ordering [orderFields]="orderFields" [orderView]="orderView" (orderChanged)="onOrderChanged($event)"></app-ordering>
</div>
-->
@if (filteredBlocks().length === 0) {
<div class="blocks-list-empty">
No blocks found
@ -28,38 +22,38 @@
<mat-card
class="blocks-list-card"
draggable="true"
[matTooltip]="'Drag ' + block.type + ' onto the canvas'"
matTooltipShowDelay="600"
(dragstart)="onDragStart($event, block)">
<mat-card-header class="blocks-list-card-header">
<div matCardAvatar class="blocks-list-card-avatar">
<mat-icon [fontIcon]="block.userInteractive ? 'touch_app' : 'auto_awesome'"></mat-icon>
</div>
<div class="blocks-list-card-heading">
<div class="blocks-list-card-title-row">
<mat-card-title class="blocks-list-card-title">{{ block.type }}</mat-card-title>
<button
type="button"
class="blocks-list-card-info"
matTooltip="{{ block.description }}"
aria-label="Show block description"
title=""
(pointerdown)="$event.stopPropagation()"
(click)="$event.stopPropagation()">
<mat-icon fontIcon="info"></mat-icon>
</button>
<div class="blocks-list-card-row">
<mat-icon
class="blocks-list-card-kind"
[class.blocks-list-card-kind--interactive]="block.userInteractive"
[fontIcon]="block.userInteractive ? 'touch_app' : 'auto_awesome'"></mat-icon>
<div class="blocks-list-card-meta">
<div class="blocks-list-card-title">{{ block.type }}</div>
<div class="blocks-list-card-sub">
{{ block.userInteractive ? 'Human step' : 'Automated step' }}
</div>
<mat-card-subtitle class="blocks-list-card-subtitle">
{{ block.userInteractive ? 'Interactive' : 'Automatic' }}
</mat-card-subtitle>
</div>
</mat-card-header>
<mat-card-content class="blocks-list-card-content">
<p class="blocks-list-card-copy">{{ block.description }}</p>
</mat-card-content>
<mat-card-actions class="blocks-list-card-actions" align="end">
<mat-chip class="blocks-list-card-chip" [class.blocks-list-card-chip-interactive]="block.userInteractive">
{{ block.userInteractive ? 'Human step' : 'Automated step' }}
</mat-chip>
</mat-card-actions>
<button
type="button"
class="blocks-list-card-info"
[class.blocks-list-card-info--open]="infoOpenFor() === block.type"
[attr.aria-expanded]="infoOpenFor() === block.type"
[attr.aria-label]="'What ' + block.type + ' does'"
(pointerdown)="$event.stopPropagation()"
(click)="toggleInfo(block.type, $event)">
<mat-icon fontIcon="info_outline"></mat-icon>
</button>
</div>
@if (infoOpenFor() === block.type) {
<p class="blocks-list-card-copy">{{ block.description }}</p>
}
</mat-card>
}
</div>

View File

@ -6,6 +6,18 @@ import { vi } from 'vitest';
import { BlocksList } from './blocks-list';
const BLOCK_TYPES = [
{
type: 'LLMBlock',
family: 'block',
description: 'Runs a single prompt against an LLM and produces a textual response.',
userInteractive: false,
configurationType: null,
configurationClass: null,
schema: null
}
];
describe('BlocksList', () => {
let component: BlocksList;
let fixture: ComponentFixture<BlocksList>;
@ -19,9 +31,9 @@ describe('BlocksList', () => {
provide: BlocksService,
useValue: {
catalogLoading: signal(false),
blockTypes: signal([]),
blockTypes: signal(BLOCK_TYPES),
hasLoadedBlockTypes: vi.fn().mockReturnValue(true),
getAllBlocksTypes: vi.fn().mockResolvedValue(signal([]))
getAllBlocksTypes: vi.fn().mockResolvedValue(signal(BLOCK_TYPES))
}
}
]
@ -37,4 +49,33 @@ describe('BlocksList', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('keeps every card draggable, which is the whole point of the palette', () => {
const card = fixture.nativeElement.querySelector('.blocks-list-card');
expect(card).not.toBeNull();
expect(card.getAttribute('draggable')).toBe('true');
});
it('hides the description until the info button asks for it', () => {
// Descriptions run to several hundred characters; showing them all turns the palette into a wall
// of text, and a tooltip could not render them readably either.
expect(fixture.nativeElement.querySelector('.blocks-list-card-copy')).toBeNull();
fixture.nativeElement.querySelector('.blocks-list-card-info').click();
fixture.detectChanges();
const copy = fixture.nativeElement.querySelector('.blocks-list-card-copy');
expect(copy.textContent).toContain('Runs a single prompt');
fixture.nativeElement.querySelector('.blocks-list-card-info').click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.blocks-list-card-copy')).toBeNull();
});
it('shows one description at a time, so the list cannot unfold into a wall of text', () => {
component.toggleInfo('LLMBlock', new Event('click'));
component.toggleInfo('SomethingElse', new Event('click'));
expect(component.infoOpenFor()).toBe('SomethingElse');
});
});

View File

@ -3,7 +3,6 @@ import { BlockType } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
@ -13,7 +12,7 @@ import { BLOCK_TYPE_DRAG_MIME } from './block-drag';
@Component({
selector: 'app-blocks-list',
imports: [MatCardModule, MatChipsModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
imports: [MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
templateUrl: './blocks-list.html',
styleUrl: './blocks-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -25,6 +24,18 @@ export class BlocksList extends ListStateViewHolder<BlockType> {
private blocksService = inject(BlocksService);
loading: WritableSignal<boolean> = signal(true);
/**
* Which card is showing its description, expanded in place. Descriptions run to several hundred
* characters, which a tooltip cannot show readably, and a modal is heavy for a hint you read
* while dragging.
*/
readonly infoOpenFor = signal<string | null>(null);
toggleInfo(type: string, event: Event) {
event.stopPropagation();
this.infoOpenFor.update((current) => (current === type ? null : type));
}
readonly serviceLoading = this.blocksService.catalogLoading;
/*

View File

@ -1,8 +1,12 @@
:host {
/*
* Palette lives here rather than in the markup so the blocks and containers lists, which share
* these class names, stay identical apart from their accent.
*/
.blocks-list-root {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
height: 100%;
min-height: 0;
}
.blocks-list-loading {
@ -12,187 +16,138 @@
height: 8rem;
}
.blocks-list-root {
display: flex;
flex: 1 1 auto;
flex-direction: column;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
overflow-x: hidden;
}
.blocks-list-search {
width: 100%;
padding: 8px 0;
}
.blocks-list-search mat-form-field {
width: 100%;
}
.blocks-list-empty {
display: flex;
flex: 1;
flex-direction: column;
padding-top: 2.5rem;
margin-top: 2.5rem;
color: #94a3b8;
text-align: center;
}
.blocks-list-items {
display: flex;
flex: 1 1 auto;
flex: 1;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: 100%;
min-width: 0;
gap: 4px;
min-height: 0;
box-sizing: border-box;
overflow-x: hidden;
padding-bottom: 12px;
overflow-y: auto;
padding: 8px 0 20px;
}
/* Flat by default: a palette you drag from should not compete with the canvas. */
.blocks-list-card {
display: block;
flex-shrink: 0;
width: 100%;
min-width: 0;
overflow: hidden;
padding: 7px 8px;
border: 1px solid #e2e8f0;
border-left: 3px solid #cbd5e1;
border-radius: 6px;
background: #ffffff;
box-shadow: none;
/* The whole card is the drag source, so say so. */
cursor: grab;
border: 1px solid #dbe7f5;
border-radius: 16px;
background:
radial-gradient(circle at top right, rgba(56, 189, 248, 0.12), transparent 34%),
linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
box-shadow:
0 12px 26px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
transition: background-color 0.12s ease, border-color 0.12s ease;
}
.blocks-list-card--container {
border-color: #bae6fd;
background:
radial-gradient(circle at top right, rgba(14, 165, 233, 0.16), transparent 34%),
linear-gradient(180deg, #ffffff 0%, #f0f9ff 100%);
.blocks-list-card:hover {
border-color: #cbd5e1;
background: #f8fafc;
}
.blocks-list-card:active {
cursor: grabbing;
}
.blocks-list-card:hover {
transform: translateY(-2px);
border-color: #38bdf8;
box-shadow: 0 18px 30px rgba(14, 116, 144, 0.14);
.blocks-list-card--container {
border-left-color: #a78bfa;
}
.blocks-list-card-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 14px 0;
}
.blocks-list-card-heading {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
}
.blocks-list-card-title-row {
.blocks-list-card-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.blocks-list-card-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
margin: 0;
border-radius: 12px;
color: #075985;
background: linear-gradient(180deg, #e0f2fe 0%, #bae6fd 100%);
.blocks-list-card-kind {
flex: 0 0 auto;
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
color: #94a3b8;
}
.blocks-list-card-avatar--container {
color: #155e75;
background: linear-gradient(180deg, #cffafe 0%, #a5f3fc 100%);
.blocks-list-card-kind--interactive {
color: #0ea5e9;
}
.blocks-list-card-avatar .mat-icon {
width: 22px;
height: 22px;
font-size: 22px;
.blocks-list-card-kind--container {
color: #7c3aed;
}
.blocks-list-card-meta {
flex: 1 1 auto;
min-width: 0;
}
.blocks-list-card-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 700;
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
.blocks-list-card-info {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(224, 242, 254, 0.95);
color: #0369a1;
cursor: default;
flex-shrink: 0;
}
.blocks-list-card-info .mat-icon {
width: 14px;
height: 14px;
font-size: 14px;
}
.blocks-list-card-subtitle {
color: #475569;
font-size: 12px;
}
.blocks-list-card-content {
padding: 0 14px 8px;
}
.blocks-list-card-copy {
margin: 0;
font-size: 12px;
line-height: 1.45;
.blocks-list-card-sub {
font-size: 11px;
color: #64748b;
}
.blocks-list-card-actions {
padding: 0 14px 14px !important;
margin: 0;
.blocks-list-card-info {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: #cbd5e1;
cursor: help;
}
.blocks-list-card-chip {
min-height: 28px;
font-size: 12px;
font-weight: 600;
.blocks-list-card:hover .blocks-list-card-info {
color: #64748b;
}
.blocks-list-card-chip-container {
color: #0c4a6e !important;
background: rgba(224, 242, 254, 0.95) !important;
border: 1px solid rgba(56, 189, 248, 0.72);
.blocks-list-card-info--open {
background: #e2e8f0;
color: #334155;
}
.blocks-list-card-info .mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
}
/* Expanded in place: these descriptions run to several hundred characters. */
.blocks-list-card-copy {
margin: 6px 0 2px;
padding-top: 6px;
border-top: 1px dashed #e2e8f0;
font-size: 11.5px;
line-height: 1.45;
color: #475569;
cursor: text;
}

View File

@ -22,38 +22,33 @@
<mat-card
class="blocks-list-card blocks-list-card--container"
draggable="true"
[matTooltip]="'Drag ' + container.type + ' onto the canvas'"
matTooltipShowDelay="600"
(dragstart)="onDragStart($event, container)">
<mat-card-header class="blocks-list-card-header">
<div matCardAvatar class="blocks-list-card-avatar blocks-list-card-avatar--container">
<mat-icon fontIcon="inventory_2"></mat-icon>
<div class="blocks-list-card-row">
<mat-icon class="blocks-list-card-kind blocks-list-card-kind--container" fontIcon="inventory_2"></mat-icon>
<div class="blocks-list-card-meta">
<div class="blocks-list-card-title">{{ container.type }}</div>
<div class="blocks-list-card-sub">Container</div>
</div>
<div class="blocks-list-card-heading">
<div class="blocks-list-card-title-row">
<mat-card-title class="blocks-list-card-title">{{ container.type }}</mat-card-title>
<button
type="button"
class="blocks-list-card-info"
matTooltip="{{ container.description }}"
aria-label="Show container description"
title=""
(pointerdown)="$event.stopPropagation()"
(click)="$event.stopPropagation()">
<mat-icon fontIcon="info"></mat-icon>
</button>
</div>
<mat-card-subtitle class="blocks-list-card-subtitle">
Container
</mat-card-subtitle>
</div>
</mat-card-header>
<mat-card-content class="blocks-list-card-content">
<p class="blocks-list-card-copy">{{ container.description }}</p>
</mat-card-content>
<mat-card-actions class="blocks-list-card-actions" align="end">
<mat-chip class="blocks-list-card-chip blocks-list-card-chip-container">
Container node
</mat-chip>
</mat-card-actions>
<button
type="button"
class="blocks-list-card-info"
[class.blocks-list-card-info--open]="infoOpenFor() === container.type"
[attr.aria-expanded]="infoOpenFor() === container.type"
[attr.aria-label]="'What ' + container.type + ' does'"
(pointerdown)="$event.stopPropagation()"
(click)="toggleInfo(container.type, $event)">
<mat-icon fontIcon="info_outline"></mat-icon>
</button>
</div>
@if (infoOpenFor() === container.type) {
<p class="blocks-list-card-copy">{{ container.description }}</p>
}
</mat-card>
}
</div>

View File

@ -4,7 +4,6 @@ import { ContainersService } from '@services/containers/containers';
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
import { FormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
@ -14,7 +13,7 @@ import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
@Component({
selector: 'app-containers-list',
imports: [FormsModule, MatCardModule, MatChipsModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
imports: [FormsModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
templateUrl: './containers-list.html',
styleUrl: './containers-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -25,6 +24,18 @@ export class ContainersList extends ListStateViewHolder<BlockType> {
private containersService = inject(ContainersService);
loading: WritableSignal<boolean> = signal(true);
/**
* Which card is showing its description, expanded in place. Descriptions run to several hundred
* characters, which a tooltip cannot show readably, and a modal is heavy for a hint you read
* while dragging.
*/
readonly infoOpenFor = signal<string | null>(null);
toggleInfo(type: string, event: Event) {
event.stopPropagation();
this.infoOpenFor.update((current) => (current === type ? null : type));
}
readonly serviceLoading = this.containersService.catalogLoading;
containerTypes?: Signal<BlockType[]>;