Merge branch 'mai/knuth/phase-5g-mbrian-nav' (phase 5g slice B: mobile bottom-nav + drawer)

This commit is contained in:
mAi
2026-05-25 16:40:19 +02:00
4 changed files with 320 additions and 7 deletions

View File

@@ -735,6 +735,32 @@ Not surfaced: item-creation markers (too noisy for a month grid), Gitea issues (
- [ ] Seed migration for the seven day-one areas
- [ ] README + run instructions
## 18. Layout: sidebar + bottom-nav (Phase 5g)
Top-nav `<header>` retired. Layout chrome now mirrors mBrian's surface so the two apps feel consistent on the same device.
**Desktop (≥768px)** fixed-left `<aside class="projax-sidebar">`:
- Width via `--projax-sidebar-width` (default `220px`), collapsed via `--projax-sidebar-collapsed-width` (`56px`). `html[data-sidebar-collapsed="true"]` flips between them with a 200 ms ease transition.
- Three sections: brand nav (Tree / Dashboard / Calendar / Timeline / Graph / Admin, each an inline SVG + label) bottom (theme toggle + sign-out + collapse toggle).
- Active item is marked server-side: `layout.tmpl` compares `.Path` (injected by `web/server.go render()` from `r.URL.Path`) against each item's `href` and emits `class="nav-item active"` on match. No JS needed for the active marker.
- Collapse toggle persists state in `localStorage["projax.sidebar.collapsed"]`. A pre-paint `<script>` block in `<head>` restores the attribute on `<html>` before first paint so the main-content margin doesn't flash 220 px 56 px on every navigation. ~15 lines of vanilla JS, no framework.
- `main.projax-main` carries `margin-left: var(--projax-sidebar-width)` so content lives to the right of the sidebar. The transition matches the sidebar's so they move in sync.
**Mobile (≤767px)** fixed-bottom `<nav class="projax-bottom-nav">` with five slots:
- Tree / Dashboard / **[+ New] center raised circle** / Calendar / Menu.
- Center "+ New" is a 44×44 px raised circle with `margin-top: -10px` (mBrian's capture-button pattern) but points at `/new` because projax has no separate capture flow.
- "Menu" is a `<details>` element with `<summary>` styled as the bottom-nav-item. Tapping pops a small absolute-positioned `drawer-sheet` 8 px above the bottom-nav with the overflow items (Timeline, Graph, Admin, Theme toggle, Sign out). Default browser `<details>` toggle handles open/close + tap-outside-dismiss no JS, no gesture wiring.
- Height = `calc(56px + env(safe-area-inset-bottom, 0px))` and `padding-bottom: env(safe-area-inset-bottom)` so the iOS PWA install doesn't put the nav under the home indicator. Main content gets `padding-bottom: calc(56px + 1rem + env(safe-area-inset-bottom))` so rows aren't hidden under the nav.
- Sidebar is `display: none` at this breakpoint; bottom-nav is `display: none` at `≥768px`. Single surface visible at a time.
**Theme toggle** same handler binds to both the sidebar button (#theme-toggle) and the drawer button (#theme-toggle-drawer); either surface flips `data-theme` on `<html>` and writes the projax_theme cookie. Existing Phase 4b semantics preserved exactly; only the button location changes.
**What was deliberately parked**:
- mBrian's sidebar resize handle. Their `Sidebar.svelte` has a `$effect`-feedback bug they spent a debug session on (see mBrian `docs/sidebar-resize-debug.md`). Static `220 / 56 px` is sufficient revisit only if multiple users push for it.
- Quick-capture modal (mBrian's center circle opens an in-app capture sheet). projax's "+ New" is just a link to `/new`.
- Quick-switcher / saved-searches / Today / Work / Quick-log slots from mBrian none have analogues in projax. The 5-slot bottom-nav stays scoped to projax's actual surfaces.
- Drawer slide-up gesture. `<details>` with a CSS `transform: translateY(8px) → 0` keyframe is enough for v1; a real bottom-sheet gesture is v2 polish.
## 10. References
- Project CLAUDE.md (this repo) purpose, constraints, gated worker flow

View File

@@ -98,6 +98,83 @@ func TestLayoutNoTopHeader(t *testing.T) {
}
}
// TestLayoutBottomNavMarkup pins the Slice-B mobile bottom-nav shape: five
// slots in the documented order (Tree / Dashboard / + New / Calendar /
// Menu), the +New slot is a raised .capture-circle pointing at /new, and
// the Menu opens a <details> drawer with the overflow items inside.
func TestLayoutBottomNavMarkup(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
if !strings.Contains(body, `<nav class="projax-bottom-nav"`) {
t.Fatalf("expected <nav class=\"projax-bottom-nav\"> in body, got: %s", truncate(body, 400))
}
// 5-slot anchors / details element.
for _, want := range []string{
`<a href="/" class="bottom-nav-item`,
`<a href="/dashboard" class="bottom-nav-item`,
`<a href="/new" class="bottom-nav-item capture-btn"`,
`class="capture-circle"`,
`<a href="/calendar" class="bottom-nav-item`,
`<details class="projax-mobile-drawer"`,
} {
if !strings.Contains(body, want) {
t.Errorf("bottom-nav body missing %q", want)
}
}
// Drawer overflow items: Timeline, Graph, Admin, theme toggle, sign-out.
for _, want := range []string{
`<a href="/timeline" class="drawer-item`,
`<a href="/graph" class="drawer-item`,
`<a href="/admin" class="drawer-item`,
`id="theme-toggle-drawer"`,
`<form method="post" action="/logout" class="drawer-form">`,
} {
if !strings.Contains(body, want) {
t.Errorf("drawer body missing %q", want)
}
}
}
// TestLayoutBottomNavActiveClass mirrors TestLayoutActiveClass — the
// server-side .Path marker fires on the bottom-nav too so a user on
// /calendar sees the Calendar slot highlighted (and not Tree/Dashboard).
func TestLayoutBottomNavActiveClass(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar")
if !strings.Contains(body, `<a href="/calendar" class="bottom-nav-item active"`) {
t.Errorf("expected Calendar bottom-nav-item to carry .active on /calendar")
}
if strings.Contains(body, `<a href="/" class="bottom-nav-item active"`) {
t.Errorf("Tree bottom-nav-item should NOT be active on /calendar")
}
}
// TestLayoutThemeToggleBoundToBothButtons proves the single theme handler
// is wired to both the sidebar (#theme-toggle) and the drawer
// (#theme-toggle-drawer) so flipping either swaps the data-theme. Without
// this the mobile drawer's theme button would be a dead element.
func TestLayoutThemeToggleBoundToBothButtons(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard")
// Both buttons present.
if !strings.Contains(body, `id="theme-toggle"`) {
t.Errorf("sidebar theme-toggle button missing")
}
if !strings.Contains(body, `id="theme-toggle-drawer"`) {
t.Errorf("drawer theme-toggle-drawer button missing")
}
// Inline handler enumerates BOTH ids — keeps drift detection cheap.
if !strings.Contains(body, `getElementById('theme-toggle-drawer')`) {
t.Errorf("expected theme handler to enumerate #theme-toggle-drawer")
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s

View File

@@ -1002,3 +1002,134 @@ html[data-sidebar-collapsed="true"] .projax-sidebar .collapse-icon {
Slice A can ship without leaving the phone with a 220px-wide hole. */
.projax-sidebar { display: none; }
}
/* --- Phase 5g layout: mobile bottom-nav + drawer (≤767px) --- */
.projax-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(56px + env(safe-area-inset-bottom, 0px));
padding-bottom: env(safe-area-inset-bottom, 0px);
background: var(--bg-alt);
border-top: 1px solid var(--border);
display: flex;
align-items: stretch;
justify-content: space-around;
z-index: 1021;
}
.projax-bottom-nav .bottom-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 44px;
min-height: 44px;
padding: 4px 8px;
color: var(--muted);
text-decoration: none;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
font-size: 0.7rem;
line-height: 1;
-webkit-tap-highlight-color: transparent;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
.projax-bottom-nav .bottom-nav-item.active {
color: var(--accent);
}
.projax-bottom-nav .bottom-nav-item span { line-height: 1; }
.projax-bottom-nav .capture-btn { padding: 0; }
.projax-bottom-nav .capture-circle {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--accent);
color: var(--accent-fg);
display: flex;
align-items: center;
justify-content: center;
margin-top: -10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Drawer that pops up from the Menu slot. <details> handles open/close
natively — no JS. The drawer-sheet is absolutely positioned ABOVE the
bottom-nav so it doesn't push the grid; tap-outside still closes via
browser's default <details> dismiss because we keep the sheet inside
the <details> subtree. */
.projax-mobile-drawer { position: static; display: contents; }
.projax-mobile-drawer summary.drawer-toggle {
list-style: none;
outline: none;
}
.projax-mobile-drawer summary.drawer-toggle::-webkit-details-marker {
display: none;
}
.projax-mobile-drawer[open] summary.drawer-toggle { color: var(--accent); }
.projax-mobile-drawer .drawer-sheet {
position: fixed;
right: 8px;
bottom: calc(56px + env(safe-area-inset-bottom, 0px) + 8px);
width: min(260px, calc(100vw - 16px));
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.32);
display: flex;
flex-direction: column;
gap: 2px;
z-index: 1022;
animation: projax-drawer-up 160ms ease-out both;
}
@keyframes projax-drawer-up {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.projax-mobile-drawer .drawer-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
color: var(--fg);
text-decoration: none;
font-size: 0.95em;
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
width: 100%;
text-align: left;
min-height: 40px;
}
.projax-mobile-drawer .drawer-item:hover { background: var(--bg-alt); }
.projax-mobile-drawer .drawer-item.active { color: var(--accent); background: var(--bg-alt); }
.projax-mobile-drawer .drawer-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.projax-mobile-drawer .drawer-icon.theme-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.1em;
}
.projax-mobile-drawer .drawer-form { margin: 0; }
.projax-mobile-drawer .drawer-logout { color: var(--bad); }
@media (min-width: 768px) {
.projax-bottom-nav { display: none; }
}
@media (max-width: 767px) {
main.projax-main {
padding-bottom: calc(56px + 1rem + env(safe-area-inset-bottom, 0px));
}
}

View File

@@ -114,28 +114,107 @@
<main class="projax-main">
{{template "content" .}}
</main>
<nav class="projax-bottom-nav" aria-label="Mobile navigation">
<a href="/" class="bottom-nav-item{{if eq $path "/"}} active{{end}}" aria-label="Tree">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
<span>Tree</span>
</a>
<a href="/dashboard" class="bottom-nav-item{{if eq $path "/dashboard"}} active{{end}}" aria-label="Dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
</svg>
<span>Dash</span>
</a>
<a href="/new" class="bottom-nav-item capture-btn" aria-label="New item">
<span class="capture-circle">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" width="22" height="22" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</span>
</a>
<a href="/calendar" class="bottom-nav-item{{if eq $path "/calendar"}} active{{end}}" aria-label="Calendar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span>Cal</span>
</a>
<details class="projax-mobile-drawer">
<summary class="bottom-nav-item drawer-toggle" aria-label="More — Timeline, Graph, Admin">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
<span>Menu</span>
</summary>
<div class="drawer-sheet" role="menu">
<a href="/timeline" class="drawer-item{{if eq $path "/timeline"}} active{{end}}" role="menuitem">
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span>Timeline</span>
</a>
<a href="/graph" class="drawer-item{{if eq $path "/graph"}} active{{end}}" role="menuitem">
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
<span>Graph</span>
</a>
<a href="/admin" class="drawer-item{{if eq $path "/admin"}} active{{end}}" role="menuitem">
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span>Admin</span>
</a>
<button type="button" id="theme-toggle-drawer" class="drawer-item" aria-label="Toggle theme" data-theme="{{.Theme}}">
<span class="drawer-icon theme-icon" aria-hidden="true">{{if eq .Theme "light"}}☾{{else}}☀{{end}}</span>
<span>Theme</span>
</button>
<form method="post" action="/logout" class="drawer-form">
<button type="submit" class="drawer-item drawer-logout">
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span>Sign out</span>
</button>
</form>
</div>
</details>
</nav>
<script>
// Phase 4b — theme toggle. Writes the projax_theme cookie (1-year Max-Age,
// Path=/, SameSite=Lax) and flips data-theme + theme-color in place so the
// user sees the swap without a reload. Server already injected the initial
// value from the cookie, so first paint never flashes the wrong theme.
// Phase 5g — same handler binds to BOTH the sidebar button (desktop) and
// the drawer button (mobile menu) so either surface flips the theme
// identically.
(function() {
var btn = document.getElementById('theme-toggle');
if (!btn) return;
var icon = btn.querySelector('.theme-icon');
var btns = [
document.getElementById('theme-toggle'),
document.getElementById('theme-toggle-drawer'),
].filter(Boolean);
if (btns.length === 0) return;
var root = document.documentElement;
var meta = document.querySelector('meta[name="theme-color"]');
var themeColors = { dark: '#161616', light: '#f0efe8' };
btn.addEventListener('click', function() {
function flip() {
var current = root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
var next = current === 'light' ? 'dark' : 'light';
root.setAttribute('data-theme', next);
btn.setAttribute('data-theme', next);
if (icon) icon.textContent = next === 'light' ? '☾' : '☀';
btns.forEach(function(b) {
b.setAttribute('data-theme', next);
var ic = b.querySelector('.theme-icon');
if (ic) ic.textContent = next === 'light' ? '☾' : '☀';
});
if (meta) meta.setAttribute('content', themeColors[next]);
var oneYear = 60 * 60 * 24 * 365;
document.cookie = 'projax_theme=' + next + '; Max-Age=' + oneYear + '; Path=/; SameSite=Lax';
});
}
btns.forEach(function(b) { b.addEventListener('click', flip); });
})();
</script>
<script>