Skip to content

fix: name 80 more unlabeled form controls in the Goals, Brain, MeatSpace and Agent tabs (#4156) - #4313

Merged
atomantic merged 3 commits into
mainfrom
claim/issue-4156
Aug 15, 2026
Merged

fix: name 80 more unlabeled form controls in the Goals, Brain, MeatSpace and Agent tabs (#4156)#4313
atomantic merged 3 commits into
mainfrom
claim/issue-4156

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Summary

Continues the accessible-name sweep past the page-level slice shipped in #4307. That pass worked the named pages; this one works four whole component subtrees, so the result is coherent per-area rather than a scatter of individual fixes:

  • client/src/components/goals/** (8 files)
  • client/src/components/brain/tabs/** (6 files)
  • client/src/components/meatspace/tabs/** (5 files)
  • client/src/components/agents/tabs/** (2 files)

Every candidate was read in context before being touched — controls already named by a wrapper were left alone. 80 genuinely-unnamed controls got names:

  • Visible label + htmlFor/id where the surface had room. The shared FormField for GoalEditForm's title/description and all five of MemoryTab's add/edit forms (people, projects, ideas, admin, memories), which previously ran on placeholders alone. Existing orphan <label> elements — GoalEditForm's "Time slot"/"Duration", AlcoholTab's "Volume" — got the pairing they were missing rather than a new one bolted on.
  • aria-label for compact toolbars, inline table-edit rows, and repeated list rows (GoalPlanSection's phase/sub-goal rows index their labels), where a visible label would break the layout.

LifestyleTab's page-local FieldGroup rendered a <label> that pointed at nothing. It now takes an htmlFor prop for its three single-control groups, and falls back to role="group" + aria-labelledby for the button sets, which have no labelable control. Wrapping was not an option there: an implicit <label> would pull the live value readout and the hint paragraph beside each slider into the accessible name.

The a11y guard learns that shape. hasMatchingExplicitLabel now also credits a same-file wrapper that forwards htmlFor onto a <label> it fills with its own label prop, under the same limits as the existing label-wrapper recognizer (same-file function declarations only, so a missed one is a false negative). A probe test pins the bypasses shut — a forwarder whose <label> holds no text, and a call site with no label=, both still count as unnamed.

No allowlist was broadened. All 80 corresponding PREEXISTING_INPUT_NAME_ALLOWLIST entries in client/src/a11yConventions.test.js are removed, so these controls are enforced by the repo-wide guard instead of exempted — 222 entries down to 142.

Test plan

  • cd client && npm test — 650 files / 7939 tests pass.
  • cd client && npm run lint — clean.
  • client/src/a11yConventions.test.js passes with the 80 allowlist entries deleted, which is the actual regression check: re-adding any entry is no longer needed for these files, and removing a fix without the entry fails the suite.
  • New probe only credits an htmlFor-forwarding wrapper that really names the control asserts the new recognizer fires for a wrapper that labels, and does not fire for a text-less <label>, a call site missing label=, or a non-matching id.

Remaining

This is a deliberate slice of a long tail, not the whole backlog. PREEXISTING_INPUT_NAME_ALLOWLIST still carries 142 entries, largest clusters:

  • src/pages/** (26, mostly AIProviders.jsx and StackerNews.jsx)
  • src/components/meatspace/post/** (14)
  • src/components/cos/tabs/** (14)
  • src/components/settings/** (11)
  • src/components/universeBuilder/** (9), src/components/pipeline/arcCanvas/** (9), src/components/music/** (8), src/components/digital-twin/tabs/** (8), and a long tail of 1–6 per directory

<select> and <textarea> also remain outside the guard's scope entirely (it scans <input> only); that extension is tracked separately per the earlier discussion on this issue. This PR did name the <select>/<textarea> controls it encountered inside the four swept subtrees, but they are not yet guarded.

Refs #4156

…ace and Agent tabs (#4156)

Continues the accessible-name sweep past the page-level slice shipped in
#4307, working four whole component subtrees rather than scattered hits:
client/src/components/goals, brain/tabs, meatspace/tabs and agents/tabs.
Each candidate was read in context first — controls already named by a
wrapper were left alone.

Where the surface had room the control gets a visible label wired with
htmlFor/id (the shared FormField, or an existing orphan <label> given the
pairing it was missing); compact toolbars and inline table-edit rows get
aria-label instead. MemoryTab's five add/edit forms moved to FormField so
every field carries a visible label rather than a placeholder alone.

LifestyleTab's page-local FieldGroup rendered a <label> that pointed at
nothing. It now takes an htmlFor prop for its three single-control groups
and falls back to role="group" + aria-labelledby for the button sets,
which have no labelable control. Wrapping was not an option there — an
implicit <label> would pull the live value readout and the hint paragraph
beside each slider into the accessible name.

The a11y guard learns that shape: hasMatchingExplicitLabel now also
credits a same-file wrapper that forwards htmlFor onto a <label> it fills
with its own `label` prop, under the same limits as the existing
label-wrapper recognizer. A probe test pins the bypasses shut — a
forwarder whose <label> holds no text, and a call site with no `label=`,
both still count as unnamed.

All 80 corresponding PREEXISTING_INPUT_NAME_ALLOWLIST entries are removed,
so these controls are now enforced by the guard instead of exempted.
…mposition rows to Milestone (#4156)

Three findings from the review pass:

- `label={true}` (and its `<Field label>` shorthand) read as a usable label
  attribute, but React renders `{true}` as no text at all — so a wrapper
  call site could claim a name it never renders. Both the existing
  label-wrapper check and the new htmlFor forwarder now share one
  isUsableLabelAttributeValue predicate that rejects it. No call site in
  the tree passes `label={true}`, so this closes a hole rather than
  changing a verdict.
- GoalPlanSection's proposedDecomposition rows are milestones — the panel
  itself says "N milestones proposed" — not sub-goals. Renamed the two
  aria-labels so screen readers say what the row is.
- The forwarder recognizer relies on the input scan handing it
  maskComments(src), which is what keeps a commented-out wrapper from
  registering. That was load-bearing and undocumented; it now says so, and
  the probe test asserts it against masked source instead of looking safe
  for the wrong reason.
… the shared predicate too (#4156)

isNestedInLabeledFormField was the third site carrying the same inline
label-validity check and the only one left accepting `<FormField label>` /
`label={true}` as a usable name. All three now share
isUsableLabelAttributeValue, so the rule cannot drift apart per call path.
@atomantic
atomantic merged commit eaea6b0 into main Aug 15, 2026
7 checks passed
@atomantic
atomantic deleted the claim/issue-4156 branch August 15, 2026 19:52
atomantic added a commit that referenced this pull request Aug 15, 2026
…ing (#4156)

Continues the unlabeled-control sweep on the Chief of Staff, MeatSpace POST
and Voice settings surfaces, and closes two blind spots that were keeping
correctly-labeled controls on the allowlist:

- The htmlFor-forwarding wrapper recognizer hardcoded a prop named `htmlFor`.
  StackerNews's `Field({ id, label })` does the same job through `id`, so its
  13 already-labeled controls looked unnamed. Both prop names are now read out
  of the wrapper, and both must still be parameters of it.
- A FormField whose only child is a conditional (`{isSelect ? <select/> :
  <input/>}`) was not credited, though React clones the generated id onto
  whichever branch renders. Rendered lists stay excluded: Children.map
  flattens an array and clones only its first element.

VoiceTab carried a page-local clone of the shared FormField; it is deleted in
favour of the real one, which is what the guard already understands.

A new test fails on any allowlist entry that no longer matches an unnamed
input, so the burn-down can only shrink. It immediately caught 9 entries left
behind by #4313.

PREEXISTING_INPUT_NAME_ALLOWLIST: 142 -> 71.
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.

1 participant