Design only — no code changes. Five-slot bottom bar for phones (<768px), center slot opens slide-up Quick-Add sheet (Frist / Termin / Projekt), right slot reuses the existing mobile sidebar drawer. Tablets and desktop unchanged. Awaiting m's review before implementation.
21 KiB
Design: PWA Mobile BottomNav + Drawer
Author: cronus (inventor)
Date: 2026-04-26
Task: t-paliad-041
Status: Design complete — awaiting m's go/no-go before implementation
Reference: ~/dev/web/docs/pwa-baseline.md (canonical PWA pattern across m's web surfaces)
1. Executive Summary
Paliad's current navigation is desktop-first: a 64px collapsed / 240px
expanded left Sidebar (with hover/pin) on ≥1024px, a slide-out
drawer with a top-left hamburger on <1024px. This works on a laptop. On
a phone it does not — the hamburger is a long thumb-stretch and every
common action (open Agenda, create a Frist) is two taps deep behind it.
This design adds a bottom navigation bar for phones (<768px) per
the m-stack PWA baseline:
- 5-slot fixed bottom bar with thumb-reach icons.
- Center slot opens a slide-up Quick-Add sheet (Frist / Termin / Projekt).
- Right-most slot opens the existing mobile sidebar drawer (no new drawer — we reuse what already works).
- Auto-hides when the on-screen keyboard opens (
visualViewportwatcher). - Honors
safe-area-inset-bottomso iOS home-indicator doesn't sit on top of the buttons.
The desktop Sidebar (≥1024px) is unchanged. Tablets (768-1023px) keep the current hamburger-drawer pattern. Only phones gain BottomNav.
PWA shell items split into "do now" (cheap, required) and "defer to a follow-up task":
- Now:
viewport-fit=cover,theme-color,apple-mobile-web-app-*meta tags so iOS draws under the notch andsafe-area-insetactually has values. - Later (separate task):
manifest.json+ icon assets, service worker, add-to-home-screen prompt UI.
2. Why These Choices (HLC Patent Lawyer Perspective)
The user is a litigator-in-the-hallway — between meetings, on the train, in court anteroom. The phone use-case is overwhelmingly read rather than create:
- "What's coming up this week?" → Agenda, Dashboard
- "What's the status on this matter?" → Projekte detail
- "Quickly capture a Frist I just got told about" → create Frist
- "What's the Frist for replying to office action X?" → Fristenrechner (rare on phone)
- "Settings / Glossar / Kostenrechner" → desk activities, rare on phone
The bottom slots therefore optimise for read-heavy, with a single prominent capture path in the center. Tools/Wissen/Settings live in the drawer because phone use of those is rare and a one-tap detour is fine.
3. Slot Layout
5 slots, decided:
┌─────────────────────────────────────────────────────────┐
│ │
│ [page content] │
│ │
├─────────────────────────────────────────────────────────┤
│ │
│ [🏠] [📁] ╔══[+]══╗ [📅] [☰] │
│ Start Projekte Anlegen Agenda Menü │
│ │
└─────────────────────────────────────────────────────────┘
↑ ↑ ↑ ↑ ↑
Dash Projekte Quick-Add Agenda Drawer
(/dashboard) (/projects) (sheet) (/agenda) (toggle)
| Slot | Label DE | Label EN | Target | Icon (reuse from Sidebar.tsx) |
|---|---|---|---|---|
| 1 | Start | Home | /dashboard |
ICON_GAUGE |
| 2 | Projekte | Projects | /projects |
ICON_FOLDER |
| 3 | Anlegen | New | (opens sheet) | ICON_PLUS (new) |
| 4 | Agenda | Agenda | /agenda |
ICON_AGENDA |
| 5 | Menü | Menu | (opens drawer) | ICON_MENU |
Why Dashboard + Agenda over Dashboard + Fristen
Initial brief proposed [Dashboard / Projekte / + / Fristen / Menu] or
[... / Agenda / Menu]. Agenda wins because:
- Agenda merges Fristen and Termine into one date-sorted timeline (shipped in t-paliad-030). On a phone you almost never want one but not the other — you want "what's next".
- Fristen is reachable from Agenda items (each row deep-links to
/deadlines/{id}) and from the drawer. - Dashboard already gives the high-level "traffic light" overview (overdue / today / week / later) — Agenda gives the actionable list underneath. Pairing them in the BottomNav covers ~80% of phone reads.
Why Projekte not Termine
Termine alone is too narrow for a top-level slot. A patent lawyer's mental model is "I'm working on matter X" — Projekte is the natural hub. Termine is reachable from a project's detail page or from Agenda.
Active-state highlighting
Same rule the Sidebar already uses (navItem active logic): a slot is
active when its href is a prefix of currentPath. So /projects/abc
keeps the Projekte slot lit, /deadlines/{id} lights nothing in
BottomNav (deadlines aren't a top-level slot — that's fine, the
breadcrumb still works).
4. Center Slot: Quick-Add Sheet
A slide-up sheet (not a navigation) with three options:
┌─────────────────────────────────┐
│ ───── │ ← drag-handle
│ │
│ 📅 Frist anlegen › │ → /deadlines/new
│ 🗓 Termin anlegen › │ → /appointments/new
│ 📁 Projekt anlegen › │ → /projects/new
│ │
│ [Abbrechen] │
└─────────────────────────────────┘
Why a sheet, not a deep-link
| Option | Pros | Cons |
|---|---|---|
| Sheet w/ 3 options ✅ | One predictable place; works on every page; matches "primary capture/add" idiom from baseline doc | One extra tap vs deep-link |
Always /deadlines/new |
Zero-tap deadline creation | Wrong default ~30% of the time (Termin/Projekt also frequent); no escape if user wanted Termin |
| Context-aware (per page) | Smart defaults | Surprising — same button does different things on different pages; harder to learn |
The sheet is also where new capture types can be added later (Quick Note, Voice memo) without redesign. Cheap to extend.
Sheet mechanics (mvp — does not fully copy otto-pwa)
- Native
<dialog>element viadialog.showModal(). - Slide-up via CSS
transform: translateY(100%) → translateY(0),transition: transform 220ms ease-out. - Backdrop tap dismisses (
dialog::backdropclick handler). - ESC closes (native
<dialog>behavior). - Drag-to-dismiss is NOT in v1. The full otto-pwa pointer-event
pattern (handle hit-area + pointermove transform + 120px threshold)
is great but adds ~80 lines for a phone-only flourish. Ship without
it; if m wants it, a follow-up task adds it copying otto-pwa
voice-modalverbatim. max-height: 60vh(we have only 3 rows; 92vh from the baseline doc is for sheets that contain scrollable lists).
Tapping a sheet row
Just navigates: window.location.href = "/deadlines/new" etc. The
existing /deadlines/new, /appointments/new, /projects/new pages
already work on mobile (form layout is single-column). No new endpoints.
Note: /projects/new requires admin in current implementation — for a
non-admin user, that row should be hidden (read window.__PALIAD_ME__
or whatever the page exposes; if not exposed, just always show and let
the destination page error gracefully — m's call).
5. Drawer: Reuse What's There
No new drawer. The existing Sidebar.tsx already renders into a
fixed-left aside that, at <1024px, is transform: translateX(-100%)
by default and slides to translateX(0) when class mobile-open is
toggled. The hamburger button + .sidebar-overlay already do the open
mechanics.
The BottomNav [Menü] slot wires into the same toggle that the
hamburger uses — they call the same toggleMobileSidebar().
Hamburger fate
At <768px (BottomNav visible): the existing top-left hamburger is
hidden (the BottomNav menu slot does the same job, in a thumb-reach
spot). At 768-1023px: hamburger stays visible, BottomNav stays
hidden — current behavior preserved.
What's in the drawer
It's the existing Sidebar — Dashboard, Übersicht (Dashboard, Agenda, Team), Arbeit (Projekte, Fristen, Termine), Werkzeuge, Wissen, Ressourcen, Einstellungen, plus the bottom block (Neuigkeiten, invite, DE/EN, Logout). Nothing duplicated, nothing pruned. Items already in the BottomNav (Dashboard, Projekte, Agenda) also still appear in the drawer — that's intentional, the drawer is the canonical map.
Drawer trigger options considered
| Trigger | Verdict |
|---|---|
BottomNav [Menü] slot ✅ |
Standard, discoverable, thumb-reach |
| Top-left hamburger (legacy) | Hidden on phones; lives on for tablets |
| Edge-swipe from left | No — conflicts with project-detail tabs that already overflow-scroll horizontally on mobile |
| ESC closes | Already implemented via closeMobile() |
Matches mBrian/otto pattern: button-triggered, no swipe.
6. Breakpoints
≥1024px : Desktop sidebar (hover-expand, pin)
768-1023px : Slide-out drawer + top-left hamburger (current behavior)
<768px : Slide-out drawer + BottomNav (hamburger hidden)
Two distinct thresholds because they answer different questions:
- 1024px = "is there room for a persistent collapsed sidebar?"
- 768px = "is this a phone — do we need a thumb-reach bar?"
The existing 1023px breakpoint stays. We add a new 767px breakpoint
specifically for showing/hiding BottomNav and hiding the legacy
hamburger.
The pwa-baseline doc says 768px throughout — that's the BottomNav breakpoint. The doc doesn't mandate the 1024 sidebar threshold; that's a paliad-specific affordance worth preserving.
7. Visual Spec
Bar dimensions
- Height:
56px(--bottom-nav-height, matches baseline doc). - Background:
var(--color-surface)(#ffffff). - Top border:
1px solid var(--color-border). - Box-shadow: subtle upward
0 -1px 3px rgba(0,0,0,0.04)— looks attached to the screen edge, not floating. - Position:
fixed; bottom: 0; left: 0; right: 0; - Padding-bottom:
env(safe-area-inset-bottom)— additive to the 56px, so on iPhone X+ the bar effectively grows to account for the home indicator without overlapping it. - Width: 100%, slots
flex: 1. - Z-index:
30(above content, below sidebar overlay z=35 so the drawer always covers BottomNav, below modals z=100).
Slot
- 56px tall, full-width slot, vertical icon (~22px) + label (10-11px).
- Active slot: lime accent
var(--color-accent)icon + label, with a thin lime top-bar (3px tall) at the slot top edge. - Inactive slot:
var(--color-text-muted)icon + label. - Tap target: full slot — no inner padding gymnastics. iOS HIG ≥44pt; 56px height + ~70px wide slot easily clears that.
Center slot ([+])
- Visually elevated: a 48px circular lime button raised ~4px above the
bar (negative margin-top), white plus-icon, subtle
box-shadow: var(--shadow-md). - Same width slot underneath; the circle is decoration, the whole slot is the tap target.
- This is the only "loud" pattern; matches the baseline doc's "primary capture/add action" emphasis.
Quick-Add sheet
- Width: 100vw on mobile, max 480px on tablet (the sheet should never appear on desktop because the [+] slot only exists on phones, but cap width as belt-and-braces).
- Border-radius:
16px 16px 0 0(top corners rounded, bottom flush). - Padding-bottom:
env(safe-area-inset-bottom)so the cancel row sits above the home indicator. - Backdrop:
rgba(0,0,0,0.5)via<dialog>::backdrop.
Layout reflow
Pages with body.has-sidebar need extra bottom padding on mobile so
the BottomNav doesn't cover the last row of content. New CSS rule:
@media (max-width: 767px) {
body.has-sidebar main {
padding-bottom: calc(var(--bottom-nav-height) + 1rem
+ env(safe-area-inset-bottom));
}
}
main gets the padding rather than body so the BottomNav's own
fixed-position remains glued to the viewport edge.
Keyboard-open hide
body.keyboard-open .bottom-nav {
transform: translateY(120%);
transition: transform 200ms ease-out;
}
Toggle from JS via visualViewport.height delta > 100px (see §9).
8. Files to Add / Change
New files
| File | Purpose |
|---|---|
frontend/src/components/BottomNav.tsx |
TSX component, exports BottomNav({currentPath, role?}) |
frontend/src/client/bottom-nav.ts |
initBottomNav() — drawer toggle wiring, sheet open/close, visualViewport keyboard watcher |
Modified files
| File | Change |
|---|---|
frontend/src/components/Sidebar.tsx |
Hamburger button gains a class so CSS can hide it <768px (.sidebar-hamburger.hide-on-phone or just by media query — no markup change needed). Add id on the toggle target so bottom-nav.ts can find/share it. |
frontend/src/client/sidebar.ts |
Export toggleMobileSidebar() so bottom-nav.ts re-uses the exact same open/close/overlay code (don't duplicate). |
frontend/src/client/index.ts |
Add import { initBottomNav } from "./bottom-nav"; initBottomNav(); |
frontend/src/styles/global.css |
Add ~120 lines: --bottom-nav-height token, .bottom-nav + slot styles, <768px media query showing BottomNav and hiding hamburger, keyboard-open transform, body.has-sidebar main padding-bottom rule, sheet styles. |
All page *.tsx files (~25) |
(a) Replace <meta name="viewport" content="width=device-width, initial-scale=1.0" /> with <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />. (b) Add <BottomNav currentPath="..." /> next to existing <Sidebar currentPath="..." /> in each page. Easiest as a sed for (a); each page is touched once for (b). |
frontend/build.ts |
Add bottom-nav.ts entry... — actually bottom-nav.ts is imported by index.ts so it gets bundled into index.js — no separate entry needed. |
Optionally (low-cost, highly recommended)
| File | Change |
|---|---|
All page *.tsx <head> |
Add <meta name="theme-color" content="#65a30d" /> (lime, matches accent) so iOS Safari paints the URL bar lime in standalone mode. |
All page *.tsx <head> |
Add <meta name="apple-mobile-web-app-capable" content="yes" /> and <meta name="apple-mobile-web-app-status-bar-style" content="default" />. Cheap, no asset dependency. |
These three meta-tag rows are a one-time sed across 25 files; adding them now means we don't need a follow-up just to redo the sweep.
NOT in this task
manifest.jsonand icon assets (192/512 maskable PNGs) → follow-up.- Service worker /
sw.js/ app-shell caching → follow-up. beforeinstallpromptadd-to-home-screen UI → follow-up.
These are tracked under §11 below as proposed t-paliad-04* follow-ups.
9. Behavior Spec (bottom-nav.ts)
// Pseudo-shape (real impl will follow paliad style — no narration comments).
import { toggleMobileSidebar } from "./sidebar";
export function initBottomNav() {
initDrawerSlot(); // [Menü] tap → toggleMobileSidebar()
initQuickAddSheet(); // [+] tap → dialog.showModal(); rows nav
initKeyboardWatcher(); // visualViewport resize → body.keyboard-open
}
Keyboard watcher (the one tricky bit)
function initKeyboardWatcher() {
if (!window.visualViewport) return; // older browsers: no-op
const baseHeight = window.innerHeight;
const KEYBOARD_THRESHOLD = 100; // px shrink == keyboard
window.visualViewport.addEventListener("resize", () => {
const delta = baseHeight - window.visualViewport!.height;
document.body.classList.toggle("keyboard-open", delta > KEYBOARD_THRESHOLD);
});
}
baseHeight is captured once at init — re-orientation events update it
via a window.orientationchange handler. Edge case: a user who rotates
the phone while the keyboard is open will see the bar reappear briefly
until the keyboard re-deploys. Acceptable.
Active-tab class on navigation
The TSX component renders the active class server-side from
currentPath, identical to Sidebar. No client-side recomputation
needed.
10. Z-index Map (post-change)
| Layer | z-index | Notes |
|---|---|---|
| Page content | auto | |
| Header | 10 | Existing .header |
| BottomNav | 30 | New |
| Sidebar overlay (drawer backdrop) | 35 | Existing |
| Sidebar drawer | 40 | Existing |
| Top-left hamburger (legacy, tablet) | 50 | Existing — hidden <768px |
| Quick-Add sheet backdrop | 90 | New (or just rely on <dialog>::backdrop) |
| Quick-Add sheet card | 100 | New, same tier as .modal-overlay |
| Existing modal-overlay (invite, etc.) | 100 | Existing |
When the drawer is open over the BottomNav: the drawer (z=40) is wider than the BottomNav (z=30), and its overlay (z=35) sits above the BottomNav — so the BottomNav is fully covered. ✓
When the Quick-Add sheet is open over the BottomNav: sheet (z=100) sits above; backdrop dims everything below including BottomNav. ✓
11. Rollout Plan
Single PR, single coherent commit per phase per task convention:
- Phase 1 (this task, after m's go): land BottomNav + Quick-Add sheet
- drawer wiring + viewport-fit meta + theme-color meta. One commit
on this worktree's branch (
mai/cronus/pwa-mobile-bottom-nav), self-merge tomainper t-paliad-038/039/040 precedent.
- drawer wiring + viewport-fit meta + theme-color meta. One commit
on this worktree's branch (
- Verify on mobile breakpoint: Playwright (
browser_resizeto 375×812 iPhone X) — confirm: BottomNav renders, sheet opens, drawer opens from[Menü], no double-hamburger, content padding leaves the last item visible above the bar. Login astester@hlc.deto test the authenticated paths. - Build green:
bun run buildandgo build ./... && go vet ./... && go test ./....
Follow-up tasks proposed (NOT in this task)
t-paliad-04X—manifest.json+ 192/512 maskable icons +<link rel="apple-touch-icon">on every page → installable PWA.t-paliad-04Y—sw.jsnetwork-first cache app-shell strategy (copy from mBrian; keep tiny — just/dashboardand/assets/global.css).t-paliad-04Z—beforeinstallpromptUI: a one-time toast ("Add Paliad to your Home Screen?") gated by a localStoragepaliad-pwa-prompt-dismissedflag.t-paliad-04W— Drag-to-dismiss for Quick-Add sheet (otto-pwa pattern verbatim).t-paliad-04V— Project-detail tabs horizontal-overflow polish (already-known tablet/phone problem; surfaced again here but out of scope).
12. Open Questions for m
- Slot 4: Agenda or Fristen? Design picks Agenda. Brief offered either. If you prefer the more old-school Fristen (deadlines only, no Termine), it's a one-line swap. Recommendation: Agenda.
- Center [+] slot: sheet or deep-link? Design picks the 3-option
slide-up sheet. If you prefer to skip the sheet and have [+] always
go to
/deadlines/new(the most-frequent capture), say so — simpler, no<dialog>. Recommendation: sheet. - PWA shell items: Add the 3 meta tags now (viewport-fit, theme-color, apple-mobile-web-app-capable) but defer manifest + service worker + install prompt to follow-up tasks? Recommendation: yes — meta now, manifest/SW/prompt later.
/projects/newquick-add row visibility: non-admins can't create projects. Hide the row for them, or always show and let the page gracefully error? Recommendation: always show, defer the permission-aware row to a follow-up — keeps this PR self-contained and matches what the Sidebar already does (Projekteis shown to everyone; admin-gating happens on the destination page).- Badge counts on BottomNav slots (e.g. red-dot on Agenda when an overdue Frist is due today)? Nice-to-have, not in v1. Out of scope here. Confirm: defer to follow-up.
- Tablet (768-1023px) behavior: keep as-is (hamburger drawer, no BottomNav)? The pwa-baseline doc draws the line at 768 — we honor it on the BottomNav side. Confirm: yes, no BottomNav on tablet.
13. Acceptance Mapping
| Requirement | How design satisfies |
|---|---|
Design doc at docs/design-pwa-bottom-nav.md |
This file |
| BottomNav renders <768px, hidden ≥768px | §6 + media query in §8 |
| Mobile drawer slides out, mirrors desktop Sidebar | §5 — reuses existing Sidebar.tsx + slide-out CSS |
| Keyboard-open hides BottomNav | §9 visualViewport watcher + body.keyboard-open CSS |
| safe-area-inset-bottom padding on iOS | §7 dimensions + §8 viewport-fit=cover meta |
| No regression in desktop layout | Desktop ≥1024px untouched; only <768px adds BottomNav and hides hamburger; tablet 768-1023px unchanged |
| Single commit per phase | §11 rollout |