From 8f1064282ae0098c22947ef66f012d8733b5e361 Mon Sep 17 00:00:00 2001 From: Sauer Date: Tue, 21 Apr 2026 18:24:09 +0200 Subject: [PATCH 01/31] Add design doc for mobile shell and /scan feature Captures the navigation, scanner, result sheet, per-page mobile transformations, and PWA manifest decided during brainstorming. Lives in branch-docs/ so it travels with the feature branch. --- .../2026-04-21-mobile-and-scan-design.md | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 branch-docs/2026-04-21-mobile-and-scan-design.md diff --git a/branch-docs/2026-04-21-mobile-and-scan-design.md b/branch-docs/2026-04-21-mobile-and-scan-design.md new file mode 100644 index 0000000..bc84d21 --- /dev/null +++ b/branch-docs/2026-04-21-mobile-and-scan-design.md @@ -0,0 +1,243 @@ +# Mobile-first shell and `/scan` feature + +**Branch:** `feat/mobile-scan` +**Date:** 2026-04-21 +**Scope:** Make Stowage mobile-friendly and add a camera-driven `/scan` page. + +## Summary + +Stowage is desktop-only today. This work adds: + +1. A responsive shell that swaps the sidebar for a bottom navigation bar on screens below `lg` (1024px). +2. A new `/scan` route that uses the device camera to read Stowage barcodes and opens a quick-actions sheet for the scanned asset. +3. Mobile-friendly layouts for the field flows (assets, asset detail, services, dashboard, locations) and "usable, not polished" treatment for admin flows. +4. A web-app manifest so users can install Stowage to their home screen. + +No service worker, no offline support, no push notifications in this scope. + +## Navigation + +### Desktop (≥1024px) + +Unchanged. Existing `AppShell` renders `AppSidebar` + `Topbar` + `
`. SCAN does not appear in the sidebar — it is mobile-only. + +### Mobile (<1024px) + +Sidebar is hidden. A fixed `BottomNav` spans the bottom of the viewport with five slots: + +``` +Home · Assets · SCAN · Services · More +``` + +- Home → `/dashboard` +- Assets → `/assets` +- SCAN → `/scan` (elevated center button) +- Services → `/services` +- More → opens a bottom sheet with Locations, Taxonomy, Labels, Settings, Sign out, Theme toggle + +The topbar stays but shrinks to logo + search icon + avatar. The existing `SidebarTrigger` is hidden below `lg`. `
` gets `pb-20` below `lg` to clear the nav. + +### SCAN button + +- Icon: `ScanLine` from `lucide-react` (the universal "laser through a frame" icon). +- Shape: 60×60px circle, floats ~18px above the bar on a notched background. +- Colour: a new token `--scan` (teal-600 `#0d9488` light / teal-500 `#14b8a6` dark) with `--scan-foreground` white. Distinct from the existing burnt-orange `--primary`, read as "scanner beam". +- 4px `ring-background`, `shadow-lg` + `shadow-[--scan]/30` glow, `active:scale-95`, `navigator.vibrate(10)` on tap. +- No label beneath; the icon alone carries it. +- No idle animation on the icon. The scan-line sweep lives on the `/scan` page. + +## `/scan` page + +### Route + +`src/app/(app)/scan/page.tsx` → renders `ScanPageClient`. Sits inside the authed app group so the existing `AppLayout` handles access control. + +### Layout + +Immersive, edge-to-edge. Shell (topbar + bottom nav) stays visible — SCAN must be exitable. + +``` +┌─────────────────────────────────────┐ +│ ← Back 🔦 │ +├─────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────┐ │ +│ │ [camera preview] │ │ +│ │ ╭─╮ ╭─╮ │ │ +│ │ │ │ +│ │ ← animated teal line → │ │ +│ │ │ │ +│ │ ╭─╮ ╭─╮ │ │ +│ └─────────────────────────┘ │ +│ │ +│ Point at a Stowage label │ +│ │ +│ ┌───────────────────────┐ │ +│ │ Enter asset tag │ │ +│ └───────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +- **Reticle.** Four L-shaped corners in `--scan`, 2px stroke, 18px arms, framing a ~260px square. +- **Scan line.** 2px horizontal line in `--scan` sweeping top-to-bottom at ~1.5s per cycle via `@keyframes`. Pauses on `visibilitychange` to save battery. +- **Manual entry.** "Enter asset tag" button is always visible. Opens a sheet with a focused text input that runs the same resolver as a scan. +- **Torch.** Top-right icon. Shown only when the active `MediaStreamTrack` reports `getCapabilities().torch === true`. Toggles via `applyConstraints({ advanced: [{ torch }] })`. +- **Back.** Stops the stream and calls `router.back()`. +- **Permission denied / insecure context.** Replace the camera viewport with a neutral state: `CameraOff` icon, one-line explainer, help link to browser settings, manual-entry button remains. + +## Scanner implementation + +### `@zxing/browser` + +One dependency, one code path, works everywhere including Firefox. Imported dynamically inside the hook so it does not enter the main bundle. + +### Hook + +`useBarcodeScanner` in `src/hooks/use-barcode-scanner.ts`: + +```ts +type ScanResult = { text: string; format: "CODE_128" | "DATA_MATRIX" | "QR_CODE" }; + +function useBarcodeScanner(options: { + videoRef: RefObject; + onResult: (result: ScanResult) => void; + enabled: boolean; +}): { + state: "idle" | "requesting" | "scanning" | "denied" | "insecure" | "error"; + error: string | null; + torch: { supported: boolean; on: boolean; toggle: () => void }; + restart: () => void; +}; +``` + +- Dynamic-imports `BrowserMultiFormatReader` restricted to `CODE_128`, `DATA_MATRIX`, `QR_CODE`. +- Camera constraint: `{ facingMode: { ideal: "environment" } }`. +- Decode cadence: ~200ms per frame. +- Duplicate suppression: ignores repeat `text` values within a 2-second window. +- Always stops every `MediaStreamTrack` on unmount and on `enabled: false`. + +### Resolver + +`src/lib/scan.ts` exports `resolveScanTarget(text, appOrigin)`: + +1. If `text` is a URL with matching origin and path `/assets/` → fetch asset detail. +2. Otherwise → unresolved. +3. If asset fetch 404s or is forbidden → unresolved. + +Returns `{ status: "asset", asset } | { status: "unresolved", rawText }`. Strict by design — we do not pretend to understand foreign barcodes. + +### Post-scan state + +The page owns a `target` state. While `target !== null`, the scanner is paused. Dismissing the sheet clears `target` and resumes scanning so the user can walk a row of shelves scanning in sequence. + +- Success haptic: `navigator.vibrate?.(15)`. +- Unresolved haptic: `[10, 60, 10]`. +- Asset fetches go through TanStack Query keyed `["asset", id]`, so "View details" opens the asset page from cache. + +## Result sheet and quick actions + +When `target.status === "asset"`: + +``` +┌─────────────────────────────────────┐ +│ ─── drag handle ─── │ +├─────────────────────────────────────┤ +│ [STATUS] Drill #47 │ +│ AST-0047 · Workshop / Bench 3 │ +├─────────────────────────────────────┤ +│ ┌───────┐ ┌───────┐ ┌───────┐ │ +│ │ View │ │Status │ │ Move │ │ +│ └───────┘ └───────┘ └───────┘ │ +│ ┌───────┐ ┌───────┐ ┌───────┐ │ +│ │ Photo │ │ Note │ │Service│ │ +│ └───────┘ └───────┘ └───────┘ │ +├─────────────────────────────────────┤ +│ Scan another ↓ │ +└─────────────────────────────────────┘ +``` + +The six actions: + +1. **View details** → `router.push('/assets/' + id)`. +2. **Status** → nested sheet with the five `ASSET_STATUS_OPTIONS` as a radio list → `updateAsset({ status })`. +3. **Move** → nested sheet with a searchable location list → `updateAsset({ locationId })`. +4. **Photo** → `` launched programmatically → `attachments.uploadAttachment(assetId, file)`. +5. **Note** → single `