Skip to content

fix(approval-card, diff-review): accessibility and touch: Approve all's focus, what a card runs, code left to right, 16px fields - #174

Merged
dgesteves merged 6 commits into
mainfrom
fix/su-a11y-touch-focus-describe-ltr-coarse
Oct 27, 2026
Merged

dgesteves merged 6 commits into
mainfrom
fix/su-a11y-touch-focus-describe-ltr-coarse

Conversation

@dgesteves

@dgesteves dgesteves commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Roadmap item: su-a11y-touch (SU2-44 and SU2-35), with the review follow-ups from SU2-27 (#151) and SU2-45 (#157).

What was wrong

  • Approve all lost focus and its announcement. When onRespond updates the parts, as the AI SDK's addToolApprovalResponse does, ToolApprovalBatch returned null in the same commit that set "2 approvals approved.", so its live region left the page with the text, and focus fell from the removed button to <body>. The old unit test used a vi.fn() that never updated the parts, so the bar stayed mounted and the test passed.
  • A focused card did not say what it would run. Its description was the reason and the key hint only: no risk ("Critical"), no command, and nothing about the rule S or A would save. The announcement after S was "Approved for this session", without the rule.
  • A critical press let go said nothing. Escape, focus leaving, or a newer card taking ⌘/Ctrl+Enter disarmed it silently; the button just read "Approve" again.
  • The card's focus ring never drew. outline-none sets --tw-outline-style: none, so focus-visible:outline-2 drew no outline when focus landed on the card after a decision (Chrome: outline-style: none, width 2px).
  • Right to left reordered code. In a dir="rtl" page, ~/acme/chat-app showed as acme/chat-app/~, the rule pattern pnpm add* as *pnpm add, the deny-always key cap ⇧N as N⇧, and diff lines moved their trailing ;.
  • Forced colours. Approve, Approve all and Apply had no border, so they rendered as bare text; a pressed Accept and the picked layout looked like the others.
  • iOS zoom. The reason, argument, rule-pattern and comment fields were 12 to 13px, and the docs search 13.5px; iOS zooms into a field under 16px. Key caps showed on phones.
  • Smaller ones: applying a review was not announced; at 320px a hunk's range was cut to @@ -11,6 … (and to 4px on a decided hunk); "Show all 1 unchanged lines"; the playground's Ask button was named "Ask the docs" while it shows "Ask ⌘K" (WCAG 2.5.3).

What changed

Library (approval-card.tsx, diff-review.tsx, lib/labels.ts, and Kbd in lib/primitives.tsx):

  • ToolApprovalBatch keeps its status element (with data-signoff, so styles.css still hides it when used on its own) while it has something to say, so the announcement outlives the bar. Once the answered requests leave waiting, and only if focus fell to <body>, it focuses the first ToolApprovalCard it answered, found by a new data-approval-id. I kept this inside the batch rather than hoisting a live region into AgentMessage, so a batch rendered on its own (as in the approval rules demo) gets the fix too. The announcement clears after 3 s, as the card's does.
  • The pending card's description is now: reason, then a hidden summary (risk, "Runs … in …" or the first three arguments, and "A lasting decision covers …" in describeRule's words), then the key hint. The reason stays first, so apps that match their description at the start are unaffected. Invisible characters in the command are named as Revealed names them. The command is read whole, however long, where an argument is cut at 80 characters: what runs is never cut in what is announced (a comment says so). Lasting decisions announce "Approved for this session: run_command with command npm test*.".
  • useArmed takes an onCancel, called once when a press is let go by Escape, focus leaving, an outside click or a newer card taking the shortcut, and not when a decision clears it. In Chrome, removing the bar fires focusout on its button, which first overwrote "2 approvals approved." with "Confirmation cancelled."; a decision now marks the press as let go before that happens.
  • focus-visible:outline-solid on the card, so its ring draws.
  • dir="ltr" on the command preview, the JSON preview and editor, the rule pattern inputs and tool name, the hunk code and unchanged lines, hunk ranges, file paths and every key cap (Kbd). dir="auto" on comments and argument fields, which are the person's or the agent's text.
  • pointer-coarse:text-[length:max(16px,1rem)] on every text field (16px even where the app's root font is smaller), pointer-coarse:hidden on key caps and hint lines.
  • forced-colors:border on the primary buttons (no change outside forced colours), a 2px border on a pressed Accept or Reject, and an underline on the picked layout.
  • DiffReview announces "Review sent: 3 of 4 accepted, 1 comment." in its own live region: at once after a sync onSubmit, once the promise resolves when onSubmit returns one, and not at all if it rejects (saying what failed is the app's, as the DiffReview and accessibility pages now say). It holds until the review announces anything else, and is then gone, even when the review later says again what it said before Apply.
  • Hunk header: flex-wrap, so the buttons drop to their own line instead of truncating the range.
  • New labels: approvalCard.summary, approvalCard.resolvedFor, approvalCard.confirmCancelled, diffReview.submitted, with the Portuguese in all three copies and the count in labels.md (218: 143 strings, 75 functions, counted from the built defaultLabels). showAll, moreAfter and moreBefore say "line" for 1. Minor changeset, since a translation typed as SignoffLabels needs the new keys.

Site: accessibility.md describes the new behaviour (and no longer says the batch takes focus with it), plus touch, forced colours and right to left sections; labels.md updates the right-to-left note. The Ask button is named by its words ("Ask" visible, " the docs" for screen readers, key cap aria-hidden and given as aria-keyshortcuts). The docs search is 16px on a coarse pointer. limits.md gives the sizes pnpm size measures with this branch: ApprovalCard 22.3 kB, DiffReview 44.3 kB, useDiffReview 16.4 kB, AgentMessage 91.5 kB (36.8 kB from /custom-markdown), signoffTools 55.8 kB, the main entry 129.0 kB, and useAgUiAgent 3.6 kB (already so on main, within the check's 0.2 kB). styles.css is now 10.7 kB gzipped and 289 kB uncompressed, gzipped as pnpm size does (a build of main measures 10.6 kB and 286.6 kB, so the old "9.9 kB, 269 kB" was already behind). The README badge reads 0.4–91.5 kB (readme.test.ts checks it against the table), and the README and the comparison page, which readme-claims.test.ts keeps in the same words, give DiffReview as 44.3 kB.

scripts/a11y-audit.mjs: after axe, it checks forced colours (focus rings on a card and through its controls and on a hunk, borders on risk badges and the primary buttons, + and − on every changed line, a pressed Accept against an unpressed Reject, the underlined layout), dir=rtl (diff lines, the command, card JSON and rule inputs compute to ltr on three docs pages) and a coarse pointer at 390px (fields at 16px or more, docs search included, and no visible key caps).

How it was tested

Files outside the listed boundary, each needed by the change: lib/primitives.tsx (only Kbd's dir, added to this PR's scope in the second review round), content/components/diff-review.md (one sentence on a promise from onSubmit), test/labels-locale.test.tsx (two steps, so a state shows resolvedFor and submitted, as that test requires of every label), test/docs-claims.test.ts (it asserted the old "takes focus with it" wording), and the two default-markdown-output snapshots. Since X2-22 (#175) merged, limits.md is free, and its size table and styles.css line are updated here; README.md (the size badge and DiffReview's size) and comparison.md (the same DiffReview size, in the README's words) go with it.

Second review round

  • "Review sent" no longer comes back. It was shown whenever the review's announcement equalled the one from before Apply, so in Chrome on /docs/components/diff-review, A on hunk 2, Apply, R on hunk 2, A on hunk 2 read "Review sent: 2 of 6 accepted, 1 comment." instead of "Hunk 2 of 6 accepted. 3 remaining.". It is now cleared, during render, once the announcement differs. A promise from onSubmit is announced when it resolves, and nothing is announced if it rejects (the rejection still reaches the app as before). Kbd is dir="ltr". A comment on why the card's description reads the whole command. The styles.css figure for main above is corrected.
  • Tests: diff-review-submit.test.tsx now has 5 (A, Apply, R, A on one hunk reads "Hunk 1 of 2 accepted. 1 remaining."; a promise that resolves, then announces; one that rejects, announces nothing and reports one rejection), and rtl-code.test.tsx checks the ⇧N key cap. With the source changes reverted, all four fail. Against main's src/, the new and changed tests now fail 31, the markup snapshot among them. Both default-markdown-output snapshots gain dir="ltr" on four key caps and nothing else, React 18's written with React 18 installed as the React 18 CI job does, then package.json and pnpm-lock.yaml restored.
  • Gate, run on this round's commit: pnpm install --frozen-lockfile --prefer-offline, pnpm format:check, pnpm lint, pnpm lint:registry, pnpm build:lib, pnpm size (no row changed), pnpm typecheck, pnpm test (93 files, 1552 tests), pnpm test:site (84 tests), pnpm registry:check: all pass. With React 18 installed: tsc -p tsconfig.src.json and the suite (1542 passed, 10 skipped) pass.
  • Browser, with the playground built and served locally: the repro above reads "Hunk 2 of 6 accepted. 3 remaining." at the end, in Chrome and in WebKit; with the page set to dir="rtl", the card's ⇧N key cap computes ltr and draws ⇧ left of N in both. pnpm a11y passes in Chrome (53 checks).

Follow-ups

  • Parts of SU2-44's findings in files this PR does not own: dir="ltr" on JsonView in the timeline, Markdown code and the timeline's call summary (lib/primitives.tsx, markdown.tsx, tool-call-timeline.tsx); JsonView's always-focusable <pre> and a spoken "Copied" (lib/primitives.tsx); the timeline trigger cut to "Run c…" at 320px (tool-call-timeline.tsx); an announce="important" mode (agent-status.tsx); touch range selection and Alt+A on AZERTY (use-diff-review.ts); focus after Apply in the playground (playground.tsx).
  • The docs site's own key caps (the keyboard table in components/docs/component-page.tsx) are not dir="ltr"; the site is never right to left, so they only reorder when a page is forced to dir="rtl".
  • Not done here: DiffReview as a group rather than a region landmark (a semantics change that four test files outside this PR assert); showing the saved rule on the resolved card, not only announcing it; mirroring the layout with logical properties.
  • Before a release: one check on a real iPhone that the 16px fields no longer zoom (Playwright's WebKit measures the font sizes but does not emulate iOS's zoom), and on a real macOS Firefox for the no-focus-on-click path. Shift for lasting decisions (S and A), which the finding suggested, is not part of this PR.

…ement, describe what runs, code left to right, touch and forced colours

- ToolApprovalBatch: the announcement outlives the bar, and once the answers
  reach the parts focus moves to the first card answered, not <body>.
- ApprovalCard: described by the risk, what runs and the rule a lasting
  decision saves; lasting decisions announce that rule; a critical press let
  go says "Confirmation cancelled."; the card's focus ring draws again.
- DiffReview: Apply is announced; a narrow hunk's buttons wrap rather than
  cut its range.
- dir="ltr" on commands, JSON, rule patterns, diff lines, ranges and paths;
  dir="auto" on comments and argument fields.
- 16px text fields and no key caps under pointer: coarse; borders and
  pressed states that forced colours keep.
…pnpm a11y

After axe, the audit checks what it cannot see: in forced colours, focus
rings on a card and a hunk, borders on risk badges and the primary buttons,
the diff's + and -, and pressed or picked toggles; with dir=rtl, code,
commands and rule patterns compute to ltr; on a coarse pointer, text fields
are 16px and no key caps show.
…n touch screens

The Ask button's aria-label ("Ask the docs") left out what it shows
("Ask ⌘K"), which WCAG 2.5.3 asks the name to contain. Its name now comes
from its words, with the key cap hidden and given as aria-keyshortcuts.
…us-describe-ltr-coarse

The two default-markdown-output snapshots conflicted: main's Sources
container queries (#209) and this branch's card markup. vitest -u
rewrote both, the React 18 one with React 18 installed as CI's React 18
job does, then package.json and pnpm-lock.yaml were restored.
…and review

pnpm size after merging main: ApprovalCard 22.3 kB, DiffReview 44.3 kB,
useDiffReview 16.4 kB, AgentMessage 91.5 kB (36.8 kB from
/custom-markdown), signoffTools 55.8 kB, the main entry 129.0 kB, and
useAgUiAgent 3.6 kB (already on main). styles.css is 10.7 kB gzipped,
289 kB uncompressed (main: 10.5 kB, 284 kB). The README badge reads
0.4-91.5 kB, and the README and comparison page give DiffReview's
44.3 kB in the same words.
… to right

- DiffReview cleared "Review sent" only by comparison: once the review
  later said again what it said before Apply (A, Apply, R, A on one hunk),
  the stale "Review sent" came back. It now clears once the review says
  anything else.
- When onSubmit returns a promise, "Review sent" is announced once it
  resolves, and nothing is announced if it rejects (the app says what
  failed). A sync onSubmit announces at once, as before.
- Kbd is dir="ltr", so in a right-to-left page the deny-always key cap
  reads Shift then N, not the other way round.
- A comment on why a card's description reads the whole command.
- Docs: accessibility.md and the DiffReview page say what a promise does;
  the changeset says both.
@dgesteves
dgesteves merged commit f364231 into main Oct 27, 2026
13 checks passed
@dgesteves
dgesteves deleted the fix/su-a11y-touch-focus-describe-ltr-coarse branch October 27, 2026 19:14
kasettadoo-sys pushed a commit to kasettadoo-sys/signoff-ui that referenced this pull request Oct 28, 2026
…he repository

- /docs/api lists every export of each entry point in package.json, with
  its declaration and doc comment, read from dist/*.d.ts when the site
  builds (lib/api.ts, getApiIndex). content/docs/api.md holds the prose and
  one `<!-- exports: <entry point> -->` line per entry point, which
  readGuide fills in, so the page, its .md, the docs search, Ask and
  llms-full.txt all have the exports. An export several entry points share
  is listed once, under signoff-ui/core when it is there. An export a
  component page documents links that page; the others link the pages that
  use them. In the sidebar under Resources.
- CONTRIBUTING.md: a Where to start section. The checks list adds pnpm
  perf, test:site, registry:build, smoke:assistant-ui and the CopilotKit
  smoke, which CI runs, and says how to run the browser checks in WebKit
  and Firefox. The kit-scope path is packages/signoff-ui/scripts. The
  version step moves every example but the playground, not only
  examples/coding-agent.
- RELEASING.md: the same for the version pull request.
- ARCHITECTURE.md: the package's layers and entry points, the build, the
  registry, the site, the examples and the checks.
- ROADMAP.md: dgesteves#172 and dgesteves#173 (0.10.1) and dgesteves#174 (0.11.0) move to a Released
  section, with dgesteves#171, dgesteves#175 and dgesteves#211, which merged with no release. In review
  is empty.
- test/api-index.test.ts (site) reads each entry point's exports with
  TypeScript and checks the page lists each, with its declaration and doc
  comment, and that its links land. test/contributor-docs.test.ts (library)
  checks CONTRIBUTING's checks against ci.yml, the paths CONTRIBUTING and
  ARCHITECTURE name, what the version step moves, and the roadmap against
  the changelog.
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