fix(ui): make the menu dismissable and the form enforce what it prints - #192
Conversation
Six interaction affordances a visitor reaches for without thinking, each verified absent in a real browser at 390px before being fixed. The mobile menu could only be closed by the toggle it can itself cover. Escape did nothing, a press outside did nothing, and the page scrolled freely behind it — measured running to y=600 with the sheet still pinned over the top. There were no keydown handlers anywhere in src/. Now a useDismissable hook wires up all three, and the toggle names the sheet it controls via aria-controls, which aria-expanded alone never did. The contact form advertised rules it had no way to apply. The validation panel listed "Name: 2–100 characters" and "Message: 10–1000" while the inputs carried nothing but `required`, so a nine-character message passed the client check, cost a network round trip, and came back as a red box quoting a rule stated on screen. Those numbers lived only in worker.js; they now live in src/data/contactLimits.js, which both the Worker and the form import — so the enforcement, the native minlength/maxlength, and the printed copy are one source. The textarea gains a counter, because maxlength otherwise just stops accepting keystrokes with no explanation. The outcome panels announced themselves to screen readers and to nobody else. They render above the fields, so on a phone the reason a submission failed can be off-screen while you are still looking at the button you pressed; focus now moves to the panel, which both scrolls it into view and puts the next Tab on the mailto fallback. Also: the form's fields were the only place in the codebase using focus:outline-none, downgrading a 2px outline to a 1px ring at 40% opacity on the controls where keyboard focus matters most. Collapsing the projects list yanked the button several screens up on mobile. And ScrollProgress exposed two decorative gradient bars to assistive tech. The e2e test earned its place immediately: it caught a regression this change introduced, where tapping a menu item navigated nowhere. The open sheet's `overflow: hidden` silently discards a scrollTo issued under it, so the scroll now waits a frame for the lock to lift. jsdom could not have found that — it does not implement scrolling, so it cannot refuse one. Verified: 331 unit tests (13 new, each mutation-tested to confirm it fails without its fix), 31 e2e, lint, copyright, build. Lighthouse accessibility 1.00 on all four URLs; dist/_headers byte-identical, so no CSP hash moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
aswin-portfolio | e5afd78 | Commit Preview URL Branch Preview URL |
Aug 03 2026, 09:42 AM |
Aswincloud-Bot
left a comment
There was a problem hiding this comment.
Auto-approved: @Aswinmcw is a member of @Aswincloud/admins.
There was a problem hiding this comment.
🟡 Not ready to approve
The message counter can disagree with native maxLength behavior (trimmed vs raw length), producing misleading “N left” feedback when the browser won’t accept more input.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR improves core UI interaction affordances (mobile nav dismissal, form validation enforcement, and feedback visibility) by introducing a reusable dismissal hook, sharing contact-length limits between the browser and Worker, and adding targeted accessibility and regression tests.
Changes:
- Add
useDismissableto close the mobile menu via Escape, outside press, and scroll locking; update nav scrolling timing to avoid lock-related scroll drops. - Centralize contact form length rules in
src/data/contactLimits.jsand apply them in bothworker.jsandContactSection.jsx, including native constraints and a live message counter. - Improve UX/accessibility with focus management for submit outcome panels, decorative
aria-hiddenon scroll bars, and tests (unit + e2e) covering these affordances.
File summaries
| File | Description |
|---|---|
| worker.js | Imports shared contact limits and re-exports them for existing Worker callers/tests. |
| src/hooks/useDismissable.js | New hook for Escape/outside-press dismissal and optional scroll locking. |
| src/hooks/index.js | Re-exports useDismissable from the hooks barrel. |
| src/data/contactLimits.js | New shared source-of-truth for contact input bounds + displayed hints. |
| src/components/sections/ProjectsSection.jsx | Keeps the “Show less” button in view after collapse via scrollIntoView. |
| src/components/sections/ContactSection.jsx | Enforces shared limits (trim-aware), adds message counter, and focuses outcome panels. |
| src/components/ScrollProgress.jsx | Marks decorative progress/glow bars as aria-hidden. |
| src/components/Navigation.jsx | Wires useDismissable, adds aria-controls, and defers section scroll by a frame. |
| src/tests/uiAffordances.test.jsx | New Vitest suite covering dismissals, scroll lock, form constraints, counter, focus, and outline. |
| e2e/uiAffordances.spec.js | New Playwright coverage for real scrolling + mobile menu dismissal behavior. |
| e2e/labelInName.spec.js | Updates fixture message length to satisfy new client-side validation. |
Review details
- Files reviewed: 11/11 changed files
- Comments generated: 2
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Copilot review on #192, both comments substantive. The counter measured trimmed length against `maxLength`, which counts the raw value. On a message ending in trailing spaces it promised characters the browser had already stopped accepting — "10 left" on a dead keyboard, which is the exact confusion the counter was added to answer. It also had no floor, so a programmatic set past the ceiling would have rendered "-24 left". Two different lengths bind at the two ends, so quote whichever one actually applies: raw against the ceiling (the browser's rule), trimmed against the floor (the Worker trims before validating). Ceiling is checked first — someone who cannot type another character does not need to be told to type more. Also collapse worker.js's duplicate module specifier into one import plus a re-export of the binding. Both fixes are covered and mutation-tested: reverting the ceiling to trimmed length fails the raw-length test, and widening the negative guard fails the negative-count test, each with no other test affected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six interaction affordances a visitor reaches for without thinking. Each one was verified absent in a real browser at 390px before being fixed — not inferred from reading the source.
What was broken
The mobile menu could only be closed by the toggle it can itself cover. Measured:
There were no
keydownhandlers anywhere insrc/. A newuseDismissablehook wires up all three, and the toggle now names the sheet it controls witharia-controls—aria-expandedalone says "something is expanded" without saying what.The contact form advertised rules it had no way to apply. The validation panel listed "Name: 2–100 characters" and "Message: 10–1000 characters" while the inputs carried nothing but
required— nominLength, nomaxLength, no counter (all verified null in the browser). So a nine-character message passed the client check atContactSection.jsx:92, cost a network round trip, and came back as a red box quoting a rule already on screen.Those numbers lived only in
worker.js. They now live insrc/data/contactLimits.js, which both the Worker and the form import, so the enforcement, the native constraints, and the printed copy are one source and cannot drift. The textarea gains a live counter, becausemaxLengthotherwise just stops accepting keystrokes with no explanation — it stays quiet in the middle of the range and speaks only at the two boundaries that matter.contactLimits.jsrather than importingworker.jsdirectly: the Worker is 640 lines of route handling and email templates, and reaching into it for three numbers would pull all of that into the browser bundle. The dependency points the other way.The outcome panels announced themselves to screen readers and to nobody else.
role='alert'/role='status'are correct, but the panels render above the fields — so on a phone the reason a submission failed can be off-screen while you're still looking at the button you pressed. Focus now moves to the panel, which both scrolls it into view and puts the next Tab on the mailto fallback.Three smaller ones: the form's fields were the only place in the codebase using
focus:outline-none, downgradingindex.css's 2px outline to a 1px ring at 40% opacity on the controls where keyboard focus matters most; collapsing the projects list yanked the button several screens up on mobile; andScrollProgressexposed two decorative gradient bars to assistive tech.The e2e test earned its place immediately
It caught a regression this change introduced: tapping a menu item navigated nowhere. The open sheet's
overflow: hiddensilently discards ascrollToissued underneath it, so the scroll now waits one frame for the lock to lift.The jsdom suite could not have found that — jsdom doesn't implement scrolling, so it cannot refuse one. That's why the scroll-lock assertion is duplicated across both layers rather than deduplicated.
Deliberately not changed
Several things I expected to find are already handled, with comments saying so:
MotionConfig reducedMotion='user'(App.jsx:237), the global*:focus-visiblering, the skip link, and the footer'spy-1.5/w-fitpadding that exists specifically to clear the WCAG 2.5.8 24px floor.Also checked and not flagged: the 32px copy-email button and 40px social icons clear the 24px minimum, and the inline "Stack" link at 21px is explicitly exempt as inline text in a sentence.
Verification
outline-nonefailed exactly 1. No test passes for the wrong reason.noindexceiling the assertMatrix already exempts)dist/_headersbyte-identical — verified withdiff, so no CSP hash movedwrangler deploy --dry-runclean, confirming the Worker still bundles with its new importnpm audit— 0 vulnerabilitiesOne existing e2e fixture needed updating:
labelInName.spec.jsfilled a 5-character message, which the new client-side validation correctly rejects before the in-flight state that test is about.🤖 Generated with Claude Code