Skip to content

name 33 more form controls and stop the a11y allowlist from rotting - #4316

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

name 33 more form controls and stop the a11y allowlist from rotting#4316
atomantic merged 2 commits into
mainfrom
claim/issue-4156

Conversation

@atomantic

@atomantic atomantic commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Summary

Continues the unlabeled-form-control sweep from #4156, and closes the guard blind spots that were keeping already-correct controls on the backlog.

Controls named (33 across 12 files) — Chief of Staff (TaskAddForm, AgentCard, ConfigRow, JobsTab, MemoryEditModal, TaskItem, ScheduleEditor), MeatSpace POST (EpigeneticTracker and the drill runners), and MorseTrainer's slider rows. A real visible label wherever the surface had room — FormField for JobsTab's stacked create/edit forms, <label htmlFor>/id pairing for ConfigRow and SliderRow (which also makes the row text a click target for a 16px checkbox) — and aria-label only in compact toolbars, inline table-edit rows and centered drill answer fields, where context supplies the name and there is no room for a label.

Three guard shapes taught, not worked around — adding an aria-label to a control that already has a visible label shadows that text, so these were fixed in the recognizer instead:

  • The htmlFor-forwarding recognizer hardcoded a prop named htmlFor. StackerNews.jsx's Field({ id, label, children }) does the same job through id, so its 13 already-labeled controls read as unnamed. Both prop names are now captured from the wrapper's own source, 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, so crediting every control in a .map() would exempt all but one. A .map() in the other ternary branch does not disqualify the shape.
  • Conversely, isNestedInLabeledFormField credited any element literally spelled <FormField> with a label=, unverified, across 313 call sites — the guard's widest exemption. It now requires the file to import the shared component, so a page-local FormField cannot silently exempt everything under it.

One duplicate deletedVoiceTab.jsx carried a page-local clone of the shared FormField (same label/hint/children/className contract, same useId + cloneElement id injection). It is replaced by the real component, which the guard already understands; spacing is preserved (the clone's space-y-1 and FormField's mb-1 on label and hint are equivalent, and every call site has a single child).

The allowlist can now only shrink — a new test fails on any PREEXISTING_INPUT_NAME_ALLOWLIST entry that no longer matches an unnamed input. A stale entry is not harmless: it silently re-exempts the next control to land on that same anchor. It immediately caught 9 entries left behind by #4313 when the label-wrapping recognizer landed.

PREEXISTING_INPUT_NAME_ALLOWLIST: 142 → 71, exactly half the remaining backlog. No entry was broadened.

Review pass

The second commit addresses a /simplify review of the first, including two defects in its own fixes:

  • JobsTab's create/edit forms are stacked full-width fields whose only visible affordance was a placeholder. Naming them with aria-label satisfied the guard while leaving sighted users with no label the moment they typed — preserving the placeholder-as-label defect and certifying it as fixed. They now use FormField.
  • Two aria-labels truncated the title text that was already serving as the accessible name ("Cron expression" for "Cron expression: minute hour dayOfMonth month dayOfWeek"). The detail is folded back in.
  • The two allowlist rules re-scanned every tracked file independently; they now share one memoized scan (same question, opposite comparison). ~30% off this file's suite time, and the two can no longer drift on what "unnamed" means.
  • localLabelWrapperNames and localHtmlForForwarders had byte-identical declaration-walking prologues, now behind forEachLocalComponent. isEnclosedInListCall reuses balancedCallAt instead of hand-rolling a fourth string-aware paren scanner.

Test plan

  • cd client && npm test — 651 files, 7955 tests, all passing.
  • cd client && npm run lint — clean (biome, 2048 files, --error-on-warnings).
  • cd client && npm run build — clean.
  • Reviewed by antigravity against the full diff, focused on false positives in the recognizers — no findings.
  • New regression tests in client/src/a11yConventions.test.js:
    • keeps no stale entries in the pre-existing input allowlist.
    • credits a FormField whose only child is a conditional, but not a list — probes the ternary shape, a .map() in the sibling branch, a rendered list, a control nested one element deeper, an unlabeled FormField, and a FormField that is not the shared one.
    • The htmlFor-forwarder test gains the renamed-prop case plus its bypasses: a call site omitting label=, and an htmlFor= attribute that is not the prop the wrapper forwards.

Remaining

71 entries. Largest clusters: pipeline/arcCanvas/** (9), universeBuilder/** (9), digital-twin/tabs/** (8), src/pages/** (7), calendar/** (6), apps/tabs/** (5), then 1–3 per directory. Same caveat as before — verify each candidate in context, do not mass-apply.

Follow-ups filed from the review:

<select> and <textarea> remain outside the guard's scope entirely, tracked in #4309.

Refs #4156

…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.
…label JobsTab's forms (#4156)

Review of the previous commit surfaced two defects in its own fixes and three
cleanups:

- JobsTab's create/edit forms are stacked full-width fields whose only visible
  affordance was a placeholder. Naming them with aria-label satisfied the guard
  while leaving sighted users with no label the moment they typed — the
  placeholder-as-label defect preserved and certified as fixed. They now use
  FormField, like the rest of the diff.
- Two aria-labels truncated the `title` text that was already serving as the
  accessible name ("Cron expression" for "Cron expression: minute hour
  dayOfMonth month dayOfWeek"). Folded the detail back in.
- isNestedInLabeledFormField credited any element literally spelled
  <FormField>, unverified, across 313 call sites — the guard's widest
  exemption. It now requires the file to import the shared component.
- The two allowlist rules re-scanned every tracked file independently. They
  share one memoized scan: same question, opposite comparison. Suite time for
  the file drops ~30%, and the two can no longer drift on what "unnamed" means.
- localLabelWrapperNames and localHtmlForForwarders had byte-identical
  declaration-walking prologues; both now consume forEachLocalComponent.
  isEnclosedInListCall reuses balancedCallAt instead of hand-rolling a fourth
  string-aware paren scanner.

Follow-ups filed: #4317 (unify the wrapper recognizers; arrow-function and
imported wrappers are both invisible today) and #4318 (an apostrophe in JSX
text breaks matchingBraceEnd, which is why one AIProviders field stays on the
allowlist while its four identical siblings came off).
@atomantic
atomantic merged commit 1d31056 into main Aug 15, 2026
7 checks passed
@atomantic
atomantic deleted the claim/issue-4156 branch August 15, 2026 20:20
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