Merge branch 'mai/knuth/phase-5g-mbrian-nav' (phase 5g slice B: mobile bottom-nav + drawer)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user