Skip to content

fix(ui): make the menu dismissable and the form enforce what it prints - #192

Merged
Aswinmcw merged 2 commits into
mainfrom
fix/ui-affordances-menu-dismissal-form-validation
Aug 3, 2026
Merged

fix(ui): make the menu dismissable and the form enforce what it prints#192
Aswinmcw merged 2 commits into
mainfrom
fix/ui-affordances-menu-dismissal-form-validation

Conversation

@Aswinmcw

@Aswinmcw Aswinmcw commented Aug 3, 2026

Copy link
Copy Markdown
Member

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:

behavior before after
Escape closes it ❌ stayed open
Press outside closes it ❌ stayed open
Page scroll locked ❌ ran to y=600 under the sheet ✅ stays at 0

There were no keydown handlers anywhere in src/. A new useDismissable hook wires up all three, and the toggle now names the sheet it controls with aria-controlsaria-expanded alone 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 — no minLength, no maxLength, no counter (all verified null in the browser). So a nine-character message passed the client check at ContactSection.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 in src/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, because maxLength otherwise 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.js rather than importing worker.js directly: 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, downgrading index.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; and ScrollProgress exposed 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: hidden silently discards a scrollTo issued 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-visible ring, the skip link, and the footer's py-1.5/w-fit padding 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

  • 331 unit tests (13 new), 31 e2e (4 new), lint, copyright, build — all green
  • Every new test mutation-tested. Reverting the dismissal hook failed exactly 3; reverting the submit guard failed exactly 2; removing the focus move failed exactly 1; restoring outline-none failed exactly 1. No test passes for the wrong reason.
  • Lighthouse accessibility 1.00 on all four URLs; all assertions pass (404.html's SEO 0.63 is the pre-existing noindex ceiling the assertMatrix already exempts)
  • dist/_headers byte-identical — verified with diff, so no CSP hash moved
  • wrangler deploy --dry-run clean, confirming the Worker still bundles with its new import
  • npm audit — 0 vulnerabilities

One existing e2e fixture needed updating: labelInName.spec.js filled 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

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>
@Aswinmcw
Aswinmcw requested review from a team and Aswin-coder as code owners August 3, 2026 09:28
Copilot AI review requested due to automatic review settings August 3, 2026 09:28
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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 Aswincloud-Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-approved: @Aswinmcw is a member of @Aswincloud/admins.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 useDismissable to 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.js and apply them in both worker.js and ContactSection.jsx, including native constraints and a live message counter.
  • Improve UX/accessibility with focus management for submit outcome panels, decorative aria-hidden on 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.

Comment thread worker.js Outdated
Comment thread src/components/sections/ContactSection.jsx
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>
@Aswinmcw
Aswinmcw added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit d3bb5e9 Aug 3, 2026
14 checks passed
@Aswinmcw
Aswinmcw deleted the fix/ui-affordances-menu-dismissal-form-validation branch August 3, 2026 09:46
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.

5 participants