Skip to content

fix(dashboard): carry agent state by shape not colour, keyboard drop zone, decorative icons, truncated ids (A11Y-6b/13/14/15) - #17

Merged
ra-co88 merged 3 commits into
developfrom
fix/a11y-6b-state-indicators
Oct 19, 2026
Merged

ra-co88 merged 3 commits into
developfrom
fix/a11y-6b-state-indicators

Conversation

@ra-co88

@ra-co88 ra-co88 commented Oct 19, 2026

Copy link
Copy Markdown
Owner

Closes the A11Y-6b finding and the A11Y-13/14/15/16 re-do. Branch cut fresh from develop (per the lesson from #15's mid-flight squash merge), three commits, each verified to contain the files it describes.

A11Y-6b — state carried by shape, not colour

The indicator was a bare coloured span using saturated red/green/amber. Measured before fixing, under a deuteranopia simulation (Brettel/Viénot):

pair normal vision deuteranopia
running–failed 1.65:1 1.10:1
running–stopped 1.19:1 1.31:1
stopped–failed 1.96:1 1.44:1

At 1.10:1 running and failed are visually identical. The obvious fix — a better palette — is falsified by measurement: brute-forcing hue/luminance combinations across the three groups topped out at 1.57:1, still far below the 3:1 WCAG asks for non-text UI. Colour cannot carry this signal, so shape now does:

  • running — solid disc
  • created / stopped — hollow ring (present but inert)
  • starting / stopping — half-filled ring (in transition)
  • failed / error — square (the only non-round shape, legible in greyscale)

Colour is retained as reinforcement, with darker values that all clear 3:1 against white (3.36–6.47:1). The old #22c55e was 2.28:1 and genuinely failed 1.4.11 — the test reports that exact figure when reverted.

A defect the shape change exposed: AgentCard mapped starting/stopping to the same amber as stopped/created, so "starting up" and "shut down" were indistinguishable there. Its palette is now aligned with the dots so one agent state reads the same everywhere.

Accessible names: all three dot sites are now aria-hidden. The second (dropdown item) and third (Select option) had neither aria-hidden nor a text alternative, so colour was their only carrier. selectOption now threads a state label into the Select option's accessible name, which previously carried only the disconnected case. AgentCard was already accessible — it renders formatAgentState() as text — so only its palette changed.

A11Y-13 — decorative icons

Three Lucide SVGs in ServiceRestartOverlay (Power, CheckCircle, AlertTriangle) were unlabelled. They are decorative — the adjacent text already states the phase — so each is aria-hidden rather than given a redundant label.

A11Y-15 — the drop zone was unreachable by keyboard

It was a <div> with onClick and nothing else: no role, no tabIndex, no key handler. A keyboard user could not reach it at all, and Enter or Space did nothing. Now role="button" + tabIndex + explicit Enter/Space handling, with preventDefault on Space so it does not also scroll the page.

While busy it takes tabIndex={-1} and aria-disabled, and the click handler is guarded. That branch is tested, not assumed — parsing runs inside handleConfirm, so a hung parser promise holds busy=true with the zone mounted.

A11Y-14 — truncated id with no way to read it

CopyableResourceId's inline variant renders label: value into a span that truncates with an ellipsis, and there was no title anywhere. A long id was unrecoverable by sight while the button's accessible name still carried the full string — which is exactly why a screen-reader-only check would have missed it. Title added on the button (not the inner span) so it applies whichever text node is clipped, and it is additive: real text content untouched, accessible name unchanged.

A11Y-16 — no fix needed, it was already done

The inventory row read "tabIndex = 0" as evidence of a live defect, but ExpertColorPicker already renders real <button>s with aria-label/aria-pressed, aria-hidden swatches and role="group". Native buttons need no tabIndex. A grep count is not a finding — "0 tabIndex" can mean "already accessible". Recording this so the row stops being re-queued.

Test plan

  • npx tsc -b → clean
  • npx eslint on changed files → 0 errors
  • New: AgentSelector.stateIndicator.test.ts (6), 1 added to AgentSelector.a11y.test.tsx, 3 added to SkillImportModal.test.tsx, CopyableResourceId.a11y.test.tsx (3)
  • Mutation-verified: reverting failed to round fails the shape case; restoring #22c55e fails the contrast case with the 2.28 figure; removing onKeyDown fails only the Enter/Space case; removing role/tabIndex/aria fails all three; removing the title fails the two title cases while the accessible-name case keeps passing (which is the point — the title is additive).

Notes

  • Pre-existing, load-order-dependent vitest failures unrelated to this branch (AuthGuard, AvatarDropdown.displayName, EmojiPicker, PdfDocumentPreview.windowed, DocumentPreviewCore.docxSanitize, RootDirSelect.expand, TeamMemberPicker). Baseline-verified by stashing this branch and re-running: they fail identically. src/pages/Experts alone is 83/83.
  • Neither CI nor make all runs vitest, so these are outside the ship bar — but worth a separate look, since a suite that passes alone and fails together is a real footgun.

The state indicator was a bare coloured span using saturated red/green/amber.
Measured under a deuteranopia simulation (Brettel/Vienot), running vs failed
separated by only 1.10:1 — visually identical. The finding assumed a better
palette would fix it; measurement says otherwise: tuning hue alone topped out at
1.57:1, far below the 3:1 WCAG asks for non-text UI. Colour cannot carry this
signal, so shape now does.

Each state group gets a distinct silhouette, legible at 6px and in greyscale:
  running          solid disc
  created/stopped  hollow ring        (present but inert)
  starting/stopping half-filled ring  (in transition)
  failed/error     square             (the only non-round shape)

Colour is retained as reinforcement, with darker values that all clear 3:1
against white. The old #22c55e was 2.28:1 and genuinely failed 1.4.11 — the test
reports that exact figure when reverted.

Also fixes a defect the shape change exposed: AgentCard mapped starting and
stopping to the SAME amber as stopped and created, so "starting up" and "shut
down" were indistinguishable there. Its palette is now aligned with the dots so
one agent state reads the same everywhere.

Accessible names: all three dot sites are now aria-hidden. The second (dropdown
item) and third (Select option) had no aria-hidden and no text alternative, so
colour was their only carrier. AgentChip already names its tab "<agent> . <state>"
from A11Y-9; selectOption now threads a state label into the Select option's
accessible name, which previously carried only the disconnected case. AgentCard
was already accessible — it renders formatAgentState() as text — so only its
palette changed.

AgentSelector.stateIndicator.test.ts (6 cases) pins the shapes and the 3:1 floor;
1 case added to the A11Y-9 suite pins aria-hidden on all three sites.
Mutation-verified both directions.
…ons (A11Y-13, A11Y-15)

A11Y-15 — the zip drop zone was a <div> with onClick and nothing else: no role,
no tabIndex, no key handler. A keyboard user could not reach it at all, and
Enter or Space did nothing. It is now role="button" with tabIndex and an explicit
key handler for both Enter and Space, so it is fully operable without a mouse.
Space calls preventDefault so it does not also scroll the page. While busy it
takes tabIndex={-1} and aria-disabled, and the click handler is guarded.

The busy branch is real, not decorative: parsing runs inside handleConfirm, so a
slow parser holds busy=true with the zone mounted. The test drives exactly that
with a hung promise, which is why the tabIndex={-1} branch is tested rather than
assumed unreachable.

A11Y-13 — ServiceRestartOverlay's three state icons (Power, CheckCircle,
AlertTriangle) were unlabelled Lucide SVGs. They are decorative: the adjacent
text already states the phase, so each is aria-hidden rather than given a
redundant label.

Verified: 7 tests in SkillImportModal.test.tsx (3 new) plus 32 across the
skills/overlay suites. Mutation-verified both ways — removing onKeyDown fails
only the Enter/Space case, and removing role/tabIndex/aria fails all three.
…-14)

The inline variant renders "label: value" into a span that truncates with an
ellipsis, and there was no title anywhere. A long id was therefore unrecoverable
for a sighted user even though the button's accessible name still carried the
whole string - which is why a screen-reader-only check would have missed it
entirely.

The title goes on the button rather than the inner span so it applies whichever
text node is actually clipped, and it is additive: the real text content is
untouched, so the accessible name is unchanged.

CopyableResourceId.a11y.test.tsx (3 cases) pins the title in both the inline and
non-inline variants and asserts the accessible name still contains the value.
Mutation-verified: removing the title fails the two title cases while the
accessible-name case keeps passing, which is the point.
@ra-co88
ra-co88 merged commit 2607fd8 into develop Oct 19, 2026
@ra-co88
ra-co88 deleted the fix/a11y-6b-state-indicators branch October 19, 2026 20:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants