feat(header): hamburger dropdown nav on mobile (owner permission)
TODO.md L9 (owner permission 2026-08-27, roadmap A5): "The navbar on
mobile is way too squished. Make it a hamburger dropdown menu with a
nice animation." At <=640px the nav links leave the bar — a 44px
#nav-toggle opens #app-nav as an animated (180ms slide+fade)
edge-to-edge dropdown with comfortable rows and the auth visibility
contract intact inside the menu; at >640px the bar is byte-identical
to pre-phase-46 (hamburger absent, inline pills as before).
- frontend/*.html (all six pages): the shared bar gains the
#nav-toggle button (type=button, aria-expanded=false,
aria-controls="app-nav", aria-label="Menu", aria-hidden 3-line
SVG icon) immediately before the nav, and the nav gains
id="app-nav" — one <nav>, no duplicated links, so the whoami reveal
works inside the menu unchanged (phase-34 same-bar contract intact).
- frontend/assets/styles.css: .nav-toggle is display:none outside media
queries (desktop untouched); the <=640px block adds the 44px toggle
(+hover in the .steering-toggle:hover family, sized 20px icon), turns
.app-nav into the dropdown (absolute top:100% edge-to-edge under the
sticky header, surface + hairline + --shadow-lg, z-index 21 =
header+1, closed state invisible + non-interactive with the 180ms
opacity/transform/visibility-delayed pair, .is-open the only
opener), and comfortable 1rem/0.75rem menu rows — superseding the
phase-34/35 pill-squeeze rules for .nav-link/.app-nav (the 900px
tablet block, action pills, and 58px bar height untouched). The
reduced-motion block stills BOTH the closed and .is-open states: the
.is-open rule (0,2,0) out-specifies a bare .app-nav (0,1,0), so the
override must name both — verified live in Chromium (task 03).
- frontend/assets/header.js: ONE module-owned binding (import-time,
null-safe like the sign-out binding): click toggles .is-open +
aria-expanded in sync, a delegated nav-link click closes, Esc closes
and refocuses the toggle, and matchMedia("(max-width: 640px)")
change drops the state on resize back to desktop. The binding
touches only the container — ship-hidden whoami links stay hidden.
- tests/unit/test_hamburger_nav.py (new): the markup/CSS/JS contract
pins (six identical toggles in the shared row, desktop byte-
identical, dropdown + .is-open + 180ms + reduced-motion rules, the
superseded squeeze rules gone, the one-binding behavior).
- tests/e2e/test_shared_header.py: assert_shared_bar gains mobile=True
(at <=640px the bar shows the hamburger + the closed nav; the
per-role menu contents are pinned by the story suite).
- tests/e2e/test_mobile_hamburger_nav.py (new, story suite, 375x812):
toggle is a visible >=44px target, menu closed (opacity 0 /
visibility hidden), no horizontal overflow; anonymous menu shows
exactly "Chat" (admin-only links stay hidden inside); admin menu
shows all four links (whoami reveal inside the menu); a link click
navigates + the arrival page ships closed; Esc closes and refocuses
the toggle (outside click does NOT close — accepted: the locked
close set is Esc + link + resize, no backdrop); the 180ms
opacity/transform pair is live and reducedMotion:reduce stills both
states with open/close still working; 1280x800 regression — toggle
display:none, all four inline links inside the header band.
Gates: unit+integration 773 passed; app/ coverage TOTAL 99%
(unchanged — frontend-only phase); story E2E 7 passed in isolation
(mock LLM, DB up); regression suites test_nav_consistency (6) /
test_header_consistency (3) / test_shared_header (6) /
test_responsive_polish (7) / test_tuning_nav_link (4) all pass in
isolation; ruff check + pyright clean. A11 honored: no CDN, no new
assets.
Also records the 46_mobile_hamburger_nav todo/ -> complete/ move.
This commit is contained in:
@@ -21,6 +21,18 @@
|
|||||||
* contract on the SAME cached whoami (one fetch, no extra request);
|
* contract on the SAME cached whoami (one fetch, no extra request);
|
||||||
* • the sign-out click binding (POST /api/logout → reload) — moved
|
* • the sign-out click binding (POST /api/logout → reload) — moved
|
||||||
* here from app.js so there is exactly one implementation;
|
* here from app.js so there is exactly one implementation;
|
||||||
|
* • the mobile hamburger binding (phase 46, owner permission
|
||||||
|
* 2026-08-27, TODO.md L9) — at ≤640px (CSS hides the button
|
||||||
|
* elsewhere) the #nav-toggle button opens the nav as an animated
|
||||||
|
* dropdown (#app-nav .is-open — the 180ms slide+fade state from
|
||||||
|
* task 01's CSS): a click toggles it with aria-expanded kept in
|
||||||
|
* sync, a nav link click shuts it (the navigation happens anyway),
|
||||||
|
* Esc shuts it and returns focus to the toggle, and resizing back
|
||||||
|
* to >640px drops the open state (matchMedia change) so
|
||||||
|
* aria-expanded stays honest. One binding for all six pages; a
|
||||||
|
* page without either element is a no-op. The binding toggles
|
||||||
|
* ONLY the container — the nav links keep their ship-hidden
|
||||||
|
* whoami contract (hidden links stay hidden inside the menu);
|
||||||
* • the steering-notes controls (phase 15, moved here from app.js in
|
* • the steering-notes controls (phase 15, moved here from app.js in
|
||||||
* phase 34) — the #steering-toggle open/close + the #steering-panel
|
* phase 34) — the #steering-toggle open/close + the #steering-panel
|
||||||
* list (newest-first, textContent-rendered, per-note delete, count
|
* list (newest-first, textContent-rendered, per-note delete, count
|
||||||
@@ -181,6 +193,46 @@ if (signOutBtn) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- mobile hamburger (phase 46; module-owned) ----------
|
||||||
|
* ≤640px only (CSS hides the button elsewhere): #nav-toggle opens the
|
||||||
|
* nav as a dropdown (#app-nav .is-open — the animated state, task 01
|
||||||
|
* CSS). One binding for all six pages; a page without either element
|
||||||
|
* is a no-op, like the rest of this module. The nav LINKS keep their
|
||||||
|
* ship-hidden whoami contract (hidden links stay hidden inside the
|
||||||
|
* menu) — this binding only toggles the container. */
|
||||||
|
const navToggle = document.querySelector("#nav-toggle");
|
||||||
|
const appNav = document.querySelector("#app-nav");
|
||||||
|
|
||||||
|
function setNavMenu(open) {
|
||||||
|
if (!appNav || !navToggle) return;
|
||||||
|
appNav.classList.toggle("is-open", open);
|
||||||
|
navToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navToggle && appNav) {
|
||||||
|
navToggle.addEventListener("click", () =>
|
||||||
|
setNavMenu(!appNav.classList.contains("is-open")));
|
||||||
|
// A link click navigates (or closes same-page) — shut the menu.
|
||||||
|
appNav.addEventListener("click", (e) => {
|
||||||
|
if (e.target.closest("a")) setNavMenu(false);
|
||||||
|
});
|
||||||
|
// Esc closes while open (document-level; the sync failure modal's
|
||||||
|
// Esc acts only while IT is open — the two never fight for a key).
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && appNav.classList.contains("is-open")) {
|
||||||
|
setNavMenu(false);
|
||||||
|
navToggle.focus(); // focus returns to the opener
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Resize back to desktop: the inline nav reappears — no stale open
|
||||||
|
// state (the .is-open class is scoped by the ≤640px CSS anyway, but
|
||||||
|
// dropping it keeps aria-expanded honest).
|
||||||
|
const mq = window.matchMedia("(max-width: 640px)");
|
||||||
|
const onMqChange = () => { if (!mq.matches) setNavMenu(false); };
|
||||||
|
if (mq.addEventListener) mq.addEventListener("change", onMqChange);
|
||||||
|
else mq.addListener(onMqChange); // older engines, defensive
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- steering notes (phase 15; module-owned from phase 34) ----------
|
/* ---------- steering notes (phase 15; module-owned from phase 34) ----------
|
||||||
*
|
*
|
||||||
* The owner's tuning notes steer every future answer: they live in
|
* The owner's tuning notes steer every future answer: they live in
|
||||||
|
|||||||
@@ -274,6 +274,14 @@ html::after {
|
|||||||
.nav-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
.nav-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||||
.nav-link.is-active { background: var(--brand); color: var(--bg); }
|
.nav-link.is-active { background: var(--brand); color: var(--bg); }
|
||||||
|
|
||||||
|
/* Phase 46 (owner permission 2026-08-27, TODO.md L9): the mobile
|
||||||
|
hamburger button — desktop is byte-identical to before (the control
|
||||||
|
is absent outside the ≤640px block, which re-displays it and turns
|
||||||
|
the nav into the dropdown). :focus-visible inherits the global 3px
|
||||||
|
outline rule; the 44px target + the rest of the look live in the
|
||||||
|
≤640px block below. */
|
||||||
|
.nav-toggle { display: none; }
|
||||||
|
|
||||||
/* "New chat" reset (phase 14): ghost pill in the chat header, hover like
|
/* "New chat" reset (phase 14): ghost pill in the chat header, hover like
|
||||||
a nav link. ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft
|
a nav link. ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft
|
||||||
≈6.9:1 — both WCAG AA. Icon-only below 640px (aria-label keeps the
|
≈6.9:1 — both WCAG AA. Icon-only below 640px (aria-label keeps the
|
||||||
@@ -2163,8 +2171,66 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.nav-link { padding: 0.3rem 0.25rem; font-size: 0.72rem; }
|
/* Phase 46 (owner permission 2026-08-27, TODO.md L9): the nav links
|
||||||
.app-nav { gap: 0.05rem; }
|
LEAVE the bar at phone widths — the old pill-squeeze rules for
|
||||||
|
.nav-link / .app-nav (0.72rem pills, 0.05rem gap, in place of this
|
||||||
|
comment) are superseded by the #nav-toggle dropdown below. The
|
||||||
|
action pills' squeeze rules further down are untouched, and the
|
||||||
|
900px tablet block keeps squeezing the INLINE nav at 641–900px
|
||||||
|
(the hamburger is absent there). */
|
||||||
|
.nav-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.nav-toggle:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||||
|
/* The icon is sized (an unsized inline SVG would default to 300px
|
||||||
|
and blow the bar out); 20px reads as a proper hamburger inside
|
||||||
|
the 44px target. */
|
||||||
|
.nav-toggle svg { width: 20px; height: 20px; display: block; }
|
||||||
|
/* The nav becomes the dropdown. The containing block is the sticky
|
||||||
|
.app-header (.header-inner is not positioned), so the menu spans
|
||||||
|
the header's full width — edge to edge — intended on mobile;
|
||||||
|
z-index 21 = header (20) + 1, above the bar content. */
|
||||||
|
.app-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
z-index: 21;
|
||||||
|
/* Closed state (default): invisible and non-interactive — task 02's
|
||||||
|
header.js is the only opener (.is-open + aria-expanded). */
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 180ms ease, transform 180ms ease, visibility 0s linear 180ms;
|
||||||
|
}
|
||||||
|
.app-nav.is-open {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: opacity 180ms ease, transform 180ms ease, visibility 0s;
|
||||||
|
}
|
||||||
|
/* Menu rows: comfortable ≥44px targets (0.75rem × 2 + the 1rem line)
|
||||||
|
and readable text — replaces the old .nav-link pill squeeze. */
|
||||||
|
.app-nav .nav-link { padding: 0.75rem 1.25rem; font-size: 1rem; }
|
||||||
.new-chat-btn { padding: 0.4rem 0.3rem; }
|
.new-chat-btn { padding: 0.4rem 0.3rem; }
|
||||||
.new-chat-label { display: none; }
|
.new-chat-label { display: none; }
|
||||||
.new-chat-btn svg { display: block; }
|
.new-chat-btn svg { display: block; }
|
||||||
@@ -2248,3 +2314,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phase 46: prefers-reduced-motion stills the mobile menu — no
|
||||||
|
180ms slide+fade; open/close snaps (the visibility/opacity flip
|
||||||
|
applies instantly) and stays correct. BOTH states are named: the
|
||||||
|
.is-open rule (0,2,0) would otherwise out-specify a bare .app-nav
|
||||||
|
(0,1,0) and the OPEN transition would still animate. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-nav,
|
||||||
|
.app-nav.is-open { transition: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,14 @@
|
|||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<nav class="app-nav" aria-label="Primary">
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
|
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||||
|
an animated dropdown. Behavior: assets/header.js. -->
|
||||||
|
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||||
|
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||||
<a href="/" class="nav-link">Chat</a>
|
<a href="/" class="nav-link">Chat</a>
|
||||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Sources link is admin-only (owner
|
2026-08-26): the Sources link is admin-only (owner
|
||||||
|
|||||||
@@ -24,7 +24,14 @@
|
|||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<nav class="app-nav" aria-label="Primary">
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
|
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||||
|
an animated dropdown. Behavior: assets/header.js. -->
|
||||||
|
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||||
|
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||||
<a href="/" class="nav-link">Chat</a>
|
<a href="/" class="nav-link">Chat</a>
|
||||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Sources link is admin-only (owner
|
2026-08-26): the Sources link is admin-only (owner
|
||||||
|
|||||||
+8
-1
@@ -17,7 +17,14 @@
|
|||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<nav class="app-nav" aria-label="Primary">
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
|
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||||
|
an animated dropdown. Behavior: assets/header.js. -->
|
||||||
|
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||||
|
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||||
<a href="/" class="nav-link is-active" aria-current="page">Chat</a>
|
<a href="/" class="nav-link is-active" aria-current="page">Chat</a>
|
||||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Sources link is admin-only (owner
|
2026-08-26): the Sources link is admin-only (owner
|
||||||
|
|||||||
+8
-1
@@ -18,7 +18,14 @@
|
|||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<nav class="app-nav" aria-label="Primary">
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
|
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||||
|
an animated dropdown. Behavior: assets/header.js. -->
|
||||||
|
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||||
|
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||||
<a href="/" class="nav-link">Chat</a>
|
<a href="/" class="nav-link">Chat</a>
|
||||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Sources link is admin-only (owner
|
2026-08-26): the Sources link is admin-only (owner
|
||||||
|
|||||||
@@ -17,7 +17,14 @@
|
|||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<nav class="app-nav" aria-label="Primary">
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
|
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||||
|
an animated dropdown. Behavior: assets/header.js. -->
|
||||||
|
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||||
|
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||||
<a href="/" class="nav-link">Chat</a>
|
<a href="/" class="nav-link">Chat</a>
|
||||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Sources link is admin-only (owner
|
2026-08-26): the Sources link is admin-only (owner
|
||||||
|
|||||||
@@ -17,7 +17,14 @@
|
|||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<nav class="app-nav" aria-label="Primary">
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
|
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||||
|
an animated dropdown. Behavior: assets/header.js. -->
|
||||||
|
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||||
|
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||||
<a href="/" class="nav-link">Chat</a>
|
<a href="/" class="nav-link">Chat</a>
|
||||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Sources link is admin-only (owner
|
2026-08-26): the Sources link is admin-only (owner
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"""Phase 46 E2E (Playwright): the mobile hamburger dropdown nav.
|
||||||
|
|
||||||
|
Story: ``.agent/user_stories/mobile-hamburger-nav.md``
|
||||||
|
TODO.md L9 (owner permission 2026-08-27): "The navbar on mobile is way
|
||||||
|
too squished. Make it a hamburger dropdown menu with a nice animation."
|
||||||
|
|
||||||
|
Run in isolation (mock LLM; DB up: ``podman compose up -d db``):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov
|
||||||
|
|
||||||
|
Contract under test: at ≤640px the nav links LEAVE the bar — a 44px
|
||||||
|
``#nav-toggle`` hamburger opens ``#app-nav`` as an animated (180ms
|
||||||
|
slide+fade) edge-to-edge dropdown with comfortable rows, the auth
|
||||||
|
visibility contract intact INSIDE the menu; at >640px the bar is
|
||||||
|
byte-identical to pre-phase-46 (hamburger absent, inline pills). No
|
||||||
|
document is ever needed — the suite exercises the shared header only.
|
||||||
|
|
||||||
|
The conftest ``page`` fixture is 1280×800, so the mobile tests create
|
||||||
|
fresh 375×812 pages via the session ``browser`` fixture (one page per
|
||||||
|
test; the reduced-motion test gets its own context).
|
||||||
|
|
||||||
|
Test → story mapping (Playwright Mapping Rule):
|
||||||
|
|
||||||
|
1. ``test_mobile_hamburger_visible_and_bar_roomy`` — 375px: the toggle
|
||||||
|
is a ≥44px visible button (``aria-expanded="false"``, closed), the
|
||||||
|
inline nav links are not visible in the bar (the closed dropdown is
|
||||||
|
opacity 0 + visibility hidden), and the page does not overflow
|
||||||
|
horizontally.
|
||||||
|
2. ``test_anonymous_menu_contents`` — anonymous at 375px: the menu
|
||||||
|
shows EXACTLY one visible link ("Chat"); the three admin-only links
|
||||||
|
stay ``hidden`` inside the menu; the open flips ``aria-expanded``.
|
||||||
|
3. ``test_admin_menu_contents`` — admin at 375px: the menu shows all
|
||||||
|
four links (the whoami reveal works inside the menu).
|
||||||
|
4. ``test_link_click_navigates_and_closes`` — admin at 375px: clicking
|
||||||
|
"Sources" navigates to /sources.html and the menu on the arrival
|
||||||
|
page ships closed.
|
||||||
|
5. ``test_esc_and_outside_close`` — Esc closes AND returns focus to the
|
||||||
|
toggle; an outside click does NOT close (accepted — see the test
|
||||||
|
docstring for why).
|
||||||
|
6. ``test_animation_and_reduced_motion`` — motion allowed: the
|
||||||
|
180ms opacity/transform transition pair is live and the open flips
|
||||||
|
class + aria; ``reducedMotion: "reduce"``: no transition in EITHER
|
||||||
|
state (the .is-open state included — the specificity trap) and
|
||||||
|
open/close still works.
|
||||||
|
7. ``test_desktop_unchanged`` — 1280×800 regression: the hamburger is
|
||||||
|
``display: none`` and the inline nav renders in the bar exactly as
|
||||||
|
before (admin: all four links, all inside the header band).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from playwright.sync_api import Browser, Page, ViewportSize, expect
|
||||||
|
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
|
||||||
|
MOBILE: ViewportSize = {"width": 375, "height": 812} # the story's phone viewport
|
||||||
|
DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size
|
||||||
|
|
||||||
|
NAV_LINKS = ("#app-nav a[href='/']", "#nav-sources", "#nav-git-sources", "#nav-tuning")
|
||||||
|
LINK_TEXTS = ("Chat", "Sources", "Git sources", "Tuning")
|
||||||
|
|
||||||
|
|
||||||
|
def _mobile_page(browser: Browser) -> Page:
|
||||||
|
"""A fresh 375×812 page (the conftest ``page`` is 1280×800)."""
|
||||||
|
return browser.new_page(viewport=MOBILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_settled_anonymous(page: Page) -> None:
|
||||||
|
"""Wait until whoami has resolved for the anonymous visitor (Sign in
|
||||||
|
visible — the phase-16 settled state the header pins)."""
|
||||||
|
expect(page.locator("#sign-in-link")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_settled_admin(page: Page) -> None:
|
||||||
|
"""Wait until whoami has resolved for the admin (Sign out visible)
|
||||||
|
AND the whoami reveal has un-hidden the admin-only nav links (the
|
||||||
|
menu-contents assertions must run on a settled auth state)."""
|
||||||
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=10_000)
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
|
||||||
|
timeout=10_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_nav_links(page: Page) -> list[str]:
|
||||||
|
"""The texts of the nav links that are actually visible (Playwright
|
||||||
|
visibility: non-empty box AND not visibility:hidden/display:none —
|
||||||
|
so the closed dropdown and the ``hidden`` admin links both count as
|
||||||
|
invisible)."""
|
||||||
|
return [
|
||||||
|
page.locator(sel).inner_text()
|
||||||
|
for sel in NAV_LINKS
|
||||||
|
if page.locator(sel).is_visible()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _open_menu(page: Page) -> None:
|
||||||
|
page.click("#nav-toggle")
|
||||||
|
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
|
||||||
|
# to_have_class(string) is an EXACT match on the class attribute —
|
||||||
|
# the nav is "app-nav is-open", so match the token with a regex.
|
||||||
|
expect(page.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
|
||||||
|
expect(page.locator("#app-nav")).to_have_css("opacity", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_menu_closed(page: Page) -> None:
|
||||||
|
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
|
||||||
|
assert "is-open" not in (page.locator("#app-nav").get_attribute("class") or ""), (
|
||||||
|
"the closed menu must not carry the .is-open state"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Bar: the toggle is a roomy 44px target and the nav is out of the bar
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_hamburger_visible_and_bar_roomy(
|
||||||
|
browser: Browser, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""AC1/AC5: at 375px the bar carries a ≥44px ``#nav-toggle`` (closed,
|
||||||
|
``aria-expanded="false"``), the inline nav links are NOT visible in
|
||||||
|
the bar (the closed dropdown is opacity 0 + visibility hidden — the
|
||||||
|
layout box is top:100% less the 8px slide offset, so the story's
|
||||||
|
opacity-0 branch is what pins it), and there is no horizontal
|
||||||
|
overflow — the old squished pills are gone, so the bar has room."""
|
||||||
|
page = _mobile_page(browser)
|
||||||
|
try:
|
||||||
|
page.goto(app_url)
|
||||||
|
_wait_settled_anonymous(page)
|
||||||
|
|
||||||
|
# The hamburger: a visible ≥44px×44px touch target (the phase-07
|
||||||
|
# floor), shipped closed.
|
||||||
|
toggle = page.locator("#nav-toggle")
|
||||||
|
expect(toggle).to_be_visible()
|
||||||
|
box = toggle.bounding_box()
|
||||||
|
assert box is not None, "the toggle must have a box"
|
||||||
|
assert box["width"] >= 44 and box["height"] >= 44, (
|
||||||
|
f"the toggle must be a ≥44px touch target, got "
|
||||||
|
f"{box['width']:.0f}×{box['height']:.0f}"
|
||||||
|
)
|
||||||
|
expect(toggle).to_have_attribute("aria-expanded", "false")
|
||||||
|
|
||||||
|
# The inline nav links are not visible in the bar: the closed
|
||||||
|
# dropdown is opacity 0 + visibility hidden. (The layout box is
|
||||||
|
# top:100% minus the 8px slide offset — inside the band — but
|
||||||
|
# invisible, which is the acceptance branch: opacity 0.)
|
||||||
|
assert page.evaluate(
|
||||||
|
"() => getComputedStyle(document.querySelector('#app-nav')).opacity"
|
||||||
|
) == "0", "the closed menu must be opacity 0"
|
||||||
|
assert page.evaluate(
|
||||||
|
"() => getComputedStyle(document.querySelector('#app-nav')).visibility"
|
||||||
|
) == "hidden", "the closed menu must be visibility hidden"
|
||||||
|
for sel in NAV_LINKS:
|
||||||
|
assert not page.locator(sel).is_visible(), (
|
||||||
|
f"{sel} must not be visible in the bar with the menu closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No horizontal overflow at 375px (the old four squished text
|
||||||
|
# pills are gone from the bar).
|
||||||
|
assert page.evaluate(
|
||||||
|
"() => document.documentElement.scrollWidth"
|
||||||
|
) <= page.evaluate("() => window.innerWidth"), (
|
||||||
|
"the 375px bar must not overflow horizontally"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Menu contents per auth state (the whoami contract inside the menu)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_menu_contents(
|
||||||
|
browser: Browser, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""AC2 (anonymous): at 375px the opened menu shows EXACTLY one
|
||||||
|
visible link — "Chat". The three admin-only links keep their
|
||||||
|
ship-hidden state INSIDE the menu (the phase-19/35 contract is
|
||||||
|
preserved by reusing the same <nav> element); opening flips
|
||||||
|
aria-expanded true."""
|
||||||
|
page = _mobile_page(browser)
|
||||||
|
try:
|
||||||
|
page.goto(app_url)
|
||||||
|
_wait_settled_anonymous(page)
|
||||||
|
_assert_menu_closed(page)
|
||||||
|
|
||||||
|
_open_menu(page)
|
||||||
|
assert _visible_nav_links(page) == ["Chat"], (
|
||||||
|
"anonymous: the menu must show exactly one visible link (Chat)"
|
||||||
|
)
|
||||||
|
for sel in ("#nav-sources", "#nav-git-sources", "#nav-tuning"):
|
||||||
|
expect(page.locator(sel)).to_be_hidden()
|
||||||
|
|
||||||
|
# A second click closes it again — aria-expanded round-trips.
|
||||||
|
page.click("#nav-toggle")
|
||||||
|
_assert_menu_closed(page)
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_menu_contents(
|
||||||
|
browser: Browser, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""AC2 (admin): at 375px the opened menu shows ALL FOUR links —
|
||||||
|
Chat / Sources / Git sources / Tuning — i.e. the whoami reveal
|
||||||
|
works inside the menu exactly as it does inline (one <nav>, one
|
||||||
|
set of links, the same hidden attributes header.js drives)."""
|
||||||
|
page = _mobile_page(browser)
|
||||||
|
try:
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
_wait_settled_admin(page)
|
||||||
|
|
||||||
|
_open_menu(page)
|
||||||
|
assert _visible_nav_links(page) == list(LINK_TEXTS), (
|
||||||
|
f"admin: the menu must show all four links, got {_visible_nav_links(page)}"
|
||||||
|
)
|
||||||
|
for sel in NAV_LINKS:
|
||||||
|
expect(page.locator(sel)).to_be_visible()
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Link close + navigation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_click_navigates_and_closes(
|
||||||
|
browser: Browser, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""AC4: a menu link click navigates AND closes the menu — and the
|
||||||
|
arrival page ships the fresh (closed) header: aria-expanded false,
|
||||||
|
no .is-open, menu invisible."""
|
||||||
|
page = _mobile_page(browser)
|
||||||
|
try:
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
_wait_settled_admin(page)
|
||||||
|
|
||||||
|
_open_menu(page)
|
||||||
|
expect(page.locator("#nav-sources")).to_be_visible()
|
||||||
|
page.click("#nav-sources")
|
||||||
|
expect(page).to_have_url(app_url + "/sources.html", timeout=15_000)
|
||||||
|
|
||||||
|
# The arrival page: a fresh header, shipped closed.
|
||||||
|
_assert_menu_closed(page)
|
||||||
|
expect(page.locator("#app-nav")).to_be_hidden()
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Esc close (+ focus return) and the accepted outside-click behavior
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_esc_and_outside_close(
|
||||||
|
browser: Browser, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""AC4: Esc closes the menu AND returns focus to the toggle (the
|
||||||
|
opener — a keyboard user never loses their place).
|
||||||
|
|
||||||
|
Accepted behavior (NOT a defect, per the task-02 contract and story
|
||||||
|
AC 4): an OUTSIDE click does not close the menu. The locked close
|
||||||
|
set is Esc + link + resize — the story's AC 4 lists exactly those
|
||||||
|
three, and the owner-locked scope (2026-08-27) does not include a
|
||||||
|
backdrop click (there is no backdrop element at all — the menu is
|
||||||
|
the nav itself dropping out of the sticky header). This test pins
|
||||||
|
the menu STAYING OPEN on an outside click so an accidental
|
||||||
|
backdrop-close implementation cannot sneak in later."""
|
||||||
|
page = _mobile_page(browser)
|
||||||
|
try:
|
||||||
|
page.goto(app_url)
|
||||||
|
_wait_settled_anonymous(page)
|
||||||
|
|
||||||
|
# Esc closes + refocuses the opener.
|
||||||
|
_open_menu(page)
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
_assert_menu_closed(page)
|
||||||
|
assert page.evaluate("() => document.activeElement.id") == "nav-toggle", (
|
||||||
|
"Esc-close must return focus to the #nav-toggle opener"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Outside click: the menu STAYS open (accepted behavior — the
|
||||||
|
# locked close set is Esc + link + resize, not backdrop click).
|
||||||
|
_open_menu(page)
|
||||||
|
page.locator("footer span").first.click() # a neutral, non-link point
|
||||||
|
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
|
||||||
|
assert "is-open" in (page.locator("#app-nav").get_attribute("class") or ""), (
|
||||||
|
"accepted behavior: an outside click must NOT close the menu"
|
||||||
|
)
|
||||||
|
# …and the menu is still fully usable (Esc still settles it).
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
_assert_menu_closed(page)
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Animation + prefers-reduced-motion
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_animation_and_reduced_motion(
|
||||||
|
browser: Browser, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""AC3: motion allowed — the menu carries the 180ms opacity/transform
|
||||||
|
transition pair and opening flips the .is-open class + aria-expanded
|
||||||
|
in lockstep. reducedMotion: "reduce" — no transition in EITHER state
|
||||||
|
(the .is-open rule is higher-specificity than a bare .app-nav rule,
|
||||||
|
so the override must name both — pinned here against the live
|
||||||
|
computed style) and open/close still works, instantly."""
|
||||||
|
# Motion allowed: the 180ms slide+fade pair is live.
|
||||||
|
page = _mobile_page(browser)
|
||||||
|
try:
|
||||||
|
page.goto(app_url)
|
||||||
|
_wait_settled_anonymous(page)
|
||||||
|
report = page.evaluate(
|
||||||
|
"() => { const cs = getComputedStyle(document.querySelector('#app-nav'));"
|
||||||
|
" return { duration: cs.transitionDuration, property: cs.transitionProperty };"
|
||||||
|
" }"
|
||||||
|
)
|
||||||
|
assert "0.18s" in report["duration"], (
|
||||||
|
f"the menu must transition in 180ms, got {report['duration']!r}"
|
||||||
|
)
|
||||||
|
assert "opacity" in report["property"] and "transform" in report["property"], (
|
||||||
|
f"the transition must cover the opacity/transform slide+fade, "
|
||||||
|
f"got {report['property']!r}"
|
||||||
|
)
|
||||||
|
# Opening flips class + aria together (the animated state).
|
||||||
|
_open_menu(page)
|
||||||
|
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
_assert_menu_closed(page)
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
# Reduced motion: stills in both states, open/close still works.
|
||||||
|
context = browser.new_context(
|
||||||
|
reduced_motion="reduce", viewport=MOBILE
|
||||||
|
)
|
||||||
|
rpage = context.new_page()
|
||||||
|
try:
|
||||||
|
rpage.goto(app_url)
|
||||||
|
_wait_settled_anonymous(rpage)
|
||||||
|
|
||||||
|
def _stilled(el: str) -> str:
|
||||||
|
return rpage.evaluate(
|
||||||
|
f"() => getComputedStyle(document.querySelector('{el}')).transitionDuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _stilled("#app-nav") == "0s", (
|
||||||
|
f"reduced motion: closed state must not transition, got {_stilled('#app-nav')!r}"
|
||||||
|
)
|
||||||
|
rpage.click("#nav-toggle")
|
||||||
|
expect(rpage.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
|
||||||
|
expect(rpage.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
|
||||||
|
assert _stilled("#app-nav") == "0s", (
|
||||||
|
"reduced motion: the .is-open state must not transition either "
|
||||||
|
"(the override must out-specificity .app-nav.is-open)"
|
||||||
|
)
|
||||||
|
rpage.keyboard.press("Escape")
|
||||||
|
_assert_menu_closed(rpage)
|
||||||
|
assert not rpage.locator("#app-nav").is_visible(), (
|
||||||
|
"reduced motion: the closed menu must be invisible"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
context.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Desktop regression: the bar is byte-identical to pre-phase-46
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_desktop_unchanged(
|
||||||
|
browser: Browser, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""AC6 (regression): at 1280×800 the hamburger is absent
|
||||||
|
(``display: none`` — outside the ≤640px block) and the inline nav
|
||||||
|
renders in the bar exactly as before — admin sees all four links,
|
||||||
|
every one of them INSIDE the header band (no dropdown at this
|
||||||
|
width; the phase-34/35 bar contract is intact)."""
|
||||||
|
page = browser.new_page(viewport=DESKTOP)
|
||||||
|
try:
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
_wait_settled_admin(page)
|
||||||
|
|
||||||
|
# The hamburger is absent on desktop (global display:none).
|
||||||
|
toggle = page.locator("#nav-toggle")
|
||||||
|
expect(toggle).to_be_hidden()
|
||||||
|
assert page.evaluate(
|
||||||
|
"() => getComputedStyle(document.querySelector('#nav-toggle')).display"
|
||||||
|
) == "none", "the toggle must be display:none outside the ≤640px block"
|
||||||
|
|
||||||
|
# The inline nav: all four links visible, each box inside the
|
||||||
|
# header band (the dropdown positioning only applies ≤640px).
|
||||||
|
expect(page.locator("#app-nav")).to_be_visible()
|
||||||
|
header_box = page.locator("header.app-header").bounding_box()
|
||||||
|
assert header_box is not None
|
||||||
|
for sel in NAV_LINKS:
|
||||||
|
link = page.locator(sel)
|
||||||
|
expect(link).to_be_visible()
|
||||||
|
box = link.bounding_box()
|
||||||
|
assert box is not None
|
||||||
|
assert box["y"] >= header_box["y"] - 1, f"{sel}: link is above the header"
|
||||||
|
assert box["y"] + box["height"] <= header_box["y"] + header_box["height"] + 1, (
|
||||||
|
f"{sel}: the inline link must sit inside the header band"
|
||||||
|
)
|
||||||
|
assert _visible_nav_links(page) == list(LINK_TEXTS)
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
@@ -119,7 +119,7 @@ def _bar_selector(page_kind: str) -> str:
|
|||||||
return ".doc-header .app-header" if page_kind == "viewer" else ".app-header"
|
return ".doc-header .app-header" if page_kind == "viewer" else ".app-header"
|
||||||
|
|
||||||
|
|
||||||
def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
|
def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = False) -> None:
|
||||||
"""Assert the phase-19 shared-bar contract on the page the ``page``
|
"""Assert the phase-19 shared-bar contract on the page the ``page``
|
||||||
is already showing.
|
is already showing.
|
||||||
|
|
||||||
@@ -128,6 +128,13 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
|
|||||||
in the HTML, so "exactly one is visible" means /api/whoami resolved
|
in the HTML, so "exactly one is visible" means /api/whoami resolved
|
||||||
and header.js (``initSharedHeader``) did its toggle — before any
|
and header.js (``initSharedHeader``) did its toggle — before any
|
||||||
assertion runs.
|
assertion runs.
|
||||||
|
|
||||||
|
``mobile`` (phase 46, owner permission 2026-08-27, ``TODO.md`` L9):
|
||||||
|
at ≤640px the nav links no longer sit inline — the bar carries the
|
||||||
|
44px ``#nav-toggle`` hamburger and the nav ships as the CLOSED
|
||||||
|
(invisible) dropdown. Per-role link visibility INSIDE the menu is
|
||||||
|
pinned by ``test_mobile_hamburger_nav.py`` (phase 46, task 03); this
|
||||||
|
helper pins the bar-level contract only.
|
||||||
"""
|
"""
|
||||||
# Settled auth state: exactly one of Sign in / Sign out is visible
|
# Settled auth state: exactly one of Sign in / Sign out is visible
|
||||||
# (phase-16 semantics, now owned by the shared module).
|
# (phase-16 semantics, now owned by the shared module).
|
||||||
@@ -147,6 +154,16 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
|
|||||||
# hidden and are revealed for admin (phase-16 UX revision, owner
|
# hidden and are revealed for admin (phase-16 UX revision, owner
|
||||||
# permission 2026-08-23; the soft-gate page and the A10 API split
|
# permission 2026-08-23; the soft-gate page and the A10 API split
|
||||||
# are untouched).
|
# are untouched).
|
||||||
|
#
|
||||||
|
# Phase 46 (owner permission 2026-08-27, ``TODO.md`` L9): at ≤640px
|
||||||
|
# the links live in the #nav-toggle dropdown instead — the bar
|
||||||
|
# shows the hamburger and the nav is the closed (invisible +
|
||||||
|
# non-interactive) panel; the per-role link visibility inside the
|
||||||
|
# menu is pinned by test_mobile_hamburger_nav.py (phase 46 task 03).
|
||||||
|
if mobile:
|
||||||
|
expect(page.locator("#nav-toggle")).to_be_visible()
|
||||||
|
expect(page.locator("#app-nav")).to_be_hidden()
|
||||||
|
else:
|
||||||
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
|
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
|
||||||
for link_id in ("#nav-sources", "#nav-tuning"):
|
for link_id in ("#nav-sources", "#nav-tuning"):
|
||||||
nav = page.locator(link_id)
|
nav = page.locator(link_id)
|
||||||
@@ -342,6 +359,12 @@ def test_sign_out_from_viewer_returns_to_anonymous(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 6. Mobile (375×812): 58px bars, no horizontal overflow, in BOTH auth
|
# 6. Mobile (375×812): 58px bars, no horizontal overflow, in BOTH auth
|
||||||
# states — the new pills never grow the bar
|
# states — the new pills never grow the bar
|
||||||
|
#
|
||||||
|
# Phase 46 adaptation (owner permission 2026-08-27, ``TODO.md`` L9):
|
||||||
|
# at ≤640px the nav links leave the bar — the hamburger (#nav-toggle)
|
||||||
|
# is visible and the nav is the closed dropdown; the per-role link
|
||||||
|
# visibility inside the menu is pinned by
|
||||||
|
# test_mobile_hamburger_nav.py (phase 46, task 03).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -359,15 +382,16 @@ def test_mobile_bar_fits_and_heights_held(
|
|||||||
):
|
):
|
||||||
page.goto(app_url + path)
|
page.goto(app_url + path)
|
||||||
# 58px at 375px is asserted inside assert_shared_bar…
|
# 58px at 375px is asserted inside assert_shared_bar…
|
||||||
assert_shared_bar(page, admin=admin, page_kind=kind)
|
assert_shared_bar(page, admin=admin, page_kind=kind, mobile=True)
|
||||||
# …and the pills (icon-only at ≤640px) fit without overflow.
|
# …and the pills (icon-only at ≤640px) + the hamburger fit
|
||||||
|
# without overflow.
|
||||||
_assert_no_overflow(page, f"{kind} @375px (admin={admin})")
|
_assert_no_overflow(page, f"{kind} @375px (admin={admin})")
|
||||||
|
|
||||||
# Anonymous: the two icon pills are Sign in + New chat.
|
# Anonymous: the two icon pills are Sign in + New chat.
|
||||||
check_all(admin=False)
|
check_all(admin=False)
|
||||||
|
|
||||||
# Signed in: Sign out + the Sources nav link join the bars — and the
|
# Signed in: Sign out joins the bars (the admin-only nav links join
|
||||||
# bar never grows.
|
# the MENU, not the bar — phase 46) — and the bar never grows.
|
||||||
login(page, app_url, next="/")
|
login(page, app_url, next="/")
|
||||||
expect(page).to_have_url(app_url + "/")
|
expect(page).to_have_url(app_url + "/")
|
||||||
check_all(admin=True)
|
check_all(admin=True)
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
"""Unit: the mobile hamburger nav — markup + CSS contract (phase 46).
|
||||||
|
|
||||||
|
TODO.md L9 (owner permission 2026-08-27): "The navbar on mobile is way too
|
||||||
|
squished. Make it a hamburger dropdown menu with a nice animation."
|
||||||
|
|
||||||
|
This module pins the source-level contract for tasks 01 and 02:
|
||||||
|
|
||||||
|
* all SIX pages carry the identical ``#nav-toggle`` button (``aria-expanded``
|
||||||
|
/ ``aria-controls="app-nav"`` / ``aria-label="Menu"``), positioned inside
|
||||||
|
the shared ``.header-inner`` row immediately before the nav — and the nav
|
||||||
|
itself keeps the single ``<nav class="app-nav" id="app-nav">`` (no
|
||||||
|
duplicated links, so the whoami reveal keeps working unchanged);
|
||||||
|
* desktop is byte-identical to before: ``.nav-toggle { display: none }``
|
||||||
|
OUTSIDE any media query (the inline nav is untouched at >640px);
|
||||||
|
* the ≤640px block turns the nav into the animated dropdown: the 44px
|
||||||
|
toggle, the edge-to-edge absolute panel (containing block = the sticky
|
||||||
|
``.app-header``, z-index header+1), the closed state (invisible +
|
||||||
|
non-interactive), the ``.is-open`` state, the 180ms transition pair, the
|
||||||
|
comfortable menu rows, and the ``prefers-reduced-motion`` override;
|
||||||
|
* the superseded ≤640px nav-pill squeeze rules are gone (the 900px tablet
|
||||||
|
block still squeezes the inline nav at 641–900px, and the action pills'
|
||||||
|
squeeze rules + the 58px bar height are untouched);
|
||||||
|
* the header.js toggle behavior (task 02) is ONE module-owned binding —
|
||||||
|
the same import-time pattern as the sign-out/steering bindings: null-
|
||||||
|
safe lookups of ``#nav-toggle`` + ``#app-nav``, a ``setNavMenu`` that
|
||||||
|
syncs BOTH the ``.is-open`` class and ``aria-expanded``, a click
|
||||||
|
toggle, a delegated nav-link close, an Esc close that refocuses the
|
||||||
|
opener, and a matchMedia resize-back-to-desktop close; the binding
|
||||||
|
touches ONLY the container (the ship-hidden whoami links are left to
|
||||||
|
``initSharedHeader``).
|
||||||
|
|
||||||
|
The browser behavior (open/close, Esc, link-close, resize-close, the menu's
|
||||||
|
animated states) is E2E-covered by ``tests/e2e/test_mobile_hamburger_nav.py``
|
||||||
|
(task 03).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||||
|
ASSETS = FRONTEND / "assets"
|
||||||
|
STYLES_CSS = ASSETS / "styles.css"
|
||||||
|
|
||||||
|
#: The six pages of the app (phase 46: the shared bar contract extends to
|
||||||
|
#: the phase-35 git-sources page — the hamburger is part of that bar).
|
||||||
|
PAGES = (
|
||||||
|
FRONTEND / "index.html",
|
||||||
|
FRONTEND / "sources.html",
|
||||||
|
FRONTEND / "document.html",
|
||||||
|
FRONTEND / "git-sources.html",
|
||||||
|
FRONTEND / "login.html",
|
||||||
|
FRONTEND / "tuning.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
NAV_TAG = '<nav class="app-nav" id="app-nav" aria-label="Primary">'
|
||||||
|
|
||||||
|
|
||||||
|
def _text(path: Path) -> str:
|
||||||
|
assert path.is_file(), f"missing frontend file: {path}"
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _css() -> str:
|
||||||
|
"""styles.css with comments stripped (a comment may legally carry
|
||||||
|
braces — e.g. the [hidden] rule documents the UA snippet — so the
|
||||||
|
brace-matching helpers below must never see them)."""
|
||||||
|
return re.sub(r"/\*.*?\*/", "", _text(STYLES_CSS), flags=re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def _media_block(css: str, query: str) -> str:
|
||||||
|
"""The full text of the FIRST ``@media <query>`` block (brace-matched,
|
||||||
|
nested rules included verbatim)."""
|
||||||
|
m = re.search(re.escape(query) + r"[^{]*\{", css)
|
||||||
|
assert m, f"missing {query!r} media query in styles.css"
|
||||||
|
depth = 0
|
||||||
|
for i in range(m.end() - 1, len(css)):
|
||||||
|
if css[i] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif css[i] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return css[m.start() : i + 1]
|
||||||
|
raise AssertionError(f"unbalanced braces in {query!r} media block")
|
||||||
|
|
||||||
|
|
||||||
|
def _global_css(css: str) -> str:
|
||||||
|
"""The rules OUTSIDE any @media block (the desktop baseline), in file
|
||||||
|
order."""
|
||||||
|
out: list[str] = []
|
||||||
|
pos = 0
|
||||||
|
while True:
|
||||||
|
m = re.search(r"@media[^{]*\{", css[pos:])
|
||||||
|
if not m:
|
||||||
|
out.append(css[pos:])
|
||||||
|
break
|
||||||
|
start = pos + m.end() - 1 # the @media's own opening brace
|
||||||
|
depth = 0
|
||||||
|
i = start
|
||||||
|
while i < len(css):
|
||||||
|
if css[i] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif css[i] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
out.append(css[pos:start])
|
||||||
|
pos = i + 1
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_block(css: str, selector: str) -> str:
|
||||||
|
"""The first rule body for ``selector`` (e.g. ``.app-nav.is-open``)."""
|
||||||
|
m = re.search(re.escape(selector) + r"[^{}]*\{([^}]*)\}", css)
|
||||||
|
assert m, f"missing rule for {selector!r}"
|
||||||
|
return m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- markup: the identical toggle + labeled nav on all six pages ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_six_pages_carry_the_hamburger_toggle() -> None:
|
||||||
|
"""Every page carries the #nav-toggle button with the full aria
|
||||||
|
contract: a real button (type=button), aria-expanded defaulting to
|
||||||
|
"false", aria-controls pointing at the nav, the accessible name
|
||||||
|
"Menu", and the 3-line SVG icon (aria-hidden — the name comes from
|
||||||
|
aria-label). Exactly once per page (no duplicated control)."""
|
||||||
|
for html in PAGES:
|
||||||
|
text = _text(html)
|
||||||
|
tags = re.findall(r"<button[^>]*id=\"nav-toggle\"[^>]*>", text)
|
||||||
|
assert len(tags) == 1, f"{html.name}: exactly one #nav-toggle (found {len(tags)})"
|
||||||
|
tag = tags[0]
|
||||||
|
assert 'type="button"' in tag, f"{html.name}: the toggle must be a real button"
|
||||||
|
assert 'class="nav-toggle"' in tag, f"{html.name}: the toggle class is missing"
|
||||||
|
assert 'aria-expanded="false"' in tag, (
|
||||||
|
f"{html.name}: the toggle ships closed (aria-expanded=false)"
|
||||||
|
)
|
||||||
|
assert 'aria-controls="app-nav"' in tag, (
|
||||||
|
f"{html.name}: aria-controls must point at the nav's id"
|
||||||
|
)
|
||||||
|
assert 'aria-label="Menu"' in tag, f"{html.name}: the toggle is labeled 'Menu'"
|
||||||
|
# The button body is the aria-hidden 3-line hamburger icon.
|
||||||
|
end = text.find("</button>", text.find('id="nav-toggle"'))
|
||||||
|
body = text[text.find('id="nav-toggle"') : end]
|
||||||
|
assert 'aria-hidden="true"' in body, f"{html.name}: the icon is aria-hidden"
|
||||||
|
assert "M4 7h16M4 12h16M4 17h16" in body, (
|
||||||
|
f"{html.name}: the icon is the 3-line hamburger path"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_lives_in_the_shared_bar_right_before_the_nav() -> None:
|
||||||
|
"""The toggle is part of the shared bar block: inside the
|
||||||
|
.header-inner row, immediately before the <nav> — the same position
|
||||||
|
on every page (phase-34 identical-bar contract)."""
|
||||||
|
for html in PAGES:
|
||||||
|
text = _text(html)
|
||||||
|
inner = text.find('<div class="container header-inner">')
|
||||||
|
assert inner != -1, f"{html.name}: missing the shared .header-inner row"
|
||||||
|
toggle = text.find('id="nav-toggle"', inner)
|
||||||
|
nav = text.find(NAV_TAG, inner)
|
||||||
|
assert -1 < toggle < nav, (
|
||||||
|
f"{html.name}: the toggle must sit in the shared row, before the nav"
|
||||||
|
)
|
||||||
|
# The toggle is the only new element between the brand and the nav
|
||||||
|
# (no stray control broke the phase-34 order).
|
||||||
|
segment = text[inner:nav]
|
||||||
|
assert 'class="brand"' in segment, f"{html.name}: brand precedes the toggle"
|
||||||
|
assert 'id="steering-toggle"' not in segment, (
|
||||||
|
f"{html.name}: the toggle must not follow the action controls"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_six_pages_carry_the_labeled_nav_with_id() -> None:
|
||||||
|
"""The nav keeps its single element + label and gains ONLY the id
|
||||||
|
(the whoami reveal targets the same four links — no duplicated
|
||||||
|
markup, so the phase-19/35 visibility rules apply inside the menu
|
||||||
|
exactly as before)."""
|
||||||
|
for html in PAGES:
|
||||||
|
text = _text(html)
|
||||||
|
assert text.count(NAV_TAG) == 1, (
|
||||||
|
f"{html.name}: exactly one <nav class='app-nav' id='app-nav' "
|
||||||
|
"aria-label='Primary'> (no duplicated nav)"
|
||||||
|
)
|
||||||
|
# The four links, all still shipping inside that nav (the
|
||||||
|
# admin-only three keep their hidden attributes).
|
||||||
|
region = text[text.find(NAV_TAG) : text.find("</nav>")]
|
||||||
|
for link in ('href="/" class="nav-link', 'id="nav-sources" hidden',
|
||||||
|
'id="nav-git-sources" hidden', 'id="nav-tuning" hidden'):
|
||||||
|
assert link in region, f"{html.name}: nav lost {link!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- CSS: desktop byte-identical, mobile dropdown ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_is_absent_on_desktop_outside_media_queries() -> None:
|
||||||
|
"""Global (non-media) CSS hides the toggle — the desktop bar is
|
||||||
|
byte-identical to pre-phase-46 (the ≤640px block re-displays it)."""
|
||||||
|
css = _global_css(_css())
|
||||||
|
block = _rule_block(css, ".nav-toggle")
|
||||||
|
assert "display: none" in block, (
|
||||||
|
".nav-toggle must be display:none outside media queries (desktop)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_z_index_is_20_so_the_dropdown_uses_21() -> None:
|
||||||
|
"""The dropdown's z-index is the header's z-index + 1 — pinned as a
|
||||||
|
RELATIONSHIP (if the header z-index ever moves, the menu must move
|
||||||
|
with it)."""
|
||||||
|
css = _css()
|
||||||
|
header = _rule_block(_global_css(css), ".app-header")
|
||||||
|
m = re.search(r"z-index:\s*(\d+)", header)
|
||||||
|
assert m, "the .app-header must keep its z-index"
|
||||||
|
assert int(m.group(1)) + 1 == 21, "the dropdown is pinned at header+1 (21)"
|
||||||
|
mobile = _media_block(css, "@media (max-width: 640px)")
|
||||||
|
assert "z-index: 21" in _rule_block(mobile, ".app-nav")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_block_renders_the_44px_toggle() -> None:
|
||||||
|
"""At ≤640px the toggle is a 44px×44px button (the phase-07 touch
|
||||||
|
floor), ghost look like the other pills, a hover state in the
|
||||||
|
.steering-toggle:hover family, and a SIZED icon (an unsized inline
|
||||||
|
SVG defaults to 300px and would blow the bar out at 360px).
|
||||||
|
:focus-visible needs no rule — the global 3px outline applies."""
|
||||||
|
mobile = _media_block(_css(), "@media (max-width: 640px)")
|
||||||
|
block = _rule_block(mobile, ".nav-toggle")
|
||||||
|
for decl in (
|
||||||
|
"display: inline-flex",
|
||||||
|
"align-items: center",
|
||||||
|
"justify-content: center",
|
||||||
|
"width: 44px",
|
||||||
|
"height: 44px",
|
||||||
|
"border: 0",
|
||||||
|
"border-radius: var(--radius-sm)",
|
||||||
|
"cursor: pointer",
|
||||||
|
):
|
||||||
|
assert decl in block, f"mobile .nav-toggle missing {decl!r}"
|
||||||
|
assert "color: var(--ink)" in block, "the icon ink is the page ink"
|
||||||
|
hover = _rule_block(mobile, ".nav-toggle:hover")
|
||||||
|
assert "background: var(--brand-soft)" in hover
|
||||||
|
assert "color: var(--brand-ink)" in hover, (
|
||||||
|
"hover matches the .steering-toggle:hover family (≈6.9:1 pair)"
|
||||||
|
)
|
||||||
|
icon = _rule_block(mobile, ".nav-toggle svg")
|
||||||
|
assert "width: 20px" in icon and "height: 20px" in icon, (
|
||||||
|
"the hamburger icon must be sized (no 300px default)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_block_turns_the_nav_into_the_dropdown() -> None:
|
||||||
|
"""The closed (default) mobile nav is the invisible, non-interactive
|
||||||
|
dropdown panel: absolute edge-to-edge under the sticky .app-header
|
||||||
|
(the containing block — .header-inner is not positioned), the
|
||||||
|
surface background + hairline + existing shadow token, and the
|
||||||
|
slide+fade closed state with the 180ms transition pair."""
|
||||||
|
mobile = _media_block(_css(), "@media (max-width: 640px)")
|
||||||
|
block = _rule_block(mobile, ".app-nav")
|
||||||
|
for decl in (
|
||||||
|
"position: absolute",
|
||||||
|
"top: 100%",
|
||||||
|
"left: 0",
|
||||||
|
"right: 0",
|
||||||
|
"flex-direction: column",
|
||||||
|
"background: var(--surface)",
|
||||||
|
"border-bottom: 1px solid var(--line)",
|
||||||
|
"box-shadow: var(--shadow-lg)",
|
||||||
|
"z-index: 21",
|
||||||
|
# closed state — invisible and non-interactive
|
||||||
|
"visibility: hidden",
|
||||||
|
"opacity: 0",
|
||||||
|
"transform: translateY(-8px)",
|
||||||
|
"pointer-events: none",
|
||||||
|
# the 180ms slide+fade (visibility is the delayed snap)
|
||||||
|
"transition: opacity 180ms ease, transform 180ms ease, visibility 0s linear 180ms",
|
||||||
|
):
|
||||||
|
assert decl in block, f"mobile .app-nav dropdown missing {decl!r}"
|
||||||
|
assert "gap: 0" in block, "column menu rows stack with no pill gap"
|
||||||
|
assert "margin-left: 0" in block, (
|
||||||
|
"the desktop margin-left:auto must not shift the absolute panel"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_open_state_is_the_only_opener() -> None:
|
||||||
|
"""The .is-open state (added by task 02's header.js) flips every
|
||||||
|
closed-state property back, with the matching 180ms transition and
|
||||||
|
an UNDELAYED visibility snap (the menu can never linger visible but
|
||||||
|
dead while closing)."""
|
||||||
|
mobile = _media_block(_css(), "@media (max-width: 640px)")
|
||||||
|
block = _rule_block(mobile, ".app-nav.is-open")
|
||||||
|
for decl in (
|
||||||
|
"visibility: visible",
|
||||||
|
"opacity: 1",
|
||||||
|
"transform: none",
|
||||||
|
"pointer-events: auto",
|
||||||
|
"transition: opacity 180ms ease, transform 180ms ease, visibility 0s",
|
||||||
|
):
|
||||||
|
assert decl in block, f".app-nav.is-open missing {decl!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_menu_rows_are_comfortable_targets() -> None:
|
||||||
|
"""The menu rows supersede the pill-squeeze: 1rem text with 0.75rem
|
||||||
|
vertical padding (≥44px with the line box) — the old 0.72rem /
|
||||||
|
0.3rem 0.25rem squeeze is gone from the ≤640px block."""
|
||||||
|
mobile = _media_block(_css(), "@media (max-width: 640px)")
|
||||||
|
rows = _rule_block(mobile, ".app-nav .nav-link")
|
||||||
|
assert "padding: 0.75rem 1.25rem" in rows
|
||||||
|
assert "font-size: 1rem" in rows
|
||||||
|
# The superseded squeeze rules are GONE from the ≤640px block…
|
||||||
|
assert "0.72rem" not in mobile, "the 0.72rem nav-pill font is superseded"
|
||||||
|
assert "padding: 0.3rem 0.25rem" not in mobile, (
|
||||||
|
"the 0.3rem 0.25rem nav-pill padding is superseded"
|
||||||
|
)
|
||||||
|
assert "gap: 0.05rem" not in mobile, "the 0.05rem nav gap is superseded"
|
||||||
|
# …while the rest of the block is untouched: the tablet block still
|
||||||
|
# squeezes the inline nav at 641–900px, the action pills keep their
|
||||||
|
# icon-only squeeze, and the 58px bar height is pinned.
|
||||||
|
tablet = _media_block(_css(), "@media (max-width: 900px)")
|
||||||
|
assert re.search(r"\.nav-link\s*\{[^}]*0\.85rem", tablet), (
|
||||||
|
"the 900px tablet squeeze of the inline nav must be untouched"
|
||||||
|
)
|
||||||
|
assert "gap: 0.15rem" in tablet, "the 900px nav gap squeeze must be untouched"
|
||||||
|
for untouched in (
|
||||||
|
".new-chat-label { display: none; }",
|
||||||
|
".auth-label { display: none; }",
|
||||||
|
".sync-label { display: none; }",
|
||||||
|
":root { --header-h: 58px; }",
|
||||||
|
):
|
||||||
|
assert untouched in mobile, f"the ≤640px block lost {untouched!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reduced_motion_stills_the_menu() -> None:
|
||||||
|
"""prefers-reduced-motion kills the 180ms slide+fade in BOTH states —
|
||||||
|
open/close snaps (the visibility/opacity flip applies instantly) and
|
||||||
|
stays correct. The override must name .app-nav.is-open too: that rule
|
||||||
|
carries higher specificity (0,2,0) than a bare .app-nav (0,1,0), so
|
||||||
|
without it the OPEN transition would still animate under the
|
||||||
|
preference (verified live in Chromium, phase 46 task 03)."""
|
||||||
|
css = _css()
|
||||||
|
blocks = []
|
||||||
|
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\)[^{]*\{", css):
|
||||||
|
depth = 0
|
||||||
|
for i in range(m.end() - 1, len(css)):
|
||||||
|
if css[i] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif css[i] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
blocks.append(css[m.start() : i + 1])
|
||||||
|
break
|
||||||
|
assert any(
|
||||||
|
".app-nav.is-open" in b and "transition: none" in b for b in blocks
|
||||||
|
), (
|
||||||
|
"a prefers-reduced-motion block must still .app-nav in BOTH the "
|
||||||
|
"closed and .is-open states (specificity: a bare .app-nav rule "
|
||||||
|
"loses to .app-nav.is-open)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- header.js: the module-owned toggle binding (task 02) ----------
|
||||||
|
|
||||||
|
HEADER_JS = ASSETS / "header.js"
|
||||||
|
|
||||||
|
|
||||||
|
def _js() -> str:
|
||||||
|
return _text(HEADER_JS)
|
||||||
|
|
||||||
|
|
||||||
|
def _hamburger_section(js: str) -> str:
|
||||||
|
"""The header.js mobile-hamburger section (from its banner comment to
|
||||||
|
the next section banner), so the pins target the binding and not
|
||||||
|
unrelated code that happens to mention the same ids."""
|
||||||
|
start = js.find("/* ---------- mobile hamburger")
|
||||||
|
assert start != -1, "missing the mobile hamburger section in header.js"
|
||||||
|
end = js.find("/* ----------", start + 1)
|
||||||
|
assert end != -1, "the hamburger section must end before the next section"
|
||||||
|
return js[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def _js_clean(code: str) -> str:
|
||||||
|
"""JS with block + line comments stripped (comments may legally
|
||||||
|
contain keywords — the pins below must match executable code
|
||||||
|
only). The hamburger section carries no string literals with ``//``
|
||||||
|
in them, so the line-comment strip is safe here."""
|
||||||
|
code = re.sub(r"/\*.*?\*/", "", code, flags=re.S)
|
||||||
|
return re.sub(r"//[^\n]*", "", code)
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_js_looks_up_toggle_and_nav_null_safe() -> None:
|
||||||
|
"""The binding follows the module's import-time pattern (like the
|
||||||
|
sign-out binding): look up #nav-toggle and #app-nav with
|
||||||
|
querySelector at import, and guard the whole binding block behind
|
||||||
|
``navToggle && appNav`` — a page lacking either element is a
|
||||||
|
complete no-op."""
|
||||||
|
js = _js()
|
||||||
|
assert 'document.querySelector("#nav-toggle")' in js, (
|
||||||
|
"header.js must look up the #nav-toggle button"
|
||||||
|
)
|
||||||
|
assert 'document.querySelector("#app-nav")' in js, (
|
||||||
|
"header.js must look up the #app-nav container"
|
||||||
|
)
|
||||||
|
section = _hamburger_section(js)
|
||||||
|
assert "if (navToggle && appNav)" in section, (
|
||||||
|
"the binding block must be null-safe (navToggle && appNav guard)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_nav_menu_syncs_both_class_and_aria() -> None:
|
||||||
|
"""The single state setter syncs BOTH surfaces at once — the
|
||||||
|
animated ``.is-open`` class on the nav AND the toggle's
|
||||||
|
aria-expanded (true/false) — so the ARIA state can never drift
|
||||||
|
from the visual state."""
|
||||||
|
section = _hamburger_section(_js())
|
||||||
|
assert "function setNavMenu(open)" in section, (
|
||||||
|
"a setNavMenu state setter must own the open/close transition"
|
||||||
|
)
|
||||||
|
assert 'appNav.classList.toggle("is-open", open)' in section, (
|
||||||
|
"setNavMenu must set/unset the .is-open class on the nav"
|
||||||
|
)
|
||||||
|
assert 'navToggle.setAttribute("aria-expanded"' in section, (
|
||||||
|
"setNavMenu must keep aria-expanded in sync"
|
||||||
|
)
|
||||||
|
assert '"true"' in section and '"false"' in section, (
|
||||||
|
"aria-expanded must be set to the explicit true/false strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_click_on_the_toggle_toggles_the_menu() -> None:
|
||||||
|
"""Clicking the toggle flips the CURRENT state (read from the
|
||||||
|
.is-open class, not a local boolean — a second binding could
|
||||||
|
never desync it)."""
|
||||||
|
section = _hamburger_section(_js())
|
||||||
|
assert 'navToggle.addEventListener("click"' in section, (
|
||||||
|
"the toggle button must own the click-to-open/close binding"
|
||||||
|
)
|
||||||
|
assert "setNavMenu(!appNav.classList.contains(\"is-open\"))" in section, (
|
||||||
|
"the click must toggle the menu off its current .is-open state"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delegated_nav_link_click_closes_the_menu() -> None:
|
||||||
|
"""A click on ANY nav link (delegated on the nav container — the
|
||||||
|
links keep their hidden/whoami contract, so no per-link binding)
|
||||||
|
shuts the menu; the navigation then proceeds normally."""
|
||||||
|
section = _hamburger_section(_js())
|
||||||
|
assert 'appNav.addEventListener("click"' in section, (
|
||||||
|
"link-close must be delegated on the nav container"
|
||||||
|
)
|
||||||
|
assert "e.target.closest(\"a\")" in section, (
|
||||||
|
"the delegation must hit-test for a link (e.target.closest('a'))"
|
||||||
|
)
|
||||||
|
assert "setNavMenu(false)" in section, "a link click must close the menu"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_closes_the_menu_and_refocuses_the_opener() -> None:
|
||||||
|
"""Esc (document-level keydown) closes the menu only while it is
|
||||||
|
open, and focus returns to the toggle — the opener — so a keyboard
|
||||||
|
user never loses their place."""
|
||||||
|
section = _js_clean(_hamburger_section(_js()))
|
||||||
|
assert 'document.addEventListener("keydown"' in section, (
|
||||||
|
"Esc-close must be a document-level keydown binding"
|
||||||
|
)
|
||||||
|
assert 'e.key === "Escape"' in section, "the handler must match the Escape key"
|
||||||
|
assert 'appNav.classList.contains("is-open")' in section, (
|
||||||
|
"Esc must act only while the menu is open"
|
||||||
|
)
|
||||||
|
assert "setNavMenu(false)" in section, "Esc must close the menu"
|
||||||
|
assert "navToggle.focus()" in section, (
|
||||||
|
"Esc-close must return focus to the toggle (the opener)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resizing_back_to_desktop_closes_the_menu() -> None:
|
||||||
|
"""Crossing back above 640px drops the open state (matchMedia
|
||||||
|
change): the inline nav reappears and aria-expanded stays honest
|
||||||
|
(the ≤640px CSS scopes .is-open anyway, but the class is dropped
|
||||||
|
so a resize round-trip never ships a stale true). The modern
|
||||||
|
addEventListener API is used with the older addListener as a
|
||||||
|
defensive fallback."""
|
||||||
|
section = _hamburger_section(_js())
|
||||||
|
assert 'window.matchMedia("(max-width: 640px)")' in section, (
|
||||||
|
"the resize cleanup must watch the SAME ≤640px breakpoint as the CSS"
|
||||||
|
)
|
||||||
|
assert "if (!mq.matches) setNavMenu(false)" in section, (
|
||||||
|
"leaving mobile (mq no longer matches) must close the menu"
|
||||||
|
)
|
||||||
|
assert 'mq.addEventListener("change"' in section, (
|
||||||
|
"the modern MediaQueryList listener must be used"
|
||||||
|
)
|
||||||
|
assert "mq.addListener(" in section, "the older-engine addListener fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_binding_toggles_only_the_container() -> None:
|
||||||
|
"""The auth visibility contract is untouched: the binding never
|
||||||
|
assigns ``.hidden`` and never touches the whoami links — hidden
|
||||||
|
links stay hidden inside the menu, exactly as on the inline bar.
|
||||||
|
Only the .is-open container class + aria-expanded move."""
|
||||||
|
section = _js_clean(_hamburger_section(_js()))
|
||||||
|
assert ".hidden" not in section, (
|
||||||
|
"the hamburger binding must not hide/reveal any element"
|
||||||
|
)
|
||||||
|
for link in ("nav-sources", "nav-git-sources", "nav-tuning"):
|
||||||
|
assert link not in section, (
|
||||||
|
f"the hamburger binding must not touch the {link!r} link"
|
||||||
|
)
|
||||||
|
# The only class the section manipulates is the container's state.
|
||||||
|
class_toggles = re.findall(r"classList\.(?:add|remove|toggle)\(\s*\"([^\"]+)\"", section)
|
||||||
|
assert class_toggles == ["is-open"], (
|
||||||
|
f"only the container .is-open class may move (found {class_toggles})"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user