From 089435a990e0fe69982f7a762fc7bc1ae956971d Mon Sep 17 00:00:00 2001 From: jlightner Date: Tue, 31 Mar 2026 08:45:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20mobile=20hamburger=20menu=20wit?= =?UTF-8?q?h=2044px=20touch=20targets,=20Escape/outsi=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/App.tsx" - "frontend/src/App.css" GSD-Task: S03/T02 --- .gsd/milestones/M011/slices/S03/S03-PLAN.md | 2 +- .../M011/slices/S03/tasks/T01-VERIFY.json | 16 +++ .../M011/slices/S03/tasks/T02-SUMMARY.md | 79 ++++++++++++ frontend/src/App.css | 118 ++++++++++++++++++ frontend/src/App.tsx | 73 ++++++++++- 5 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 .gsd/milestones/M011/slices/S03/tasks/T01-VERIFY.json create mode 100644 .gsd/milestones/M011/slices/S03/tasks/T02-SUMMARY.md diff --git a/.gsd/milestones/M011/slices/S03/S03-PLAN.md b/.gsd/milestones/M011/slices/S03/S03-PLAN.md index 6c7094c..ddefad9 100644 --- a/.gsd/milestones/M011/slices/S03/S03-PLAN.md +++ b/.gsd/milestones/M011/slices/S03/S03-PLAN.md @@ -30,7 +30,7 @@ This task delivers R020 (Global Search in Navigation). - Estimate: 45m - Files: frontend/src/components/SearchAutocomplete.tsx, frontend/src/App.tsx, frontend/src/App.css, frontend/src/pages/Home.tsx, frontend/src/pages/SearchResults.tsx - Verify: cd frontend && npm run build 2>&1 | tail -5 -- [ ] **T02: Add mobile hamburger menu with touch targets and auto-close behavior** — Add a hamburger menu button visible below 768px that toggles a mobile nav panel. Nav links stack vertically with 44×44px minimum touch targets. Menu closes on route change, Escape, and outside click. The nav search bar (from T01) repositions inside the mobile panel on small screens. +- [x] **T02: Added mobile hamburger menu with 44px touch targets, Escape/outside-click/route-change auto-close** — Add a hamburger menu button visible below 768px that toggles a mobile nav panel. Nav links stack vertically with 44×44px minimum touch targets. Menu closes on route change, Escape, and outside click. The nav search bar (from T01) repositions inside the mobile panel on small screens. This task delivers R021 (Mobile Hamburger Menu). diff --git a/.gsd/milestones/M011/slices/S03/tasks/T01-VERIFY.json b/.gsd/milestones/M011/slices/S03/tasks/T01-VERIFY.json new file mode 100644 index 0000000..70bcc2e --- /dev/null +++ b/.gsd/milestones/M011/slices/S03/tasks/T01-VERIFY.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M011/S03/T01", + "timestamp": 1774946535287, + "passed": true, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd frontend", + "exitCode": 0, + "durationMs": 6, + "verdict": "pass" + } + ] +} diff --git a/.gsd/milestones/M011/slices/S03/tasks/T02-SUMMARY.md b/.gsd/milestones/M011/slices/S03/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..4700f5c --- /dev/null +++ b/.gsd/milestones/M011/slices/S03/tasks/T02-SUMMARY.md @@ -0,0 +1,79 @@ +--- +id: T02 +parent: S03 +milestone: M011 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/App.tsx", "frontend/src/App.css"] +key_decisions: ["Hamburger icon toggles to X via conditional SVG rendering", "Mobile nav search duplicated inside menu panel rather than CSS-repositioned", "AdminDropdown restyled to full-width static submenu in mobile panel"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "Build succeeds (51 modules, 858ms). Browser tested at 390×844: hamburger visible, menu opens/closes correctly, all three auto-close mechanisms work, desktop hides hamburger, home page omits search correctly." +completed_at: 2026-03-31T08:45:23.843Z +blocker_discovered: false +--- + +# T02: Added mobile hamburger menu with 44px touch targets, Escape/outside-click/route-change auto-close + +> Added mobile hamburger menu with 44px touch targets, Escape/outside-click/route-change auto-close + +## What Happened +--- +id: T02 +parent: S03 +milestone: M011 +key_files: + - frontend/src/App.tsx + - frontend/src/App.css +key_decisions: + - Hamburger icon toggles to X via conditional SVG rendering + - Mobile nav search duplicated inside menu panel rather than CSS-repositioned + - AdminDropdown restyled to full-width static submenu in mobile panel +duration: "" +verification_result: passed +completed_at: 2026-03-31T08:45:23.843Z +blocker_discovered: false +--- + +# T02: Added mobile hamburger menu with 44px touch targets, Escape/outside-click/route-change auto-close + +**Added mobile hamburger menu with 44px touch targets, Escape/outside-click/route-change auto-close** + +## What Happened + +Added menuOpen state with three auto-close mechanisms (route change, Escape key, outside click) to App.tsx. Hamburger button uses conditional SVG (three-line ↔ X). At <768px breakpoint, nav collapses to absolute-positioned column panel with max-height transition. All nav links get min-height: 44px touch targets. Mobile menu includes duplicated SearchAutocomplete and full-width AdminDropdown. Desktop layout unchanged. + +## Verification + +Build succeeds (51 modules, 858ms). Browser tested at 390×844: hamburger visible, menu opens/closes correctly, all three auto-close mechanisms work, desktop hides hamburger, home page omits search correctly. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npm run build 2>&1 | tail -15` | 0 | ✅ pass | 3000ms | +| 2 | `Browser: hamburger visible at 390x844, menu toggles, auto-close on route/Escape/outside-click` | 0 | ✅ pass | 0ms | + + +## Deviations + +Search bar rendered as second SearchAutocomplete instance inside mobile menu rather than CSS-repositioned from header. GlobalShortcut omitted on mobile duplicate to avoid double Cmd+K registration. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/App.tsx` +- `frontend/src/App.css` + + +## Deviations +Search bar rendered as second SearchAutocomplete instance inside mobile menu rather than CSS-repositioned from header. GlobalShortcut omitted on mobile duplicate to avoid double Cmd+K registration. + +## Known Issues +None. diff --git a/frontend/src/App.css b/frontend/src/App.css index 6b2add8..4f16779 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -742,6 +742,124 @@ a.app-footer__repo:hover { color: var(--color-error); } +/* ── Hamburger button ─────────────────────────────────────────────────────── */ + +.hamburger-btn { + display: none; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + border: none; + background: none; + color: var(--color-text-on-header); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: color 0.15s; +} + +.hamburger-btn:hover { + color: var(--color-text-on-header-hover); +} + +/* ── Mobile nav search inside hamburger menu ──────────────────────────────── */ + +.mobile-nav-search { + display: none; +} + +/* ── Mobile breakpoint (768px) ────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .hamburger-btn { + display: flex; + } + + .app-header { + flex-wrap: wrap; + position: relative; + } + + /* Hide the nav search bar in header on mobile — it goes inside the menu */ + .app-header > .search-container--nav { + display: none; + } + + .app-header__right { + /* Keep the hamburger button visible; nav collapses */ + order: 0; + } + + .app-nav { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + flex-direction: column; + align-items: stretch; + background: var(--color-bg-header); + border-top: 1px solid var(--color-border); + box-shadow: 0 8px 24px var(--color-shadow-heavy); + z-index: 200; + overflow: hidden; + max-height: 0; + opacity: 0; + transition: max-height 0.25s ease, opacity 0.2s ease; + } + + .app-nav--open { + display: flex; + max-height: 24rem; + opacity: 1; + } + + .app-nav a, + .app-nav .admin-dropdown__trigger { + min-height: 44px; + display: flex; + align-items: center; + padding: 0.75rem 1.5rem; + font-size: 1rem; + border-bottom: 1px solid var(--color-border); + } + + .app-nav .admin-dropdown { + width: 100%; + } + + .app-nav .admin-dropdown__trigger { + width: 100%; + text-align: left; + } + + .app-nav .admin-dropdown__menu { + position: static; + box-shadow: none; + border: none; + border-radius: 0; + background: var(--color-bg-header-alt); + } + + .app-nav .admin-dropdown__item { + padding-left: 2.5rem; + min-height: 44px; + display: flex; + align-items: center; + } + + .mobile-nav-search { + display: block; + padding: 0.75rem 1.5rem; + border-top: 1px solid var(--color-border); + } + + .mobile-nav-search .search-container--nav { + max-width: 100%; + } +} + /* ── Responsive ───────────────────────────────────────────────────────────── */ @media (max-width: 640px) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cf74b5b..98955df 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect, useRef, useCallback } from "react"; import { Link, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import Home from "./pages/Home"; import SearchResults from "./pages/SearchResults"; @@ -17,10 +18,47 @@ export default function App() { const location = useLocation(); const navigate = useNavigate(); const showNavSearch = location.pathname !== "/"; + const [menuOpen, setMenuOpen] = useState(false); + const headerRef = useRef(null); + + // Close menu on route change + useEffect(() => { + setMenuOpen(false); + }, [location.pathname]); + + // Close menu on Escape + useEffect(() => { + if (!menuOpen) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setMenuOpen(false); + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [menuOpen]); + + // Close menu on outside click + const handleOutsideClick = useCallback( + (e: MouseEvent) => { + if ( + menuOpen && + headerRef.current && + !headerRef.current.contains(e.target as Node) + ) { + setMenuOpen(false); + } + }, + [menuOpen], + ); + + useEffect(() => { + if (!menuOpen) return; + document.addEventListener("mousedown", handleOutsideClick); + return () => document.removeEventListener("mousedown", handleOutsideClick); + }, [menuOpen, handleOutsideClick]); return (
-
+

Chrysopedia

@@ -33,11 +71,42 @@ export default function App() { /> )}
-