Initial scaffold: React 19 + Vite + Tailwind + shadcn + Vitest

Scaffolds APESS 2026 workshop platform per PRD §4.1 with strict-TDD
infrastructure and the 1300-LOC ceiling enforced via ESLint.

- React 19.2 + Vite 8 + TypeScript 6 baseline
- Tailwind v3 + shadcn/ui (new-york style) configured (components.json)
- Path alias @/* -> src/*
- Zustand session store mirroring the prototype's shared.js shape
  (team, device, phases, harness, stats, add, submission) with
  sessionStorage persistence
- PhaseStrip component covering the five PRD-mandated phases, wired
  to react-router-dom v7 with active/done/pending data-state surfaces
  for snapshot-friendly testing
- Vitest + React Testing Library + jsdom green: 4/4 tests pass
- ESLint max-lines: 1300 enforced; largest file currently 106 LOC

Next: shadcn primitive install, landing page from prototype index.html,
workshop screens 1-5, /admin instructor dashboard, /judge review.
This commit is contained in:
Omar Sobh
2026-06-09 18:30:34 -05:00
commit 3d433b9b1d
23 changed files with 3851 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+58
View File
@@ -0,0 +1,58 @@
# APESS 2026 — Workshop Platform
Production web app for the **15th Asia-Pacific-Euro Summer School on Smart Structures Technology**, FabLab Torino, July 27 2026.
Guides 60+ PhD students across 15 teams from kit unboxing → ZeroClaw firmware deployment → Agent Design Document (ADD) submission in a single 5.5-hour session.
- **Live URL** (planned): https://apess.redclaw.dev
- **API** (planned): https://api.apess.redclaw.dev
- **Host node**: `zeroclaw-gw-03` (Architect)
- **PRD**: `APESS-2026-Workshop-PRD-v1.0.docx` (separate, confidential)
## Stack
- **React 19** + TypeScript + Vite 8
- **Tailwind CSS v3** + **shadcn/ui** (new-york style)
- **React Router v7** for client routing
- **Zustand** for cross-screen session state (sessionStorage-persisted)
- **Vitest** + React Testing Library + jsdom for strict TDD
- **Web Serial API** for live USB serial to Arduino UNO R4 WiFi
## Conventions
- **Strict TDD**: every feature lands as a failing test first, then the minimum impl to make it pass.
- **1300 LOC ceiling**: enforced by ESLint `max-lines`. Split files before they exceed.
- **Path alias**: `@/` → `src/`.
- **Co-located tests**: `Foo.tsx` next to `Foo.test.tsx`.
## Commands
```bash
pnpm install
pnpm dev # local dev server
pnpm test # vitest run (one-shot)
pnpm test:watch # vitest watch mode
pnpm build # type-check + production build
pnpm lint # eslint (includes 1300-LOC rule)
pnpm typecheck # tsc --noEmit
```
## Screens (per PRD §5.1)
| Route | Screen |
|---|---|
| `/` | Landing + programme |
| `/workshop` | Team registration + device connect |
| `/workshop/setup` | Environment setup (OS-aware) |
| `/workshop/module1` | Module 1 — Sense → Reason |
| `/workshop/module2` | Module 2 — Harness engineering |
| `/workshop/add` | ADD builder + submit |
| `/lecture` | Lecture page (5 movements) |
| `/admin` | Instructor dashboard (15-card grid, WS-driven) |
| `/judge` | Judge review + score + leaderboard |
## Related
- Firmware: `clawverse/zeroclaw` (separate repo, planned)
- Backend API: `api/` subdir in this repo (TBD)
- Infrastructure: Valhalla vault `20 Infrastructure/20 Nodes/zeroclaw-gw-03.md`
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+28
View File
@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'coverage', 'node_modules']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
rules: {
'max-lines': [
'error',
{ max: 1300, skipBlankLines: false, skipComments: false },
],
},
},
])
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="APESS 2026 Workshop — on-device agentic systems for structural intelligence. FabLab Torino, July 27." />
<title>APESS 2026 · Workshop</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=JetBrains+Mono:wght@300;400;500;700&family=Newsreader:wght@400;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
{
"name": "apress",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/ui": "^4.1.8",
"autoprefixer": "^10.5.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.8"
}
}
+3139
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+66
View File
@@ -0,0 +1,66 @@
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
import { PhaseStrip } from '@/components/PhaseStrip'
function Landing() {
return (
<main className="min-h-screen flex flex-col">
<header className="px-8 py-6 border-b border-border flex items-center justify-between">
<div className="font-mono text-xs tracking-widest uppercase text-muted-foreground">
APESS <span className="text-primary font-bold">2026</span>
</div>
<Link
to="/workshop"
className="font-mono text-xs px-4 py-2 rounded-md bg-primary text-primary-foreground hover:opacity-90"
>
Enter workshop →
</Link>
</header>
<section className="flex-1 flex flex-col items-center justify-center px-8 text-center gap-6 max-w-3xl mx-auto">
<p className="font-mono text-[11px] tracking-wider uppercase text-primary">
FabLab Torino · July 27, 2026
</p>
<h1 className="text-5xl font-bold tracking-tight leading-tight">
On-device agentic systems<br />
<span className="text-primary">for structural intelligence</span>
</h1>
<p className="text-base text-muted-foreground leading-relaxed max-w-xl">
Build a ZeroClaw agent that senses, reasons, and responds autonomously on an
Arduino UNO R4 WiFi. Five-layer Agent Design Document by 19:00 CEST.
</p>
</section>
<footer className="px-8 py-4 border-t border-border font-mono text-[10px] text-muted-foreground flex justify-between">
<span>RedClaw Systems</span>
<span>apess.redclaw.dev</span>
</footer>
</main>
)
}
function WorkshopShell({ title, phase }: { title: string; phase: Parameters<typeof PhaseStrip>[0]['active'] }) {
return (
<main className="min-h-screen">
<PhaseStrip active={phase} />
<section className="px-8 py-12">
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground mt-2">
Screen under construction. See PRD §5.1 for spec.
</p>
</section>
</main>
)
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/workshop" element={<WorkshopShell title="Team registration" phase="reg" />} />
<Route path="/workshop/setup" element={<WorkshopShell title="Environment setup" phase="setup" />} />
<Route path="/workshop/module1" element={<WorkshopShell title="Module 1 · Sense → Reason" phase="m1" />} />
<Route path="/workshop/module2" element={<WorkshopShell title="Module 2 · Harness engineering" phase="m2" />} />
<Route path="/workshop/add" element={<WorkshopShell title="ADD builder & submit" phase="add" />} />
</Routes>
</BrowserRouter>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { PhaseStrip } from './PhaseStrip'
import { useSession } from '@/store/session'
function renderAt(path: string, active?: Parameters<typeof PhaseStrip>[0]['active']) {
return render(
<MemoryRouter initialEntries={[path]}>
<PhaseStrip active={active} />
</MemoryRouter>,
)
}
describe('PhaseStrip', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('renders all five PRD-mandated phases in order', () => {
renderAt('/workshop')
const phases = screen.getAllByRole('link')
expect(phases).toHaveLength(5)
expect(phases.map((p) => p.textContent)).toEqual([
expect.stringContaining('Team reg'),
expect.stringContaining('Env setup'),
expect.stringContaining('Module 1'),
expect.stringContaining('Module 2'),
expect.stringContaining('ADD · submit'),
])
})
it('marks the active phase with data-state="active"', () => {
renderAt('/workshop/module1', 'm1')
const m1 = screen.getByText('Module 1').closest('a')
expect(m1).toHaveAttribute('data-state', 'active')
})
it('marks completed phases with data-state="done" from session store', () => {
useSession.getState().completePhase('reg')
useSession.getState().completePhase('setup')
renderAt('/workshop/module1', 'm1')
expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('data-state', 'done')
expect(screen.getByText('Env setup').closest('a')).toHaveAttribute('data-state', 'done')
expect(screen.getByText('Module 1').closest('a')).toHaveAttribute('data-state', 'active')
expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('data-state', 'pending')
})
it('links each phase to its workshop sub-route', () => {
renderAt('/workshop')
expect(screen.getByText('Team reg').closest('a')).toHaveAttribute('href', '/workshop')
expect(screen.getByText('Env setup').closest('a')).toHaveAttribute('href', '/workshop/setup')
expect(screen.getByText('Module 1').closest('a')).toHaveAttribute('href', '/workshop/module1')
expect(screen.getByText('Module 2').closest('a')).toHaveAttribute('href', '/workshop/module2')
expect(screen.getByText('ADD · submit').closest('a')).toHaveAttribute('href', '/workshop/add')
})
})
+50
View File
@@ -0,0 +1,50 @@
import { NavLink } from 'react-router-dom'
import { cn } from '@/lib/utils'
import { useSession, type PhaseKey } from '@/store/session'
interface PhaseMeta {
key: PhaseKey
name: string
to: string
}
const PHASES: PhaseMeta[] = [
{ key: 'reg', name: 'Team reg', to: '/workshop' },
{ key: 'setup', name: 'Env setup', to: '/workshop/setup' },
{ key: 'm1', name: 'Module 1', to: '/workshop/module1' },
{ key: 'm2', name: 'Module 2', to: '/workshop/module2' },
{ key: 'add', name: 'ADD · submit', to: '/workshop/add' },
]
export interface PhaseStripProps {
active?: PhaseKey
}
export function PhaseStrip({ active }: PhaseStripProps) {
const phases = useSession((s) => s.phases)
return (
<nav aria-label="Workshop phases" data-testid="phase-strip" className="grid grid-cols-5 gap-2 px-8 py-4 border-b border-border">
{PHASES.map((p, i) => {
const done = phases[p.key]
const isActive = active === p.key
return (
<NavLink
key={p.key}
to={p.to}
data-phase={p.key}
data-state={done ? 'done' : isActive ? 'active' : 'pending'}
className={cn(
'flex flex-col gap-1 px-3 py-2 rounded-md border text-left transition',
done && 'border-teal bg-teal/5 text-teal',
!done && isActive && 'border-primary bg-primary/5 text-primary',
!done && !isActive && 'border-border text-muted-foreground hover:border-primary/40',
)}
>
<span className="font-mono text-[10px] uppercase tracking-wider opacity-70">Phase {i + 1}</span>
<span className="text-sm font-semibold tracking-tight">{p.name}</span>
</NavLink>
)
})}
</nav>
)
}
+60
View File
@@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 211 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96%;
--secondary-foreground: 222 47% 11%;
--muted: 210 40% 96%;
--muted-foreground: 215 16% 47%;
--accent: 211 100% 50%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 211 100% 50%;
--radius: 0.5rem;
}
.dark {
--background: 222 47% 11%;
--foreground: 210 40% 98%;
--card: 222 47% 11%;
--card-foreground: 210 40% 98%;
--popover: 222 47% 11%;
--popover-foreground: 210 40% 98%;
--primary: 211 100% 60%;
--primary-foreground: 222 47% 11%;
--secondary: 217 32% 17%;
--secondary-foreground: 210 40% 98%;
--muted: 217 32% 17%;
--muted-foreground: 215 20% 65%;
--accent: 217 32% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62% 50%;
--destructive-foreground: 210 40% 98%;
--border: 217 32% 17%;
--input: 217 32% 17%;
--ring: 211 100% 60%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-feature-settings: 'rlig' 1, 'calt' 1;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+7
View File
@@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})
+106
View File
@@ -0,0 +1,106 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
export type PhaseKey = 'reg' | 'setup' | 'm1' | 'm2' | 'add'
export const PHASE_ORDER: PhaseKey[] = ['reg', 'setup', 'm1', 'm2', 'add']
export type Provider = 'anthropic' | 'groq' | 'openai'
export interface Team {
name: string
members: string[]
kit: string
}
export interface Device {
connected: boolean
port: string | null
uptimeS: number
}
export interface Harness {
thresholdG: number
thresholdDb: number
callsPerMinute: number
provider: Provider
model: string
}
export interface SessionStats {
calls: number
nominal: number
anomalous: number
critical: number
}
export interface AddLayers {
L1: unknown | null
L2: string
L3: string
L4: string
L5: string
}
export interface Submission {
code: string | null
submittedAt: string | null
}
export interface SessionState {
team: Team
device: Device
phases: Record<PhaseKey, boolean>
harness: Harness
stats: SessionStats
add: AddLayers
submission: Submission
setTeam: (patch: Partial<Team>) => void
setDevice: (patch: Partial<Device>) => void
completePhase: (phase: PhaseKey) => void
setHarness: (patch: Partial<Harness>) => void
recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void
setAddLayer: <K extends keyof AddLayers>(key: K, value: AddLayers[K]) => void
setSubmission: (s: Submission) => void
reset: () => void
}
const initial = {
team: { name: '', members: [] as string[], kit: 'KIT-01' },
device: { connected: false, port: null, uptimeS: 0 },
phases: { reg: false, setup: false, m1: false, m2: false, add: false } as Record<PhaseKey, boolean>,
harness: {
thresholdG: 0.8,
thresholdDb: 65,
callsPerMinute: 8,
provider: 'anthropic' as Provider,
model: 'claude-haiku-4-5',
},
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
add: { L1: null, L2: '', L3: '', L4: '', L5: '' } as AddLayers,
submission: { code: null, submittedAt: null } as Submission,
}
export const useSession = create<SessionState>()(
persist(
(set) => ({
...initial,
setTeam: (patch) => set((s) => ({ team: { ...s.team, ...patch } })),
setDevice: (patch) => set((s) => ({ device: { ...s.device, ...patch } })),
completePhase: (phase) =>
set((s) => ({ phases: { ...s.phases, [phase]: true } })),
setHarness: (patch) => set((s) => ({ harness: { ...s.harness, ...patch } })),
recordEvent: (kind) =>
set((s) => ({
stats: { ...s.stats, calls: s.stats.calls + 1, [kind]: s.stats[kind] + 1 },
})),
setAddLayer: (key, value) => set((s) => ({ add: { ...s.add, [key]: value } })),
setSubmission: (submission) => set({ submission }),
reset: () => set(initial),
}),
{
name: 'apess_state',
storage: createJSONStorage(() => sessionStorage),
},
),
)
+57
View File
@@ -0,0 +1,57 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['Newsreader', 'ui-serif', 'serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
teal: 'hsl(166 80% 36%)',
rose: 'hsl(347 77% 50%)',
amber: 'hsl(38 92% 50%)',
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@/*": ["./src/*"]
},
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'node:path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'node:path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/setupTests.ts'],
css: true,
},
})