diff --git a/web/index.html b/web/index.html
index 11a3844..91e096a 100644
--- a/web/index.html
+++ b/web/index.html
@@ -5,6 +5,7 @@
+
HWLab
diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png
new file mode 100644
index 0000000..a678aa6
Binary files /dev/null and b/web/public/icons/icon-192.png differ
diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png
new file mode 100644
index 0000000..db90e5f
Binary files /dev/null and b/web/public/icons/icon-512.png differ
diff --git a/web/public/manifest.json b/web/public/manifest.json
new file mode 100644
index 0000000..c88bde5
--- /dev/null
+++ b/web/public/manifest.json
@@ -0,0 +1,26 @@
+{
+ "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": []
+}
diff --git a/web/public/sw.js b/web/public/sw.js
new file mode 100644
index 0000000..38ff09a
--- /dev/null
+++ b/web/public/sw.js
@@ -0,0 +1,56 @@
+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
+ })
+ )
+ )
+})
diff --git a/web/scripts/gen-icons.cjs b/web/scripts/gen-icons.cjs
new file mode 100644
index 0000000..1378aaf
--- /dev/null
+++ b/web/scripts/gen-icons.cjs
@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+// Generates minimal PNG icons for the PWA manifest.
+// Uses pure Node.js — no canvas or image library needed.
+// Output: valid PNG icons with an "H" monogram on black background with volt (#faff69) color.
+// 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')
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 3f5cf86..534b85f 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -3,8 +3,10 @@ import { RouterProvider } from '@tanstack/react-router'
import { Toaster } from 'react-hot-toast'
import { queryClient } from '@/lib/queryClient'
import { router } from '@/router'
+import { usePWA } from '@/hooks/usePWA'
export function App() {
+ usePWA()
return (
diff --git a/web/src/hooks/usePWA.ts b/web/src/hooks/usePWA.ts
new file mode 100644
index 0000000..fd6bf08
--- /dev/null
+++ b/web/src/hooks/usePWA.ts
@@ -0,0 +1,18 @@
+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)
+ })
+ })
+ }
+ }, [])
+}