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

27 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
03-dashboard-intake-ui 01 execute 1
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
true
UI-06
truths artifacts key_links
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
path provides
web/package.json npm project manifest with all dependencies pinned
path provides
web/tailwind.config.ts ClickHouse design tokens as Tailwind CSS custom colors/fonts
path provides
web/src/main.tsx React 18 entry point with Router + QueryClient providers
path provides
web/src/router.tsx TanStack Router route tree with placeholder routes
path provides
web/src/store/ui.ts Zustand UI store (view mode toggle, scanner state)
path provides
web/components.json shadcn/ui CLI config
path provides
web/src/components/ui/button.tsx shadcn/ui Button primitive
path provides
web/src/components/ui/card.tsx shadcn/ui Card primitive
path provides
web/src/components/ui/badge.tsx shadcn/ui Badge primitive
from to via
web/src/main.tsx web/src/router.tsx RouterProvider from @tanstack/react-router
from to via
web/tailwind.config.ts web/src/styles/globals.css @tailwind directives + CSS custom properties
from to via
web/vite.config.ts http://localhost:8080 proxy: { '/api': 'http://localhost:8080' }
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.

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

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md @assets.go @web/dist/index.html

From assets.go:

//go:embed web/dist
var StaticFiles embed.FS

From internal/api/router.go (SPA fallback handler):

// Serves static files from embedded FS; falls back to index.html for client-side routing
r.Handle("/*", spaHandler{staticFS: staticFiles})
Task 1: Scaffold Vite+React project and apply ClickHouse design tokens 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 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.
cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -5 `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. Task 2: Wire React app entry point with Router, QueryClient, design primitives, and shadcn/ui components 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 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.
cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10 && ls dist/assets/*.js 2>/dev/null | head -3 `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.

<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>
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

<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>
After completion, create `.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md` following the summary template.