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

21 KiB
Raw Blame History

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]           │
└─────────────────────────────────┘
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:

@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.
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)

// 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:

  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-04Xmanifest.json + 192/512 maskable icons + <link rel="apple-touch-icon"> on every page → installable PWA.
  • t-paliad-04Ysw.js network-first cache app-shell strategy (copy from mBrian; keep tiny — just /dashboard and /assets/global.css).
  • t-paliad-04Zbeforeinstallprompt 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