Interface review: accessibility, contrast, scanner report and motion - #10
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
|
React Doctor found no new issues. 🎉 Reviewed by React Doctor for commit |
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 witharia-expanded, and the scan reports throughrole="progressbar"plus a status region. The header and footer sat inside<main>, which removed thebannerandcontentinfolandmarks; there was no skip link.MotionConfigwas never mounted, so JS animations ignoredprefers-reduced-motionentirely.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-goodaliased--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
line-clamp-2had been silently cancelled by an adjacentblockclass, 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.cssis 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:
cd59937includes the nav<button>-inside-<a>fix and the silently-failing URL validation, because both sat on the lines being retimed;4b953b2combines redesign, motion and a11y forscan/**.Not done
font-mono font-boldremains browser-synthesized. Only theweight: "400"metadata was corrected.scan-progress.tsx:78— a native<progress>cannot host the animated fill, so the ARIA pattern stays.Verification
turbo typecheck3/3,ultracite checkclean. 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 to1e-05s.