Skip to content

Interface review: accessibility, contrast, scanner report and motion - #10

Merged
PunGrumpy merged 16 commits into
mainfrom
refine/interface-review
Jul 29, 2026
Merged

Interface review: accessibility, contrast, scanner report and motion#10
PunGrumpy merged 16 commits into
mainfrom
refine/interface-review

Conversation

@PunGrumpy

Copy link
Copy Markdown
Owner

Cross-discipline interface review of the scanner, then the fixes it turned up. Every finding below was confirmed at runtime with Playwright rather than inferred from source, and the before/after numbers are measured.

What changed

Accessibility. Sorting and row expansion in the pages table were mouse-only — zero focusable controls. Header sorts are now real buttons carrying aria-sort, rows get a disclosure button with aria-expanded, and the scan reports through role="progressbar" plus a status region. The header and footer sat inside <main>, which removed the banner and contentinfo landmarks; there was no skip link. MotionConfig was never mounted, so JS animations ignored prefers-reduced-motion entirely.

Contrast. In light mode the status hues were too light to pass as text on their own tints — the fair badge measured 2.02:1, now 4.54:1. --score-good aliased --primary, so the four-band ramp ran green → black → amber → red; it gets its own hue at 118°, picked to maximise OKLab ΔE against its neighbours.

Scanner report. The report opened with five donut gauges reading 71, 71, 72, 72 and 71 — 300 vertical pixels of near-identical arcs, which are not comparable at a glance anyway. The overall score keeps a ring, the categories become bars, and both count up. Grouping moved from nested card borders to space and headings. The distribution chart duplicated its own legend beneath itself, so recharts is replaced by a CSS part-to-whole bar.

Motion. Nine durations, five easing curves and two duplicated spring configs, with entry delays that accumulated rather than staggered — the last block on the homepage did not settle until ~2.15s.

Measured

before after
Fair badge contrast (light) 2.02:1 4.54:1
Report height 1424px 878px
Longest issue row 788px (37 wrapped lines) 98px
Last block settles ~2150ms ~340ms
Durations in use 9 3
Easing curves 5 1
Keyboard-reachable sort controls 0 6

line-clamp-2 had been silently cancelled by an adjacent block class, which is how one row reached 788px. Expanding the full issue list is now bounded by a scroll frame instead of running the column thousands of pixels past the table, and the summary column sticks so a long table does not strand it above dead space.

The motion curve was picked for perceptibility, not taste. Traced in the browser, pairing a 6px travel with a drawer curve moved opacity from 0 to 0.74 in 25ms across three visible pixels — content simply popped in. easeOutCubic reaches ~58% at a quarter of the duration, so the movement stays legible.

Reviewing

The five commits are independent and each typechecks on its own; apps/web/app/globals.css is deliberately split across three of them because it carries three unrelated concerns. Reading them in order is easier than reading the squashed diff.

Two commits carry more than one concern, called out in their own messages: cd59937 includes the nav <button>-inside-<a> fix and the silently-failing URL validation, because both sat on the lines being retimed; 4b953b2 combines redesign, motion and a11y for scan/**.

Not done

  • A bold Martian Mono face still is not shipped, so font-mono font-bold remains browser-synthesized. Only the weight: "400" metadata was corrected.
  • One accepted lint warning at scan-progress.tsx:78 — a native <progress> cannot host the animated fill, so the ARIA pattern stays.
  • Capitalization outside the scanner report is still mixed ("Copy HTML" vs "Get in touch").

Verification

turbo typecheck 3/3, ultracite check clean. Checked in light and dark at 320 / 375 / 1440 and at 200% zoom: no horizontal overflow, no text under 12px, no unnamed focusable controls. Reduced motion collapses every transition to 1e-05s.

PunGrumpy and others added 6 commits July 29, 2026 16:36
The header and footer were nested inside <main>, which removed the
banner and contentinfo landmarks and left the page with no skip link.
Motion JS animations ignored prefers-reduced-motion entirely because
MotionConfig was never mounted, and <Toaster> was missing so the copy
confirmation never appeared.

Also: FormMessage now carries role="alert"; edit-mode inputs get an
accessible name and 16px text so iOS stops zooming on focus; the footer
status labels were inverted, reporting a total outage as "Degraded
performance"; and the mono family declares weight 400 rather than
claiming faces it does not ship.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In light mode the status and score hues were too light to pass as text
on their own /5 and /10 tints — the fair badge measured 2.02:1. Each
hue is now solved for the highest lightness that still clears 4.5:1,
with chroma clamped back into sRGB.

--score-good also aliased --primary, which is pure black in light mode
and pure white in dark, so the four-band ramp ran green → black →
amber → red. It gets its own hue at 118°, chosen to maximise OKLab ΔE
against its neighbours while keeping the ramp monotonic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The codebase carried nine durations (0.15 … 1.4s), five easing curves
and two duplicated spring configs, so no two panels opened at the same
speed. lib/motion.ts is now the one source for duration, easing,
stagger and travel, mirrored by --ease-out-custom for the CSS side.

The curve is chosen for perceptibility. Traced in the browser, the
previous pairing of a 6px travel with a drawer curve moved opacity from
0 to 0.74 in 25ms across three visible pixels — content simply popped
in. easeOutCubic reaches ~58% at a quarter of the duration, so the
movement stays legible the whole way.

Bare transition-* utilities also fell back to Tailwind's 150ms and
cubic-bezier(0.4, 0, 0.2, 1), which is why button press feedback never
matched the JS animations. --default-transition-* is declared in :root
rather than @theme because those two keys are read as plain custom
properties, so a later declaration is what wins the cascade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entry delays were accumulating rather than staggering: hero at 0.8s and
1.2s, then the checker at 1.4s, 1.6s and 1.8s, so the last block did
not settle until roughly 2.15s. Navigation staggered at 0.4s per item,
spending 1.2s on two buttons. Everything now derives from STAGGER and
the whole ladder resolves inside ~600ms.

ViewAnimation owns the enter transform, which call sites had been
hand-rolling into two directions and two magnitudes. The blur-in is
gone: filter is not a transform, so MotionConfig could not suppress it
under reduced motion, and it made text shimmer on the way in.

Two unrelated fixes ride along in these files, since they touch the
same lines: the nav rendered <button> inside <a>, which is invalid and
created two tab stops per item, and the URL field reported nothing at
all for a malformed host because onError only handled serverError.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The report led with five donut gauges reading 71, 71, 72, 72 and 71 —
three hundred vertical pixels spent on five near-identical arcs, and
arc length is not comparable at a glance anyway. The overall score
keeps a ring; the four categories become bars, and both now count up
so the number is not frozen while the graphic moves beneath it.

Grouping came from nested card borders; it now comes from space and
headings via ReportSection, so the report is one box deep and gains a
real heading outline. The distribution chart duplicated its own legend
underneath itself, so recharts is replaced by a CSS part-to-whole bar
and a <dl> — which also drops the dynamic(ssr:false) wrapper.

Issue rows previously clipped the tag, the identifier, because a
bordered count chip shared its line, and line-clamp-2 was silently
cancelled by an adjacent `block` class: one row measured 788px with the
message wrapping to 37 lines. Rows now cap at 98px. Expanding the full
list is bounded by a scroll frame rather than running the column
thousands of pixels past the table, and the column sticks so a long
table no longer leaves the summary stranded above dead space.

Sorting and row expansion were mouse-only. Header sorts are real
buttons carrying aria-sort, rows get a disclosure button with
aria-expanded, and the scan phase reports through role="progressbar"
and a status region. Graphics take the brighter --score-*-fill ramp,
since arcs and bars carry no text and the accessible text values read
as muddy olive at that size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
og-tester Ready Ready Preview, Comment Jul 29, 2026 2:35pm

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit 2a490ed.

PunGrumpy and others added 2 commits July 29, 2026 17:01
react-doctor flagged both. The category bars grew their own `width`,
which re-runs layout every frame; the fill is now full width and slides
in from the left instead. Translating rather than scaling also keeps
the rounded right cap undistorted, which scaleX would squash. Same
approach as Radix Progress.

The scan status row carried `duration-240` for its enter keyframes, but
`duration-*` on its own sets transition-duration with no
transition-property — which CSS defaults to `all`, quietly transitioning
every property on that element. `transition-none` pins it off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The codebase already uses size-* elsewhere (size-4 on icons, size-full
on the gauge svg), so `h-full w-full` was just drift.

Left components/ui/** alone: those are shadcn-generated and get
re-synced by the CLI, so cosmetic edits there only create merge noise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PunGrumpy and others added 8 commits July 29, 2026 21:28
The issue list and the pages table had byte-identical expand/collapse
blocks. Both now spread `collapse` from lib/motion.

This also clears react-doctor's no-layout-property-animation, which
fired six times across the two files. Animating height between 0 and
"auto" is the one case where a layout property is right — motion
measures the target and runs a FLIP, and the rule's own validation
prompt lists that shape as a false positive — but the rule matches on
object literals in JSX, so hoisting the shared object satisfies it
without a suppression comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Animating width re-runs layout every frame, and this bar retweens on
every page the scanner completes. The fill is now full width and
translated in from the left, which stays on the compositor and keeps
the rounded cap undistorted.

The shine sweeps relative to the fill, so with the fill now spanning
the whole track it would have spent most of each pass in the clipped
region — at low progress it would flash only briefly near the right
edge. Counter-translating it by the same amount pins it back over the
part of the bar that is actually on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The array index was part of the key, so React reused rows across a
re-sort and could paint one diagnostic's text against another's
severity styling. Tag alone is not unique — a page can fail the same
tag twice — but tag + message is, and it survives reordering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The boundary sat inside the `hasData` branch, so it was removed by the
same condition as its children and could never observe them leaving —
the outgoing preview vanished instead of playing its exit. The
condition moves onto the six platform children, and the empty state
joins them as another keyed child of the same boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only actionClientWithMeta is imported anywhere. actionClient was a
second, meta-less client no caller used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three exclusions, each verified rather than assumed:

.xmcp/** is xmcp's build output, regenerated on every dev/build run and
already in .gitignore, so its crypto and JSON-in-HTML findings are
vendor code we neither wrote nor ship from source.

tools/** holds the MCP tool entry points. xmcp discovers them by
convention and wires them through the generated .xmcp/import-map.js, so
dead-code analysis sees no static import and calls all three
unreachable — the dev server logs "Registered 3 tools" on boot.

hooks/use-mobile.ts is imported only by components/ui/sidebar.tsx,
which the pre-existing components/ui/** entry already excludes. It read
as orphaned purely as a side effect of that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/api/og, /api/sitemap and /api/robots each took a URL straight from a
query parameter and fetched it server-side with no protocol or host
check, which made the server a proxy into anything it could reach —
cloud metadata at 169.254.169.254, container-internal services, or
loopback. Validating the host alone would not have been enough, since a
permitted host can redirect into a blocked one, so safeFetch follows
redirects manually and re-checks every hop.

Verified against 16 targets — loopback, RFC1918, CGNAT, link-local,
IPv6 unique/link-local, IPv4-mapped forms and non-http schemes — all
refused, with example.com still returning 200.

One of those cases caught a bug in the guard itself: URL normalisation
rewrites [::ffff:127.0.0.1] to the hex form ::ffff:7f00:1, which the
dotted-quad pattern missed, so mapped loopback went straight through.
Both spellings are handled now.

Known gaps: DNS rebinding is still possible, because the address is
resolved before the request rather than pinned for the connection, and
packages/core does its own fetching for /api/scan and is not covered
here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PunGrumpy
PunGrumpy merged commit 561cac9 into main Jul 29, 2026
5 checks passed
@PunGrumpy
PunGrumpy deleted the refine/interface-review branch July 29, 2026 14:38
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