Skip to content

Commit ace5467

Browse files
andresdjassoclaude
andcommitted
fix(workflow): floor header-only card height, adopt brand tag palette
The Start card intermittently collapsed after load, squashing the action-menu tab so its icon row sat over the card. `.workflow-drag-handle` is the host the border renderer measures, and both it and the header row took their height from `blockHeight && blockHeight > 0`. `blockHeight` comes from the deterministic-dimensions pass and is already floored at MIN_PAINTED_HEIGHT (48), but it is absent on the first frames — and with no floor the host collapsed to its natural content height (25.5px for a header-only trigger, exactly the title's line box). The border builds its perimeter from `host.offsetHeight`, so that window painted a sub-floor card: too little straight edge remained on the vertical runs for the action-menu tab, which collapsed into the corner arcs. Whether you saw it depended purely on whether the dimension publish had landed, which is why it reproduced on one workflow and not another. Floor all three: the host, the header row (so `items-center` centres the title and type tag rather than pinning them to the top), and the border's own `offsetHeight` read. Also raise ACTION_MENU_CONTENT_READY_THRESHOLD to 0.9. At 0.8 the 24px icon row was revealed while the swell had only reached 22.4px of its 28px — shorter than the row it contains. Secondary to the above, but a real overflow window on its own. The test now pins the ratio rather than the constant. Tag palette moves to fixed brand values (hex, not derived oklch) with two inks — #F8F8F8 on dark fills, #1A1A1A on light. Tones are renamed to match what they render. `green` (2.55:1) and `orange` (3.15:1) sit under WCAG AA against their paired ink; both are deliberate brand decisions and are documented in the component. Deploy and Run take two new Button variants rather than className overrides, so `tertiary` stays green everywhere else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2f51ecc commit ace5467

7 files changed

Lines changed: 104 additions & 64 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,11 @@ export function Deploy({
127127
<Button
128128
className='h-[30px] gap-1.5 px-2.5'
129129
variant={
130-
isRegistryLoading ? 'active' : changeDetected || !isDeployed ? 'tertiary' : 'active'
130+
isRegistryLoading
131+
? 'active'
132+
: changeDetected || !isDeployed
133+
? 'graphiteSubtle'
134+
: 'active'
131135
}
132136
onClick={onDeployClick}
133137
disabled={isRegistryLoading || isDisabled}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -742,7 +742,7 @@ export const Panel = memo(function Panel() {
742742
/>
743743
<Button
744744
className='h-[30px] gap-2 px-2.5'
745-
variant={isExecuting ? 'active' : 'tertiary'}
745+
variant={isExecuting ? 'active' : 'graphite'}
746746
onClick={isExecuting ? cancelWorkflow : () => runWorkflow()}
747747
disabled={!isExecuting && isButtonDisabled}
748748
>

apps/sim/lib/workflows/blocks/workflow-block-border-mount.test.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,17 @@ describe('WorkflowBlockBorder mount', () => {
165165
expect(getWorkflowBorderFrameDeltaSeconds(10_000, 100)).toBeCloseTo(1 / 30)
166166
})
167167

168-
it('reveals action-menu content only after the opening swell is ready', () => {
169-
expect(isActionMenuSwellReady(1, 0.79)).toBe(false)
170-
expect(isActionMenuSwellReady(1, 0.8)).toBe(true)
168+
it('reveals action-menu content only once the swell can contain it', () => {
169+
/* The row clips to the swell, so revealing it while the swell is shorter
170+
than the buttons collides the icons with the card's top edge. Pin the
171+
ratio, not the threshold constant — 24px of buttons in a 28px swell. */
172+
const ACTION_ROW_HEIGHT_PX = 24
173+
const OPEN_SWELL_HEIGHT_PX = 28
174+
const minimumSafeFraction = ACTION_ROW_HEIGHT_PX / OPEN_SWELL_HEIGHT_PX
175+
176+
expect(isActionMenuSwellReady(1, minimumSafeFraction - 0.001)).toBe(false)
171177
expect(isActionMenuSwellReady(1, 1)).toBe(true)
178+
/* Never reveal while retracting, however far open the swell still is. */
172179
expect(isActionMenuSwellReady(0, 1)).toBe(false)
173180
})
174181

packages/emcn/src/components/button/button.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ const buttonVariants = cva(
2121
secondary: 'bg-[var(--brand-secondary)] text-[var(--text-primary)]',
2222
tertiary:
2323
'bg-[var(--brand-accent)] text-[var(--text-inverse)] hover-hover:text-[var(--text-inverse)] hover-hover:bg-[var(--brand-accent-hover)] dark:bg-[var(--brand-accent)] dark:hover-hover:bg-[var(--brand-accent-hover)] dark:text-[var(--text-inverse)] dark:hover-hover:text-[var(--text-inverse)]',
24+
/* Fixed brand pairs, matching the workflow type tags rather than the
25+
theme tokens — `graphite` is the Agent tag's fill and ink. Both hold
26+
one value across modes, so neither takes a `dark:` override. */
27+
graphite:
28+
'bg-[#3B3B3B] text-[#F8F8F8] hover-hover:text-[#F8F8F8] hover-hover:brightness-125',
29+
graphiteSubtle:
30+
'bg-[#E6E6E6] text-[#3B3B3B] hover-hover:text-[#3B3B3B] hover-hover:brightness-[0.96]',
2431
ghost: 'text-[var(--text-secondary)] hover-hover:text-[var(--text-primary)]',
2532
subtle:
2633
'text-[var(--text-body)] hover-hover:text-[var(--text-body)] hover-hover:bg-[var(--surface-4)]',

packages/emcn/src/components/chip-tag/chip-tag.tsx

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,26 @@ import { cn } from '../../lib/cn'
2020
* inverse-surface convention one step softer than near-black. For eyebrow
2121
* kickers and emphasis labels that should read as a solid chip rather than a
2222
* bordered one.
23-
* - `workflow` — a restrained type cue for workflow cards. Pair with `tone`;
24-
* the icon remains the non-color identifier.
23+
* - `workflow` — the brand type cue for workflow cards. Pair with `tone`; the
24+
* icon inherits the label colour and remains the non-colour identifier.
25+
*
26+
* These are fixed brand values, not derived ones — do not "correct" a hex
27+
* for contrast or gamut. Every label is one of two inks: `#F8F8F8` when the
28+
* fill is dark, `#1A1A1A` when it is light. `#3B3B3B` appears only as
29+
* `inverse`'s fill, never as text. One value serves both modes; the tones
30+
* carry no `dark:` overrides.
31+
*
32+
* Contrast against the paired ink varies, and two pairs sit under WCAG AA
33+
* (4.5:1) for normal text: `green` at 3.98:1 and `orange` at 3.15:1. These
34+
* are brand decisions rather than oversights. Because the label is short and
35+
* always duplicated by an icon and the block name beside it, the tag is a
36+
* redundant cue rather than the sole carrier of the information — but do not
37+
* reuse either pairing anywhere the label stands alone.
38+
*
39+
* `neutral` is the only tone that is not a solid fill — an unmapped block
40+
* type reads as a white, outlined slot rather than as one more colour in the
41+
* set. Every other tone is fill-only, so it is also the only one whose edge
42+
* depends on the ring rather than on the fill itself.
2543
* - `invite` — recipient pill used in invite/sharing flows. Borrows the chip
2644
* family's icon gap (`gap-1.5`), `--text-body` label, and `--text-icon`
2745
* leading/trailing icons; pairs with the `invalid` boolean to flip to an
@@ -43,11 +61,11 @@ const chipTagVariants = cva(
4361
tone: {
4462
neutral: '',
4563
inverse: '',
46-
teal: '',
47-
indigo: '',
48-
indigoStrong: '',
49-
violet: '',
50-
amber: '',
64+
ash: '',
65+
orange: '',
66+
blue: '',
67+
green: '',
68+
yellow: '',
5169
},
5270
},
5371
compoundVariants: [
@@ -59,45 +77,18 @@ const chipTagVariants = cva(
5977
{
6078
variant: 'workflow',
6179
tone: 'neutral',
62-
className:
63-
'bg-[oklch(0.91_0.012_230)] text-[oklch(0.29_0.015_230)] dark:bg-[oklch(0.32_0.012_230)] dark:text-[oklch(0.9_0.015_230)]',
64-
},
65-
{
66-
variant: 'workflow',
67-
tone: 'inverse',
68-
className:
69-
'bg-[oklch(0.4386_0_0)] text-[oklch(1_0_0)] dark:bg-[oklch(1_0_0)] dark:text-[oklch(0.4386_0_0)]',
70-
},
71-
{
72-
variant: 'workflow',
73-
tone: 'teal',
74-
className:
75-
'bg-[oklch(0.91_0.025_190)] text-[oklch(0.3_0.045_190)] dark:bg-[oklch(0.31_0.035_190)] dark:text-[oklch(0.9_0.035_190)]',
76-
},
77-
{
78-
variant: 'workflow',
79-
tone: 'indigo',
80-
className:
81-
'bg-[oklch(0.91_0.03_270)] text-[oklch(0.3_0.05_270)] dark:bg-[oklch(0.31_0.04_270)] dark:text-[oklch(0.9_0.035_270)]',
82-
},
83-
{
84-
variant: 'workflow',
85-
tone: 'indigoStrong',
86-
className:
87-
'bg-[oklch(0.88_0.065_270)] text-[oklch(0.28_0.075_270)] dark:bg-[oklch(0.33_0.065_270)] dark:text-[oklch(0.92_0.05_270)]',
88-
},
89-
{
90-
variant: 'workflow',
91-
tone: 'violet',
92-
className:
93-
'bg-[oklch(0.91_0.028_305)] text-[oklch(0.3_0.045_305)] dark:bg-[oklch(0.31_0.04_305)] dark:text-[oklch(0.9_0.035_305)]',
94-
},
95-
{
96-
variant: 'workflow',
97-
tone: 'amber',
98-
className:
99-
'bg-[oklch(0.92_0.032_80)] text-[oklch(0.32_0.055_70)] dark:bg-[oklch(0.32_0.04_80)] dark:text-[oklch(0.91_0.04_80)]',
80+
/* The only outlined tone. An unmapped block type reads as an empty
81+
slot rather than a colour, so the fill is plain white and an inset
82+
ring — not a border — carries the edge, keeping the tag the same
83+
size as every filled sibling. */
84+
className: 'bg-[#FFFFFF] text-[#1A1A1A] shadow-[inset_0_0_0_1px_#C3C3C3]',
10085
},
86+
{ variant: 'workflow', tone: 'inverse', className: 'bg-[#3B3B3B] text-[#F8F8F8]' },
87+
{ variant: 'workflow', tone: 'ash', className: 'bg-[#E6E6E6] text-[#1A1A1A]' },
88+
{ variant: 'workflow', tone: 'orange', className: 'bg-[#FF4C00] text-[#F8F8F8]' },
89+
{ variant: 'workflow', tone: 'blue', className: 'bg-[#0062FF] text-[#F8F8F8]' },
90+
{ variant: 'workflow', tone: 'green', className: 'bg-[#188F00] text-[#F8F8F8]' },
91+
{ variant: 'workflow', tone: 'yellow', className: 'bg-[#FFEF08] text-[#1A1A1A]' },
10192
],
10293
defaultVariants: {
10394
variant: 'mono',
@@ -162,7 +153,13 @@ export function ChipTag({
162153
rightIconDisabled,
163154
...props
164155
}: ChipTagProps) {
165-
const iconClass = cn('size-[14px] flex-shrink-0', !invalid && 'text-[var(--text-icon)]')
156+
/* `workflow` icons inherit the tone's tinted label colour. The shared
157+
`--text-icon` gray is tuned for this component's light surfaces and would
158+
all but disappear on a tone's deep fill. */
159+
const iconClass = cn(
160+
'size-[14px] flex-shrink-0',
161+
!invalid && variant !== 'workflow' && 'text-[var(--text-icon)]'
162+
)
166163
const interactive = RightIcon != null && onRightIconClick != null
167164

168165
return (

packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type PositionedSourceHandleSide,
66
type WorkflowCardSide,
77
} from '@sim/workflow-types/workflow'
8+
import { BLOCK_DIMENSIONS } from '../dimensions'
89

910
const BORDER_PADDING_PX = 36
1011
const SAMPLE_SPACING_PX = 1
@@ -70,7 +71,14 @@ const SPRING_STIFFNESS = 300
7071
const SPRING_DAMPING = 32
7172
const ACTION_MENU_SPRING_STIFFNESS = 480
7273
const ACTION_MENU_SPRING_DAMPING = 38
73-
const ACTION_MENU_CONTENT_READY_THRESHOLD = 0.8
74+
/**
75+
* Must stay above the action row's own height as a fraction of the fully-open
76+
* swell (24px of buttons in a 28px swell = 0.857). Below that the row is
77+
* revealed while the swell is still shorter than it is, and because the row
78+
* clips to the swell height the icons collide with the card's top edge for the
79+
* remainder of the opening spring.
80+
*/
81+
const ACTION_MENU_CONTENT_READY_THRESHOLD = 0.9
7482
/** Quick deswell when the swell hands off to a hovered knob. */
7583
const CURSOR_SNAP_SPRING_STIFFNESS = 560
7684
const CURSOR_SNAP_SPRING_DAMPING = 42
@@ -867,7 +875,11 @@ export function WorkflowBlockBorder({
867875
/* offsetWidth/Height, not getBoundingClientRect: the card sits inside
868876
the canvas' zoom transform and a scaled rect would drift the geometry. */
869877
const width = host.offsetWidth
870-
const nextHeight = host.offsetHeight
878+
/* Clamped: below MIN_PAINTED_HEIGHT the perimeter has no straight run
879+
left on the vertical edges and the action-menu tab collapses into the
880+
corner arcs. The host carries the same floor; this keeps the geometry
881+
correct even if a caller sizes one some other way. */
882+
const nextHeight = Math.max(host.offsetHeight, BLOCK_DIMENSIONS.MIN_PAINTED_HEIGHT)
871883
if (Number.isFinite(width) && Number.isFinite(nextHeight) && width > 0 && nextHeight > 0) {
872884
setSize((current) =>
873885
current.width === width && current.height === nextHeight

packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,14 @@ export function getNearestBranchCursorHandleId(
102102

103103
const WORKFLOW_TYPE_ACCENTS = {
104104
agent: { variant: 'workflow', tone: 'inverse' },
105-
api: { variant: 'workflow', tone: 'indigo' },
106-
condition: { variant: 'workflow', tone: 'violet' },
107-
credential: { variant: 'workflow', tone: 'amber' },
108-
file: { variant: 'workflow', tone: 'indigo' },
109-
file_v5: { variant: 'workflow', tone: 'indigo' },
110-
function: { variant: 'workflow', tone: 'violet' },
111-
router_v2: { variant: 'workflow', tone: 'indigoStrong' },
112-
table: { variant: 'workflow', tone: 'teal' },
105+
api: { variant: 'workflow', tone: 'orange' },
106+
condition: { variant: 'workflow', tone: 'ash' },
107+
credential: { variant: 'workflow', tone: 'yellow' },
108+
file: { variant: 'workflow', tone: 'orange' },
109+
file_v5: { variant: 'workflow', tone: 'orange' },
110+
function: { variant: 'workflow', tone: 'ash' },
111+
router_v2: { variant: 'workflow', tone: 'blue' },
112+
table: { variant: 'workflow', tone: 'green' },
113113
} as const
114114

115115
const DEFAULT_WORKFLOW_TYPE_ACCENT = { variant: 'workflow', tone: 'neutral' } as const
@@ -796,7 +796,15 @@ export function WorkflowBlockView({
796796
className={cn(
797797
'workflow-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-2xl [&:active]:cursor-grabbing'
798798
)}
799-
style={blockHeight && blockHeight > 0 ? { minHeight: blockHeight } : undefined}
799+
/* Never let the host fall below the shortest silhouette the border can
800+
paint. `blockHeight` arrives from the deterministic-dimensions pass
801+
and is already floored at MIN_PAINTED_HEIGHT, but it is absent on the
802+
first frames — and with no floor the host collapses to its natural
803+
content height (~25px for a header-only trigger). The border builds
804+
its perimeter from `host.offsetHeight`, so that window paints a
805+
sub-floor card: the action-menu tab is squashed and its icon row
806+
spills onto the card. */
807+
style={{ minHeight: Math.max(blockHeight ?? 0, BLOCK_DIMENSIONS.MIN_PAINTED_HEIGHT) }}
800808
>
801809
<WorkflowBlockBorder
802810
nodeId={id}
@@ -868,9 +876,14 @@ export function WorkflowBlockView({
868876
'flex items-center justify-between px-2',
869877
hasContentBelowHeader && 'h-[40px]'
870878
)}
879+
/* Header-only cards stretch the header to the full card so its
880+
`items-center` centres the title and type tag. Floored for the
881+
same reason the host is: without it the row collapses to its
882+
natural height on the frames before `blockHeight` arrives and the
883+
content pins to the top of the card. */
871884
style={
872-
!hasContentBelowHeader && blockHeight && blockHeight > 0
873-
? { height: blockHeight }
885+
!hasContentBelowHeader
886+
? { height: Math.max(blockHeight ?? 0, BLOCK_DIMENSIONS.MIN_PAINTED_HEIGHT) }
874887
: undefined
875888
}
876889
>

0 commit comments

Comments
 (0)