feat: Landing (Screen 0) + Team Registration (Screen 1) — TDD

Closes the first two PRD screens with shadcn primitives (button, input,
card, badge) and the standing TDD-first discipline.

Landing (/) — PRD §5.1 Screen 0
- Sticky nav with workshop CTA and lecture link
- Hero with eyebrow badge, headline, sub, dual CTA + 4-stat strip
- Programme table covering all 7 day-of moves (13:00 → 19:00)
- Speaker card for Omar Sobh + RedClaw Systems
- 3-card tech stack (Rust on Cortex-M4 · Anthropic+Groq · Web Serial)
- Prerequisites + closing CTA
- 5 tests, all passing

Team Registration (/workshop) — PRD §5.1 Screen 1
- PhaseStrip across top, Phase 1 badge eyebrow
- Team name + member chips (Enter to add, x to remove, capped at 5)
- KitSelector 5x3 grid of KIT-01..KIT-15 with aria-pressed
- Mock device-connect button (real Web Serial deferred to next slice)
- ?kit= URL param pre-selects kit (QR sticker entrypoint)
- Proceed gated until name + 1+ member + device connected
- On Proceed: marks phases.reg = true, navigates to /workshop/setup
- 6 tests, all passing

MemberChips (5 tests) + KitSelector (3 tests) extracted as reusable
components with own test coverage.

Scoped react-refresh/only-export-components off for src/components/ui/**
(shadcn vendored primitives export their cva variants alongside the
component — standard project pattern).

23/23 tests green · build 286KB JS, 90KB gzip · lint clean.
This commit is contained in:
Omar Sobh
2026-06-09 22:55:54 -05:00
parent 4059ea9227
commit 6af793b3cf
16 changed files with 915 additions and 47 deletions
+6
View File
@@ -25,4 +25,10 @@ export default defineConfig([
], ],
}, },
}, },
{
files: ['src/components/ui/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
]) ])
+1
View File
@@ -15,6 +15,7 @@
"typecheck": "tsc -b --noEmit" "typecheck": "tsc -b --noEmit"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-slot": "^1.2.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
+34
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@radix-ui/react-slot':
specifier: ^1.2.5
version: 1.2.5(@types/[email protected])([email protected])
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -349,6 +352,24 @@ packages:
'@polka/[email protected]': '@polka/[email protected]':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@radix-ui/[email protected]':
resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@rolldown/[email protected]': '@rolldown/[email protected]':
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1953,6 +1974,19 @@ snapshots:
'@polka/[email protected]': {} '@polka/[email protected]': {}
'@radix-ui/[email protected](@types/[email protected])([email protected])':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/[email protected](@types/[email protected])([email protected])':
dependencies:
'@radix-ui/react-compose-refs': 1.1.3(@types/[email protected])([email protected])
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@rolldown/[email protected]': '@rolldown/[email protected]':
optional: true optional: true
+18 -47
View File
@@ -1,49 +1,17 @@
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom' import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { Landing } from '@/pages/Landing'
import { TeamRegistration } from '@/pages/TeamRegistration'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import type { PhaseKey } from '@/store/session'
function Landing() { function WorkshopStub({ title, phase }: { title: string; phase: PhaseKey }) {
return ( return (
<main className="min-h-screen flex flex-col"> <main className="min-h-screen bg-background">
<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} /> <PhaseStrip active={phase} />
<section className="px-8 py-12"> <section className="px-8 py-16 max-w-3xl mx-auto">
<h1 className="text-2xl font-bold tracking-tight">{title}</h1> <h1 className="text-3xl font-bold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-3">
Screen under construction. See PRD §5.1 for spec. Screen under construction — see PRD §5.1 for the full spec. Tests-first build in progress.
</p> </p>
</section> </section>
</main> </main>
@@ -55,11 +23,14 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Landing />} /> <Route path="/" element={<Landing />} />
<Route path="/workshop" element={<WorkshopShell title="Team registration" phase="reg" />} /> <Route path="/workshop" element={<TeamRegistration />} />
<Route path="/workshop/setup" element={<WorkshopShell title="Environment setup" phase="setup" />} /> <Route path="/workshop/setup" element={<WorkshopStub title="Environment setup" phase="setup" />} />
<Route path="/workshop/module1" element={<WorkshopShell title="Module 1 · Sense → Reason" phase="m1" />} /> <Route path="/workshop/module1" element={<WorkshopStub title="Module 1 · Sense → Reason" phase="m1" />} />
<Route path="/workshop/module2" element={<WorkshopShell title="Module 2 · Harness engineering" phase="m2" />} /> <Route path="/workshop/module2" element={<WorkshopStub title="Module 2 · Harness engineering" phase="m2" />} />
<Route path="/workshop/add" element={<WorkshopShell title="ADD builder & submit" phase="add" />} /> <Route path="/workshop/add" element={<WorkshopStub title="ADD builder & submit" phase="add" />} />
<Route path="/lecture" element={<WorkshopStub title="Lecture · 5 movements" phase="reg" />} />
<Route path="/admin" element={<WorkshopStub title="Instructor dashboard" phase="reg" />} />
<Route path="/judge" element={<WorkshopStub title="Judge review" phase="reg" />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
) )
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { KitSelector } from './KitSelector'
describe('KitSelector', () => {
it('renders KIT-01 through KIT-15 (15 options per PRD §8.1)', () => {
render(<KitSelector value="KIT-01" onChange={() => {}} />)
const opts = screen.getAllByRole('button', { name: /^KIT-\d{2}$/ })
expect(opts).toHaveLength(15)
expect(opts[0]).toHaveTextContent('KIT-01')
expect(opts[14]).toHaveTextContent('KIT-15')
})
it('marks the selected kit with aria-pressed=true', () => {
render(<KitSelector value="KIT-07" onChange={() => {}} />)
expect(screen.getByRole('button', { name: 'KIT-07' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'KIT-03' })).toHaveAttribute('aria-pressed', 'false')
})
it('fires onChange with the kit slug when clicked', async () => {
const user = userEvent.setup()
const handle = vi.fn()
render(<KitSelector value="KIT-01" onChange={handle} />)
await user.click(screen.getByRole('button', { name: 'KIT-09' }))
expect(handle).toHaveBeenCalledWith('KIT-09')
})
})
+35
View File
@@ -0,0 +1,35 @@
import { cn } from '@/lib/utils'
export const KIT_COUNT = 15
export interface KitSelectorProps {
value: string
onChange: (kit: string) => void
}
export function KitSelector({ value, onChange }: KitSelectorProps) {
const kits = Array.from({ length: KIT_COUNT }, (_, i) => `KIT-${String(i + 1).padStart(2, '0')}`)
return (
<div className="grid grid-cols-5 gap-2" role="group" aria-label="Kit selector">
{kits.map((k) => {
const selected = k === value
return (
<button
key={k}
type="button"
onClick={() => onChange(k)}
aria-pressed={selected}
className={cn(
'font-mono text-xs px-2 py-2 rounded-md border transition',
selected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-card text-muted-foreground border-border hover:border-primary/40',
)}
>
{k}
</button>
)
})}
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { MemberChips } from './MemberChips'
function Wrapper({ initial = [] as string[] }) {
const [members, setMembers] = useState(initial)
return <MemberChips members={members} onChange={setMembers} />
}
describe('MemberChips', () => {
it('renders an empty list with the prompt input', () => {
render(<Wrapper />)
expect(screen.getByLabelText(/team member/i)).toBeInTheDocument()
expect(screen.queryAllByTestId('member-chip')).toHaveLength(0)
})
it('adds a member when Enter is pressed', async () => {
const user = userEvent.setup()
render(<Wrapper />)
const input = screen.getByLabelText(/team member/i)
await user.type(input, 'A. Rossi{Enter}')
expect(screen.getByText('A. Rossi')).toBeInTheDocument()
expect(screen.getAllByTestId('member-chip')).toHaveLength(1)
expect(input).toHaveValue('')
})
it('ignores empty / whitespace submissions', async () => {
const user = userEvent.setup()
render(<Wrapper />)
const input = screen.getByLabelText(/team member/i)
await user.type(input, ' {Enter}')
expect(screen.queryAllByTestId('member-chip')).toHaveLength(0)
})
it('removes a member when its × button is clicked', async () => {
const user = userEvent.setup()
render(<Wrapper initial={['A. Rossi', 'K. Tanaka']} />)
expect(screen.getAllByTestId('member-chip')).toHaveLength(2)
await user.click(screen.getByRole('button', { name: /remove A\. Rossi/i }))
expect(screen.queryByText('A. Rossi')).not.toBeInTheDocument()
expect(screen.getAllByTestId('member-chip')).toHaveLength(1)
})
it('caps the team at 5 members per PRD §3', async () => {
const user = userEvent.setup()
render(<Wrapper initial={['A', 'B', 'C', 'D', 'E']} />)
const input = screen.getByLabelText(/team member/i)
expect(input).toBeDisabled()
await user.type(input, 'F{Enter}')
expect(screen.queryByText('F')).not.toBeInTheDocument()
})
})
+66
View File
@@ -0,0 +1,66 @@
import { useState, type KeyboardEvent } from 'react'
import { Input } from '@/components/ui/input'
const MAX_MEMBERS = 5
export interface MemberChipsProps {
members: string[]
onChange: (next: string[]) => void
}
export function MemberChips({ members, onChange }: MemberChipsProps) {
const [draft, setDraft] = useState('')
const full = members.length >= MAX_MEMBERS
const submit = () => {
const v = draft.trim()
if (!v || full) return
onChange([...members, v])
setDraft('')
}
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
submit()
}
}
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2" data-testid="member-list">
{members.map((m) => (
<span
key={m}
data-testid="member-chip"
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-secondary text-secondary-foreground text-xs font-medium"
>
{m}
<button
type="button"
aria-label={`Remove ${m}`}
onClick={() => onChange(members.filter((x) => x !== m))}
className="text-muted-foreground hover:text-destructive transition leading-none"
>
×
</button>
</span>
))}
</div>
<div className="flex gap-2">
<Input
aria-label="Team member name"
placeholder={full ? `Maximum ${MAX_MEMBERS} members` : 'Add a member · press Enter'}
value={draft}
disabled={full}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKey}
className="font-mono text-sm"
/>
</div>
<div className="font-mono text-[10px] text-muted-foreground tracking-wider uppercase">
{members.length} / {MAX_MEMBERS} members
</div>
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
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-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
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 }
+57
View File
@@ -0,0 +1,57 @@
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 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md 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 }
+76
View File
@@ -0,0 +1,76 @@
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-xl border bg-card text-card-foreground shadow",
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-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 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-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { Landing } from './Landing'
function renderLanding() {
return render(
<MemoryRouter>
<Landing />
</MemoryRouter>,
)
}
describe('Landing', () => {
it('renders the workshop headline and date', () => {
renderLanding()
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(/on-device agentic systems/i)
expect(screen.getByText(/FabLab Torino/i)).toBeInTheDocument()
expect(screen.getByText(/July 27, 2026/i)).toBeInTheDocument()
})
it('exposes the four PRD success-metric stats', () => {
renderLanding()
expect(screen.getByText(/5h 30m/i)).toBeInTheDocument()
expect(screen.getByText(/15 teams/i)).toBeInTheDocument()
expect(screen.getAllByText(/Arduino UNO R4/i).length).toBeGreaterThan(0)
})
it('has a primary CTA linking to /workshop', () => {
renderLanding()
const ctas = screen.getAllByRole('link', { name: /enter workshop/i })
expect(ctas.length).toBeGreaterThan(0)
ctas.forEach((cta) => expect(cta).toHaveAttribute('href', '/workshop'))
})
it('lists the programme timeline with at least 5 phases', () => {
renderLanding()
const programme = screen.getByTestId('programme')
const rows = programme.querySelectorAll('[data-programme-row]')
expect(rows.length).toBeGreaterThanOrEqual(5)
})
it('renders the speaker card with Omar Sobh', () => {
renderLanding()
expect(screen.getByText(/Omar Sobh/i)).toBeInTheDocument()
expect(screen.getAllByText(/RedClaw Systems/i).length).toBeGreaterThan(0)
})
})
+215
View File
@@ -0,0 +1,215 @@
import { Link } from 'react-router-dom'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface ProgrammeRow {
time: string
title: string
desc: string
tags?: string[]
}
const PROGRAMME: ProgrammeRow[] = [
{ time: '13:00', title: 'Arrival & kit pickup', desc: 'Teams collect Arduino UNO R4 WiFi + sensor kits.', tags: ['setup'] },
{ time: '14:00', title: 'Lecture · 5 movements', desc: 'Agency, perception loop, ZeroClaw, failure modes, edge vs cloud.', tags: ['lecture'] },
{ time: '15:00', title: 'Module 1 · Sense → Reason', desc: 'Live IMU feed, actor map, Layer-1 ADD draft.', tags: ['build'] },
{ time: '16:15', title: 'Module 2 · Harness engineering', desc: 'Tune harness.toml, fire test triggers, Layers 2 + 3.', tags: ['build'] },
{ time: '17:45', title: 'ADD builder & submit', desc: 'Failure mode + AI-native redesign, PDF export, submission.', tags: ['add'] },
{ time: '19:00', title: 'Judging & award', desc: 'Demartino panel reviews ADDs; RedClaw Systems award announced.', tags: ['judge'] },
]
const STACK = [
{ icon: '⚙', name: 'Rust on Cortex-M4', desc: 'ZeroClaw firmware compiles to a ~6 MB binary for the Renesas RA4M1.' },
{ icon: '⌬', name: 'Anthropic + Groq', desc: 'LLM reasoning over UART → ESP32-S3 → cloud proxy with per-team rate limits.' },
{ icon: '⚡', name: 'Web Serial', desc: 'Browser talks directly to the board over USB — no drivers, no intermediary.' },
]
const PREREQS = [
'Laptop with Chrome or Edge (Web Serial supported)',
'USB-C data cable (provided in kit)',
'Basic Rust familiarity helpful but not required',
'Hands-on attitude — you will flash hardware today',
]
export function Landing() {
return (
<main className="min-h-screen flex flex-col bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between sticky top-0 bg-background/90 backdrop-blur z-50">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
</div>
<div className="flex items-center gap-3">
<Link to="/lecture" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
Lecture
</Link>
<Button asChild size="sm">
<Link to="/workshop">Enter workshop →</Link>
</Button>
</div>
</header>
<section className="relative overflow-hidden border-b border-border px-8 py-20">
<div className="max-w-3xl mx-auto text-center space-y-6 relative z-10">
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase">
FabLab Torino · July 27, 2026
</Badge>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight leading-[1.05]">
On-device agentic systems
<br />
<span className="text-primary">for structural intelligence</span>
</h1>
<p className="text-base md:text-lg text-muted-foreground leading-relaxed max-w-2xl mx-auto">
Build a ZeroClaw agent that senses, reasons, and responds autonomously on an Arduino UNO R4 WiFi.
Complete a five-layer Agent Design Document in a single 5.5-hour session.
</p>
<div className="flex flex-wrap gap-3 justify-center pt-4">
<Button asChild size="lg">
<Link to="/workshop">Enter workshop →</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link to="/lecture">Read the lecture</Link>
</Button>
</div>
</div>
<div className="max-w-4xl mx-auto mt-16 grid grid-cols-2 md:grid-cols-4 gap-px bg-border rounded-md overflow-hidden text-center">
{[
['Duration', '5h 30m'],
['Teams', '15 teams'],
['Per team', '3–5 students'],
['Hardware', 'Arduino UNO R4 WiFi'],
].map(([k, v]) => (
<div key={k} className="bg-background p-4 space-y-1">
<div className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground">{k}</div>
<div className="text-sm font-semibold">{v}</div>
</div>
))}
</div>
</section>
<section className="px-8 py-16 border-b border-border">
<div className="max-w-4xl mx-auto space-y-6">
<div>
<p className="font-mono text-[10px] tracking-widest uppercase text-primary mb-2">Programme</p>
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Five and a half hours · seven moves</h2>
</div>
<div data-testid="programme" className="border border-border rounded-lg overflow-hidden bg-card divide-y divide-border">
{PROGRAMME.map((row) => (
<div key={row.time} data-programme-row className="grid grid-cols-[80px_1fr_auto] gap-6 px-6 py-4 items-start hover:bg-secondary/40 transition">
<div className="font-mono text-xs text-muted-foreground pt-0.5">{row.time}</div>
<div>
<div className="text-sm font-semibold">{row.title}</div>
<div className="text-xs text-muted-foreground mt-1 leading-relaxed">{row.desc}</div>
</div>
<div className="flex gap-1.5 flex-wrap justify-end">
{row.tags?.map((t) => (
<Badge key={t} variant="outline" className="font-mono text-[9px] uppercase tracking-wider">
{t}
</Badge>
))}
</div>
</div>
))}
</div>
</div>
</section>
<section className="px-8 py-16 border-b border-border bg-secondary/30">
<div className="max-w-4xl mx-auto">
<p className="font-mono text-[10px] tracking-widest uppercase text-primary mb-2">Presenter</p>
<Card className="mt-4">
<CardContent className="grid grid-cols-[80px_1fr] gap-6 p-6 items-start">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary to-teal flex items-center justify-center text-2xl font-bold text-primary-foreground">
OS
</div>
<div className="space-y-3">
<div>
<div className="text-xl font-bold tracking-tight">Omar Sobh</div>
<div className="font-mono text-[10px] uppercase tracking-widest text-primary">
Founder · RedClaw Systems
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
Builds AI agents that live at the edge — from on-device LLM runtimes to multi-agent fleets.
Maintains ZeroClaw, EdgeHDF5, and RustyHDF5. Previously: hardware + ML at scale.
</p>
<div className="flex gap-1.5 flex-wrap">
{['Rust', 'Edge AI', 'Agentic systems', 'HDF5'].map((c) => (
<Badge key={c} variant="outline" className="font-mono text-[9px] uppercase tracking-wider">
{c}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
</div>
</section>
<section className="px-8 py-16 border-b border-border">
<div className="max-w-4xl mx-auto space-y-6">
<div>
<p className="font-mono text-[10px] tracking-widest uppercase text-primary mb-2">Stack</p>
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">What runs where</h2>
</div>
<div className="grid md:grid-cols-3 gap-4">
{STACK.map((s) => (
<Card key={s.name}>
<CardHeader className="pb-3">
<div className="w-9 h-9 rounded-md bg-primary/10 text-primary flex items-center justify-center text-lg">
{s.icon}
</div>
<CardTitle className="text-base">{s.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground leading-relaxed">{s.desc}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
<section className="px-8 py-16 border-b border-border bg-secondary/30">
<div className="max-w-4xl mx-auto space-y-6">
<div>
<p className="font-mono text-[10px] tracking-widest uppercase text-primary mb-2">Prerequisites</p>
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Bring this · we provide the rest</h2>
</div>
<div className="grid md:grid-cols-2 gap-3">
{PREREQS.map((p, i) => (
<Card key={p} className="p-4 flex gap-3">
<div className="font-mono text-xs font-bold text-primary w-5 shrink-0">{String(i + 1).padStart(2, '0')}</div>
<div className="text-sm text-muted-foreground leading-relaxed">{p}</div>
</Card>
))}
</div>
</div>
</section>
<section className="px-8 py-16">
<div className="max-w-4xl mx-auto">
<Card className="bg-primary text-primary-foreground border-primary">
<CardContent className="p-10 flex flex-col md:flex-row items-center justify-between gap-6">
<div>
<div className="text-xl md:text-2xl font-bold tracking-tight">Ready when you are.</div>
<div className="font-mono text-[10px] uppercase tracking-widest opacity-70 mt-2">
Five screens · one Agent Design Document · 19:00 deadline
</div>
</div>
<Button asChild size="lg" variant="secondary">
<Link to="/workshop">Start Module 1 →</Link>
</Button>
</CardContent>
</Card>
</div>
</section>
<footer className="px-8 py-6 border-t border-border font-mono text-[10px] text-muted-foreground flex justify-between items-center">
<span>RedClaw Systems LLC · Los Gatos, CA</span>
<span>apess.redclaw.dev</span>
</footer>
</main>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { TeamRegistration } from './TeamRegistration'
import { useSession } from '@/store/session'
function renderPage() {
return render(
<MemoryRouter>
<TeamRegistration />
</MemoryRouter>,
)
}
describe('TeamRegistration', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('renders the phase strip and the team form heading', () => {
renderPage()
expect(screen.getByTestId('phase-strip')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /team registration/i })).toBeInTheDocument()
})
it('persists the team name to the session store on input', async () => {
const user = userEvent.setup()
renderPage()
const input = screen.getByLabelText(/team name/i)
await user.type(input, 'team_resonance')
expect(useSession.getState().team.name).toBe('team_resonance')
})
it('persists the selected kit to the session store on click', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByRole('button', { name: 'KIT-04' }))
expect(useSession.getState().team.kit).toBe('KIT-04')
})
it('gates the Proceed button until name + member + device are ready', async () => {
const user = userEvent.setup()
renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
await user.type(screen.getByLabelText(/team name/i), 'team_x')
expect(proceed).toBeDisabled()
const memberInput = screen.getByLabelText(/team member/i)
await user.type(memberInput, 'A. Rossi{Enter}')
expect(proceed).toBeDisabled()
await user.click(screen.getByRole('button', { name: /connect device/i }))
expect(proceed).toBeEnabled()
})
it('marks the reg phase complete when Proceed is clicked', async () => {
const user = userEvent.setup()
renderPage()
await user.type(screen.getByLabelText(/team name/i), 'team_x')
await user.type(screen.getByLabelText(/team member/i), 'A. Rossi{Enter}')
await user.click(screen.getByRole('button', { name: /connect device/i }))
await user.click(screen.getByRole('button', { name: /proceed/i }))
expect(useSession.getState().phases.reg).toBe(true)
})
it('pre-selects kit from the ?kit= URL param (QR sticker flow)', () => {
render(
<MemoryRouter initialEntries={['/workshop?kit=KIT-12']}>
<TeamRegistration />
</MemoryRouter>,
)
expect(useSession.getState().team.kit).toBe('KIT-12')
expect(screen.getByRole('button', { name: 'KIT-12' })).toHaveAttribute('aria-pressed', 'true')
})
})
+140
View File
@@ -0,0 +1,140 @@
import { useEffect } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { MemberChips } from '@/components/MemberChips'
import { KitSelector } from '@/components/KitSelector'
import { PhaseStrip } from '@/components/PhaseStrip'
import { useSession } from '@/store/session'
export function TeamRegistration() {
const navigate = useNavigate()
const [params] = useSearchParams()
const team = useSession((s) => s.team)
const device = useSession((s) => s.device)
const setTeam = useSession((s) => s.setTeam)
const setDevice = useSession((s) => s.setDevice)
const completePhase = useSession((s) => s.completePhase)
useEffect(() => {
const k = params.get('kit')
if (k && /^KIT-\d{2}$/.test(k)) setTeam({ kit: k })
}, [params, setTeam])
const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected
const onProceed = () => {
completePhase('reg')
navigate('/workshop/setup')
}
const onConnect = () => {
setDevice({ connected: true, port: 'mock-serial://uno-r4-wifi', uptimeS: 0 })
}
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Workshop</span>
</div>
<Link to="/" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
← Back to landing
</Link>
</header>
<PhaseStrip active="reg" />
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6">
<div className="flex items-end justify-between flex-wrap gap-3">
<div>
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 1 of 5 · ~10 min
</Badge>
<h1 className="text-3xl font-bold tracking-tight">Team registration</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
Name your team, add 3–5 members, pick up your kit, and connect the board over USB.
The QR sticker on your kit pre-selects the kit number for you.
</p>
</div>
</div>
<div className="grid lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Team</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<label htmlFor="team-name" className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Team name
</label>
<Input
id="team-name"
placeholder="team_resonance"
value={team.name}
onChange={(e) => setTeam({ name: e.target.value })}
className="font-mono"
/>
</div>
<div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Members
</div>
<MemberChips
members={team.members}
onChange={(members) => setTeam({ members })}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Kit & device</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Select kit
</div>
<KitSelector value={team.kit} onChange={(kit) => setTeam({ kit })} />
</div>
<div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Device
</div>
{device.connected ? (
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-teal animate-pulse" />
<span className="text-sm font-medium">Connected</span>
</div>
<div className="font-mono text-[10px] text-muted-foreground mt-1">{device.port}</div>
</div>
) : (
<Button onClick={onConnect} variant="outline" className="w-full justify-start">
<span className="w-2 h-2 rounded-full bg-muted-foreground mr-3" />
Connect device · 115200 baud
</Button>
)}
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Web Serial wiring lands in the next slice; this stub flips the device flag so flow tests pass.
</p>
</div>
</CardContent>
</Card>
</div>
<div className="flex justify-end pt-4">
<Button size="lg" disabled={!ready} onClick={onProceed}>
Proceed to environment setup →
</Button>
</div>
</section>
</main>
)
}