Fold the mobile header into a single hamburger menu (#119)

On phones the header spanned two rows: the title block plus a control bar of
the °F/°C toggle, bell, account and hamburger. Wrap the nav in a .nav-panel
inside the <details> menu and inject the °F/°C toggle and account/bell into that
panel instead of into the header row. On desktop the panel is display:contents,
so the inline top bar renders exactly as before (order restored via flex order);
on phones the panel becomes a right-anchored, viewport-clamped dropdown and the
header collapses to a single row (logo + name + hamburger, tagline hidden).

The viewport clamp also fixes the SEO/city pages, whose menu dropdown previously
ran off-screen.
This commit is contained in:
Emi Griffith 2026-07-16 05:15:00 -07:00 committed by GitHub
parent 68745e16c6
commit cde03de0f0
9 changed files with 94 additions and 39 deletions

View file

@ -259,8 +259,12 @@ function ensureAcctEl() {
if (!acctEl) {
acctEl = document.createElement("div");
acctEl.className = "acct";
const anchor = brand.querySelector(".nav-menu") || brand.querySelector(".view-nav");
brand.insertBefore(acctEl, anchor); // top-right, just left of the nav menu
// The account/bell live inside the nav menu panel: on desktop the panel is
// display:contents so they render inline top-right; on phones they stack
// inside the hamburger dropdown. Fall back to a brand sibling if unwrapped.
const panel = brand.querySelector(".nav-panel");
if (panel) panel.appendChild(acctEl);
else brand.insertBefore(acctEl, brand.querySelector(".view-nav"));
}
return acctEl;
}

View file

@ -35,6 +35,7 @@
</div>
<details class="nav-menu">
<summary class="nav-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></summary>
<div class="nav-panel">
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar" class="active">Calendar</a>
@ -42,6 +43,7 @@
<a href="compare" data-view="compare">Compare</a>
<a href="climate">Climate</a>
</nav>
</div>
</details>
</div>
</header>

View file

@ -36,6 +36,7 @@
</div>
<details class="nav-menu">
<summary class="nav-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></summary>
<div class="nav-panel">
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a>
@ -43,6 +44,7 @@
<a href="compare" data-view="compare" class="active">Compare</a>
<a href="climate">Climate</a>
</nav>
</div>
</details>
</div>
</header>

View file

@ -36,6 +36,7 @@
</div>
<details class="nav-menu">
<summary class="nav-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></summary>
<div class="nav-panel">
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a>
@ -43,6 +44,7 @@
<a href="compare" data-view="compare">Compare</a>
<a href="climate">Climate</a>
</nav>
</div>
</details>
</div>
</header>

View file

@ -39,6 +39,7 @@
</div>
<details class="nav-menu">
<summary class="nav-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></summary>
<div class="nav-panel">
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map" class="active">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a>
@ -46,6 +47,7 @@
<a href="compare" data-view="compare">Compare</a>
<a href="climate">Climate</a>
</nav>
</div>
</details>
</div>
</header>

View file

@ -35,6 +35,7 @@
</div>
<details class="nav-menu">
<summary class="nav-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></summary>
<div class="nav-panel">
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a>
@ -42,6 +43,7 @@
<a href="compare" data-view="compare">Compare</a>
<a href="climate">Climate</a>
</nav>
</div>
</details>
</div>
</header>

View file

@ -491,15 +491,23 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.view-nav a:hover { color: var(--text); }
.view-nav a.active { background: var(--accent); color: #fff; }
/* Hamburger wrapper. On desktop it's transparent (display:contents) so the nav
renders inline as pills and the hamburger summary is hidden; on phones (the
640px block) it becomes a dropdown while the F/C toggle, bell and account stay
in the header bar. */
/* Hamburger wrapper. On desktop the menu and its panel are transparent
(display:contents), so the nav pills plus the injected °F/°C toggle, bell and
account render inline in the header row and the hamburger summary is hidden;
on phones (the 640px block) the panel becomes a dropdown behind the hamburger. */
.nav-menu { display: contents; }
/* A closed <details> hides its non-summary content; force the nav visible on
.nav-panel { display: contents; }
/* A closed <details> hides its non-summary content; force the panel visible on
desktop (the 640px block re-hides it behind the hamburger on phones). */
.nav-menu > .view-nav { display: inline-flex; }
.nav-menu > .view-nav,
.nav-panel > .view-nav { display: inline-flex; }
.nav-menu::details-content { display: contents; }
/* The controls are injected into the panel after the nav in DOM order; restore
the header's visual order °F/°C · account · tabs on desktop. On phones the
640px block resets these so the dropdown stacks nav °F/°C account. */
.nav-panel > .unit-toggle { order: 1; }
.nav-panel > .acct { order: 2; }
.nav-panel > .view-nav { order: 3; }
.nav-toggle { display: none; list-style: none; cursor: pointer; }
.nav-toggle::-webkit-details-marker { display: none; }
.nav-toggle svg { display: block; }
@ -1308,43 +1316,70 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
header { padding: 14px 16px; }
h1 { font-size: 19px; }
.tag { font-size: 12px; }
/* Drop the tagline on phones so the header stays a tight single row. */
.tag { display: none; }
/* The title (logo + name + tagline) claims the whole first row so its text never
collapses to a one-word-per-line sliver; the F/C toggle, bell, account and
hamburger wrap to a control bar on the row below. (A lone hamburger the SEO
pages, which have no toggle/account is small enough to stay on the title row.) */
.brand { flex-wrap: wrap; align-items: flex-start; row-gap: 12px; }
.brand > div:not(.unit-toggle):not(.acct) { flex: 1 1 0; min-width: calc(100% - 92px); }
.logo { align-self: flex-start; margin-top: 3px; }
/* The view navigation collapses into a hamburger dropdown; the F/C toggle, bell
and account sit in the control bar beside it. */
/* A real box again (not display:contents) so it's the positioning context for
the absolute dropdown and holds the hamburger. */
.nav-menu { display: inline-flex; position: relative; align-self: center; }
/* One compact row: logo + name on the left, the hamburger flush-right. The nav,
°F/°C toggle, bell and account all fold into the hamburger dropdown below, so
the header never grows a second control-bar row. */
.brand { flex-wrap: nowrap; align-items: center; gap: 10px; }
.brand > div:not(.unit-toggle):not(.acct) { flex: 1 1 auto; min-width: 0; }
.logo { align-self: center; margin-top: 0; }
/* A real box again (not display:contents) so it anchors the absolute dropdown
and holds the hamburger, pinned to the header's right edge. */
.nav-menu { display: inline-flex; position: relative; align-self: center; flex: 0 0 auto; }
.nav-toggle {
display: inline-flex; align-items: center; justify-content: center;
width: 44px; height: 40px; border: 1px solid var(--border); border-radius: 10px;
width: 44px; height: 44px; border: 1px solid var(--border); border-radius: 12px;
background: var(--surface-2); color: var(--text);
}
.nav-menu[open] .nav-toggle { border-color: var(--accent); color: var(--accent); }
.view-nav {
position: absolute; right: 0; top: calc(100% + 8px); z-index: 60; margin: 0;
flex-direction: column; align-items: stretch; width: max-content; min-width: 176px;
box-shadow: 0 12px 30px rgba(0, 0, 0, .35);
/* The panel is the dropdown: right-anchored and clamped to the viewport so it
never runs off-screen (this also fixes the SEO/city pages, whose only panel
item is the nav). Contents stack nav °F/°C account top-to-bottom. */
.nav-panel {
display: flex; flex-direction: column; align-items: stretch; gap: 8px;
position: absolute; right: 0; top: calc(100% + 8px); z-index: 60;
margin: 0; padding: 10px; width: max-content; min-width: 224px;
max-width: calc(100vw - 28px);
background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
box-shadow: 0 16px 40px rgba(0, 0, 0, .45);
}
.nav-menu:not([open]) .view-nav { display: none; }
.view-nav a { min-height: 44px; padding: 10px 14px; justify-content: flex-start; }
.nav-menu:not([open]) .nav-panel { display: none; }
/* Reset the desktop cluster order — the dropdown wants plain DOM order. */
.nav-panel > .unit-toggle, .nav-panel > .acct, .nav-panel > .view-nav { order: 0; }
/* Nav links: a full-width vertical list, hairline-separated from the controls
below (the :not(:last-child) keeps SEO pages nav only divider-free). */
.view-nav {
display: flex; flex-direction: column; align-items: stretch; gap: 2px; margin: 0;
background: transparent; border: 0; border-radius: 0; padding: 0;
}
.nav-panel > .view-nav:not(:last-child) {
padding-bottom: 8px; margin-bottom: 2px; border-bottom: 1px solid var(--border);
}
.view-nav a { min-height: 44px; padding: 11px 12px; justify-content: flex-start; border-radius: 9px; }
.view-nav a + a::before { display: none; }
/* Collapse the account entry to an icon-only button so it doesn't crush the
title/tagline on narrow screens (it still opens the modal / menu). */
.acct-btn span { display: none; }
.acct-btn { padding: 8px; min-width: 40px; justify-content: center; }
/* The bell sits left of the account button, so a wide dropdown anchored to the
bell (.notif) overflowed off the left of the screen. Drop .notif's positioning
context so the dropdown anchors to the .acct cluster at the header's right
edge instead, keeping it fully on-screen. */
.notif { position: static; }
/* °F/°C toggle: full-width segmented control inside the dropdown. */
.nav-panel > .unit-toggle { margin: 0; width: 100%; align-self: stretch; }
.nav-panel > .unit-toggle button { flex: 1; }
/* Account row (bell + account/Sign in) spans the dropdown; the account button
keeps its label and fills, the bell stays a fixed square. Popovers open below,
clamped to the viewport so the notification list never overflows. */
.nav-panel > .acct { margin: 0; width: 100%; align-self: stretch; }
.nav-panel > .acct .acct-menu { flex: 1; }
.nav-panel > .acct .acct-btn:not(.notif-btn) { width: 100%; justify-content: center; }
/* The bell's notification list would anchor to the small bell button and spill
off the left edge; drop .notif's positioning context so it anchors to the
panel instead dropping below the menu, right-aligned and within the
viewport. The account menu already fits; just clamp its width. */
.nav-panel .notif { position: static; }
.nav-panel .notif-pop { right: 0; width: min(340px, calc(100vw - 24px)); }
.acct-pop { max-width: calc(100vw - 40px); }
main { padding: 14px 14px 40px; }
.controls { gap: 12px; margin-bottom: 14px; }

View file

@ -33,6 +33,7 @@
</div>
<details class="nav-menu">
<summary class="nav-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></summary>
<div class="nav-panel">
<nav class="view-nav" aria-label="Views">
<a href="./" data-view="map">Weekly</a>
<a href="calendar" data-view="calendar">Calendar</a>
@ -40,6 +41,7 @@
<a href="compare" data-view="compare">Compare</a>
<a href="climate">Climate</a>
</nav>
</div>
</details>
</div>
</header>

View file

@ -63,7 +63,11 @@ function syncUnitToggle(wrap) {
const b = e.target.closest("button[data-unit]");
if (b) setUnit(b.dataset.unit);
});
const anchor = brand.querySelector(".nav-menu") || brand.querySelector(".view-nav");
brand.insertBefore(wrap, anchor); // sits just left of the nav (top-right)
// Lives inside the nav menu panel: display:contents keeps it inline top-right
// on desktop; on phones it stacks inside the hamburger dropdown. Fall back to a
// brand sibling if the panel isn't present.
const panel = brand.querySelector(".nav-panel");
if (panel) panel.appendChild(wrap);
else brand.insertBefore(wrap, brand.querySelector(".view-nav"));
syncUnitToggle(wrap);
})();