Phase 5g slice B. Fills the ≤767px gap left by slice A (sidebar
display:none on mobile) with a fixed-bottom 5-slot nav + a drawer for
overflow items. iOS PWA install respects safe-area-inset-bottom so the
nav clears the home indicator.
web/templates/layout.tmpl:
- New <nav class="projax-bottom-nav"> with five slots:
Tree (/) → Dashboard (/dashboard) → +New (/new, raised circle)
→ Calendar (/calendar) → Menu (drawer).
- Center "+ New" slot is a raised .capture-circle (margin-top: -10px,
44×44px, accent background) — mBrian's capture-button pattern, but
pointing at /new because projax has no separate capture flow.
- Menu slot is a <details class="projax-mobile-drawer"> whose <summary>
IS the bottom-nav-item. Tapping pops a drawer-sheet absolutely
positioned 8px above the bottom-nav with overflow items: Timeline,
Graph, Admin, theme toggle, sign-out. Browser-default <details>
handles open/close + tap-outside-dismiss — no JS, no gesture wiring.
- Active class on bottom-nav-item + drawer-item via same .Path-driven
server-side pattern slice A introduced.
- Theme toggle handler now binds to BOTH #theme-toggle (sidebar) AND
#theme-toggle-drawer (drawer). Flipping either updates the icon on
both buttons, sets data-theme on <html>, writes the cookie.
web/static/style.css:
- .projax-bottom-nav: fixed bottom, height = calc(56px +
env(safe-area-inset-bottom, 0)), flex justify-around, z-index 1021.
- .bottom-nav-item: 44×44px min, column-flex, touch-action: none for the
capture-button so iOS doesn't intercept the tap.
- .capture-circle: 44×44px raised circle, accent background.
- .projax-mobile-drawer .drawer-sheet: fixed, bottom-right anchored
above the nav, min(260px, calc(100vw - 16px)) wide, slide-up animation
via @keyframes projax-drawer-up (translateY 8→0, 160ms ease-out).
- @media (min-width: 768px): bottom-nav hidden.
- @media (max-width: 767px): main.projax-main gets padding-bottom =
calc(56px + 1rem + env(safe-area-inset-bottom)) so rows aren't hidden
behind the nav.
docs/design.md:
- New §18 (Layout: sidebar + bottom-nav, Phase 5g). Documents both
surfaces' breakpoints, the .Path-driven active marker, the pre-paint
localStorage restore, the theme-toggle dual-binding, and the four
features I deliberately did not port from mBrian (resize handle,
capture modal, quick-switcher/saved-searches/Today/Work, slide-up
gesture).
Tests (web/layout_test.go):
- TestLayoutBottomNavMarkup: 5 slots present in documented order, +New
is .capture-btn with .capture-circle, Menu is <details>, drawer holds
Timeline/Graph/Admin/theme/sign-out.
- TestLayoutBottomNavActiveClass: /calendar render highlights Calendar
slot only.
- TestLayoutThemeToggleBoundToBothButtons: handler enumerates both
button ids so flipping either flips the theme.
All 10 layout tests pass (7 from slice A + 3 from slice B). Full web
suite green. No test source edits to pre-existing tests — the bottom-
nav is additive markup.
184 lines
6.8 KiB
Go
184 lines
6.8 KiB
Go
package web_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestLayoutSidebarOnDesktop confirms the Phase 5g sidebar markup is
|
|
// rendered with all six nav items (Tree / Dashboard / Calendar /
|
|
// Timeline / Graph / Admin). Per-item href + label asserted so a stray
|
|
// edit can't silently lose a section.
|
|
func TestLayoutSidebarOnDesktop(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/dashboard")
|
|
if !strings.Contains(body, `<aside class="projax-sidebar"`) {
|
|
t.Fatalf("expected <aside class=\"projax-sidebar\"> in body, got: %s", truncate(body, 400))
|
|
}
|
|
for _, want := range []struct {
|
|
href, label string
|
|
}{
|
|
{`/`, "Tree"},
|
|
{`/dashboard`, "Dashboard"},
|
|
{`/calendar`, "Calendar"},
|
|
{`/timeline`, "Timeline"},
|
|
{`/graph`, "Graph"},
|
|
{`/admin`, "Admin"},
|
|
} {
|
|
if !strings.Contains(body, `href="`+want.href+`"`) {
|
|
t.Errorf("sidebar missing href=%q", want.href)
|
|
}
|
|
if !strings.Contains(body, `<span class="nav-label">`+want.label+`</span>`) {
|
|
t.Errorf("sidebar missing label %q", want.label)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLayoutActiveClass proves the server-side active marker fires only
|
|
// on the cell whose href matches the request path. Render is driven by
|
|
// the .Path field the render helper injects from r.URL.Path.
|
|
func TestLayoutActiveClass(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/dashboard")
|
|
// Dashboard item should be active.
|
|
if !strings.Contains(body, `class="nav-item active" title="Dashboard"`) {
|
|
t.Errorf("expected Dashboard nav-item to carry .active on /dashboard, body: %s", truncate(body, 400))
|
|
}
|
|
// Tree item (href="/") must NOT be active on the /dashboard page.
|
|
// The Tree anchor opens with the exact-path active match; on /dashboard
|
|
// the substring `class="nav-item" title="Tree"` should be present and
|
|
// not its `active` sibling.
|
|
if !strings.Contains(body, `class="nav-item" title="Tree"`) {
|
|
t.Errorf("expected Tree nav-item to be non-active on /dashboard")
|
|
}
|
|
if strings.Contains(body, `class="nav-item active" title="Tree"`) {
|
|
t.Errorf("Tree nav-item should NOT be active on /dashboard")
|
|
}
|
|
}
|
|
|
|
// TestLayoutCollapseScript proves the inline pre-paint script that
|
|
// restores the sidebar collapsed state from localStorage ships unchanged.
|
|
// Without it the main-content margin would flash from 220px → 56px on
|
|
// every navigation when the user has the sidebar collapsed.
|
|
func TestLayoutCollapseScript(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/dashboard")
|
|
// Pre-paint restore script.
|
|
if !strings.Contains(body, `localStorage.getItem('projax.sidebar.collapsed')`) {
|
|
t.Errorf("expected pre-paint localStorage restore script in layout")
|
|
}
|
|
// The collapse-toggle button + its handler are also part of the chrome.
|
|
if !strings.Contains(body, `id="sidebar-collapse"`) {
|
|
t.Errorf("expected #sidebar-collapse button in layout")
|
|
}
|
|
if !strings.Contains(body, `localStorage.setItem('projax.sidebar.collapsed'`) {
|
|
t.Errorf("expected toggle handler that persists state")
|
|
}
|
|
}
|
|
|
|
// TestLayoutNoTopHeader proves the pre-5g <header> chrome is gone so
|
|
// callers that asserted on old top-nav markup can't keep passing by
|
|
// accident. Belt-and-braces guard for the migration.
|
|
func TestLayoutNoTopHeader(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/dashboard")
|
|
if strings.Contains(body, `<header>`) || strings.Contains(body, `<header `) {
|
|
t.Errorf("expected the pre-5g top <header> to be gone, but rendered body has one: %s", truncate(body, 400))
|
|
}
|
|
if strings.Contains(body, `class="logout-btn"`) {
|
|
t.Errorf("expected the pre-5g .logout-btn to be replaced by the sidebar .logout-item")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return s[:n] + "…"
|
|
}
|