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.
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:
| Complaint | What it actually is | Fix |
|---|---|---|
| "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 diagonally | A 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:
| Work | Owner | This 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 Tidy | an implementation agent, on #485 | Treated as shipped baseline. §3 states what this design must not do to it. |
| Card system, connector presentation, palette IA | this PD | The 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
live · dark — no card edges at all
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:
| Cue | Light | Dark |
|---|---|---|
| card vs canvas floor | 1.00:1 | 1.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"
border 1.14:1 · shadow 1.25:1
today · dark — the reported bug
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:
- Repoints the idle card from
border-border-subtletoborder-border-strong.GovernedCanvas.tsx STATUS_BORDER.idle - Themes the elevation properly —
--shadow-cast(black 10% light, 55% dark) plus--shadow-rim(transparent light, white 6% dark) composed into--shadow-raisedwith aninset 0 0 0 1px. The rim is the cue a near-black surface can actually carry.index.css, per-theme - 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--cardin 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:
| Gap | Why it matters | Proposed |
|---|---|---|
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:
| Commitment | Effect on card height |
|---|---|
| #477, shipped — a switch grows one port band per arm | Height 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
kind | Height 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 name clamps at two lines (
-webkit-line-clamp: 2, full text in the existingtitle). Not for tidiness — an unbounded name makes card height a function of string length, which is the one input the layout cannot know before render. - Nothing may move the port axis after first paint. A chip that grows on hover, or a meta line that appears on selection, would re-slant every wire into the card. States change colour, never height.
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)
~60% dead space · arm names live on the wires
switch · proposed (arm rows)
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
proposed · same dot everywhere, 12px, kind hue
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.
| Token | Contract | Light | Dark |
|---|---|---|---|
--canvas-ground | the floor behind the cards; must sit below the card | #e9edf4 | #0b0e14 |
--card-raised | the 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-strong | hover 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:
- The shadow is already solved — adopt !737's tokens verbatim.
--shadow-raised, composed from--shadow-castand--shadow-rim, with the rim doing the work in dark (white 6% inset) and resolving to a no-op in light where the cast reads on its own. This page proposes no alternative shadow naming; a second scheme for the same job would be its own defect. - The port dot's ring follows the surface. The 12px dot is ringed
border-2 border-card(GovernedCanvas.tsx:69). When the card surface becomes--card-raised, that ring must move with it or every port grows a halo of the old colour.
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:
- Kind hue on the 24px icon chipNodeKindChip.tsx:99
- The same kind hue again, as a 3px full-bleed barGovernedCanvas.tsx:425 KindAccent
- Status hue on the borderGovernedCanvas.tsx:77 STATUS_BORDER
- Status hue again in the corner badgeGovernedCanvas.tsx:214
- 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:
| Fact | Carrier |
|---|---|
| node kind | the icon chip — hue and glyph, the richer of the two. Drop KindAccent. |
| run status | the border, at full token strength |
| run verdict | the corner badge |
| selection | the ring |
| proposal diff | the top badge (only while previewing a proposal) |
Two more corrections fall out of reading the card as type rather than as colour:
- Name first. Today an uppercase mono band ("NOTIFY", 11.5px, letter-spaced) sits
above the name you came to read. n8n leads with the name. Move the kind into the
secondary line —
Notify · #ops-room— losing no information and no vocabulary. - One left edge. The head starts at 12px and the title at 44px
(
GovernedCanvas.tsx:270), so the card has two left edges. Putting the chip beside a single text column gives one. - Selection ring should be
--ring, not--primary. In dark,--primaryis #e7eaf2 — a near-white 2px halo at 14.5:1 against the card, louder than the running state it competes with.--ring(#5b9dff) reads at 6.4:1, which is ample. - Status borders drop their
/40alpha in dark.border-success/40over the new card measures 2.45:1 — under the 3:1 the edge contract requires. Full strength measures 8.6:1.
7 · Before / after, both themes
before · light
before · dark
after · light
after · dark
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
| Property | Today | Proposed |
|---|---|---|
| card width | 240px | 240px — unchanged (feeds ZOOM_FLOOR) |
| corner radius | 8px (rounded-lg) | 10px |
| border | 1px --border-subtle | 1px --card-edge |
| padding | 10/12/4 head, 0/12/10/44 body | 11px 12px, single row |
| icon chip | 24px, radius 6, white 25% hairline | unchanged — it was already right |
| name | 13px/600, second in reading order | 13px/600, first |
| meta line | 11.5px mono uppercase, above the name | 11.5px mono sentence case, below |
| port dot | 12px, hover 17px, ring border-card | unchanged size; ring follows --card-raised |
| kind accent bar | 3px full-bleed | removed |
8 · States, both themes
after · light
after · dark
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:
- Add
platformto the palette's channel rowsapps/api/src/workflows/palette/index.ts:163 - Add it to resolved graph names too, for read-only canvases with no paletteapps/api/src/workflows/names/graph-names.ts:55
- Widen
NameBook/PaletteResponse, and stop dropping the fieldnames/name-book.ts:35 nameBookFromPalette - 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-328 →
db.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:
| Honest | Not honest |
|---|---|
Notify · Slack · #ops-room | Send Slack message |
Trigger · WhatsApp message from Ali | WhatsApp 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
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
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
| Service | Machinery | Brand mark today | Brandable |
|---|---|---|---|
| inbound + outbound (Baileys) | yes — 2 registries | yes | |
| Telegram | inbound + outbound | yes — 2 registries | yes |
| Slack | inbound + outbound (Socket Mode) | yes — messaging registry | yes |
| Discord | inbound + outbound | yes — messaging registry | yes |
| GitHub / GitLab | webhook in, comment out | no | not via notify — these are code-host channels, never notify targets |
| channel logo exists | yes — ChannelLogo | generic mark only, no transport slug in channel_key | |
| Webhook / cron | trigger sources, not channels | n/a | keep the kind glyph — there is no brand to show |
| none | no | out 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.tsx — PLATFORMS:
whatsapp, telegram, discord, slackThose 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.
| Question | Answer |
|---|---|
| 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 / licensing | The 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 brand | Keep 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:
| Option | Rows | Verdict |
|---|---|---|
| Service × action rows (the n8n shape) | services × kinds — unbounded, needs search | rejected — imports a problem we don't have |
| Service picker only inside node config (today) | 12 | correct but undiscoverable — nothing on screen says WhatsApp can start a workflow |
| Kind rail + a "Connectors" group of connected channels | 12 + 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
palette rail · dark
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:
| Control | Where it is today | Problem |
|---|---|---|
| Fit / centre | A 28px icon button, absolute bottom-2 left-2, on the
canvasGovernedCanvas.tsx:897-907 | Correct place, but alone — reads as a stray affordance rather than a control set. |
| ✨ Tidy layout | A 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. |
| Zoom | No 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
controls · dark
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
after · dark
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 proposes | This 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:
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:
| Invariant | Why it binds |
|---|---|
CARD_WIDTH = 240 stays | It 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 ≥ 112 | This 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-tiled | The 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
- 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-edgeso the card stops borrowing--border-strong,--canvas-groundso the floor is no longerbg-card, the port-ring token following the surface, and status borders losing/40in dark. No repaint is redone. ~1 session. - Round the read-only port — one class on the
!livebranch, 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. - Card layout and the colour rule — drop
KindAccent, single text column, name-first ordering with the two-line clamp, 10px radius,ring-ringfor selection. TouchesGovernedCanvas.tsxandcard-metrics.tsonly. Land after #485 so wire straightness is verifiable in the same session. ~1 session. - 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.
- 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.
- Platform on the wire — add
platformto the palette and graph-names projections, widenNameBook/PaletteResponse, stop dropping it innameBookFromPalette. Pure API + types, no visual change yet. ~1 session. - Branded connector nodes — widen
nep-view.ts:192, pointToolGlyph's brand branch at the platform-slug registry, addslack/discordtoexternalTransportKind, meta line readsKind · Service · Target. ~1 session. - 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
- The same graph, screenshotted light AND dark, with every card box clearly delineated in both. One workflow, one browser session, both themes — the two images attached to neutron#482. This is the primary gate; nothing else substitutes for it.
- Wires into and out of every card in that screenshot are horizontal where the graph is a chain, including into and out of the approval and across cards of different heights — the #485 baseline still holding after the card layout changed. A slanted wire on a linear chain is a regression, not a cosmetic nit.
- No read-only surface draws a square port. Checked on all four: the workflow list card, the published-graph view, the approval preview, and the run view.
- A 4-arm switch shows four labelled rows, each row's centre on its own port, no arm label
rendered loose on the canvas, and the card's height unchanged from
max(112, ports × 28). - Fit, tidy and zoom are reachable from the canvas without prior interaction. Specifically: Tidy is visible on a freshly-opened workflow that has never been dragged (disabled with a reason is a pass; absent is a fail).
- Measured, not eyeballed:
--card-edgeagainst--card-raisedis ≥3:1 in dark and ≥2:1 in light, and--card-raisedagainst--canvas-groundis ≥1.10:1 in both. Asserted for every theme template (default, catppuccin, tokyo-night, nord) × light/dark — a unit test over the parsed token values, not a screenshot. - No status border, badge, or selection ring measures under 3:1 against the new card surface in either theme.
ZOOM_FLOORis still 0.86 after the redesign, and a test assertsGATE_CARD_HEIGHT ≥ 112with the reason named. Port hit area is still 32px and the gate's four bands still tile with no gap and no overlap.- Nothing rendered on a card falls below the shipped floors: card 190px, port hit 24px, port
dot 8px, head 9px, label 10px, sublabel 9px at
ZOOM_FLOOR. - A
notifyto a Slack-origin channel and achannel-eventtrigger on a WhatsApp channel each render their brand mark, their kind hue, and a meta line naming the service — verified live, both themes. - A
notifyto an internal room renders the kind glyph and no service name. A channel whose platform is unknown does the same. Neither shows a placeholder or a broken glyph. - No card claims to send via a service. The verb stays
Notify; the service appears only as provenance of the target. - No new node kind.
NepNode["kind"]still has exactly its twelve members andgraph.ts's schema is unchanged, so published v1 graphs replay untouched. - No network request for an icon. Grep the built bundle for external image or font hosts and find none; the page renders with the network disabled.
Out of scope
| Excluded | Why |
|---|---|
| LinkedIn connector | No 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 nodes | PD009'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 cards | Declined 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 values | The #482 fix agent owns them. This page owns the contract they must satisfy and the test that keeps them satisfying it. |
| n8n runtime compatibility | We 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 transport | A 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 restyling | The 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. |