Files
paliad/docs/design-pwa-bottom-nav.md
m 263a4605e3 docs(design): add PWA mobile BottomNav design (t-paliad-041)
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.
2026-04-26 01:59:31 +02:00

479 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (`visualViewport` watcher).
- Honors `safe-area-inset-bottom` so 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 and `safe-area-inset` actually
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:
1. *"What's coming up this week?"* → Agenda, Dashboard
2. *"What's the status on this matter?"* → Projekte detail
3. *"Quickly capture a Frist I just got told about"* → create Frist
4. *"What's the Frist for replying to office action X?"* → Fristenrechner (rare on phone)
5. *"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 via `dialog.showModal()`.
- Slide-up via CSS `transform: translateY(100%) → translateY(0)`,
`transition: transform 220ms ease-out`.
- Backdrop tap dismisses (`dialog::backdrop` click 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-modal` verbatim.
- `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:
```css
@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
```css
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.json` and icon assets (192/512 maskable PNGs) → follow-up.
- Service worker / `sw.js` / app-shell caching → follow-up.
- `beforeinstallprompt` add-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`)
```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)
```ts
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:**
1. *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 to `main` per t-paliad-038/039/040 precedent.
2. *Verify on mobile breakpoint:* Playwright (`browser_resize` to
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 as `tester@hlc.de` to test
the authenticated paths.
3. *Build green:* `bun run build` and `go 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.js` network-first cache app-shell strategy (copy from mBrian; keep tiny just `/dashboard` and `/assets/global.css`).
- `t-paliad-04Z` `beforeinstallprompt` UI: a one-time toast ("Add Paliad to your Home Screen?") gated by a localStorage `paliad-pwa-prompt-dismissed` flag.
- `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
1. **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**.
2. **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**.
3. **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.**
4. **`/projects/new` quick-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 (`Projekte` is shown to
everyone; admin-gating happens on the destination page).
5. **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.**
6. **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 |