homelabby/.planning/phases/03-dashboard-intake-ui/03-05-PLAN.md
2026-04-10 06:12:02 +00:00

26 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
03-dashboard-intake-ui 05 execute 2
03-01
web/public/manifest.json
web/public/sw.js
web/public/icons/icon-192.png
web/public/icons/icon-512.png
web/src/hooks/usePWA.ts
web/src/pages/ScanPage.tsx
web/src/router.tsx
web/index.html
false
PWA-01
PWA-02
PWA-03
truths artifacts key_links
Chrome on Android shows the 'Add to Home Screen' banner for the app
The installed PWA launches in standalone mode (no browser chrome)
Visiting /scan activates the device rear camera and scans QR codes in real-time
A scanned HW-XXXXX QR code navigates the user to /item/:id for that item
Service worker caches the app shell so the UI loads offline (no network required for the shell)
path provides
web/public/manifest.json PWA web app manifest (name, icons, display=standalone, theme_color)
path provides
web/public/sw.js Service worker — app shell cache strategy for offline shell
path provides
web/src/pages/ScanPage.tsx /scan route — camera QR scanner using @zxing/browser
path provides
web/src/hooks/usePWA.ts Service worker registration hook
from to via
web/index.html web/public/manifest.json <link rel='manifest' href='/manifest.json'>
from to via
web/src/hooks/usePWA.ts web/public/sw.js navigator.serviceWorker.register('/sw.js')
from to via
web/src/pages/ScanPage.tsx http://localhost:8080/api/inventory Decoded QR text parsed as HW-XXXXX → navigate to /item/:id
Set up the PWA manifest, service worker, and camera-based QR scanner so HWLab is installable on Android and items can be looked up by scanning their printed QR labels.

Purpose: PWA installability and QR scanning close the hardware inventory loop — print a label on intake, scan it later to pull up the item record instantly. Output: Installable PWA with app shell offline caching and a /scan camera QR scanner that resolves HW IDs.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md @.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md ```typescript import { BrowserQRCodeReader, IScannerControls } from '@zxing/browser'

const reader = new BrowserQRCodeReader() // Get list of cameras: const devices = await BrowserQRCodeReader.listVideoInputDevices() // Start scanning — calls callback on each decoded result: const controls: IScannerControls = await reader.decodeFromVideoDevice( deviceId, // undefined = default (rear) camera videoElement, // HTMLVideoElement ref (result, _err, controls) => { if (result) { const text = result.getText() // text will be the QR code content controls.stop() } } ) // Stop: controls.stop()


<!-- QR code URL format (from Phase 1 PRD and LBL-01): -->
// QR codes encode: http://mac-mini.mg:8080/hw/XXXXX
// Or just the HW ID: HW-00001
// Parse: extract HW ID from URL path or direct match
// Lookup: GET /api/inventory returns hw_id field; match by hw_id to get numeric id
// Navigate: router.navigate({ to: '/item/$id', params: { id: String(numericId) } })

<!-- From Plan 01: router export -->
// import { router } from '@/router'
// router.navigate({ to: '/item/$id', params: { id: String(numericId) } })

<!-- From Plan 01: design tokens -->
// bg-canvas, text-volt, bg-near-black, border-charcoal/80
// Button variant="forest" for primary actions
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: PWA manifest, service worker, icons, and registration hook</name>
  <files>
    web/public/manifest.json,
    web/public/sw.js,
    web/public/icons/icon-192.png,
    web/public/icons/icon-512.png,
    web/src/hooks/usePWA.ts,
    web/index.html
  </files>
  <action>
    Read first: `web/index.html` (current state from Plan 01).

    **1. Create web/public/ directory structure:**
    ```
    web/public/
      manifest.json
      sw.js
      icons/
        icon-192.png    (placeholder — generate programmatically)
        icon-512.png    (placeholder — generate programmatically)
    ```

    **2. Create web/public/manifest.json** (PWA-01: installable on Android):
    ```json
    {
      "name": "HWLab",
      "short_name": "HWLab",
      "description": "Self-hosted hardware inventory with AI photo intake",
      "start_url": "/",
      "display": "standalone",
      "background_color": "#000000",
      "theme_color": "#faff69",
      "orientation": "portrait-primary",
      "icons": [
        {
          "src": "/icons/icon-192.png",
          "sizes": "192x192",
          "type": "image/png",
          "purpose": "any maskable"
        },
        {
          "src": "/icons/icon-512.png",
          "sizes": "512x512",
          "type": "image/png",
          "purpose": "any maskable"
        }
      ],
      "categories": ["utilities", "productivity"],
      "screenshots": []
    }
    ```

    **3. Generate placeholder icons using Node.js (no ImageMagick required):**
    Run this script to create minimal valid PNG icons programmatically. Create `web/scripts/gen-icons.cjs`:
    ```js
    #!/usr/bin/env node
    // Generates minimal PNG icons for the PWA manifest.
    // Uses pure Node.js — no canvas or image library needed.
    // Output: a valid 1x1 black PNG with correct PNG header and IDAT chunk.
    // For production, replace with properly designed icons.

    const fs = require('fs')
    const path = require('path')
    const zlib = require('zlib')

    function makePNG(size, bgHex, fgHex) {
      // PNG signature
      const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])

      // IHDR chunk
      const ihdrData = Buffer.alloc(13)
      ihdrData.writeUInt32BE(size, 0)
      ihdrData.writeUInt32BE(size, 4)
      ihdrData[8] = 8   // bit depth
      ihdrData[9] = 2   // color type RGB
      ihdrData[10] = 0  // compression
      ihdrData[11] = 0  // filter
      ihdrData[12] = 0  // interlace
      const ihdr = makeChunk('IHDR', ihdrData)

      // IDAT chunk — raw RGB pixel data, filtered (filter byte 0 = none per row)
      const bg = hexToRgb(bgHex)
      const fg = hexToRgb(fgHex)
      const rawRows = []
      const margin = Math.floor(size * 0.2)
      for (let y = 0; y < size; y++) {
        const row = [0] // filter byte
        for (let x = 0; x < size; x++) {
          // Simple "H" monogram in the center
          const inMargin = x >= margin && x < size - margin && y >= margin && y < size - margin
          const isStroke =
            inMargin &&
            ((x < margin + Math.floor(size * 0.08) || x > size - margin - Math.floor(size * 0.08)) ||
             (Math.abs(y - size / 2) < Math.floor(size * 0.06)))
          const c = isStroke ? fg : bg
          row.push(c[0], c[1], c[2])
        }
        rawRows.push(Buffer.from(row))
      }
      const rawData = Buffer.concat(rawRows)
      const compressed = zlib.deflateSync(rawData)
      const idat = makeChunk('IDAT', compressed)

      // IEND
      const iend = makeChunk('IEND', Buffer.alloc(0))

      return Buffer.concat([sig, ihdr, idat, iend])
    }

    function makeChunk(type, data) {
      const len = Buffer.alloc(4)
      len.writeUInt32BE(data.length)
      const typeBytes = Buffer.from(type)
      const crc = crc32(Buffer.concat([typeBytes, data]))
      const crcBuf = Buffer.alloc(4)
      crcBuf.writeInt32BE(crc)
      return Buffer.concat([len, typeBytes, data, crcBuf])
    }

    function hexToRgb(hex) {
      const n = parseInt(hex.replace('#', ''), 16)
      return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
    }

    function crc32(buf) {
      let crc = 0xffffffff
      const table = makeCRCTable()
      for (const b of buf) crc = (crc >>> 8) ^ table[(crc ^ b) & 0xff]
      return (crc ^ 0xffffffff) | 0
    }

    function makeCRCTable() {
      const t = new Int32Array(256)
      for (let i = 0; i < 256; i++) {
        let c = i
        for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
        t[i] = c
      }
      return t
    }

    const iconsDir = path.join(__dirname, '..', 'public', 'icons')
    fs.mkdirSync(iconsDir, { recursive: true })
    fs.writeFileSync(path.join(iconsDir, 'icon-192.png'), makePNG(192, '#000000', '#faff69'))
    fs.writeFileSync(path.join(iconsDir, 'icon-512.png'), makePNG(512, '#000000', '#faff69'))
    console.log('Icons generated: icon-192.png, icon-512.png')
    ```

    Run: `node web/scripts/gen-icons.cjs`

    **4. Create web/public/sw.js** — app shell cache strategy:
    ```javascript
    const CACHE_NAME = 'hwlab-shell-v1'

    // App shell assets to cache on install
    const SHELL_ASSETS = [
      '/',
      '/index.html',
    ]

    self.addEventListener('install', (event) => {
      event.waitUntil(
        caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS))
      )
      self.skipWaiting()
    })

    self.addEventListener('activate', (event) => {
      // Remove old caches
      event.waitUntil(
        caches.keys().then((keys) =>
          Promise.all(
            keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
          )
        )
      )
      self.clients.claim()
    })

    self.addEventListener('fetch', (event) => {
      const url = new URL(event.request.url)

      // API requests: network only — never cache
      if (url.pathname.startsWith('/api/')) {
        return
      }

      // Navigation requests: serve from cache, fall back to network (app shell)
      if (event.request.mode === 'navigate') {
        event.respondWith(
          caches.match('/').then((cached) => cached ?? fetch(event.request))
        )
        return
      }

      // Static assets: cache-first
      event.respondWith(
        caches.match(event.request).then(
          (cached) => cached ?? fetch(event.request).then((response) => {
            if (response.ok && event.request.method === 'GET') {
              const clone = response.clone()
              caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
            }
            return response
          })
        )
      )
    })
    ```

    **5. Create web/src/hooks/usePWA.ts** — service worker registration:
    ```typescript
    import { useEffect } from 'react'

    export function usePWA() {
      useEffect(() => {
        if ('serviceWorker' in navigator) {
          window.addEventListener('load', () => {
            navigator.serviceWorker
              .register('/sw.js')
              .then((reg) => {
                console.log('[PWA] Service worker registered:', reg.scope)
              })
              .catch((err) => {
                console.warn('[PWA] Service worker registration failed:', err)
              })
          })
        }
      }, [])
    }
    ```

    **6. Update web/src/App.tsx** — call usePWA() hook:
    Read current `web/src/App.tsx`, then add:
    ```typescript
    import { usePWA } from '@/hooks/usePWA'

    export function App() {
      usePWA()  // Register service worker
      // ... rest unchanged
    }
    ```

    **7. Verify web/index.html already has the manifest link** (it was added in Plan 01):
    `grep "manifest" web/index.html` — if missing, add `<link rel="manifest" href="/manifest.json" />` inside `<head>`.

    Add `<meta name="theme-color" content="#faff69" />` inside `<head>` if not already present.

    Run `npm run build`.
  </action>
  <verify>
    <automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -5 && ls public/icons/ && node -e "const b = require('fs').readFileSync('public/icons/icon-192.png'); console.log('PNG sig:', b.slice(0,4).toString('hex') === '89504e47' ? 'VALID' : 'INVALID')"</automated>
  </verify>
  <done>
    `npm run build` exits 0. `web/public/icons/icon-192.png` and `icon-512.png` exist as valid PNG files (PNG signature 89504E47). `web/public/manifest.json` exists with `"display": "standalone"` and `"theme_color": "#faff69"`. `web/public/sw.js` exists. `web/src/hooks/usePWA.ts` exports `usePWA`.
  </done>
</task>

<task type="auto">
  <name>Task 2: QR scanner page with @zxing/browser and router wiring</name>
  <files>
    web/src/pages/ScanPage.tsx,
    web/src/router.tsx
  </files>
  <action>
    Read first: `web/src/router.tsx` (current state — has stub scanRoute), `web/src/lib/api.ts` (fetchInventory for HW ID lookup).

    **Create web/src/pages/ScanPage.tsx** — camera QR code scanner:

    Flow:
    1. Request camera access via `BrowserQRCodeReader.listVideoInputDevices()`
    2. Stream rear camera to `<video>` element
    3. On QR decode: parse text to extract HW ID or item URL
    4. Lookup item ID from inventory: `GET /api/inventory`, find by `hw_id` match
    5. Navigate to `/item/:id`

    QR text parsing:
    - If text matches `/hw/(HW-\d+)/i` or just `HW-\d+` → extract HW ID
    - Look up numeric NetBox ID by matching hw_id in the inventory list
    - Navigate to `/item/$id`

    ```typescript
    import { useEffect, useRef, useState, useCallback } from 'react'
    import { BrowserQRCodeReader, type IScannerControls } from '@zxing/browser'
    import { useNavigate } from '@tanstack/react-router'
    import { AppShell } from '@/components/layout/AppShell'
    import { Button } from '@/components/ui/button'
    import { Camera, Loader2, QrCode, AlertCircle } from 'lucide-react'
    import { fetchInventory } from '@/lib/api'
    import { useUIStore } from '@/store/ui'
    import toast from 'react-hot-toast'

    function extractHWID(text: string): string | null {
      // Match full URL: http://mac-mini.mg:8080/hw/HW-00001
      const urlMatch = text.match(/\/hw\/(HW-\d{5,})/i)
      if (urlMatch) return urlMatch[1].toUpperCase()
      // Match bare HW ID: HW-00001
      const bareMatch = text.match(/^(HW-\d{5,})$/i)
      if (bareMatch) return bareMatch[1].toUpperCase()
      return null
    }

    type ScanState = 'idle' | 'requesting' | 'scanning' | 'resolving' | 'error'

    export function ScanPage() {
      const navigate = useNavigate()
      const videoRef = useRef<HTMLVideoElement>(null)
      const controlsRef = useRef<IScannerControls | null>(null)
      const readerRef = useRef<BrowserQRCodeReader | null>(null)
      const { setScannerActive } = useUIStore()

      const [scanState, setScanState] = useState<ScanState>('idle')
      const [errorMsg, setErrorMsg] = useState<string | null>(null)
      const [lastScanned, setLastScanned] = useState<string | null>(null)

      const stopScanner = useCallback(() => {
        controlsRef.current?.stop()
        controlsRef.current = null
        setScannerActive(false)
      }, [setScannerActive])

      // Cleanup on unmount
      useEffect(() => () => stopScanner(), [stopScanner])

      const startScanner = useCallback(async () => {
        setScanState('requesting')
        setErrorMsg(null)

        try {
          const reader = new BrowserQRCodeReader()
          readerRef.current = reader

          const devices = await BrowserQRCodeReader.listVideoInputDevices()
          if (devices.length === 0) {
            throw new Error('No camera found on this device')
          }

          // Prefer rear camera (contains "back" or "environment" in label)
          const rear = devices.find((d) =>
            /back|rear|environment/i.test(d.label)
          )
          const deviceId = rear?.deviceId ?? devices[devices.length - 1].deviceId

          setScanState('scanning')
          setScannerActive(true)

          const controls = await reader.decodeFromVideoDevice(
            deviceId,
            videoRef.current!,
            async (result, _err) => {
              if (!result) return
              const text = result.getText()
              const hwid = extractHWID(text)

              if (!hwid) {
                // Not an HWLab QR code — ignore
                return
              }

              // Debounce: skip if same code scanned recently
              if (hwid === lastScanned) return
              setLastScanned(hwid)

              controls.stop()
              setScanState('resolving')

              try {
                const items = await fetchInventory()
                const item = items.find(
                  (i) => i.hw_id?.toUpperCase() === hwid || i.asset_tag?.toUpperCase() === hwid
                )

                if (!item) {
                  toast.error(`${hwid} not found in inventory`)
                  setScanState('scanning')
                  // Restart scanner
                  startScanner()
                  return
                }

                stopScanner()
                navigate({ to: '/item/$id', params: { id: String(item.id) } })
              } catch (e) {
                toast.error('Failed to look up item')
                setScanState('scanning')
                startScanner()
              }
            }
          )

          controlsRef.current = controls
        } catch (e) {
          setErrorMsg((e as Error).message)
          setScanState('error')
          setScannerActive(false)
        }
      }, [lastScanned, navigate, setScannerActive, stopScanner])

      return (
        <AppShell>
          <div className="mb-6">
            <h1 className="font-display font-black text-3xl text-white mb-1">Scan QR Code</h1>
            <p className="text-sm text-[#a0a0a0]">Point camera at an HWLab label to open the item</p>
          </div>

          <div className="max-w-sm mx-auto space-y-4">
            {/* Camera viewfinder */}
            <div className="relative aspect-square bg-near-black rounded-card overflow-hidden border border-charcoal/80">
              <video
                ref={videoRef}
                className="w-full h-full object-cover"
                playsInline
                muted
              />

              {/* Scan reticle overlay */}
              {scanState === 'scanning' && (
                <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
                  <div className="w-48 h-48 border-2 border-volt rounded-sharp opacity-80">
                    {/* Corner brackets */}
                    <div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-volt" />
                    <div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-volt" />
                    <div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-volt" />
                    <div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-volt" />
                  </div>
                </div>
              )}

              {/* Idle state overlay */}
              {scanState === 'idle' && (
                <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-[#a0a0a0]">
                  <QrCode className="w-16 h-16" />
                  <p className="text-sm">Camera not started</p>
                </div>
              )}

              {/* Loading */}
              {(scanState === 'requesting' || scanState === 'resolving') && (
                <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-canvas/80">
                  <Loader2 className="w-10 h-10 animate-spin text-volt" />
                  <p className="text-sm text-white">
                    {scanState === 'requesting' ? 'Starting camera…' : 'Looking up item…'}
                  </p>
                </div>
              )}

              {/* Error */}
              {scanState === 'error' && (
                <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
                  <AlertCircle className="w-10 h-10 text-red-400" />
                  <p className="text-sm text-red-400">{errorMsg}</p>
                </div>
              )}
            </div>

            {/* Actions */}
            {scanState === 'idle' || scanState === 'error' ? (
              <Button variant="forest" className="w-full" onClick={startScanner}>
                <Camera className="w-4 h-4 mr-2" />
                {scanState === 'error' ? 'Try Again' : 'Start Camera'}
              </Button>
            ) : scanState === 'scanning' ? (
              <Button variant="secondary" className="w-full" onClick={stopScanner}>
                Stop Scanner
              </Button>
            ) : null}

            <p className="text-xs text-center text-[#585858]">
              Scans QR codes printed on HWLab labels (HW-XXXXX format)
            </p>
          </div>
        </AppShell>
      )
    }
    ```

    **Update web/src/router.tsx** — replace stub scanRoute with lazy ScanPage:
    Read current `web/src/router.tsx`, then update `scanRoute`:
    ```typescript
    const ScanPage = lazy(() => import('./pages/ScanPage').then(m => ({ default: m.ScanPage })))

    const scanRoute = createRoute({
      getParentRoute: () => rootRoute,
      path: '/scan',
      component: () => (
        <Suspense fallback={<Spinner />}>
          <ScanPage />
        </Suspense>
      ),
    })
    ```

    Run `npm run build`.
  </action>
  <verify>
    <automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
  </verify>
  <done>
    `npm run build` exits 0 with no TypeScript errors. `web/src/pages/ScanPage.tsx` exports `ScanPage`. `web/src/router.tsx` lazy-loads ScanPage. `extractHWID` function handles both URL and bare HW-ID formats.
  </done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <what-built>
    PWA setup and QR scanner:
    - web/public/manifest.json — installable PWA config (name, theme_color=#faff69, display=standalone, icons)
    - web/public/sw.js — app shell cache (API calls always network-only, navigation uses cache-first)
    - web/public/icons/ — 192px and 512px PNG icons (black canvas, volt "H" monogram)
    - web/src/pages/ScanPage.tsx — /scan route with live camera, volt reticle overlay, HW ID parsing
    - Service worker registered on app load via usePWA() hook
  </what-built>
  <how-to-verify>
    **PWA installability (Chrome on Android or Chrome DevTools > Application):**
    1. Build: `cd web && npm run build`
    2. Start Go backend (which serves the built SPA): `go run ./cmd/hwlab/...`
    3. Open http://mac-mini.mg:8080 in Chrome
    4. Open DevTools > Application > Manifest
    - [ ] Manifest loads with correct name, theme_color #faff69
    - [ ] Icons show as 192px and 512px (may be the simple monogram placeholder)
    - [ ] No manifest errors in DevTools
    5. DevTools > Application > Service Workers
    - [ ] sw.js registered and status is "Activated and is running"
    6. On Android Chrome: address bar shows install banner or + icon to "Add to Home screen"

    **QR scanner (http://localhost:5173/scan or installed PWA):**
    1. Navigate to /scan
    - [ ] "Scan QR Code" heading, gray QR code icon, "Start Camera" forest green button
    2. Click "Start Camera"
    - [ ] Camera permission dialog appears
    - [ ] After permission: live video feed shows in viewfinder, volt reticle overlay appears
    3. Point camera at any QR code (or print test): `http://mac-mini.mg:8080/hw/HW-00001`
    - [ ] Toast appears on match if item exists in NetBox, navigates to /item/:id
    - [ ] Toast "HW-00001 not found in inventory" if item doesn't exist yet
    4. Test "Stop Scanner" button stops camera and returns to idle state

    **Mobile layout (390px):**
    - [ ] /scan viewfinder is square and fills the screen width
    - [ ] "Start Camera" button is full-width, tap-friendly
  </how-to-verify>
  <resume-signal>Type "approved" or describe issues to fix</resume-signal>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Camera → QR decode | Raw camera frames decoded by @zxing — output is untrusted text |
| QR text → navigation | Decoded QR text is parsed before any navigation or API call |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-14 | Tampering | QR text → extractHWID | mitigate | `extractHWID` regex only matches `HW-\d{5,}` pattern; arbitrary text cannot trigger navigation; lookup uses numeric NetBox ID from server-returned data, not from QR content directly |
| T-03-15 | Info Disclosure | Camera stream | accept | Camera stream is local to the device; no frames are sent to the server; @zxing decodes frames locally |
| T-03-16 | Tampering | Service worker cache | accept | SW caches only app shell (static assets); API responses are explicitly excluded from cache (network-only path); no risk of stale auth or inventory data |
| T-03-17 | DoS | Rapid QR scan triggers | mitigate | `lastScanned` state debounces repeated scans of the same QR code; scanner stops before resolving item |
</threat_model>

<verification>
1. `cd web && npm run build` — exits 0
2. `ls web/public/manifest.json web/public/sw.js web/public/icons/icon-192.png web/public/icons/icon-512.png` — all exist
3. `node -e "require('fs').readFileSync('web/public/manifest.json'); console.log('manifest valid JSON')"` — parses without error
4. `grep "ScanPage" web/src/router.tsx` — lazy import present
5. `grep "extractHWID" web/src/pages/ScanPage.tsx` — HW ID parsing function present
6. Visual/functional verification via checkpoint task
</verification>

<success_criteria>
- manifest.json loads in Chrome DevTools with no errors, display=standalone, theme_color=#faff69
- Service worker registered and active (DevTools > Application > Service Workers)
- PWA "Add to Home Screen" installable on Android Chrome (PWA-01)
- /scan activates rear camera and scans QR codes in real-time
- Decoded HW-XXXXX QR navigates to /item/:id (PWA-03)
- QR text parsing handles both URL format (http://.../hw/HW-00001) and bare HW-ID
- Camera permission denied handled gracefully (error state, not crash)
- Human verification checkpoint passed
</success_criteria>

<output>
After completion, create `.planning/phases/03-dashboard-intake-ui/03-05-SUMMARY.md` following the summary template.
</output>