PD010 · neutron design brief · stage: proposal

Canvas Look & Branded Connectors — Cards That Survive Dark Mode

The canvas paints node cards and the canvas floor from the same colour token, so a card has never been delineated by its surface in either theme — light mode gets away with it because a black drop shadow reads on white, and dark mode has nothing left at all. Fixing that is a four-token change; looking like n8n is a separate, smaller job about spending colour once per card instead of five times, rounding a port that has been square on every read-only screen since it shipped, and giving a grown switch card something to say.

stage · proposal cause · ground and card are both bg-card proposed · --canvas-ground + --card-edge, on top of !737 measured · !737 reaches 1.62:1 — this page asks for 3:1 proposed · connectors are presentation — no new node kinds proposed · brand marks reuse the shipped in-repo SVG registry baseline · straight wires ship via #485 — not proposed here declined · uniform-height cards tracking · neutron#482

1 · The two complaints, taken separately

The owner's report bundles a bug and a taste judgement, and they have different causes and different costs:

ComplaintWhat it actually isFix
"in dark mode the box doesn't even show"A token-contract bug. Nothing on the card is visible against the floor it sits on. Reproduced and measured in §2.Four tokens (P1)
"ours is quite clumsy"Not one wrong value — five separate colour carriers competing on one 240×68 card, and a shouty uppercase band above the thing you actually want to read.Layout + one colour rule (P2)
"do the basic connectors… which n8n shows nicely with icons"Almost entirely already built. The service-glyph override exists and already beats the kind glyph; it is gated to one node kind by a single line.One line + a field on two API projections (P3)
wires slant diagonallyA layout bug, not a card bug: layers stack by card top while a port sits at 50% of its own card's height, so cards of unequal height have ports at unequal Y. Already diagnosed and being fixed as #485. shipping — baseline for this design, see §3

What this page owns, and what it does not

Three agents are on this surface at once, so the division matters:

WorkOwnerThis PD's relationship
Dark-mode colour/shadow values!737, in flight on #482 This page does not re-fix those values and does not rename its shadow tokens. It adds the one thing !737's own test leaves open — a stated threshold — plus a dedicated edge token. See §2.
Port-axis edge alignment + surfacing Tidyan implementation agent, on #485 Treated as shipped baseline. §3 states what this design must not do to it.
Card system, connector presentation, palette IAthis PDThe design direction the other two land inside.

2 · Root cause: one token, used against the wrong contract

The live canvas, same 7-node graph in both themes (trigger, a 4-arm switch, four transforms, an approval). These are captures of the deployed product, not mockups:

live · light — edges present but thin

The live workflow canvas in light mode: cards are visible but the switch card is a tall empty box, wires slant diagonally, and arm labels float on the wires.

live · dark — no card edges at all

The same canvas in dark mode: no card boundary is visible, only the coloured kind accent bar at each card's top edge marks where a card is.

Four separate defects are visible in these two frames: no card edge in dark; the switch card grown tall and left empty; wires slanting; and the port marks rendering as grey squares. Each is treated below — §2 the edge, §3 the slant and the empty switch, §4 the squares.

The canvas wrapper is painted bg-card (GovernedCanvas.tsx:799). The idle node card is painted bg-card too, with STATUS_BORDER.idle = "border-border-subtle" (GovernedCanvas.tsx:392, :82). So card-against-floor is 1.00:1 by construction, in both themes — the surface contributes exactly nothing, and the whole box rests on a 1px border plus Tailwind's shadow-sm.

Measured off the deployed canvas rather than read off the stylesheet: card fill rgb(15,19,28), card border rgb(24,31,43) at 1px — a 9-to-15 delta per channel — and the elevation shadow is rgba(0,0,0,0.1), byte-identical in both themes. That last fact makes these two independent failures, not one: a border chosen against the wrong reference, and a shadow that was never themed at all.

Those two remaining cues fail differently per theme, which is why the bug reads as dark-mode-only:

CueLightDark
card vs canvas floor1.00:11.00:1
--border-subtle vs --card 1.14:1 1.12:1
shadow-sm (black at 10%) on the card 1.25:1 — reads 1.02:1 — no-op
total available delineation thin but present ≈1.1:1 — invisible

A black-alpha shadow has no ground to darken on a near-black surface. Light mode is carried by the one cue dark mode cannot have, so the palette's dark block was never wrong on its own terms — it simply removed the only working cue and left a token that was never meant to do this job.

The token-level statement

--border-subtle is a divider token, sized to disappear against --background. It is being used as a card edge, a job that requires contrast against --card. No token in the palette carries the contract "visible against --card" — that missing contract is the bug. Nor can dark mode borrow elevation instead: --card #0f131c against --background #0b0e14 is 1.04:1, because the whole dark palette lives inside a ~4% luminance band near black. In dark mode the edge has to carry delineation alone.

today · light — thin, "clumsy"

Notify
Post the digest to #ops-room
#ops-room

border 1.14:1 · shadow 1.25:1

today · dark — the reported bug

Notify
Post the digest to #ops-room
#ops-room

border 1.12:1 · shadow 1.02:1

Both panes are the real token values. Only the chip, the accent bar and the type locate the dark card — the box itself has no edge, which is precisely the complaint.

What !737 already does, and the two gaps left

The fix for the bug half is in flight as !737, and it is structurally the right shape — this page should not fork it. It does three things:

  1. Repoints the idle card from border-border-subtle to border-border-strong.GovernedCanvas.tsx STATUS_BORDER.idle
  2. Themes the elevation properly — --shadow-cast (black 10% light, 55% dark) plus --shadow-rim (transparent light, white 6% dark) composed into --shadow-raised with an inset 0 0 0 1px. The rim is the cue a near-black surface can actually carry.index.css, per-theme
  3. Adds card-delineation.test.tsx, which parses the palette blocks and asserts the card's border is not the ground's colour and does read against --card in every theme — the enforcing test, not a screenshot.canvas/card/card-delineation.test.tsx

Measured, that lands the dark card at 1.62:1 border (up from 1.12:1) plus a rim at 1.15:1 — a real fix, and the shadow work is exactly right. Two gaps remain, and they are this page's contribution rather than a disagreement:

GapWhy it mattersProposed
The bar is unstated. "Reads against --card" is asserted as a direction, not a number, so 1.62:1 passes. 1.62:1 is a discernible edge, not a confident one, and nothing stops the next palette landing at 1.2:1 and still passing. A threshold is what makes the test a contract. Name the number and assert it: ≥3:1 in dark, ≥2:1 in light (§5). The asymmetry is justified below.
--border-strong is a shared token. It is also the read-only port fill (bg-border-strong, §4) and the org-chart line colour. This is a milder form of the original defect: a token doing a job its name does not state, so a future change made for one consumer silently moves the card edge. That is how --border-subtle got here. A dedicated --card-edge, whose name states its reference. Refinement of !737, not a replacement — and the only one of the three structural fixes still open.

Sequencing follows from that: !737 should merge as-is because the current state is a bug; the threshold and the dedicated token are a follow-up that makes the class of defect unrepeatable rather than merely repaired. The 18 further shadow-sm uses carrying the same dead-elevation defect are already tracked as #489 — including two on the node card itself (the hover toolbar's duplicate/delete buttons), which would otherwise leave the reviewed surface visibly half-finished.

3 · Edge geometry: straight wires are the baseline, not a proposal

The diagonal wires in §2's captures have a cause independent of colour. Layout stacks each layer by card top, while a single-port card's port sits at 50% of its own height. Two connected cards of unequal height therefore have ports at unequal Y, and the wire slants. n8n looks tidy here partly for a reason we cannot copy: its nodes are uniform-height, so top-alignment and port-alignment coincide for free.

#485 is already fixing it the right way — port-axis alignment: a single-inbound node aligns to the exact port feeding it, arm-aware for switch arms, and a join centres on the mean of its inbound ports. This page treats that as shipped and states only what it must not break.

Uniform-height cards: explicitly declined

The tempting aesthetic principle here is "make every card the same height and the wires go straight by construction". This design declines it, and the reason is that the product has already committed the other way:

CommitmentEffect on card height
#477, shipped — a switch grows one port band per armHeight is a function of arm count. A 12-arm switch is 336px.
PD009 §7 — generalize cardHeightFor to max(112, ports × 28) for any many-port kindHeight variance increases, deliberately, across more kinds.
A gate card is content-sized and carries Approve/Deny buttons when parked Height varies with run state on one node.

Uniform heights would forbid all three. So straightness must be height-independent, which is exactly what port-axis alignment delivers and what height uniformity only fakes. The obligation this places on the card system is narrower and worth stating as a rule: a card must expose a stable, computable port axis. Two consequences for the layout in §6:

The grown switch card is empty — fill it with the arms

Visible in both captures: the 4-arm switch is a tall box whose content occupies only the top ~40px, and its arm names (gold, silver, bronze, default) float as pills on the wires instead. The growth is correct — it is what keeps each port band above the 24px aim floor — but it currently buys dead space and pushes the labels onto the canvas.

The n8n-grade answer is to spend the height on the information the wires are carrying: one labelled arm row per port band, each row vertically centred on its own port. The card then explains its own fan-out, the floating pills become redundant, and the rows are already exactly where #485's arm-aware alignment expects the ports to be. This also gives PD009's per-arm split ports somewhere to land.

It also removes a live bug at the root rather than treating it. #488 reports two arm labels overprinting into an unreadable blob when a switch's arms converge on a nearby target: an edge label sits at its path's midpoint, and arms leaving adjacent ports for the same close target have nearly the same midpoint. Worse with more arms, and worse since #477 pushed arm ports closer together in screen space at the zoom floor. Any fix that keeps labels on the wires is collision avoidance — offsetting, staggering, hiding one. Moving the label onto its own row inside the card makes the collision impossible, because two rows cannot occupy one band. #488 should take the cheap positional fix now; arm rows are what retire it.

switch · today (empty growth)

Switch
switch
trigger.tier

~60% dead space · arm names live on the wires

switch · proposed (arm rows)

Route by tier
Switch · trigger.tier
gold
silver
bronze
default

every band labelled · port axis = row centre

Same total height, same port bands, same 28px minimum. The arm row is the port's own label, so a reader no longer traces a wire to a floating pill to learn which arm it is.

4 · A read-only canvas draws square ports

Look again at §2's captures: the port marks are grey squares. The editor's are round. Same graph, two shapes, and the cause is a missing class on one branch:

if (!live) return <Handle {...shared} className="!h-2.5 !w-2.5 !border-none !bg-border-strong" />;

The live branch renders a child <span> carrying rounded-full, so the editor gets a 12px dot. The read-only branch has no child — the Handle div is the visual, and it never receives a radius. Nor does it inherit one: the canvas imports @xyflow/react/dist/base.css, whose .react-flow__handle rule sets only position, min-size and background. The rounded appearance lives in style.css (border-radius: 100%), which this app deliberately does not import.

So every read-only surface — the workflow list card, the published-graph view, the approval preview, the run view — has always drawn 10px squares of --border-strong, and only the editor draws dots. It is a one-class fix, and it is a large share of the "clumsy" the brief names: a reader sees two different port vocabularies depending on which screen they are on.

read-only today · square, 10px, border-strong

Transform
transform
route

proposed · same dot everywhere, 12px, kind hue

Shape the payload
Transform · route

A read-only port stays non-interactive — no 32px hit box, no hover growth. Only its shape and colour join the editor's vocabulary.

5 · The card system, retokenized

Four new tokens, added to :root, .dark, and each of the three named templates in apps/ui/src/index.css. Kind hues, status hues and text colours are not touched — this is not a repaint.

TokenContractLightDark
--canvas-groundthe floor behind the cards; must sit below the card #e9edf4 #0b0e14
--card-raisedthe node surface; ≥1.15:1 against the ground #ffffff 1.17:1 #141a25 1.11:1
--card-edge≥3:1 against --card-raised in dark, ≥2:1 in light (where the shadow adds a second cue) #aeb7c7 2.02:1 #5f6b84 3.26:1
--card-edge-stronghover and any state that must out-rank the resting edge #8b93a6 3.08:1 #8b93a6 5.67:1

Two non-token changes travel with them, and both are load-bearing:

Asymmetric on purpose. A single 3:1-both-themes rule is available and would put light mode's edge at #8b93a6 — defensible, but a visibly more outlined card than the n8n look the brief asks for. Recommending the asymmetry; flagging the one-rule alternative as the owner's call.

6 · Why it reads as clumsy: colour spent five times

An n8n node spends colour once, on the icon tile. Ours spends it five times on a card that is 240×68:

  1. Kind hue on the 24px icon chipNodeKindChip.tsx:99
  2. The same kind hue again, as a 3px full-bleed barGovernedCanvas.tsx:425 KindAccent
  3. Status hue on the borderGovernedCanvas.tsx:77 STATUS_BORDER
  4. Status hue again in the corner badgeGovernedCanvas.tsx:214
  5. Proposal diff hue in a top-centre badgeGovernedCanvas.tsx:335

Items 1 and 2 encode the identical fact, and 3 and 4 encode the identical fact. The proposal is one carrier per fact:

FactCarrier
node kindthe icon chip — hue and glyph, the richer of the two. Drop KindAccent.
run statusthe border, at full token strength
run verdictthe corner badge
selectionthe ring
proposal diffthe top badge (only while previewing a proposal)

Two more corrections fall out of reading the card as type rather than as colour:

7 · Before / after, both themes

before · light

Agent turn
Draft the weekly digest
ops-analyst

before · dark

Agent turn
Draft the weekly digest
ops-analyst

after · light

Draft the weekly digest
Agent turn · ops-analyst

after · dark

Draft the weekly digest
Agent turn · ops-analyst

Same content, same 240px width, same kind hue. What changed: the floor stepped away from the card, the edge got a contract, the accent bar went, the name came first, and the two left edges became one.

Measurements

PropertyTodayProposed
card width240px240px — unchanged (feeds ZOOM_FLOOR)
corner radius8px (rounded-lg)10px
border1px --border-subtle1px --card-edge
padding10/12/4 head, 0/12/10/44 body11px 12px, single row
icon chip24px, radius 6, white 25% hairlineunchanged — it was already right
name13px/600, second in reading order13px/600, first
meta line11.5px mono uppercase, above the name11.5px mono sentence case, below
port dot12px, hover 17px, ring border-cardunchanged size; ring follows --card-raised
kind accent bar3px full-bleedremoved

8 · States, both themes

after · light

Fetch the billing report
HTTP request · hover
Fetch the billing report
HTTP request · selected
Draft the weekly digest
Agent turn · running
Post the digest to #ops-room
Notify · done
Owner signs off the digest
Approval · parked

after · dark

Fetch the billing report
HTTP request · hover
Fetch the billing report
HTTP request · selected
Draft the weekly digest
Agent turn · running
Post the digest to #ops-room
Notify · done
Owner signs off the digest
Approval · parked

The parked gate keeps its amber wash — the one card allowed a tinted surface, because a run parked on a human is the thing a run view exists to surface.

9 · Branded connectors: presentation over machinery already shipped

This is much smaller than it looks, because the mechanism exists and already wins against the kind glyph. NodeKindChip takes an optional tool and, when resolveToolGlyph matches it, renders a service glyph instead of the kind icon (NodeKindChip.tsx:89-118). It is confined to one node kind by a single line in nep-view.ts:192:

tool: node.kind === "capability" ? node.config.tool : null,

Widen that expression to also yield a resolved channel platform for notify and channel-event triggers, and a branded connector node needs no new node kind, no new chip component and no new render path.

The one real gap: the platform is not on the wire

Neither node config names a service. Both carry an opaque id:

notify.config.target = { channel_id } | { thread_id }  ·  trigger.config = { source: "channel-event", channel_id, event_kind }

The platform is recoverable server-side — the channels row's channel_key is external:<platform>:<peer>:<identity>, built in channels/linked.ts:211 — but both client projections strip it to { id, name, kind }, where kind is the structural kind (room | person | external | agent-dm), not the provider. So this needs a projection change in exactly two places, no new query:

  1. Add platform to the palette's channel rowsapps/api/src/workflows/palette/index.ts:163
  2. Add it to resolved graph names too, for read-only canvases with no paletteapps/api/src/workflows/names/graph-names.ts:55
  3. Widen NameBook / PaletteResponse, and stop dropping the fieldnames/name-book.ts:35 nameBookFromPalette
  4. Resolve platform → glyph, and widen the one line abovenep-view.ts:192

What a branded node may honestly claim

A correction the brief's framing invites, and it changes the wording on the card. A notify node does not send anything to Slack. It resolves its target to a Neutron thread and appends a message (worker-steps.ts:320-328db.appendMessage). There is no transport fan-out in the notify step at all; the only outbound-to-transport path in the product is the inbox reply, which is WhatsApp-only today (inbox/reply-core.ts:184).

So the brand mark names where this lands — the origin transport of the destination thread — not the wire the message leaves on. That keeps the verb neutral:

HonestNot honest
Notify · Slack · #ops-roomSend Slack message
Trigger · WhatsApp message from AliWhatsApp node

The channel-event trigger is the stronger case of the two: an event genuinely does arrive from that transport, so branding a trigger claims nothing extra. If the owner wants the most conservative version, brand triggers only and leave notify on its kind glyph — but the accurate-verb version above is the recommendation, because "which service is this about" is the question the icon is being asked.

connectors · light

Ali sends a message
Trigger · WhatsApp
Post the digest
Notify · Slack · #ops-room
Nudge the on-call
Notify · Telegram · Ops
Post to the standup room
Notify · #standup

Last card = the fallback: an internal room has no origin transport, so the kind glyph stays and the meta line simply omits a service.

connectors · dark

Ali sends a message
Trigger · WhatsApp
Post the digest
Notify · Slack · #ops-room
Nudge the on-call
Notify · Telegram · Ops
Post to the standup room
Notify · #standup

The kind hue stays on the chip background, so a Slack notify is still visibly a notify. The mark identifies the service; the hue identifies the kind.

That last point is the design rule worth stating outright: the brand mark replaces the glyph, never the hue. n8n can hand a whole node over to a brand because its node type is the service. Ours cannot — the kind is real, governed vocabulary that validation, wiring rules and the run view all depend on — so the kind keeps the chip's background and the service gets the foreground.

Which services can be branded, honestly

ServiceMachineryBrand mark todayBrandable
WhatsAppinbound + outbound (Baileys)yes — 2 registriesyes
Telegraminbound + outboundyes — 2 registriesyes
Slackinbound + outbound (Socket Mode)yes — messaging registryyes
Discordinbound + outboundyes — messaging registryyes
GitHub / GitLabwebhook in, comment outnonot via notify — these are code-host channels, never notify targets
Emailchannel logo existsyes — ChannelLogogeneric mark only, no transport slug in channel_key
Webhook / crontrigger sources, not channelsn/akeep the kind glyph — there is no brand to show
LinkedInnonenoout of scope — no machinery exists

A gap worth closing in the same pass: externalTransportKind (components/channels/channels.ts:34) maps wa/whatsapp/tg/telegram/email/voice but not slack or discord, so a Slack channel row in the channels list already falls through to a generic hash icon today. Same table, same fix.

10 · Icon sourcing

The recommendation is to source nothing new. Every brand mark this design needs is already in the repo, as inlined single-path simple-icons geometry keyed by the exact platform slugs the transports use:

apps/ui/src/components/sections/messaging/icons.tsxPLATFORMS: whatsapp, telegram, discord, slack

Those four ids are byte-identical to LinkedTransport.platform, and MessagingPlatforms.tsx:243 already renders mark + name + tint per provider in settings — so the connector node inherits a shipped, reviewed precedent rather than inventing one.

QuestionAnswer
Sprite sheet, icon font, or inline?Inline single-path SVG, as today. Four marks at ~1–2 KB each do not justify a sprite, and a sprite would need a fetch the CSP and the offline-admin requirement both argue against.
External CDN?No. The app is CSP-tight and the admin UI is expected to work offline. Nothing here adds a network dependency.
New npm dependency (simple-icons)?No. The package is ~3 000 icons to gain four we already have, and it would be the second source of truth for the same paths.
Hand-typing paths?Forbidden, and already a written rule in this codebase (ProviderIcons.tsx:1-7): a mistyped path renders a mangled curve silently rather than failing. Copy from the existing registry or from upstream, never retype.
Trademark / licensingThe simple-icons project is CC0, but that covers the SVG data, not the trademarks the marks depict. Use here is nominative — identifying the service a user has genuinely connected — which is the same use already shipped in settings. Three constraints follow: render each mark in its own brand form (monochrome currentColor is acceptable and is what we already do), never place a brand mark on a node that does not actually reach that service, and never imply endorsement. The generic-fallback rule below is what keeps the second constraint true.
Fallback when a channel has no brandKeep the kind glyph and omit the service from the meta line. Never invent a mark, never letter-avatar a service name — an unbranded channel is a real and common case (internal rooms, agent DMs), not a missing asset.

One structural change to ToolGlyph is worth making while there: it currently reaches brands through ChannelLogo, whose ChannelKind is web | email | wa | tg | voice — so Slack and Discord are unreachable from a node glyph today even though their paths exist elsewhere. Point ToolGlyph's brand branch at the messaging/icons.tsx registry (the one keyed by platform slug) and both the node glyph and the channels list can share one map.

11 · Palette and information architecture

The palette today is a kind rail: twelve rows, one per node kind, each with a short tag (start, tool, llm, gate, post, if…) from WorkflowEditor.tsx:73. Services are chosen afterwards, inside the floating config panel, from a seat-scoped palette response.

n8n inverts this: its palette is service-first, because in n8n the node type is the service. Copying that shape wholesale is what would explode the list — n8n's 400+ integrations × ~10 actions each is a search problem, and it is the reason n8n needs a search box before it needs a palette. We should not import that problem to gain an icon.

Recommendation: keep the kind rail canonical and add one bounded group below it. A "Connectors" group listing the channels this seat is actually connected to — each row a prefilled node, not a new kind:

OptionRowsVerdict
Service × action rows (the n8n shape)services × kinds — unbounded, needs searchrejected — imports a problem we don't have
Service picker only inside node config (today)12correct but undiscoverable — nothing on screen says WhatsApp can start a workflow
Kind rail + a "Connectors" group of connected channels12 + n, where n is what the seat has connected (single digits in practice, and already fetched by the palette call)recommended

The bound is the argument. n is not "every service we might support" — it is "channels this seat can reach", which the palette endpoint already returns and already scopes by seat. A connector row creates a trigger or a notify with channel_id prefilled; picking one is a shortcut through the config panel, never a different node.

palette rail · light

Steps
Triggerstart
Agent turnllm
Approvalgate
Notifypost
Connectors · this seat
WhatsAppstart
Slack · #ops-roompost
Telegram · Opspost

palette rail · dark

Steps
Triggerstart
Agent turnllm
Approvalgate
Notifypost
Connectors · this seat
WhatsAppstart
Slack · #ops-roompost
Telegram · Opspost

Canvas controls belong in one cluster

The owner went looking for a tidy-up control and could not find one. It exists — but the canvas's controls are scattered across three different places, at three different sizes, with one of them conditionally absent:

ControlWhere it is todayProblem
Fit / centreA 28px icon button, absolute bottom-2 left-2, on the canvasGovernedCanvas.tsx:897-907Correct place, but alone — reads as a stray affordance rather than a control set.
✨ Tidy layoutA 10.5px text button in the editor toolbar above the canvaseditor/WorkflowEditor.tsx:433-441 Wrong surface, wrong size — and gated on graph.nodes.length > 1 && graph.ui?.positions, so it does not exist until you have already dragged a node. Exactly the state in which a user has not yet made the mess they would want tidied.
ZoomNo control at all — scroll-to-pan, pinch to zoom (zoomOnScroll={false})Undiscoverable on a trackpad, impossible on a device without pinch.

Proposal: one bottom-left cluster on the canvas itself, in the same 28px button vocabulary, present on every surface that can use it. The conditional-absence bug becomes a disabled state with a reason in the tooltip — a greyed control teaches that the capability exists, an absent one teaches nothing. Tidy also stops being editor-only: a read-only canvas can re-derive the layered layout for viewing without writing positions. #485 carries the surfacing work; this is the shape it should land in.

controls · light

86%

controls · dark

86%

The readout shows the live zoom, which also makes ZOOM_FLOOR legible: a user who has hit 86% can see that the canvas has stopped shrinking and started panning, rather than assuming zoom is broken.

12 · A graph, end to end

after · light

Ali messages
Trigger · WhatsApp
Triage the request
Agent turn
Owner approves
Approval
approved
Post the outcome
Notify · Slack

after · dark

Ali messages
Trigger · WhatsApp
Triage the request
Agent turn
Owner approves
Approval
approved
Post the outcome
Notify · Slack

Cards narrowed to 176px here only to fit a four-step chain on this page. The real card stays 240px — see the invariant below.

13 · Reconciling with PD009 (split & merge)

PD009 is in flight on the same surface. The two designs agree, and in one place PD009's proposal is the better form of an invariant this page needs — worth recording so neither gets implemented against the other:

PD009 proposesThis page's position
cardHeightFor generalized to max(112, ports × 28) for any many-port kind Endorsed — adopt this form. It is the invariant-preserving generalization: because every band stays at least 28px, 24/28 = 0.857 holds for any port count, so ZOOM_FLOOR stays 0.86 whatever kinds are added later. §14 states the floor as a special case of it.
Two new kind hues, --kind-split / --kind-merge Compatible — this design keeps kind hue on the chip, so new kinds slot in unchanged. One added requirement: a new kind hue must carry a white glyph legibly, which is what the existing hues were chosen for, and must be defined in all four templates × both themes or the chip goes transparent.
A per-arm port on the split card (today every unlabelled out-edge shares one dot) Directly served by §3's arm rows — a labelled row per band is where a per-arm port belongs. Same mechanism, two kinds.
The run overlay needs a new node state (skipped / pruned); WorkflowNodeStatus is done|running|failed|stopped|idle today Accepted with a constraint: a new status adds a row to STATUS_BORDER and STATUS_BADGE, and both must satisfy §5's ≥3:1-against-the-card contract. A "pruned" node most likely wants the dim treatment plus a neutral edge rather than a new hue — five status hues on one canvas is already the ceiling.
Split as a lucide glyph is already taken by switch Confirmed (NodeKindChip.tsx KIND_ICON). Worth noting the general constraint: kind glyphs are a finite, must-be-distinct namespace, and every added kind spends one. GitFork/GitMerge read correctly and collide with nothing.

The connector pipeline, end to end

What has to exist, in order, for a Slack notify to render as a Slack notify:

already shipped to build external / upstream
channel_keyexternal:slack:…
already stored
platform on the projectionpalette + graph-names
P3
NameBook / PaletteResponsestop dropping the field
P3
nep-view.ts:192widen the glyph source
P4
NodeKindChiptool glyph beats kind glyph
renders
simple-icons pathalready in-repo, CC0 data

Only three boxes are new, none of them in the engine, the schema, or the node-kind vocabulary. The first and last already exist — which is why this reads as a presentation change rather than a feature.

14 · Invariants this must not break

The just-shipped legibility work makes the canvas's sizes load-bearing, and the most tempting version of "neater, tighter cards" would silently undo it. Three constraints, with the arithmetic:

InvariantWhy it binds
CARD_WIDTH = 240 staysIt is a LEGIBILITY entry (min 190). Narrowing the card raises ZOOM_FLOOR and makes large graphs pan sooner. Nothing in this design needs a narrower card.
A port band never falls below 28px, so GATE_CARD_HEIGHT ≥ 112This is the sharp one. ZOOM_FLOOR is pinned by the gate port band, not by the card: the band is 112 / 4 = 28px against a 24px aim floor, giving 24/28 = 0.857 → 0.86. Tightening the gate card to 104px makes the floor 0.93; to 96px makes it 1.00, at which point fitView can never shrink a graph and every workflow pans. A padding change is exactly the innocent-looking edit that would do this. Stated generally — and this is PD009's form, which is why §13 endorses it — the rule is height ≥ max(112, ports × 28); the gate is just its four-port case. Now tracked in its own right as #487, filed off this page's grounding pass, and it wants its guard before any card-metrics edit — so #487 lands ahead of P4, not with it.
Port hit area stays 32px / band-tiledThe 24px aim floor and the tiling partition (portHitBox, portBandsFor) are what make a drop land on the nearest port. A visually smaller dot is fine; a smaller hit box is not. §3's arm rows are 28px precisely so a row and its band are the same strip.
Card height must not depend on anything discovered after first paint#485's port-axis alignment computes wire geometry from card heights. A name that wraps without bound, or any state that changes height rather than colour, re-slants every wire into that card — re-introducing the bug #485 is closing. Hence the two-line clamp in §3.

Two of the three are already covered by tests (zoom-floor.test.ts, port-geometry band tests). The gate-height floor is arithmetic that no test currently asserts as a minimum — P1 should add it, so the redesign cannot regress the floor silently.

Phases

  1. The edge contract, on top of !737 — !737 merges first and keeps its --shadow-* tokens and its delineation test untouched. This phase adds what that test is missing: the stated threshold (≥3:1 dark / ≥2:1 light) asserted over all 8 palettes, a dedicated --card-edge so the card stops borrowing --border-strong, --canvas-ground so the floor is no longer bg-card, the port-ring token following the surface, and status borders losing /40 in dark. No repaint is redone. ~1 session.
  2. Round the read-only port — one class on the !live branch, plus the kind hue, so all four read-only surfaces stop drawing squares. Smallest visible win on the list and genuinely independent of everything else. ~0.3 sessions.
  3. Card layout and the colour rule — drop KindAccent, single text column, name-first ordering with the two-line clamp, 10px radius, ring-ring for selection. Touches GovernedCanvas.tsx and card-metrics.ts only. Land after #485 so wire straightness is verifiable in the same session. ~1 session.
  4. Switch arm rows — fill the grown card with one labelled 28px row per port band, retire the floating arm pills. Coordinate with PD009, which wants the same rows for split. ~1 session.
  5. Canvas control cluster — fit + tidy + zoom in one bottom-left group on the canvas, Tidy always present (disabled with a reason instead of absent), live zoom readout. Overlaps #485's surfacing work — coordinate rather than duplicate. ~0.5 sessions.
  6. Platform on the wire — add platform to the palette and graph-names projections, widen NameBook/PaletteResponse, stop dropping it in nameBookFromPalette. Pure API + types, no visual change yet. ~1 session.
  7. Branded connector nodes — widen nep-view.ts:192, point ToolGlyph's brand branch at the platform-slug registry, add slack/discord to externalTransportKind, meta line reads Kind · Service · Target. ~1 session.
  8. Connectors group in the palette — one bounded group of the seat's connected channels, each a prefilled trigger/notify. ~1 session.

P1 and P2 are independently shippable and worth shipping first. P6 is a prerequisite for P7 and invisible on its own. P5 (arm rows) and P8 (palette connectors group) are the two the owner could drop without losing the brief's intent; P2 is the cheapest visible improvement on the whole list.

Acceptance

Out of scope

ExcludedWhy
LinkedIn connectorNo machinery exists — no transport, no slug, no config. The only occurrences in the repo are prose inside seeded agent personas. Branding it would put a mark on a node that reaches nothing, which is the one thing the trademark rule above forbids.
Split / merge policy nodesPD009's subject. This design adds no node kinds precisely so the two do not collide; §13 records where they touch and which form wins.
The port-axis alignment algorithm itself#485, already in implementation. This page consumes it as a baseline and constrains the card system so as not to undo it (§3, §14). Re-specifying it here would fork a live fix.
Uniform-height cardsDeclined on the merits, not deferred — see §3. Shipped switch growth (#477) and PD009's generalization both require height to vary, so straightness has to come from port alignment instead.
Re-deciding the dark card's hex valuesThe #482 fix agent owns them. This page owns the contract they must satisfy and the test that keeps them satisfying it.
n8n runtime compatibilityWe deliberately do not run n8n's nodes (PD007 declined their item model). This is a look-and-feel and presentation brief; no import surface, no node shim.
Making notify egress to a transportA real feature with real scope — per-service formatting, rate limits, delivery receipts, failure arms. Named here only because it is what "send to Slack" would actually require, and it is not this.
Edge and wire restylingThe wires were tuned in the shipped canvas work (edges/edge-look.ts). Only the port dot ring changes here, because it is painted from the card token.