Imported from qphase-ai/Q-Learn (
frontend/AGENTS.md). Install upstream withnpx skills add qphase-ai/Q-Learn --skill frontend. Copyright stays with the author.
AGENTS.md — Q-Learn Frontend
Guidance for AI coding agents working in frontend/.
Orientation
Next.js 14 App Router frontend. The UI is a persistent VS Code-style IDE shell (AppShell) that mounts once — navigation swaps only the WorkspaceArea. State lives in Zustand stores. Real-time events arrive via Supabase Realtime channels — never by polling the API. Pro features are gated by user.subscription_status.
Read design.md in this directory before any significant change.
Directory map
frontend/src/
├── app/ Next.js App Router — page files only, no component logic
│ ├── layout.tsx Root layout — mounts AppShell
│ ├── auth/ Login / Register pages
│ └── dashboard/ learn/ circuit/ quiz/ pricing/ settings/
├── components/
│ ├── circuit/ React Flow circuit builder — GateNode, QubitWireNode, GatePalette
│ ├── tutor/ AITutorPanel — streaming, citations, KaTeX
│ ├── visualization/ ProbabilityChart, StateVectorTable, QASMViewer
│ ├── billing/ Pricing page, upgrade modal
│ └── ui/ Primitive atoms (Button, Badge, etc.)
├── stores/ Zustand stores — one per domain, no cross-store imports
├── hooks/ Custom hooks wrapping store + API logic
├── lib/
│ ├── api.ts apiFetch<T> — all FastAPI calls
│ └── supabase.ts Supabase client + Realtime subscriptions
└── types/index.ts Shared TypeScript types
Non-negotiable rules
| Rule | Consequence of breaking |
|---|---|
| Never poll the API for circuit results or tutor tokens | Creates race conditions and duplicates the Realtime subscription |
| Never add direct FastAPI WebSocket connections | The backend has no ws:// endpoints — use Supabase Realtime |
| No cross-store reactive subscriptions | Causes cascading re-renders; cross-domain reads must be point-in-time snapshots |
| No cross-workspace component imports | Workspaces are self-contained — data flows via stores only |
| Never hardcode colors or read token CSS vars directly in components (circuit gate colors excepted) | Tokens live in globals.css (light on :root, dark on .dark) — use the Tailwind semantic classes (bg-surface, text-cyber-cyan, border-overlay/10, …) or one theme breaks. Circuit gate fills have no Tailwind classes and use the theme-independent var(--gate-*) tokens directly (catalog in lib/gates.ts) |
Never use white/…/black/… for translucent fills or borders |
Use overlay/… (white in dark, black in light) |
| Always unsubscribe Supabase channels on unmount | Channel leaks accumulate across route changes |
| Never gate curriculum content | Lessons and course structure are free; only AI Tutor + execution + adaptive quizzes are Pro |
Shell architecture recap
AppShell (mounts once)
├── TitleBar 36px top
├── ActivityBar 48px left — mode switcher
├── WorkspaceArea fills remaining — swapped on mode change (lazy)
├── RightPanel 380px right — AITutorPanel, Ctrl+B
├── BottomPanel 250px bottom — simulation/console, Ctrl+J
└── StatusBar 24px bottom
Mode switch = useShellStore.setWorkspace(workspace). Quiz mode also calls setFocusMode(true).
Zustand store rules
Seven stores. Each owns one domain.
| Store | Session-only? | Persisted keys |
|---|---|---|
useShellStore |
No | rightPanelOpen, bottomPanelOpen |
useAuthStore |
No | jwt |
useLearningStore |
No | lessonProgress, xp, streak |
useCircuitStore |
Yes | — |
useTutorStore |
Yes | — |
useQuizStore |
Yes | — |
useBillingStore |
— | — |
Adding state to a store:
- Add the field to the interface
- Initialize it in the
create()call - Add an action if mutation is needed
- If it should persist, add to
partialize
Cross-store interaction pattern (snapshot, not subscription):
// In useCircuitStore.runSimulation:
const lessonId = useLearningStore.getState().currentLessonId // snapshot ✓
// Never:
const { currentLessonId } = useLearningStore() // inside another store ✗
API client
All backend calls go through apiFetch<T> in lib/api.ts:
import { apiFetch } from "@/lib/api";
const result = await apiFetch<SimulationResult>("/api/v1/simulations/execute", {
method: "POST",
body: JSON.stringify(circuitSpec),
token: useAuthStore.getState().jwt ?? undefined,
});
tokenaddsAuthorization: Bearer <token>automatically- Throws
Errorwith backend message on!res.okorjson.success === false - Returns
json.data— never unwrap manually
Realtime subscriptions
Subscribe in the component that consumes the event. Unsubscribe in the cleanup.
useEffect(() => {
const channel = supabase
.channel(`circuit:${circuitId}`)
.on("broadcast", { event: "result" }, ({ payload }) => {
circuitStore.setResults(payload);
shellStore.toggleBottomPanel();
})
.subscribe();
return () => { supabase.removeChannel(channel); };
}, [circuitId]);
| Channel | Event | Who subscribes |
|---|---|---|
circuit:{circuitId} |
result |
CircuitCanvas |
tutor:{sessionId} |
token |
AITutorPanel |
progress:{userId} |
mastery |
Dashboard |
Circuit builder
React Flow with three custom node types: GateNode, QubitWireNode, MeasurementNode.
- All circuit state (
nodes,edges) lives inuseCircuitStore onNodesChange/onEdgesChangefrom React Flow must callsetNodes/setEdges- Circuit execution: POST to
/api/v1/circuits/{id}/execute, then await Supabase Realtimeresult - Gate colors must use CSS tokens (
--gate-H,--gate-X, etc.) — never hardcoded hex
Adding a new gate:
- Add the gate token to
globals.cssif it needs a new color - Add a drag source in
GatePalette - Add the
GateNoderendering branch in the node component (checkgateType) - Add the gate to
CircuitSpec→GateSpec.typeintypes/index.ts - Coordinate with backend: add to
sandbox_adapter.pygate_map
Adding a new workspace
- Read
design.mdfor the layout spec - Add the workspace name to
Workspacetype inshellStore.ts - Add an
ActivityIconentry inActivityBar - Create the workspace component under
components/workspaces/<name>/(or a named subdir) - Add a lazy-loaded branch in
WorkspaceArea - If it needs Focus Mode, call
useShellStore.setFocusMode(true)on enter
Adding a new page route
Routes are in app/. Page files should contain only a React component that imports from components/. No business logic, no direct API calls, no store writes in page files — delegate to the workspace component.
Design system
All tokens in src/app/globals.css; CLAUDE.md is the full reference. Key rules:
- Light and dark themes (dark default) — style with the Tailwind semantic classes; see the token table in
CLAUDE.md - Depth:
backdrop-blur-*with translucentborder-overlay/10andbg-overlay/[x]fills;shadow-glow-{cyan,purple,green}for accent glows.box-shadowis allowed - Selected/active items (sidebar lessons, search results):
bg-cyber-cyan/10 text-cyber-cyan - Don't build new UI on the legacy tokens (
--quantum,--bg-base, …) inglobals.css; they are slated for removal - Math: KaTeX only (not MathJax)
- Fonts: Geist Mono (UI), Geist Sans (prose), JetBrains Mono (code)
Pro gating
Check useAuthStore.getState().user?.subscription_status === "pro" before rendering Pro features. On denial, trigger the upgrade modal from components/billing/ — do not redirect or throw.
Gated features: AI Tutor (RightPanel), circuit execution (Run button), adaptive quiz generation.
Never gate: lessons, course content, curriculum, circuit building (save without execute).
Type conventions
All shared types in types/index.ts. Do not define types inline in components or stores — add to types/index.ts and import.
Key types already defined: User, Course, Module, Lesson, GateSpec, CircuitSpec, SimulationResult, Plan.
What not to do
- Do not fetch data directly inside a Zustand store action via
useEffect— fetch in a hook or component, then call the store setter - Do not import one workspace's components into another workspace
- Do not add a Monaco Editor import to the top-level bundle — dynamic import only
- Do not add a Three.js import in Phase 1 — Bloch sphere is Phase 2, dynamic import only
- Do not add a spinner for AI tutor responses — token streaming is the UX; a spinner before the first token is acceptable
- Do not use
localStoragedirectly — all persistence goes through Zustandpersistmiddleware - Do not add
console.logstatements to committed code
