From 263a4605e38f573b8953dd248620a1ee830f1914 Mon Sep 17 00:00:00 2001 From: m Date: Sun, 26 Apr 2026 01:59:31 +0200 Subject: [PATCH] docs(design): add PWA mobile BottomNav design (t-paliad-041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/design-pwa-bottom-nav.md | 478 ++++++++++++++++++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 docs/design-pwa-bottom-nav.md diff --git a/docs/design-pwa-bottom-nav.md b/docs/design-pwa-bottom-nav.md new file mode 100644 index 0000000..6c1f7ec --- /dev/null +++ b/docs/design-pwa-bottom-nav.md @@ -0,0 +1,478 @@ +# 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 `` 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 `` 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 `::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 `` with ``. (b) Add `` next to existing `` 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` `` | Add `` (lime, matches accent) so iOS Safari paints the URL bar lime in standalone mode. | +| All page `*.tsx` `` | Add `` and ``. 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 `::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 + `` 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 ``. 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 |