A disabled Input's opacity-50 composites its affix text below AA — no ink choice can fix it, because opacity halves whatever you pick
Surfaced by MOTIR-2482 — the first sweep ever to load /settings/organization with axe. Filed rather than absorbed: it reports under color-contrast, but its CAUSE is not an ink choice, so it is not in the --el-text-* arm that card owned.
Repo: motir-core. One PR.
The measurement
tests/e2e/shell-a11y-wide.spec.ts sweeps /settings/organization with WCAG 2.1 A+AA and zero rule exclusions. axe reports color-contrast (serious) on one node:
.text-(--el-icon-field) > .text-(--el-text-secondary)
That is the motir.co/ prefix on the organization-URL field — app/(authed)/settings/organization/_components/OrgGeneralCard.tsx, the addonStart of an Input marked readOnly disabled.
Why no ink fixes it
packages/design-system/src/components/ui/Input.tsx applies disabled && 'cursor-not-allowed opacity-50' to the field WRAPPER. Opacity composites the whole subtree against the page, so it roughly halves the contrast of whatever it contains. The prefix is already --el-text-secondary, the darkest caption ink in the palette and the one that clears AA on every surface (6.80:1 on white) — and at 50% it lands under 4.5. Reaching for a darker token does not help: --el-text at 50% over white composites to roughly mid-grey.
So this is a defect in HOW the design system renders a disabled control, not in which colour a call site picked. It affects every Input that carries an addonStart / addonEnd while disabled, not just this one.
What makes it a design decision, not a patch
1.4.3 exempts disabled controls, so the INTENT here is arguably fine — but the exemption is a human judgement axe cannot make, and it does not reach the affix anyway, which is a <span> beside the input rather than the disabled control itself. Three directions, and the card should pick ONE deliberately:
- Give disabled its own tokens instead of opacity — a
--el-input-disabled-bg/--el-input-disabled-textpair, so the state is expressed as colour (AA-checkable, palette-swappable) rather than as a filter over everything. Widest blast radius, and the most correct: it is the same argument the colour rule already makes about never inventing a value outside the token layer. - Keep opacity for the control, exempt the affix — the affix is not the disabled thing; render it outside the opacity subtree.
- This field is not disabled, it is READ-ONLY — the org URL is a value to read and copy, and a disabled input is unfocusable, so today a keyboard user cannot even select it. Making it
readOnlywithoutdisabledfixes the contrast AND the reachability, but leaves the field looking editable unlessInputgrows a read-only treatment.
Read design/ for the settings area before choosing; option 1 and option 3 both change how a shipped control looks.
Acceptance criteria
/settings/organizationreports ZEROcolor-contrastviolations under the sameWCAG_TAGSand zero rule exclusions the other sweeps use.- The fix is in the SHARED
Input(or in the disabled-state tokens it reads), not only at theOrgGeneralCardcall site — every disabled input with an affix is the same defect, and a call-site patch leaves the rest. - The chosen direction is recorded in the component's own doc comment with its reasoning, so the next disabled control does not re-derive it.
- Whatever renders, the disabled state is still visually distinguishable from the enabled one — clearing the contrast rule by making disabled look enabled trades one defect for another.
- If the org URL becomes read-only rather than disabled, it is keyboard-focusable and selectable, and its value is still not editable — asserted by a test, not by inspection.
- The
.exclude()this defect forced intotests/e2e/shell-a11y-wide.spec.ts's/settings/organizationsweep — and thedata-testidhandle added toOrgGeneralCardto anchor it, both commented with this card's key — are REMOVED in the same PR, so the route returns to a zero-exclusion sweep.
Context refs
packages/design-system/src/components/ui/Input.tsx—disabled && 'cursor-not-allowed opacity-50'(line ~66) and the twoaria-hiddenaffix slots (lines ~69 / ~87).app/(authed)/settings/organization/_components/OrgGeneralCard.tsx— thereadOnly disabledURL field and itsaddonStart.packages/design-system/theme.css— the--el-input-*tokens, where a disabled pair would live.tests/e2e/shell-a11y-wide.spec.ts— the sweep that measured it, and the carve-out to remove.