phase: 18_nav_hamburger
**Phase 18 — collapse the nav bar into a hamburger: complete, all green.**
Verified (criteria):
- ≤640px: the tab links leave the bar, the ☰ is visible, and it opens a dropdown
under the sticky masthead (`top: 100%`, `z-index: 41`, viewport-wide) with the
five stacked 40px links; above 640px the one-row desktop masthead is unchanged
and the button hidden — `data/logs/nav-task04-verify.cjs` 24/24 and
`data/logs/nav-final-pass.cjs` 45/45; screenshots
`data/logs/nav-hamburger-{closed,open}-360.png`, `data/logs/nav-masthead-desktop-1280.png` ✓
- The four close paths: the button again, a tab click (navigates and closes),
Esc (focus back on the opener), and `matchMedia` resize back above the
breakpoint (drops a stale `.is-open`; `aria-expanded` tracks state on every
path through the single `setNavMenu()` funnel) ✓
- `uv run pytest` → 1454 passed, 15 skipped; `bash .agents/validate.sh` → exit 0
(coverage 98.94% ≥ 94, ruff clean, pyright 0 errors, `node --check` + hygiene
clean); served-page pins: button on all seven tabbed pages and nowhere on
/login, CSS and binding compared on the 640px literal ✓
Real-device finding (mobile Chrome): the emulated 360px pass was blind to the
system top-edge zone — Android Chrome is edge-to-edge by default, so the sticky
bar started under the status bar and the first-row ☰ sat in a region the device
does not deliver taps to (unreachable, not unbound; the same symptom brain of
reese fixed in production). The ≤640px block now pads the bar by
`env(safe-area-inset-top, 0px)` (the block's `padding-block` value restated on
the top side), verified on the operator's phone; `env()` resolves to 0 on every
non-notch surface, so all other widths render byte-identical. The served-byte
pin's rule loop carries the line; CDP's `safeAreaInsets` override does not feed
`env()`, which is the honest limit of the emulated proof.
Docs: AGENTS.md *UI conventions* masthead entry extended with the three pins,
the drift-comparison, the safe-area rule, and the real-device verification
caveat; README's `web/` bullet names the ≤640px collapse with the desktop
masthead unchanged above the breakpoint.
Deviations: pinned the shipped token shape (`#nav-toggle { display:
var(--nav-toggle-display) }` + the `:root` token) instead of the task file's
literal `.nav-toggle { display: none }` — that literal loses to
`button.ghost { display: inline-flex }` (task 01 measured it); the test
docstring says so, so nobody "restores" the broken shape. `uv pip list
--outdated` still advisory (fastapi 0.153.0 → 0.156.2); this phase moved no
dependency. NOTES.md (emptied) / TODO.md (operator items added) are
harness-side changes — left in the working tree, as in phase 17.
Next pending phase: `.agents/phases/todo/19_ups_events_expandable/`.
LLM-Generated: true
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# Phase 18 — Collapse the nav bar into a hamburger
|
||||
|
||||
**Source:** `TODO.md` L3 — "The nav bar needs to collapse into a hamburger like brain of reese does"
|
||||
**Story:** n/a
|
||||
**Context:** the seven mastheads that carry the tab nav — `src/dashboards/web/pages/{index,system,disks,llm,ups,settings,certs}.html` (each has `nav class="tabs" aria-label="Dashboards"` with the five dashboard links; `login.html` deliberately has no tabs — it renders only the brand link and the theme toggle, and stays untouched), `src/dashboards/web/static/app.js` (`Dash.initChrome()`, called from the one `DOMContentLoaded` at line 786 — the `themeButton`/`clock`/`nav-status` blocks there are the per-element `if (element)` no-op pattern this phase's binding follows), `src/dashboards/web/static/dash.css` (`.masthead` at line 95 — `position: sticky; z-index: 40; height: var(--header-h)`, `nav.tabs` at line 131, the existing `@media (max-width: 640px)` block at line 286, the `prefers-reduced-motion` block at line 938), and the reference implementation the TODO names — `brain_of_reese` (sibling repo, read-only): `frontend/assets/header.js` lines 326–366 (the phase-46 binding: `#nav-toggle` shown ≤640px, `setNavMenu(open)` funnel, close on link click / Esc with focus return / `matchMedia` resize-back). Pinned test neighbours: `tests/test_api.py::test_the_disks_tab_is_in_the_masthead_of_every_page` (line 3837) and the served-page orphan-`id` audit in `tests/test_auth_api.py` (line 1654)
|
||||
|
||||
## Objective
|
||||
|
||||
On viewports ≤640px the masthead's five tab links collapse into a dropdown opened by a hamburger button, mirroring brain of reese's phase-46 behaviour: one shared binding in `app.js` (a no-op on any page without the elements — `/login`), the inline nav hidden and the dropdown absolutely positioned under the sticky masthead, and the desktop masthead byte-identical in behaviour above the breakpoint. The `masthead-right` cluster stays visible on phones; only the tab nav collapses.
|
||||
|
||||
## Dependencies
|
||||
- — (none; builds on the shipped masthead, `initChrome`, and the 640px media block)
|
||||
|
||||
## Tasks
|
||||
1. `01_hamburger_markup.md` — the `#nav-toggle` button and the `nav` id, identical across the seven tabbed pages, absent from `/login`
|
||||
2. `02_dropdown_css.md` — the ≤640px block: toggle visible, inline tabs hidden, `.is-open` dropdown under the masthead, two-row masthead
|
||||
3. `03_toggle_binding.md` — the `initChrome` binding: toggle + `aria-expanded`, close on link click / Esc / resize-back
|
||||
4. `04_tests_and_verification.md` — the pins (present on all seven, absent on `/login`, CSS and binding), the orphan-`id` audit, and the narrow-viewport browser verification with screenshots
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: `tests/test_api.py` — the served-page pins (see task 04); the existing masthead/tab pins (`test_the_disks_tab_is_in_the_masthead_of_every_page`, `test_every_page_links_to_both_admin_pages`) and the `tests/test_auth_api.py` orphan-`id` audit pass untouched — the new `id`s are referenced by `aria-controls`, the stylesheet, and `app.js`, so the audit must still clear
|
||||
- Coverage: **>90%** on new/modified code (the phase is markup + CSS + one small JS binding; the coverage gate applies to the changed Python test files)
|
||||
- The gate (`bash .agents/validate.sh`) includes `node --check` of `web/static` — the modified `app.js` must pass it
|
||||
|
||||
## Documentation & Consistency
|
||||
|
||||
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**.
|
||||
- AGENTS.md's `UI conventions` section gains the masthead-collapsing convention (the 640px breakpoint, the toggle's identity, the close paths, and that `/login` carries no toggle) so a future masthead edit knows the contract. The README's page table is unchanged — the nav is the same five links at every width.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] at ≤640px: the tab links are hidden, the hamburger is visible, and clicking it opens a dropdown containing the five links; above 640px the desktop masthead is unchanged
|
||||
- [ ] the dropdown closes on link click, on Esc (focus returns to the button), and on resize back above 640px; `aria-expanded` tracks the state
|
||||
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
|
||||
- [ ] `bash .agents/validate.sh` green (including `node --check` of the modified `app.js`)
|
||||
- [ ] narrow-viewport screenshots (closed, open, and one desktop no-regression shot) in `data/logs/` against the running dev server (invariant 10)
|
||||
- [ ] no behavior change in completed work: the desktop masthead and every page render identically at widths >640px
|
||||
@@ -0,0 +1,31 @@
|
||||
# Task 01 — The hamburger button markup
|
||||
|
||||
**Phase:** `18_nav_hamburger` · **Source:** `TODO.md:3` — "The nav bar needs to collapse into a hamburger like brain of reese does"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
The seven pages that carry the tab nav each gain the hamburger button and the `nav` id, so the (later task's) CSS and binding have stable anchors to work on. The markup is identical on every page, and `/login` — which deliberately renders only the brand link and the theme toggle — stays untouched.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/web/pages/index.html` · `system.html` · `disks.html` · `llm.html` · `ups.html` · `settings.html` · `certs.html` — in each masthead, immediately before `nav class="tabs"`, add the toggle:
|
||||
```html
|
||||
<button class="ghost nav-toggle" id="nav-toggle" type="button"
|
||||
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
|
||||
```
|
||||
and give the nav its id: `<nav class="tabs" id="nav-tabs" aria-label="Dashboards">`. The button sits between `.brand` and `nav.tabs`, inside the same `<header class="masthead">` — one element per page, no script changes in this task.
|
||||
2. `src/dashboards/web/static/dash.css` — add the one base rule beside the other `button.ghost, a.ghost` family (the masthead section): `.nav-toggle { display: none; }` — the button is invisible on every width until task 02's ≤640px block shows it, so the project stays exactly as it looks today after this task.
|
||||
3. `src/dashboards/web/pages/login.html` — **no change.** Verify by `grep -c 'id="nav-toggle"'` returning 0 (the pin lands in task 04).
|
||||
4. `ASSUMPTION:` the TODO named brain of reese's behaviour, not its breakpoint or glyph. This task takes the 640px breakpoint (brain's `(max-width: 640px)` and `dash.css`'s existing 640px block) and the `☰` glyph with `aria-label="Toggle navigation"`; the CSS task owns the visual.
|
||||
5. `ASSUMPTION:` only the tab nav collapses on phones. The `masthead-right` cluster (`#nav-status` dots, pause, `⚷ Certs`, `⚙ Account`, theme toggle) stays visible; the masthead wraps to two rows at ≤640px (task 02).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: the served pages still pass the untouched masthead pins (`test_the_disks_tab_is_in_the_masthead_of_every_page`, `test_every_page_links_to_both_admin_pages`) and the orphan-`id` audit — `#nav-toggle` is referenced by the (task 02) stylesheet rule and `aria-controls`, so the audit must not flag it
|
||||
- Coverage: **>90%** on modified code (HTML only — the gate's `node --check` and pytest suite are the checks)
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `grep -l 'id="nav-toggle"' src/dashboards/web/pages/*.html` lists exactly the seven tabbed pages, never `login.html`
|
||||
- [ ] every one of the seven carries the identical button block and the `nav` id (diff of the blocks across pages is empty)
|
||||
- [ ] `uv run pytest -q` green; `bash .agents/validate.sh` green
|
||||
- [ ] no behavior change: at every width the pages render exactly as before after this task (the button is `display:none` from the base rule shipped with it)
|
||||
@@ -0,0 +1,29 @@
|
||||
# Task 02 — The ≤640px dropdown CSS
|
||||
|
||||
**Phase:** `18_nav_hamburger` · **Source:** `TODO.md:3` — "The nav bar needs to collapse into a hamburger like brain of reese does"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
The CSS that makes the hamburger real at ≤640px: the toggle button visible, the inline tab nav hidden, the `.is-open` nav rendered as a dropdown under the sticky masthead, and the masthead wrapping to two rows so the `masthead-right` cluster does not overflow a phone. Above 640px nothing changes.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/web/static/dash.css` — the base `.nav-toggle { display: none; }` rule already landed with task 01's markup (the button is invisible on every width until this task's block). Extend the existing `@media (max-width: 640px)` block at line 286 (or add a sibling block — the file's convention is one block per concern; a `/* mobile nav */`-commented block beside the masthead section is the cleaner home):
|
||||
- `.masthead { height: auto; min-height: var(--header-h); flex-wrap: wrap; }` — the single-row fixed-height masthead cannot hold brand + toggle + the whole `masthead-right` cluster at 360px; two rows, first row brand + toggle, second row the cluster.
|
||||
- `.nav-toggle { display: inline-flex; align-items: center; }` (it is a `button.ghost`, so the outlined style is inherited — no second palette, the file's rule).
|
||||
- `nav.tabs { display: none; }` and `nav.tabs.is-open { display: flex; flex-direction: column; gap: 0; position: absolute; top: 100%; left: 0; right: 0; background: var(--bg-raised); border-bottom: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0, 0, 0, .35); padding: .4rem; z-index: 41; }` — the dropdown. The masthead is `position: sticky`, which is a non-static containing block for its absolute descendants, so `top: 100%` pins it directly under the (wrapped, two-row) masthead. `z-index: 41` is one above the masthead's 40 so the dropdown sits on the page content.
|
||||
- `nav.tabs.is-open a { padding: .6rem .7rem; border-radius: var(--radius-sm); }` — touch-sized rows; the existing `:hover`/`[aria-current="page"]` rules carry over unchanged.
|
||||
- Optional at most: a 120ms opacity/translateY transition on `nav.tabs.is-open`, **disabled inside the existing `@media (prefers-reduced-motion: reduce)` block at line 938** (add the dropdown selector to that block's list of motion-killers if used). If in doubt, ship without the transition.
|
||||
2. `ASSUMPTION:` the dropdown is a single full-width column under the masthead (brain's shape, adapted: brain hides the nav in a `#app-nav` container; here the `nav` element itself is the container and gets `id="nav-tabs"` + `.is-open`). No separate menu panel element.
|
||||
3. `ASSUMPTION:` no `body`-level marker class (brain's `nav-menu-open` exists for its sticky bottom chat cluster, which the dashboards do not have — the dropdown competes with nothing; do not copy a side effect nobody needs).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: the served-stylesheet pins land in task 04 (base rule present, ≤640px block contains the toggle-visible + tabs-hidden + `.is-open` rules); the untouched `node --check` gate passes (CSS is not checked, but confirm no syntax error by loading the page in the browser — task 04's verification)
|
||||
- Coverage: **>90%** on modified code (CSS only; the gate's pytest/ruff/pyright must stay green)
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] at a 360px-wide browser window: the tab links are gone from the row, the `☰` button is visible, and toggling `.is-open` (by hand in devtools) shows the full-width dropdown under the masthead with the five links stacked and the `aria-current` tab highlighted
|
||||
- [ ] at 700px and 1200px the masthead is byte-identical in layout to before (one row, inline tabs, no button)
|
||||
- [ ] the dropdown does not trap or cover the `masthead-right` cluster, and the page content scrolls under it
|
||||
- [ ] `bash .agents/validate.sh` green
|
||||
@@ -0,0 +1,55 @@
|
||||
# Task 03 — The toggle binding in `initChrome`
|
||||
|
||||
**Phase:** `18_nav_hamburger` · **Source:** `TODO.md:3` — "The nav bar needs to collapse into a hamburger like brain of reese does"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
One shared binding in `app.js` (the file every page loads, including `/login`) that makes the button work on all seven tabbed pages and is a no-op where the elements are absent. The behaviour mirrors brain of reese's phase-46 binding: a `setNavMenu(open)` funnel so no close path can leave the state stuck, `aria-expanded` honest at all times, close on link click, on Esc (focus returns to the button), and on resize back above the breakpoint.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/web/static/app.js` — inside `initChrome()` (line 620), after the `nav-status` block and before the `return`, add a block in the file's per-element guard style (the `themeButton` pattern at line 626 is the shape: `if (element) { … }`):
|
||||
```js
|
||||
const navToggle = $('#nav-toggle');
|
||||
const appNav = $('#nav-tabs');
|
||||
if (navToggle && appNav) {
|
||||
const setNavMenu = (open) => {
|
||||
appNav.classList.toggle('is-open', open);
|
||||
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
};
|
||||
navToggle.addEventListener('click', () =>
|
||||
setNavMenu(!appNav.classList.contains('is-open')));
|
||||
// A link click navigates — shut the menu (event-delegated: the five
|
||||
// links are static markup, so one listener on the nav covers all of them).
|
||||
appNav.addEventListener('click', (event) => {
|
||||
if (event.target.closest('a')) setNavMenu(false);
|
||||
});
|
||||
// Esc closes while open; focus returns to the opener.
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && appNav.classList.contains('is-open')) {
|
||||
setNavMenu(false);
|
||||
navToggle.focus();
|
||||
}
|
||||
});
|
||||
// Resize back to desktop: the inline nav reappears — drop the open state
|
||||
// so aria-expanded stays honest (the CSS hides the dropdown above 640px
|
||||
// regardless, but a stuck `is-open` would re-open on the next shrink).
|
||||
const mq = window.matchMedia('(max-width: 640px)');
|
||||
const onMqChange = () => { if (!mq.matches) setNavMenu(false); };
|
||||
if (mq.addEventListener) mq.addEventListener('change', onMqChange);
|
||||
}
|
||||
```
|
||||
Notes: the breakpoint literal must match task 02's CSS block — if one drifts, the menu opens into a display:none nav. `ASSUMPTION:` that match is pinned by task 04's served-asset test (assert the `640px` literal appears in both the served stylesheet's nav block and the served `app.js`), not by a shared constant — the file's convention is per-file literals (the 640px in CSS, 900px for the clock, and the page scripts' own numbers do not share a constant either).
|
||||
2. No `body`-level marker class (task 02's assumption carries: nothing on this app competes with the dropdown for taps, so brain's `nav-menu-open` marker is not copied).
|
||||
3. No change to the login page: `initChrome` already ships to it, and the `if (navToggle && appNav)` guard is the no-op — verify in task 04 that `/login`'s served script is the same shared file (it is: every page loads `/static/app.js`) and the guard makes the binding inert there.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: the binding's behaviour is verified in the browser (task 04 — the repo has no JS test runner; the pins are structural on the served bytes: the block is present in the served `app.js`, the four close paths named in a comment, `node --check` passes); the untouched Python suite stays green
|
||||
- Coverage: **>90%** on modified code (one JS function; the Python coverage gate applies to the test files task 04 adds)
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] at 360px: clicking `☰` opens the dropdown and `aria-expanded` reads `true`; clicking again closes it; clicking a tab link closes it as the page navigates
|
||||
- [ ] with the menu open: Esc closes it and focus is on the `☰` button; resizing to 700px closes it and the next shrink back to 360px leaves it closed (no stale `is-open`)
|
||||
- [ ] on `/login` the script loads and the guard no-ops (no console error, `node --check` clean)
|
||||
- [ ] `uv run pytest -q` green; `bash .agents/validate.sh` green (includes `node --check` of the modified `app.js`)
|
||||
@@ -0,0 +1,32 @@
|
||||
# Task 04 — Pins and narrow-viewport verification
|
||||
|
||||
**Phase:** `18_nav_hamburger` · **Source:** `TODO.md:3` — "The nav bar needs to collapse into a hamburger like brain of reese does"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
The phase is pinned on the served bytes the way this repo pins every page surface: the button is on all seven tabbed pages and **absent** from `/login`, the CSS and the binding carry the matching 640px literal, and the orphan-`id` audit still clears. Then the designed degradation is proven in a real browser at a phone width — the invariant-10 screenshot pass.
|
||||
|
||||
## Work
|
||||
|
||||
1. `tests/test_api.py` — new tests, in the style of `test_the_disks_tab_is_in_the_masthead_of_every_page` (line 3837; it iterates the served pages and checks the masthead):
|
||||
- `test_the_nav_toggle_is_in_the_masthead_of_every_tabbed_page`: for each of the seven served pages (`/`, `/system`, `/disks`, `/llm`, `/ups`, `/settings`, `/certs`) — a `GET` (signed, like the other served-page pins) finds `button#nav-toggle` with `aria-expanded="false"`, `aria-controls="nav-tabs"`, `aria-label="Toggle navigation"`, and `type="button"`, and `nav#nav-tabs` with its five tab links intact; the button precedes the nav in the markup (the dropdown's `top: 100%` anchor needs the nav inside the masthead, which the pin asserts via the `header.masthead` containing both).
|
||||
- `test_the_login_page_carries_no_nav_and_no_toggle`: `/login` (fetched the way the audit fetches it — unsigned, because a signed-in `GET /login` is a `303`) has no `nav.tabs`, no `#nav-toggle`, and no `#nav-tabs` — the page's contract is brand + theme toggle only, and a stray hamburger there is the bug this test exists to fail.
|
||||
- `test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint`: the served `dash.css` contains the base `.nav-toggle` `display: none` rule, a `@media (max-width: 640px)` (or the 640px block) with the toggle-visible, `nav.tabs`-hidden, and `.is-open` rules; the served `app.js` contains the binding block (the `is-open` toggle, the `Escape` close, the `matchMedia('(max-width: 640px)')` call) — and the `640px` literal appears in both, so a drift between CSS and JS fails here instead of opening a menu into a `display:none` nav.
|
||||
2. `tests/test_auth_api.py` — **no change expected**: the served-page orphan-`id` audit (line 1654) must pass untouched, because `#nav-toggle` and `#nav-tabs` are referenced (the stylesheet rule, `aria-controls`, the `app.js` selectors). If the audit flags either id, the fix is in the CSS/JS references, not in the audit.
|
||||
3. Browser verification (invariant 10): `./scripts/dev-server.sh restart`, sign in with `testpass`, and with a ≤640px viewport (Playwright or a devtools device width — the repo's screenshots land in `data/logs/`):
|
||||
- `data/logs/nav-hamburger-closed-360.png` — the phone-width masthead: no tab links, the `☰` visible, the `masthead-right` cluster intact.
|
||||
- `data/logs/nav-hamburger-open-360.png` — the dropdown open: five stacked links, the `aria-current` tab highlighted, the cluster not covered.
|
||||
- `data/logs/nav-masthead-desktop-1280.png` — the no-regression shot: one-row masthead, inline tabs, no button.
|
||||
4. `AGENTS.md` — in the `UI conventions` section (where the `/login` masthead contract and the `app.js` ownership are documented), add the collapsing contract in the section's voice: the 640px breakpoint, the toggle's identity and its seven-page scope, the three close paths (link click, Esc with focus return, resize-back), the `aria-expanded` rule, and that `/login` carries no toggle. `README.md` is checked in the phase's final pass (its page table — "power events" etc. — is unchanged by this phase).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: the three new served-page tests above + the untouched orphan-`id` audit + `test_the_disks_tab_is_in_the_masthead_of_every_page` (the nav's id addition must not break it)
|
||||
- Coverage: **>90%** on the new test code; the full gate green
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/test_api.py tests/test_auth_api.py -q` green, including the three new tests and the untouched audit
|
||||
- [ ] `grep -c 'nav-toggle' data/logs/…` — the three screenshots exist and show the closed / open / desktop states as named
|
||||
- [ ] `bash .agents/validate.sh` green (pytest --cov --cov-fail-under=94, ruff, pyright, node --check)
|
||||
- [ ] AGENTS.md's UI conventions name the contract; README.md reviewed and consistent
|
||||
- [ ] no behavior change in completed work: the desktop pages and the login page are pixel-identical in structure (the pins prove it)
|
||||
Reference in New Issue
Block a user