Three issues with Server.Pin nodes inside ProjectScreen’s Hardware
section, all hitting the user immediately after they added a fresh
unconfigured pin via the project dashboard’s + menu:
pinNumber = 0
(the default) showed up as a black HardwareStatusDot with a “0”
in the middle. That’s the same visual the editor uses for
configured pins — it suggested the pin had been pinned to GPIO 0,
which would be wrong (and on a Pi GPIO 0 is reserved). On the
ClientScreen swarm tree, the same node correctly rendered as a
raspberry-pi icon (the unconfigured affordance).ProjectScreen; they had to navigate back to ClientScreen, find
the pin in the swarm tree, and edit from there.Boxes with no padding, so they
visually merged with each other and with the next section header.EditPin.kt’s PinRow(id) always rendered HardwareStatusDot,
regardless of whether the pin was configured. HardwareStatusDot
reads meta.pinNumber and meta.color and renders a coloured
sphere with the pin number — there is no “unconfigured” branch in
it. The swarm tree dodged the bug because it goes through
IconManager.NodeCompoundImage, which short-circuits the dot for
unconfigured pins (hardwareId.isEmpty() || pinNumber == 0) and
falls through to the painter-level icon (raspberry_pi_brands).HardwareStatusDot declared
onClick: ((PinMetaData) -> Unit) = {} — a default no-op lambda —
and unconditionally attached Modifier.clickable { onClick(meta) }.
Inside ProjectScreen’s Hardware section the outer Box had its
own .clickable { openEditor(...) }, but the inner clickable
intercepted the gesture first and ran the no-op. The result: a
click target that did precisely nothing.ProjectScreen’s Hardware section just forEach‘d each child
into a bare Box. DashboardSection’s outer Column has no
verticalArrangement, so siblings sit at 0dp from each other.
The other sections happened to mask the issue because their child
composables (Card-based LogicGateRow, SerialDeviceRow, etc.)
carry their own internal padding; PinRow does not.composeApp/.../server/pin/PinView.kt —
HardwareStatusDot.onClick from ((PinMetaData) -> Unit) = {}
to ((PinMetaData) -> Unit)? = null, and made the clickable
modifier conditional via .then(if (onClick != null) Modifier.clickable {...} else Modifier).
Read-only callers (PinRow, PinRowView) now don’t swallow the
parent’s gesture; the two existing call sites that do want a
click (IconManager.NodeCompoundImage, PiHeaderView) already
pass non-null lambdas, so they’re unaffected.composeApp/.../server/pin/EditPin.kt —
PinRow(id) now branches on meta.isConfigured. Unconfigured
pins render node.icon() (which goes through IconManager and
produces the raspberry-pi affordance) plus the same
"GPIO Pin Not Configured" text the row-mode editor shows in
NodeSummaryAndEditor. Configured pins keep the
HardwareStatusDot + hardwareId/name labels.composeApp/.../krillapp/project/ProjectScreen.kt —
Card
(Modifier.padding(PADDING_SMALL) inside) and stacked inside a
Column(verticalArrangement = Arrangement.spacedBy(SPACING_SMALL))
so adjacent rows are visually distinct and the section bottom
doesn’t bleed into the next header.composeApp/src/desktopTest/.../screenshot/Fixtures.kt +
Scenarios.kt + ScreenshotCatalog.kt — new project__hardware
Roborazzi scenario and projectHardwareSwarm() fixture: a project
with one unconfigured pin (default PinMetaData()), one configured
pin, and a serial device. Captures the rendered editor so any
future regression that re-introduces the black-0 dot for
unconfigured pins, removes the spacing, or changes the
click-to-configure affordance produces a visible PNG diff.docs/assets/screenshots/project__hardware.png — recorded
screenshot, committed.onClick lambdas eat parent gestures silently.
When a Composable accepts an onClick it doesn’t always need, the
default should be null (and the modifier should be conditionally
attached) — never {}. A no-op default still attaches clickable,
which still consumes the pointer event before anything underneath
sees it. The compiler can’t catch this; reviewers can only catch
it if they look. Default to null.IconManager.nodeIcon() knew about
pinNumber == 0; HardwareStatusDot did not. Whenever a node has a
meaningful “not configured yet” state, the row composable used in
list views should defer to IconManager (via node.icon()) for
the unconfigured case rather than reimplementing it. The
meta.isConfigured extension on PinMetaData is the right
predicate to gate on.LogicGateRow/SerialDeviceRow
because they’re Cards, but broke for PinRow which is a plain Row.
The dashboard section’s content should set
verticalArrangement = Arrangement.spacedBy(...) so spacing is a
property of the section, not a coincidence of the child
composable’s choice of wrapper.PinMetaData() (default
values, pinNumber = 0) catches it in CI on every push.