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

819 lines
27 KiB
Markdown

---
phase: 03-dashboard-intake-ui
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
- web/package.json
- web/tsconfig.json
- web/tsconfig.node.json
- web/vite.config.ts
- web/tailwind.config.ts
- web/postcss.config.cjs
- web/index.html
- web/src/main.tsx
- web/src/App.tsx
- web/src/router.tsx
- web/src/lib/queryClient.ts
- web/src/store/ui.ts
- web/src/styles/globals.css
- web/components.json
- web/src/components/ui/button.tsx
- web/src/components/ui/card.tsx
- web/src/components/ui/badge.tsx
- Makefile
autonomous: true
requirements: [UI-06]
must_haves:
truths:
- "Running `npm run dev` inside web/ serves the SPA at http://localhost:5173 with /api proxied to :8080"
- "Running `npm run build` inside web/ produces web/dist/index.html and web/dist/assets/ that Go can embed"
- "The page background is #000000 with neon volt (#faff69) as the accent color"
- "Inter font weight 900 is loaded and applied to display headings"
- "shadcn/ui Button, Card, and Badge components are available with ClickHouse tokens"
- "TanStack Router renders a /health-check route returning a plain text component"
- "TanStack Query QueryClientProvider wraps the app"
- "Zustand uiStore is defined and importable"
artifacts:
- path: "web/package.json"
provides: "npm project manifest with all dependencies pinned"
- path: "web/tailwind.config.ts"
provides: "ClickHouse design tokens as Tailwind CSS custom colors/fonts"
- path: "web/src/main.tsx"
provides: "React 18 entry point with Router + QueryClient providers"
- path: "web/src/router.tsx"
provides: "TanStack Router route tree with placeholder routes"
- path: "web/src/store/ui.ts"
provides: "Zustand UI store (view mode toggle, scanner state)"
- path: "web/components.json"
provides: "shadcn/ui CLI config"
- path: "web/src/components/ui/button.tsx"
provides: "shadcn/ui Button primitive"
- path: "web/src/components/ui/card.tsx"
provides: "shadcn/ui Card primitive"
- path: "web/src/components/ui/badge.tsx"
provides: "shadcn/ui Badge primitive"
key_links:
- from: "web/src/main.tsx"
to: "web/src/router.tsx"
via: "RouterProvider from @tanstack/react-router"
- from: "web/tailwind.config.ts"
to: "web/src/styles/globals.css"
via: "@tailwind directives + CSS custom properties"
- from: "web/vite.config.ts"
to: "http://localhost:8080"
via: "proxy: { '/api': 'http://localhost:8080' }"
---
<objective>
Bootstrap the React+TypeScript frontend with the full toolchain and ClickHouse design system.
Purpose: Every subsequent UI plan depends on this scaffold existing. The design tokens, component library, routing shell, and build pipeline must all be correct before feature development begins.
Output: A buildable SPA in web/ with Vite 5, React 18, TypeScript 5, Tailwind 3, shadcn/ui, TanStack Router v1, TanStack Query v5, Zustand v4, and the ClickHouse visual identity.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
@assets.go
@web/dist/index.html
<interfaces>
<!-- Existing Go embed setup — web/dist is already embedded by assets.go at project root -->
<!-- web/dist/index.html is currently a stub placeholder — will be replaced by Vite build output -->
<!-- The Go server uses chi router and serves web/dist via go:embed -->
From assets.go:
```go
//go:embed web/dist
var StaticFiles embed.FS
```
From internal/api/router.go (SPA fallback handler):
```go
// Serves static files from embedded FS; falls back to index.html for client-side routing
r.Handle("/*", spaHandler{staticFS: staticFiles})
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Scaffold Vite+React project and apply ClickHouse design tokens</name>
<files>
web/package.json,
web/tsconfig.json,
web/tsconfig.node.json,
web/vite.config.ts,
web/tailwind.config.ts,
web/postcss.config.cjs,
web/index.html,
web/src/styles/globals.css,
web/components.json
</files>
<action>
Read first: `web/dist/index.html` (current stub), `assets.go`, `internal/api/router.go`.
**1. Create web/package.json** with these exact dependencies:
```json
{
"name": "hwlab",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.51.0",
"@tanstack/react-router": "^1.48.0",
"@tanstack/router-devtools": "^1.48.0",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.417.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.3",
"react-hot-toast": "^2.4.1",
"tailwind-merge": "^2.5.2",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.15.0",
"@typescript-eslint/parser": "^7.15.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^9.7.0",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.3",
"vite": "^5.3.5"
}
}
```
**2. Create web/vite.config.ts:**
```typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
```
**3. Create web/tsconfig.json** (strict mode, path aliases):
```json
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
```
Create web/tsconfig.app.json:
```json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
```
Create web/tsconfig.node.json:
```json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
```
**4. Create web/postcss.config.cjs:**
```js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
```
**5. Create web/tailwind.config.ts** with ClickHouse design tokens:
```typescript
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// ClickHouse design system tokens
'volt': '#faff69', // neon yellow-green — primary CTA accent
'volt-pale': '#f4f692', // active/pressed state text
'forest': '#166534', // primary action button (Get Started)
'forest-dark':'#14572f', // forest border variant
'olive-dark': '#161600', // subtle brand text
'border-olive':'#4f5100', // ghost button borders
// Surfaces
'canvas': '#000000', // pure black background
'near-black': '#141414', // button bg, elevated surfaces
'hover-gray': '#3a3a3a', // button hover bg
'charcoal': '#414141', // primary border (used at 80% opacity in CSS)
'deep-charcoal':'#343434',// subtle dividers
},
fontFamily: {
display: ['Inter', 'system-ui', 'sans-serif'],
body: ['Inter', 'system-ui', 'sans-serif'],
code: ['Inconsolata', 'monospace'],
},
fontWeight: {
black: '900',
},
borderRadius: {
sharp: '4px',
card: '8px',
},
letterSpacing: {
widest: '1.4px',
},
},
},
plugins: [],
}
export default config
```
**6. Create web/index.html** (replace the stub — this is the Vite entry point, not the final dist):
```html
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="manifest" href="/manifest.json" />
<title>HWLab</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
<link href="https://fonts.googleapis.com/css2?family=Inconsolata:wght@600&family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet" />
</head>
<body class="bg-canvas text-white antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
**7. Create web/src/styles/globals.css** with CSS custom properties and Tailwind directives:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 0%;
--foreground: 0 0% 100%;
--card: 0 0% 8%;
--card-foreground: 0 0% 100%;
--border: 0 0% 25%;
--ring: 64 100% 71%;
--radius: 0.5rem;
}
* {
border-color: rgba(65, 65, 65, 0.8);
}
body {
background-color: #000000;
color: #ffffff;
}
/* Charcoal border utility */
.border-charcoal-80 {
border-color: rgba(65, 65, 65, 0.8);
}
/* ClickHouse card elevation */
.card-elevated {
box-shadow:
rgba(0,0,0,0.1) 0px 10px 15px -3px,
rgba(0,0,0,0.1) 0px 4px 6px -4px;
}
/* Neon accent glow (use sparingly) */
.ring-volt {
box-shadow: 0 0 0 2px #faff69;
}
/* Uppercase tracked label */
.label-upper {
text-transform: uppercase;
letter-spacing: 1.4px;
font-weight: 600;
font-size: 0.75rem;
}
}
```
**8. Create web/components.json** (shadcn/ui CLI config — dark mode, no rsc, Tailwind aliases):
```json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
```
After creating config files: `cd web && npm install` to generate package-lock.json.
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -5</automated>
</verify>
<done>
`npm run build` inside web/ completes without errors and produces web/dist/index.html and web/dist/assets/. The tailwind.config.ts contains all ClickHouse color tokens (volt, forest, canvas, charcoal, near-black). The vite.config.ts proxy routes /api to http://localhost:8080.
</done>
</task>
<task type="auto">
<name>Task 2: Wire React app entry point with Router, QueryClient, design primitives, and shadcn/ui components</name>
<files>
web/src/main.tsx,
web/src/App.tsx,
web/src/router.tsx,
web/src/lib/utils.ts,
web/src/lib/queryClient.ts,
web/src/store/ui.ts,
web/src/components/ui/button.tsx,
web/src/components/ui/card.tsx,
web/src/components/ui/badge.tsx
</files>
<action>
Read first: `web/src/styles/globals.css`, `web/tailwind.config.ts` (just created in Task 1).
**1. Create web/src/lib/utils.ts** (shadcn/ui cn helper):
```typescript
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
```
**2. Create web/src/lib/queryClient.ts:**
```typescript
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s — inventory data doesn't change rapidly
retry: 2,
refetchOnWindowFocus: false,
},
},
})
```
**3. Create web/src/store/ui.ts** (Zustand — UI state only, not server data):
```typescript
import { create } from 'zustand'
type ViewMode = 'grid' | 'list'
interface UIStore {
viewMode: ViewMode
setViewMode: (mode: ViewMode) => void
scannerActive: boolean
setScannerActive: (active: boolean) => void
intakeStep: number
setIntakeStep: (step: number) => void
}
export const useUIStore = create<UIStore>((set) => ({
viewMode: 'grid',
setViewMode: (mode) => set({ viewMode: mode }),
scannerActive: false,
setScannerActive: (active) => set({ scannerActive: active }),
intakeStep: 0,
setIntakeStep: (step) => set({ intakeStep: step }),
}))
```
**4. Create web/src/router.tsx** — TanStack Router v1 route tree with placeholder routes for all Phase 3 views:
```typescript
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
// Root layout — wraps all routes with the app shell
const rootRoute = createRootRoute({
component: () => (
<>
<Outlet />
{import.meta.env.DEV && <TanStackRouterDevtools />}
</>
),
})
// Routes — components are lazy-imported in Plan 03 and 04; stubs for now
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<div className="min-h-screen bg-canvas flex items-center justify-center">
<p className="text-volt font-display font-black text-2xl">HWLab — Dashboard loading…</p>
</div>
),
})
const itemRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/item/$id',
component: () => (
<div className="min-h-screen bg-canvas flex items-center justify-center">
<p className="text-volt font-display font-black text-2xl">Item detail loading…</p>
</div>
),
})
const intakeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/intake',
component: () => (
<div className="min-h-screen bg-canvas flex items-center justify-center">
<p className="text-volt font-display font-black text-2xl">Intake wizard loading…</p>
</div>
),
})
const scanRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/scan',
component: () => (
<div className="min-h-screen bg-canvas flex items-center justify-center">
<p className="text-volt font-display font-black text-2xl">QR Scanner loading…</p>
</div>
),
})
const routeTree = rootRoute.addChildren([indexRoute, itemRoute, intakeRoute, scanRoute])
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
})
// TypeScript type augmentation for TanStack Router
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
```
**5. Create web/src/App.tsx:**
```typescript
import { QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import { Toaster } from 'react-hot-toast'
import { queryClient } from '@/lib/queryClient'
import { router } from '@/router'
export function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster
position="bottom-right"
toastOptions={{
style: {
background: '#141414',
color: '#ffffff',
border: '1px solid rgba(65,65,65,0.8)',
},
success: {
iconTheme: { primary: '#faff69', secondary: '#000000' },
},
}}
/>
</QueryClientProvider>
)
}
```
**6. Create web/src/main.tsx:**
```typescript
import React from 'react'
import ReactDOM from 'react-dom/client'
import './styles/globals.css'
import { App } from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
```
**7. Create shadcn/ui Button component at web/src/components/ui/button.tsx** (ClickHouse-themed variants):
```typescript
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-volt disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
// Neon primary — #faff69 background, near-black text
default: 'bg-volt text-near-black rounded-sharp border border-volt hover:bg-near-black hover:text-white active:text-volt-pale',
// Forest green — primary action
forest: 'bg-forest text-white rounded-sharp border border-near-black hover:bg-near-black active:text-volt-pale',
// Dark solid
secondary: 'bg-near-black text-white rounded-sharp border border-near-black hover:bg-hover-gray active:text-volt-pale',
// Ghost / outlined
outline: 'bg-transparent text-white rounded-sharp border border-border-olive hover:bg-near-black active:text-volt-pale',
// Destructive
destructive: 'bg-red-900 text-white rounded-sharp hover:bg-red-800',
// Invisible — just text
ghost: 'bg-transparent text-white hover:text-volt',
link: 'text-volt underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 px-3 text-xs',
lg: 'h-11 px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = 'Button'
export { Button, buttonVariants }
```
**8. Create web/src/components/ui/card.tsx** (ClickHouse charcoal card):
```typescript
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-card border border-charcoal/80 bg-near-black text-white shadow-sm transition-colors',
className,
)}
{...props}
/>
),
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-4', className)} {...props} />
),
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
),
)
CardTitle.displayName = 'CardTitle'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-4 pt-0', className)} {...props} />,
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-4 pt-0', className)} {...props} />
),
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardTitle, CardContent, CardFooter }
```
**9. Create web/src/components/ui/badge.tsx** (status badges with ClickHouse tokens):
```typescript
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'border-volt/40 bg-volt/10 text-volt',
indexed: 'border-green-500/40 bg-green-500/10 text-green-400',
draft: 'border-charcoal/80 bg-charcoal/20 text-gray-400',
needs_research: 'border-yellow-500/40 bg-yellow-500/10 text-yellow-400',
researched: 'border-blue-500/40 bg-blue-500/10 text-blue-400',
complete: 'border-forest/40 bg-forest/20 text-green-300',
destructive: 'border-red-500/40 bg-red-500/10 text-red-400',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
```
**10. Update Makefile** to add frontend build and dev targets:
- Read current Makefile first.
- Add these targets after existing ones:
```makefile
frontend:
cd web && npm run build
dev-frontend:
cd web && npm run dev
dev:
air -c .air.toml
```
(Note: `dev` already exists — if it does, keep the existing `dev` and add only `frontend` and `dev-frontend`.)
Finally run `cd web && npm run build` to verify the build succeeds and web/dist gets populated.
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10 && ls dist/assets/*.js 2>/dev/null | head -3</automated>
</verify>
<done>
`npm run build` succeeds (exit 0). `web/dist/assets/` contains at least one .js file. `web/src/store/ui.ts` exports `useUIStore` with `viewMode`, `scannerActive`, `intakeStep`. `web/src/router.tsx` exports `router` with routes for `/`, `/item/$id`, `/intake`, `/scan`. All shadcn/ui primitives (Button, Card, Badge) have ClickHouse variants.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → Go /api | All API requests cross this boundary; frontend must not forge hw_id or bypass intake |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01 | Tampering | vite.config.ts proxy | accept | Dev proxy is localhost-only; no external exposure; production uses embedded SPA with no proxy |
| T-03-02 | Info Disclosure | google fonts CDN | accept | Only font metrics exposed; no auth tokens; acceptable for homelab context |
| T-03-03 | Spoofing | TanStack Router client-side routes | accept | No auth in Phase 3 — all routes are public; server enforces data access via NetBox token |
</threat_model>
<verification>
From project root:
1. `cd web && npm run build` — exits 0, produces web/dist/index.html
2. `ls web/dist/assets/*.js` — at least one JS bundle present
3. `go build ./...` — Go binary still compiles (embed path unchanged)
4. `grep -r "volt" web/tailwind.config.ts` — confirms ClickHouse tokens present
5. `grep "proxy" web/vite.config.ts` — confirms /api proxy to :8080
</verification>
<success_criteria>
- Vite 5 + React 18 + TypeScript 5 + Tailwind 3 project exists in web/
- `npm run build` produces web/dist/ ready for Go embed
- ClickHouse design tokens (volt, forest, canvas, charcoal, near-black) available as Tailwind classes
- Inter 900 weight loaded from Google Fonts
- TanStack Router renders placeholder routes for /, /item/$id, /intake, /scan
- TanStack Query QueryClientProvider wraps the app
- Zustand uiStore tracks viewMode, scannerActive, intakeStep
- shadcn/ui Button (neon/forest/secondary/outline variants), Card, Badge primitives exist
- Makefile has `frontend` and `dev-frontend` targets
</success_criteria>
<output>
After completion, create `.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md` following the summary template.
</output>