| phase |
plan |
type |
wave |
depends_on |
files_modified |
autonomous |
requirements |
must_haves |
| 03-dashboard-intake-ui |
05 |
execute |
2 |
|
| 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 |
|
| 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') |
|
|
|
|
|
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>