feat(layout): mobile bottom-nav + drawer
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.
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user