Repository navigation
fix(dashboard): carry agent state by shape not colour, keyboard drop zone, decorative icons, truncated ids (A11Y-6b/13/14/15) - #17
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
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:
Colour is retained as reinforcement, with darker values that all clear 3:1 against white (3.36–6.47:1). The old
#22c55ewas 2.28:1 and genuinely failed 1.4.11 — the test reports that exact figure when reverted.A defect the shape change exposed:
AgentCardmappedstarting/stoppingto the same amber asstopped/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 neitheraria-hiddennor a text alternative, so colour was their only carrier.selectOptionnow threads a state label into the Select option's accessible name, which previously carried only the disconnected case.AgentCardwas already accessible — it rendersformatAgentState()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 isaria-hiddenrather than given a redundant label.A11Y-15 — the drop zone was unreachable by keyboard
It was a
<div>withonClickand nothing else: no role, notabIndex, no key handler. A keyboard user could not reach it at all, and Enter or Space did nothing. Nowrole="button"+tabIndex+ explicit Enter/Space handling, withpreventDefaulton Space so it does not also scroll the page.While busy it takes
tabIndex={-1}andaria-disabled, and the click handler is guarded. That branch is tested, not assumed — parsing runs insidehandleConfirm, so a hung parser promise holdsbusy=truewith the zone mounted.A11Y-14 — truncated id with no way to read it
CopyableResourceId's inline variant renderslabel: valueinto 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
ExpertColorPickeralready renders real<button>s witharia-label/aria-pressed,aria-hiddenswatches androle="group". Native buttons need notabIndex. 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→ cleannpx eslinton changed files → 0 errorsAgentSelector.stateIndicator.test.ts(6), 1 added toAgentSelector.a11y.test.tsx, 3 added toSkillImportModal.test.tsx,CopyableResourceId.a11y.test.tsx(3)failedto round fails the shape case; restoring#22c55efails the contrast case with the 2.28 figure; removingonKeyDownfails 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
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/Expertsalone is 83/83.make allruns 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.