# Frontend Zacus V3 — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Build monorepo with 3 apps (Blockly editor, game master dashboard, 3D simulation) and shared packages (scenario-engine, UI, types).

**Architecture:** pnpm workspaces + Turborepo. React 19 + Vite for all apps. Blockly 12.4 for editor, WebSocket for dashboard, Three.js/@react-three/fiber for simulation. Shared scenario-engine portable to web + ESP32.

**Tech Stack:** React 19, Vite, TypeScript, Blockly, Three.js, @react-three/fiber, WebSocket, Zustand, Tailwind CSS, Radix UI, Vitest, pnpm, Turborepo

**Canonical scenario source:** `game/scenarios/zacus_v3_complete.yaml`

---

## Overview

| Phase | Scope | Est. Hours | Dependencies |
|-------|-------|------------|--------------|
| 1 | Monorepo scaffold + shared packages | 15h | — |
| 2 | apps/editor — Blockly V3 blocks + YAML | 25h | Phase 1 |
| 3 | apps/dashboard — expert + simple modes | 20h | Phase 1 |
| 4 | apps/simulation — 3D scene + 7 puzzles | 35h | Phase 1 |
| 5 | packages/ui — design system | 8h | Phase 1 |
| 6 | Integration + testing | 12h | Phases 2–5 |
| **Total** | | **~115h** | |

---

## Phase 1: Monorepo scaffold + shared packages (15h)

### Task 1.1 — Monorepo root scaffold (1h)

**Target directory:** `frontend-v3/` (sibling to `frontend-scratch-v2/`)

**Files to create:**

`frontend-v3/pnpm-workspace.yaml`
```yaml
packages:
  - 'apps/*'
  - 'packages/*'
```

`frontend-v3/package.json`
```json
{
  "name": "zacus-frontend-v3",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "typecheck": "turbo run typecheck"
  },
  "devDependencies": {
    "turbo": "^2.3.3",
    "typescript": "^5.7.3"
  },
  "engines": {
    "node": ">=20",
    "pnpm": ">=9"
  }
}
```

`frontend-v3/turbo.json`
```json
{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    },
    "lint": {}
  }
}
```

`frontend-v3/tsconfig.base.json`
```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "jsx": "react-jsx",
    "allowImportingTsExtensions": false,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "skipLibCheck": true
  }
}
```

### Task 1.2 — packages/shared: types, constants, YAML parser (4h)

**Directory:** `frontend-v3/packages/shared/`

`frontend-v3/packages/shared/package.json`
```json
{
  "name": "@zacus/shared",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsc -b",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  },
  "devDependencies": {
    "typescript": "^5.7.3",
    "vitest": "^2.1.8"
  },
  "dependencies": {
    "js-yaml": "^4.1.0"
  }
}
```

`frontend-v3/packages/shared/src/types.ts`
```typescript
// ============================================================
// CORE EVENT / COMMAND TYPES (WebSocket protocol BOX-3 ↔ Dashboard)
// ============================================================

export type GameEventType =
  | 'puzzle_solved'
  | 'puzzle_skipped'
  | 'hint_given'
  | 'npc_spoke'
  | 'phone_rang'
  | 'phase_changed'
  | 'timer_update'
  | 'profile_detected'
  | 'game_start'
  | 'game_end'
  | 'hw_failure';

export interface GameEvent {
  type: GameEventType;
  timestamp: number;
  data: Record<string, unknown>;
}

export type GameCommandType =
  | 'force_hint'
  | 'skip_puzzle'
  | 'add_bonus'
  | 'ring_phone'
  | 'set_duration'
  | 'manual_tts'
  | 'override_mood'
  | 'pause'
  | 'resume'
  | 'end_game';

export interface GameCommand {
  type: GameCommandType;
  data: Record<string, unknown>;
}

// ============================================================
// ENGINE STATE TYPES
// ============================================================

export type Phase = 'INTRO' | 'PROFILING' | 'ADAPTIVE' | 'CLIMAX' | 'OUTRO';
export type GroupProfile = 'TECH' | 'NON_TECH' | 'MIXED';
export type NpcMood = 'neutral' | 'impressed' | 'worried' | 'amused';
export type DurationMode = '30' | '45' | '60' | '90';
export type PuzzleProfile = 'TECH' | 'NON_TECH' | 'BOTH' | 'MIXED';

export interface EngineState {
  phase: Phase;
  groupProfile: GroupProfile | null;
  activePuzzle: string | null;
  solvedPuzzles: string[];
  skippedPuzzles: string[];
  hintsGiven: Record<string, number>;
  npcMood: NpcMood;
  elapsedMs: number;
  codeAssembled: string;
}

export interface ScoreResult {
  baseScore: number;
  timePenalty: number;
  hintPenalty: number;
  bonus: number;
  total: number;
  rank: 'S' | 'A' | 'B' | 'C' | 'D';
}

export interface EngineDecision {
  action: 'speak' | 'ring_phone' | 'play_sound' | 'add_puzzle' | 'skip_puzzle' | 'change_mood';
  data: Record<string, unknown>;
}

// ============================================================
// SCENARIO YAML SCHEMA TYPES
// ============================================================

export interface PuzzleHardware {
  id: string;
  name: string;
  type: string;
  profile: PuzzleProfile;
  phase: 'profiling' | 'adaptive' | 'climax';
}

export interface PuzzleConfig {
  id: string;
  name: string;
  // P1
  melody_notes?: string[];
  difficulty?: number;
  code_digits?: number[];
  // P2
  components?: string[];
  valid_circuit?: string;
  code_digit?: number;
  // P3
  qr_codes?: string[];
  correct_order?: number[];
  // P4
  target_freq?: number;
  range_min?: number;
  range_max?: number;
  // P5
  message?: string;
  mode?: 'tech' | 'light';
  // P6
  symbols?: string[];
  // P7
  code_length?: number;
  max_attempts?: number;
}

export interface NpcProfilingConfig {
  tech_threshold_s: number;
  nontech_threshold_s: number;
}

export interface NpcDurationConfig {
  target_minutes: number;
  mode: DurationMode;
}

export interface NpcAdaptiveRule {
  condition: 'fast' | 'slow';
  action: 'add' | 'skip' | 'hint';
}

export interface PhaseDefinition {
  type: Phase;
  puzzles: Array<{
    puzzle_ref: string;
    profile_filter: PuzzleProfile;
  }>;
}

export interface ScenarioYaml {
  id: string;
  version: string;
  title: string;
  duration: {
    target_minutes: number;
    min_minutes: number;
    max_minutes: number;
  };
  hardware: {
    puzzles: PuzzleHardware[];
  };
  scoring: {
    base_score: number;
    time_penalty_per_minute: number;
    hint_penalty: number;
    bonus_fast_completion: number;
  };
  npc: {
    profiling: NpcProfilingConfig;
    adaptive_rules: NpcAdaptiveRule[];
  };
  phases: PhaseDefinition[];
  puzzles: PuzzleConfig[];
}
```

`frontend-v3/packages/shared/src/constants.ts`
```typescript
// Puzzle IDs matching zacus_v3_complete.yaml hardware.puzzles[*].id
export const PUZZLE_IDS = ['P1_SON', 'P2_CIRCUIT', 'P3_QR', 'P4_RADIO', 'P5_MORSE', 'P6_SYMBOLES', 'P7_COFFRE'] as const;
export type PuzzleId = typeof PUZZLE_IDS[number];

export const NPC_MOODS = ['neutral', 'impressed', 'worried', 'amused'] as const;
export const PHASE_TYPES = ['INTRO', 'PROFILING', 'ADAPTIVE', 'CLIMAX', 'OUTRO'] as const;
export const DURATION_MODES = ['30', '45', '60', '90'] as const;

// NPC mood → glow color (used in dashboard + simulation)
export const NPC_MOOD_COLORS: Record<string, string> = {
  neutral: '#0071e3',
  impressed: '#34c759',
  worried: '#ff9500',
  amused: '#af52de',
};

// Theme FER accent colors
export const FER_ACCENT = '#0071e3';
export const FER_SURFACE = '#1c1c1e';
export const FER_CARD = '#2c2c2e';

// BOX-3 mDNS default hostname
export const BOX3_MDNS_HOST = 'zacus-box3.local';
export const BOX3_WS_PORT = 81;
export const BOX3_WS_RECONNECT_MS = 3000;
```

`frontend-v3/packages/shared/src/yaml-parser.ts`
```typescript
import yaml from 'js-yaml';
import type { ScenarioYaml } from './types.js';

/**
 * Parse a zacus_v3_complete.yaml string into a typed ScenarioYaml object.
 * Throws if the YAML is malformed or missing required top-level keys.
 */
export function parseScenarioYaml(raw: string): ScenarioYaml {
  const doc = yaml.load(raw);
  if (!doc || typeof doc !== 'object') {
    throw new Error('Invalid YAML: expected object at root');
  }
  const scenario = doc as Record<string, unknown>;
  const required = ['id', 'version', 'duration', 'hardware', 'scoring', 'phases', 'puzzles'];
  for (const key of required) {
    if (!(key in scenario)) {
      throw new Error(`Invalid scenario YAML: missing key "${key}"`);
    }
  }
  return scenario as unknown as ScenarioYaml;
}

/**
 * Serialize a ScenarioYaml back to YAML string.
 * Used by the Blockly editor YAML export.
 */
export function serializeScenarioYaml(scenario: ScenarioYaml): string {
  return yaml.dump(scenario, { lineWidth: 120, noRefs: true });
}
```

`frontend-v3/packages/shared/src/index.ts`
```typescript
export * from './types.js';
export * from './constants.js';
export * from './yaml-parser.js';
```

`frontend-v3/packages/shared/tsconfig.json`
```json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src"]
}
```

**Test file:** `frontend-v3/packages/shared/src/yaml-parser.test.ts`
```typescript
import { describe, it, expect } from 'vitest';
import { parseScenarioYaml, serializeScenarioYaml } from './yaml-parser.js';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

const YAML_PATH = resolve('../../../../game/scenarios/zacus_v3_complete.yaml');

describe('parseScenarioYaml', () => {
  it('parses the canonical zacus_v3_complete.yaml', () => {
    const raw = readFileSync(YAML_PATH, 'utf-8');
    const scenario = parseScenarioYaml(raw);
    expect(scenario.id).toBe('zacus_v3_complete');
    expect(scenario.hardware.puzzles).toHaveLength(7);
  });

  it('throws on empty string', () => {
    expect(() => parseScenarioYaml('')).toThrow();
  });

  it('throws when required key is missing', () => {
    expect(() => parseScenarioYaml('id: foo')).toThrow('missing key');
  });

  it('round-trips through serialize → parse without data loss', () => {
    const raw = readFileSync(YAML_PATH, 'utf-8');
    const scenario = parseScenarioYaml(raw);
    const serialized = serializeScenarioYaml(scenario);
    const reparsed = parseScenarioYaml(serialized);
    expect(reparsed.id).toBe(scenario.id);
    expect(reparsed.hardware.puzzles).toHaveLength(7);
  });
});
```

### Task 1.3 — packages/scenario-engine: Core engine (8h)

**Directory:** `frontend-v3/packages/scenario-engine/`

`frontend-v3/packages/scenario-engine/package.json`
```json
{
  "name": "@zacus/scenario-engine",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsc -b",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  },
  "dependencies": {
    "@zacus/shared": "workspace:*"
  },
  "devDependencies": {
    "typescript": "^5.7.3",
    "vitest": "^2.1.8"
  }
}
```

`frontend-v3/packages/scenario-engine/src/engine.ts`
```typescript
import type {
  ScenarioEngine,
  EngineState,
  EngineDecision,
  GameEvent,
  ScoreResult,
  Phase,
  GroupProfile,
  NpcMood,
  ScenarioYaml,
} from '@zacus/shared';
import { parseScenarioYaml } from '@zacus/shared';

// Internal engine — not exported; use ZacusScenarioEngine below.
interface EngineConfig {
  targetDuration: number;
  mode: '30' | '45' | '60' | '90';
}

export class ZacusScenarioEngine implements ScenarioEngine {
  private scenario: ScenarioYaml | null = null;
  private config: EngineConfig | null = null;
  private state: EngineState = this.#initialState();
  private startMs = 0;

  // ----------------------------------------------------------------
  load(yaml: string): void {
    this.scenario = parseScenarioYaml(yaml);
    this.state = this.#initialState();
  }

  // ----------------------------------------------------------------
  start(config: EngineConfig): void {
    if (!this.scenario) throw new Error('Call load() before start()');
    this.config = config;
    this.startMs = Date.now();
    this.state.phase = 'INTRO';
  }

  // ----------------------------------------------------------------
  tick(nowMs: number): EngineState {
    if (!this.scenario || !this.config) return this.state;
    this.state.elapsedMs = nowMs - this.startMs;
    this.#updatePhase();
    this.#updateMood();
    return { ...this.state };
  }

  // ----------------------------------------------------------------
  onEvent(event: GameEvent): EngineDecision[] {
    if (!this.scenario || !this.config) return [];
    const decisions: EngineDecision[] = [];

    switch (event.type) {
      case 'puzzle_solved': {
        const puzzleId = event.data['puzzle_id'] as string;
        this.state.solvedPuzzles.push(puzzleId);
        this.#assembleCode(puzzleId);
        decisions.push({
          action: 'change_mood',
          data: { mood: 'impressed' satisfies NpcMood },
        });
        decisions.push({
          action: 'speak',
          data: { category: 'congratulations', puzzle: puzzleId },
        });
        break;
      }

      case 'profile_detected': {
        this.state.groupProfile = event.data['profile'] as GroupProfile;
        decisions.push({
          action: 'speak',
          data: { category: 'adaptation', profile: this.state.groupProfile },
        });
        break;
      }

      case 'hint_given': {
        const puzzleId = event.data['puzzle_id'] as string;
        this.state.hintsGiven[puzzleId] = (this.state.hintsGiven[puzzleId] ?? 0) + 1;
        decisions.push({
          action: 'speak',
          data: {
            category: 'hints',
            puzzle: puzzleId,
            level: this.state.hintsGiven[puzzleId],
          },
        });
        break;
      }

      case 'puzzle_skipped': {
        const puzzleId = event.data['puzzle_id'] as string;
        this.state.skippedPuzzles.push(puzzleId);
        decisions.push({
          action: 'skip_puzzle',
          data: { puzzle_id: puzzleId },
        });
        break;
      }

      case 'phase_changed': {
        const phase = event.data['phase'] as Phase;
        this.state.phase = phase;
        decisions.push({
          action: 'ring_phone',
          data: { reason: 'phase_transition', phase },
        });
        break;
      }

      case 'game_start': {
        this.state.phase = 'PROFILING';
        decisions.push({
          action: 'speak',
          data: { category: 'narrative', scene: 'intro' },
        });
        break;
      }

      case 'game_end': {
        this.state.phase = 'OUTRO';
        decisions.push({
          action: 'speak',
          data: { category: 'narrative', scene: 'outro' },
        });
        break;
      }
    }

    return decisions;
  }

  // ----------------------------------------------------------------
  getState(): EngineState {
    return { ...this.state };
  }

  // ----------------------------------------------------------------
  getScore(): ScoreResult {
    if (!this.scenario || !this.config) {
      return { baseScore: 0, timePenalty: 0, hintPenalty: 0, bonus: 0, total: 0, rank: 'D' };
    }
    const { scoring } = this.scenario;
    const elapsedMin = this.state.elapsedMs / 60_000;
    const targetMin = this.config.targetDuration;
    const totalHints = Object.values(this.state.hintsGiven).reduce((a, b) => a + b, 0);

    const timePenalty = Math.max(0, Math.round((elapsedMin - targetMin) * scoring.time_penalty_per_minute));
    const hintPenalty = totalHints * scoring.hint_penalty;
    const bonus = elapsedMin < targetMin * 0.8 ? scoring.bonus_fast_completion : 0;
    const total = Math.max(0, scoring.base_score - timePenalty - hintPenalty + bonus);

    return {
      baseScore: scoring.base_score,
      timePenalty,
      hintPenalty,
      bonus,
      total,
      rank: this.#scoreRank(total, scoring.base_score),
    };
  }

  // ----------------------------------------------------------------
  // Private helpers
  // ----------------------------------------------------------------

  #initialState(): EngineState {
    return {
      phase: 'INTRO',
      groupProfile: null,
      activePuzzle: null,
      solvedPuzzles: [],
      skippedPuzzles: [],
      hintsGiven: {},
      npcMood: 'neutral',
      elapsedMs: 0,
      codeAssembled: '',
    };
  }

  #updatePhase(): void {
    if (!this.scenario || !this.config) return;
    const { duration } = this.scenario;
    const elapsedMin = this.state.elapsedMs / 60_000;

    if (elapsedMin < duration.profiling_minutes) {
      this.state.phase = 'PROFILING';
    } else if (elapsedMin < this.config.targetDuration - duration.climax_minutes - duration.outro_minutes) {
      this.state.phase = 'ADAPTIVE';
    } else if (elapsedMin < this.config.targetDuration - duration.outro_minutes) {
      this.state.phase = 'CLIMAX';
    } else {
      this.state.phase = 'OUTRO';
    }
  }

  #updateMood(): void {
    const { solvedPuzzles, hintsGiven, elapsedMs } = this.state;
    if (!this.config) return;
    const elapsedMin = elapsedMs / 60_000;
    const totalHints = Object.values(hintsGiven).reduce((a, b) => a + b, 0);

    if (elapsedMin < this.config.targetDuration * 0.6 && solvedPuzzles.length >= 3) {
      this.state.npcMood = 'impressed';
    } else if (totalHints > 5 || elapsedMin > this.config.targetDuration * 0.9) {
      this.state.npcMood = 'worried';
    } else {
      this.state.npcMood = 'neutral';
    }
  }

  #assembleCode(puzzleId: string): void {
    if (!this.scenario) return;
    const puzzle = this.scenario.puzzles.find((p) => p.id === puzzleId);
    if (!puzzle) return;
    // Append the code digit(s) contributed by this puzzle
    const digits = puzzle.code_digits ?? (puzzle.code_digit != null ? [puzzle.code_digit] : []);
    this.state.codeAssembled += digits.join('');
  }

  #scoreRank(total: number, base: number): ScoreResult['rank'] {
    const ratio = total / base;
    if (ratio >= 0.95) return 'S';
    if (ratio >= 0.8) return 'A';
    if (ratio >= 0.65) return 'B';
    if (ratio >= 0.5) return 'C';
    return 'D';
  }
}
```

`frontend-v3/packages/scenario-engine/src/index.ts`
```typescript
export { ZacusScenarioEngine } from './engine.js';
// Re-export the interface so consumers can type-check against it
export type { ScenarioEngine } from '@zacus/shared';
```

`frontend-v3/packages/scenario-engine/tsconfig.json`
```json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src"],
  "references": [{ "path": "../shared" }]
}
```

**Test file:** `frontend-v3/packages/scenario-engine/src/engine.test.ts`
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { ZacusScenarioEngine } from './engine.js';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

const YAML_PATH = resolve('../../../../game/scenarios/zacus_v3_complete.yaml');
const yamlRaw = readFileSync(YAML_PATH, 'utf-8');

describe('ZacusScenarioEngine', () => {
  let engine: ZacusScenarioEngine;

  beforeEach(() => {
    engine = new ZacusScenarioEngine();
    engine.load(yamlRaw);
    engine.start({ targetDuration: 60, mode: '60' });
  });

  it('starts in PROFILING phase', () => {
    const state = engine.tick(Date.now());
    expect(state.phase).toBe('PROFILING');
  });

  it('records solved puzzles on puzzle_solved event', () => {
    const decisions = engine.onEvent({
      type: 'puzzle_solved',
      timestamp: Date.now(),
      data: { puzzle_id: 'P1_SON' },
    });
    expect(engine.getState().solvedPuzzles).toContain('P1_SON');
    expect(decisions.some((d) => d.action === 'change_mood')).toBe(true);
  });

  it('tracks hints per puzzle', () => {
    engine.onEvent({ type: 'hint_given', timestamp: Date.now(), data: { puzzle_id: 'P2_CIRCUIT' } });
    engine.onEvent({ type: 'hint_given', timestamp: Date.now(), data: { puzzle_id: 'P2_CIRCUIT' } });
    expect(engine.getState().hintsGiven['P2_CIRCUIT']).toBe(2);
  });

  it('getScore returns base_score when no time exceeded and no hints', () => {
    const score = engine.getScore();
    expect(score.baseScore).toBe(1000);
    expect(score.timePenalty).toBe(0);
    expect(score.total).toBe(1000);
  });

  it('sets groupProfile on profile_detected event', () => {
    engine.onEvent({ type: 'profile_detected', timestamp: Date.now(), data: { profile: 'TECH' } });
    expect(engine.getState().groupProfile).toBe('TECH');
  });

  it('advances phase based on elapsed time', () => {
    // 55 minutes in — should be in CLIMAX (60 - 5 outro < 55 < 60)
    const fakeNow = Date.now() + 55 * 60 * 1000;
    const state = engine.tick(fakeNow);
    expect(['CLIMAX', 'OUTRO']).toContain(state.phase);
  });
});
```

### Task 1.4 — packages/ui: scaffold only (2h)

Bootstrap the UI package. Full component implementation is Phase 5, but the scaffold is needed so Phase 2–4 apps can depend on it.

`frontend-v3/packages/ui/package.json`
```json
{
  "name": "@zacus/ui",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./styles.css": "./src/styles.css"
  },
  "scripts": {
    "build": "tsc -b",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@radix-ui/react-dialog": "^1.1.4",
    "@radix-ui/react-progress": "^1.1.1",
    "@radix-ui/react-toggle": "^1.1.1",
    "@radix-ui/react-tooltip": "^1.1.6",
    "@zacus/shared": "workspace:*"
  },
  "devDependencies": {
    "typescript": "^5.7.3",
    "tailwindcss": "^3.4.17"
  },
  "peerDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}
```

`frontend-v3/packages/ui/src/index.ts`
```typescript
// Stub exports — filled in Phase 5
export { Button } from './components/Button.js';
export { Card } from './components/Card.js';
export { Badge } from './components/Badge.js';
export { Progress } from './components/Progress.js';
```

Create stub component files for each export (Button, Card, Badge, Progress) returning `null` until Phase 5. Each file follows this pattern:

`frontend-v3/packages/ui/src/components/Button.tsx`
```typescript
import type { ComponentProps } from 'react';

export interface ButtonProps extends ComponentProps<'button'> {
  variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
  size?: 'sm' | 'md' | 'lg';
}

export function Button({ variant = 'primary', size = 'md', className = '', ...props }: ButtonProps) {
  const base =
    'inline-flex items-center justify-center rounded-xl font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0071e3] disabled:opacity-50';
  const variants = {
    primary: 'bg-[#0071e3] text-white hover:bg-[#0077ed]',
    secondary: 'bg-[#2c2c2e] text-white hover:bg-[#3a3a3c]',
    ghost: 'bg-transparent text-white hover:bg-white/10',
    danger: 'bg-[#ff3b30] text-white hover:bg-[#ff453a]',
  };
  const sizes = { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4 text-base', lg: 'h-12 px-6 text-lg' };
  return <button className={`${base} ${variants[variant]} ${sizes[size]} ${className}`} {...props} />;
}
```

---

## Phase 2: Blockly Editor (25h)

### Task 2.1 — apps/editor scaffold (1h)

**Directory:** `frontend-v3/apps/editor/`

`frontend-v3/apps/editor/package.json`
```json
{
  "name": "@zacus/editor",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "lint": "eslint src"
  },
  "dependencies": {
    "@zacus/shared": "workspace:*",
    "@zacus/ui": "workspace:*",
    "blockly": "^12.4.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "zustand": "^5.0.3"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@vitejs/plugin-react": "^4.3.4",
    "typescript": "^5.7.3",
    "vite": "^6.0.11",
    "vitest": "^2.1.8"
  }
}
```

`frontend-v3/apps/editor/vite.config.ts`
```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: { port: 5173 },
  build: { target: 'esnext' },
});
```

`frontend-v3/apps/editor/src/main.tsx`
```typescript
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
import './index.css';

const root = document.getElementById('root');
if (!root) throw new Error('No #root element');
createRoot(root).render(<StrictMode><App /></StrictMode>);
```

`frontend-v3/apps/editor/src/App.tsx`
```typescript
import { EditorLayout } from './components/EditorLayout.js';

export function App() {
  return <EditorLayout />;
}
```

### Task 2.2 — 12 custom Blockly block definitions (8h)

**File:** `frontend-v3/apps/editor/src/blocks/definitions.ts`

Define all 12 blocks using the Blockly JSON definition API. Full definition for all 12 blocks:

```typescript
import * as Blockly from 'blockly/core';

export const BLOCK_DEFINITIONS = [
  // ---- PUZZLE BLOCKS --------------------------------------------------------
  {
    type: 'puzzle_sequence_sonore',
    message0: 'P1 Séquence Sonore | notes %1 | difficulté %2 | code digits %3',
    args0: [
      { type: 'field_input', name: 'MELODY_NOTES', text: 'do,re,mi,fa' },
      { type: 'field_number', name: 'DIFFICULTY', value: 1, min: 1, max: 5 },
      { type: 'field_input', name: 'CODE_DIGITS', text: '1,2' },
    ],
    colour: '#FF6B35',
    tooltip: 'P1: Séquence Sonore — joueur reproduit une mélodie',
    previousStatement: null,
    nextStatement: null,
  },
  {
    type: 'puzzle_circuit_led',
    message0: 'P2 Circuit LED | composants %1 | circuit valide %2 | code digit %3',
    args0: [
      { type: 'field_input', name: 'COMPONENTS', text: 'resistor,led,capacitor' },
      { type: 'field_input', name: 'VALID_CIRCUIT', text: 'series_parallel' },
      { type: 'field_number', name: 'CODE_DIGIT', value: 3, min: 0, max: 9 },
    ],
    colour: '#FF6B35',
    tooltip: 'P2: Circuit LED — joueur câble un circuit électronique',
    previousStatement: null,
    nextStatement: null,
  },
  {
    type: 'puzzle_qr_treasure',
    message0: 'P3 QR Treasure | QR codes %1 | ordre correct %2 | code digit %3',
    args0: [
      { type: 'field_input', name: 'QR_CODES', text: 'qr1,qr2,qr3,qr4,qr5,qr6' },
      { type: 'field_input', name: 'CORRECT_ORDER', text: '3,1,4,1,5,9' },
      { type: 'field_number', name: 'CODE_DIGIT', value: 5, min: 0, max: 9 },
    ],
    colour: '#FF6B35',
    tooltip: 'P3: QR Treasure — scanner 6 QR codes dans le bon ordre',
    previousStatement: null,
    nextStatement: null,
  },
  {
    type: 'puzzle_radio',
    message0: 'P4 Radio | fréquence cible %1 | min %2 | max %3 | code digit %4',
    args0: [
      { type: 'field_number', name: 'TARGET_FREQ', value: 93.5, min: 88, max: 108 },
      { type: 'field_number', name: 'RANGE_MIN', value: 93.0 },
      { type: 'field_number', name: 'RANGE_MAX', value: 94.0 },
      { type: 'field_number', name: 'CODE_DIGIT', value: 7, min: 0, max: 9 },
    ],
    colour: '#FF6B35',
    tooltip: 'P4: Fréquence Radio — syntoniser une fréquence précise',
    previousStatement: null,
    nextStatement: null,
  },
  {
    type: 'puzzle_morse',
    message0: 'P5 Morse | message %1 | mode %2 | code digits %3',
    args0: [
      { type: 'field_input', name: 'MESSAGE', text: 'SOS' },
      {
        type: 'field_dropdown',
        name: 'MODE',
        options: [
          ['tech (clé télégraphe)', 'tech'],
          ['light (lampe)', 'light'],
        ],
      },
      { type: 'field_input', name: 'CODE_DIGITS', text: '4,2' },
    ],
    colour: '#FF6B35',
    tooltip: 'P5: Code Morse — transmettre un message en morse',
    previousStatement: null,
    nextStatement: null,
  },
  {
    type: 'puzzle_symboles_nfc',
    message0: 'P6 Symboles NFC | symboles %1 | ordre correct %2 | code digits %3',
    args0: [
      { type: 'field_input', name: 'SYMBOLS', text: 'alpha,beta,gamma,delta,sigma,omega,phi,psi,chi,rho,mu,nu' },
      { type: 'field_input', name: 'CORRECT_ORDER', text: '1,4,7,2' },
      { type: 'field_input', name: 'CODE_DIGITS', text: '6,1' },
    ],
    colour: '#FF6B35',
    tooltip: 'P6: Symboles Alchimiques NFC — placer des tuiles dans le bon ordre',
    previousStatement: null,
    nextStatement: null,
  },
  {
    type: 'puzzle_coffre_final',
    message0: 'P7 Coffre Final | longueur code %1 | max tentatives %2',
    args0: [
      { type: 'field_number', name: 'CODE_LENGTH', value: 8, min: 4, max: 12 },
      { type: 'field_number', name: 'MAX_ATTEMPTS', value: 3, min: 1, max: 10 },
    ],
    colour: '#FF6B35',
    tooltip: 'P7: Coffre Final — entrer le code assemblé depuis tous les puzzles',
    previousStatement: null,
    nextStatement: null,
  },
  // ---- NPC BLOCKS -----------------------------------------------------------
  {
    type: 'npc_profiling',
    message0: 'NPC Profiling | seuil tech %1 s | seuil non-tech %2 s',
    args0: [
      { type: 'field_number', name: 'TECH_THRESHOLD_S', value: 300, min: 60, max: 600 },
      { type: 'field_number', name: 'NONTECH_THRESHOLD_S', value: 480, min: 60, max: 900 },
    ],
    colour: '#5B4FD6',
    tooltip: 'Configure la logique de profilage du groupe par le NPC',
    previousStatement: null,
    nextStatement: null,
  },
  {
    type: 'npc_duration',
    message0: 'NPC Durée | cible %1 min | mode %2',
    args0: [
      { type: 'field_number', name: 'TARGET_MINUTES', value: 60, min: 30, max: 90 },
      {
        type: 'field_dropdown',
        name: 'MODE',
        options: [
          ['30 min', '30'],
          ['45 min', '45'],
          ['60 min', '60'],
          ['90 min', '90'],
        ],
      },
    ],
    colour: '#5B4FD6',
    tooltip: 'Configure la durée cible de la session',
    previousStatement: null,
    nextStatement: null,
  },
  {
    type: 'npc_adaptive_rule',
    message0: 'NPC Règle | si %1 → %2',
    args0: [
      {
        type: 'field_dropdown',
        name: 'CONDITION',
        options: [
          ['trop rapide', 'fast'],
          ['trop lent', 'slow'],
        ],
      },
      {
        type: 'field_dropdown',
        name: 'ACTION',
        options: [
          ['ajouter puzzle', 'add'],
          ['passer puzzle', 'skip'],
          ['donner indice', 'hint'],
        ],
      },
    ],
    colour: '#5B4FD6',
    tooltip: 'Règle adaptative NPC (ajustement en cours de jeu)',
    previousStatement: null,
    nextStatement: null,
  },
  // ---- FLOW BLOCKS ----------------------------------------------------------
  {
    type: 'phase_container',
    message0: 'PHASE %1',
    args0: [
      {
        type: 'field_dropdown',
        name: 'PHASE_TYPE',
        options: [
          ['INTRO', 'INTRO'],
          ['PROFILING', 'PROFILING'],
          ['ADAPTIVE', 'ADAPTIVE'],
          ['CLIMAX', 'CLIMAX'],
          ['OUTRO', 'OUTRO'],
        ],
      },
    ],
    message1: '%1',
    args1: [{ type: 'input_statement', name: 'PUZZLES' }],
    colour: '#2ECC71',
    tooltip: 'Conteneur de phase du scénario',
    previousStatement: null,
    nextStatement: null,
  },
  {
    type: 'puzzle_selector',
    message0: 'Puzzle %1 | profil %2',
    args0: [
      {
        type: 'field_dropdown',
        name: 'PUZZLE_REF',
        options: [
          ['P1 Séquence Sonore', 'P1_SON'],
          ['P2 Circuit LED', 'P2_CIRCUIT'],
          ['P3 QR Treasure', 'P3_QR'],
          ['P4 Radio', 'P4_RADIO'],
          ['P5 Morse', 'P5_MORSE'],
          ['P6 Symboles NFC', 'P6_SYMBOLES'],
          ['P7 Coffre Final', 'P7_COFFRE'],
        ],
      },
      {
        type: 'field_dropdown',
        name: 'PROFILE_FILTER',
        options: [
          ['tous (BOTH)', 'BOTH'],
          ['tech', 'TECH'],
          ['non-tech', 'NON_TECH'],
        ],
      },
    ],
    colour: '#2ECC71',
    tooltip: 'Référence à un puzzle dans une phase',
    previousStatement: null,
    nextStatement: null,
  },
];

export function registerBlocks(): void {
  for (const def of BLOCK_DEFINITIONS) {
    Blockly.Blocks[def.type] = {
      init(this: Blockly.Block) {
        this.jsonInit(def);
      },
    };
  }
}
```

### Task 2.3 — Code generators (YAML export per block) (4h)

**File:** `frontend-v3/apps/editor/src/blocks/generators.ts`

```typescript
import * as Blockly from 'blockly/core';
import { javascriptGenerator } from 'blockly/javascript';

// We use the JS generator to emit YAML fragments as strings,
// then assemble them at the workspace level.

function reg(blockType: string, fn: (block: Blockly.Block) => [string, number] | string) {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  (javascriptGenerator as any)[blockType] = fn;
}

reg('puzzle_sequence_sonore', (block) => {
  const notes = block.getFieldValue('MELODY_NOTES') as string;
  const diff = block.getFieldValue('DIFFICULTY') as string;
  const digits = block.getFieldValue('CODE_DIGITS') as string;
  return `
  - id: P1_SON
    name: "Séquence Sonore"
    melody_notes: [${notes.split(',').map((n) => `"${n.trim()}"`).join(', ')}]
    difficulty: ${diff}
    code_digits: [${digits}]`;
});

reg('puzzle_circuit_led', (block) => {
  const components = block.getFieldValue('COMPONENTS') as string;
  const circuit = block.getFieldValue('VALID_CIRCUIT') as string;
  const digit = block.getFieldValue('CODE_DIGIT') as string;
  return `
  - id: P2_CIRCUIT
    name: "Circuit LED"
    components: [${components.split(',').map((c) => `"${c.trim()}"`).join(', ')}]
    valid_circuit: "${circuit}"
    code_digit: ${digit}`;
});

reg('puzzle_qr_treasure', (block) => {
  const qrs = block.getFieldValue('QR_CODES') as string;
  const order = block.getFieldValue('CORRECT_ORDER') as string;
  const digit = block.getFieldValue('CODE_DIGIT') as string;
  return `
  - id: P3_QR
    name: "QR Treasure"
    qr_codes: [${qrs.split(',').map((q) => `"${q.trim()}"`).join(', ')}]
    correct_order: [${order}]
    code_digit: ${digit}`;
});

reg('puzzle_radio', (block) => {
  const freq = block.getFieldValue('TARGET_FREQ') as string;
  const min = block.getFieldValue('RANGE_MIN') as string;
  const max = block.getFieldValue('RANGE_MAX') as string;
  const digit = block.getFieldValue('CODE_DIGIT') as string;
  return `
  - id: P4_RADIO
    name: "Fréquence Radio"
    target_freq: ${freq}
    range_min: ${min}
    range_max: ${max}
    code_digit: ${digit}`;
});

reg('puzzle_morse', (block) => {
  const msg = block.getFieldValue('MESSAGE') as string;
  const mode = block.getFieldValue('MODE') as string;
  const digits = block.getFieldValue('CODE_DIGITS') as string;
  return `
  - id: P5_MORSE
    name: "Code Morse"
    message: "${msg}"
    mode: ${mode}
    code_digits: [${digits}]`;
});

reg('puzzle_symboles_nfc', (block) => {
  const symbols = block.getFieldValue('SYMBOLS') as string;
  const order = block.getFieldValue('CORRECT_ORDER') as string;
  const digits = block.getFieldValue('CODE_DIGITS') as string;
  return `
  - id: P6_SYMBOLES
    name: "Symboles Alchimiques"
    symbols: [${symbols.split(',').map((s) => `"${s.trim()}"`).join(', ')}]
    correct_order: [${order}]
    code_digits: [${digits}]`;
});

reg('puzzle_coffre_final', (block) => {
  const len = block.getFieldValue('CODE_LENGTH') as string;
  const attempts = block.getFieldValue('MAX_ATTEMPTS') as string;
  return `
  - id: P7_COFFRE
    name: "Coffre Final"
    code_length: ${len}
    max_attempts: ${attempts}`;
});

reg('npc_profiling', (block) => {
  const tech = block.getFieldValue('TECH_THRESHOLD_S') as string;
  const nontech = block.getFieldValue('NONTECH_THRESHOLD_S') as string;
  return `
  profiling:
    tech_threshold_s: ${tech}
    nontech_threshold_s: ${nontech}`;
});

reg('npc_duration', (block) => {
  const target = block.getFieldValue('TARGET_MINUTES') as string;
  const mode = block.getFieldValue('MODE') as string;
  return `
  duration:
    target_minutes: ${target}
    mode: "${mode}"`;
});

reg('npc_adaptive_rule', (block) => {
  const cond = block.getFieldValue('CONDITION') as string;
  const action = block.getFieldValue('ACTION') as string;
  return `
  - condition: ${cond}
    action: ${action}`;
});

reg('phase_container', (block) => {
  const phaseType = block.getFieldValue('PHASE_TYPE') as string;
  const inner = javascriptGenerator.statementToCode(block, 'PUZZLES');
  return `
- type: ${phaseType}
  puzzles:${inner || '\n    []'}`;
});

reg('puzzle_selector', (block) => {
  const ref = block.getFieldValue('PUZZLE_REF') as string;
  const filter = block.getFieldValue('PROFILE_FILTER') as string;
  return `
  - puzzle_ref: ${ref}
    profile_filter: ${filter}`;
});
```

### Task 2.4 — YAML export + import (4h)

**File:** `frontend-v3/apps/editor/src/yaml/export.ts`

```typescript
import * as Blockly from 'blockly/core';
import { javascriptGenerator } from 'blockly/javascript';
import { serializeScenarioYaml, parseScenarioYaml } from '@zacus/shared';
import type { ScenarioYaml, PhaseDefinition } from '@zacus/shared';

/**
 * Export the current Blockly workspace to a zacus_v3_complete.yaml string.
 * Collects all top-level blocks, runs their generators, assembles the YAML.
 */
export function exportWorkspaceToYaml(workspace: Blockly.Workspace): string {
  const code = javascriptGenerator.workspaceToCode(workspace);
  // The generators emit YAML fragments; wrap in a minimal skeleton
  // so parseScenarioYaml can validate it.
  const skeleton = buildYamlSkeleton(code);
  // Validate by parsing (throws if invalid)
  parseScenarioYaml(skeleton);
  return skeleton;
}

function buildYamlSkeleton(generatedFragments: string): string {
  return `id: zacus_v3_complete
version: "3.1"
title: "Le Mystère du Professeur Zacus — V3 Complete"
duration:
  target_minutes: 60
  min_minutes: 30
  max_minutes: 90
  profiling_minutes: 10
  climax_minutes: 10
  outro_minutes: 5
hardware:
  puzzles: []
scoring:
  base_score: 1000
  time_penalty_per_minute: 10
  hint_penalty: 50
  bonus_fast_completion: 200
npc:
  adaptive_rules: []
phases: []
puzzles:
${generatedFragments}
`;
}
```

**File:** `frontend-v3/apps/editor/src/yaml/import.ts`

```typescript
import * as Blockly from 'blockly/core';
import { parseScenarioYaml } from '@zacus/shared';
import type { ScenarioYaml, PhaseDefinition, PuzzleConfig } from '@zacus/shared';

/**
 * Import a zacus_v3_complete.yaml string into the Blockly workspace.
 * Clears the existing workspace and recreates all blocks.
 */
export function importYamlToWorkspace(workspace: Blockly.Workspace, yaml: string): void {
  const scenario = parseScenarioYaml(yaml);
  workspace.clear();

  // Create phase_container blocks
  for (const phase of scenario.phases) {
    const phaseBlock = workspace.newBlock('phase_container') as Blockly.Block;
    phaseBlock.setFieldValue(phase.type, 'PHASE_TYPE');
    phaseBlock.initSvg?.();
    phaseBlock.render?.();

    // Nested puzzle_selector blocks
    let prevBlock: Blockly.Block | null = null;
    for (const p of phase.puzzles) {
      const selBlock = workspace.newBlock('puzzle_selector') as Blockly.Block;
      selBlock.setFieldValue(p.puzzle_ref, 'PUZZLE_REF');
      selBlock.setFieldValue(p.profile_filter, 'PROFILE_FILTER');
      selBlock.initSvg?.();
      selBlock.render?.();
      if (prevBlock === null) {
        phaseBlock.getInput('PUZZLES')?.connection?.connect(selBlock.previousConnection!);
      } else {
        prevBlock.nextConnection?.connect(selBlock.previousConnection!);
      }
      prevBlock = selBlock;
    }
  }

  // Create puzzle config blocks from scenario.puzzles
  for (const puzzle of scenario.puzzles) {
    createPuzzleBlock(workspace, puzzle);
  }

  Blockly.svgResize(workspace as Blockly.WorkspaceSvg);
}

function createPuzzleBlock(workspace: Blockly.Workspace, puzzle: PuzzleConfig): void {
  const typeMap: Record<string, string> = {
    P1_SON: 'puzzle_sequence_sonore',
    P2_CIRCUIT: 'puzzle_circuit_led',
    P3_QR: 'puzzle_qr_treasure',
    P4_RADIO: 'puzzle_radio',
    P5_MORSE: 'puzzle_morse',
    P6_SYMBOLES: 'puzzle_symboles_nfc',
    P7_COFFRE: 'puzzle_coffre_final',
  };
  const blockType = typeMap[puzzle.id];
  if (!blockType) return;
  const block = workspace.newBlock(blockType) as Blockly.Block;

  switch (puzzle.id) {
    case 'P1_SON':
      block.setFieldValue(puzzle.melody_notes?.join(',') ?? '', 'MELODY_NOTES');
      block.setFieldValue(String(puzzle.difficulty ?? 1), 'DIFFICULTY');
      block.setFieldValue(puzzle.code_digits?.join(',') ?? '', 'CODE_DIGITS');
      break;
    case 'P2_CIRCUIT':
      block.setFieldValue(puzzle.components?.join(',') ?? '', 'COMPONENTS');
      block.setFieldValue(puzzle.valid_circuit ?? '', 'VALID_CIRCUIT');
      block.setFieldValue(String(puzzle.code_digit ?? 0), 'CODE_DIGIT');
      break;
    // ... similarly for P3–P7
  }

  block.initSvg?.();
  block.render?.();
}
```

### Task 2.5 — Validation engine (2h)

**File:** `frontend-v3/apps/editor/src/validation/validator.ts`

```typescript
import type { ScenarioYaml } from '@zacus/shared';

export interface ValidationResult {
  valid: boolean;
  errors: string[];
  warnings: string[];
  summary: string;
}

export function validateScenario(scenario: ScenarioYaml): ValidationResult {
  const errors: string[] = [];
  const warnings: string[] = [];

  // Rule 1: All puzzle code_digits must sum to exactly 8 total digits
  const totalDigits = scenario.puzzles.reduce((sum, p) => {
    const count = (p.code_digits?.length ?? 0) + (p.code_digit != null ? 1 : 0);
    return sum + count;
  }, 0);
  if (totalDigits !== 8) {
    errors.push(`Les digits de code totalisent ${totalDigits} chiffres — attendu 8`);
  }

  // Rule 2: Every puzzle must be referenced in at least one phase
  const referencedPuzzles = new Set(
    scenario.phases.flatMap((ph) => ph.puzzles.map((p) => p.puzzle_ref)),
  );
  for (const puzzle of scenario.puzzles) {
    if (!referencedPuzzles.has(puzzle.id)) {
      errors.push(`Puzzle ${puzzle.id} n'est référencé dans aucune phase`);
    }
  }

  // Rule 3: PROFILING phase must have exactly 2 puzzles (one TECH, one BOTH)
  const profilingPhase = scenario.phases.find((ph) => ph.type === 'PROFILING');
  if (!profilingPhase) {
    errors.push('Aucune phase PROFILING définie');
  } else {
    if (profilingPhase.puzzles.length !== 2) {
      errors.push(`Phase PROFILING: ${profilingPhase.puzzles.length} puzzles — attendu 2`);
    }
    const filters = profilingPhase.puzzles.map((p) => p.profile_filter);
    if (!filters.includes('TECH')) warnings.push('Phase PROFILING: aucun puzzle TECH');
    if (!filters.includes('BOTH')) warnings.push('Phase PROFILING: aucun puzzle BOTH');
  }

  // Rule 4: CLIMAX phase must contain P7_COFFRE
  const climaxPhase = scenario.phases.find((ph) => ph.type === 'CLIMAX');
  if (!climaxPhase) {
    errors.push('Aucune phase CLIMAX définie');
  } else {
    const hasCoffre = climaxPhase.puzzles.some((p) => p.puzzle_ref === 'P7_COFFRE');
    if (!hasCoffre) errors.push('Phase CLIMAX: P7_COFFRE (Coffre Final) absent');
  }

  // Rule 5: Duration target must be set
  if (!scenario.duration.target_minutes) {
    errors.push('Durée cible non configurée');
  }

  const valid = errors.length === 0;
  const puzzleCount = scenario.puzzles.length;
  const durationMin = scenario.duration.target_minutes;
  const summary = valid
    ? `✅ Scénario valide — ${puzzleCount} puzzles, ~${durationMin} min`
    : `❌ ${errors.length} erreur(s) à corriger`;

  return { valid, errors, warnings, summary };
}
```

### Task 2.6 — EditorLayout component + workspace (6h)

**File:** `frontend-v3/apps/editor/src/components/EditorLayout.tsx`

```typescript
import { useEffect, useRef, useState, useCallback } from 'react';
import * as Blockly from 'blockly';
import { javascriptGenerator } from 'blockly/javascript';
import { registerBlocks } from '../blocks/definitions.js';
import { exportWorkspaceToYaml } from '../yaml/export.js';
import { importYamlToWorkspace } from '../yaml/import.js';
import { validateScenario } from '../validation/validator.js';
import { parseScenarioYaml } from '@zacus/shared';
import type { ValidationResult } from '../validation/validator.js';
import { TOOLBOX_XML } from './toolbox.js';

registerBlocks();

export function EditorLayout() {
  const containerRef = useRef<HTMLDivElement>(null);
  const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
  const [yamlPreview, setYamlPreview] = useState('');
  const [validation, setValidation] = useState<ValidationResult | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;
    const ws = Blockly.inject(containerRef.current, {
      toolbox: TOOLBOX_XML,
      grid: { spacing: 20, length: 3, colour: '#2c2c2e', snap: true },
      zoom: { controls: true, wheel: true, startScale: 0.9 },
      theme: Blockly.Themes.Dark,
      renderer: 'zelos',
    });
    workspaceRef.current = ws;
    return () => ws.dispose();
  }, []);

  const handleValidate = useCallback(() => {
    const ws = workspaceRef.current;
    if (!ws) return;
    try {
      const yaml = exportWorkspaceToYaml(ws);
      const scenario = parseScenarioYaml(yaml);
      const result = validateScenario(scenario);
      setValidation(result);
      setYamlPreview(yaml);
    } catch (e) {
      setValidation({ valid: false, errors: [String(e)], warnings: [], summary: '❌ Erreur de génération YAML' });
    }
  }, []);

  const handleExport = useCallback(() => {
    const ws = workspaceRef.current;
    if (!ws) return;
    const yaml = exportWorkspaceToYaml(ws);
    const blob = new Blob([yaml], { type: 'text/yaml' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'zacus_v3_complete.yaml';
    a.click();
    URL.revokeObjectURL(url);
  }, []);

  const handleImport = useCallback(() => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = '.yaml,.yml';
    input.onchange = async () => {
      const file = input.files?.[0];
      if (!file || !workspaceRef.current) return;
      const text = await file.text();
      importYamlToWorkspace(workspaceRef.current, text);
      handleValidate();
    };
    input.click();
  }, [handleValidate]);

  return (
    <div className="flex flex-col h-screen bg-[#1c1c1e] text-white">
      {/* Menu Bar */}
      <header className="flex items-center gap-4 px-4 h-12 bg-[#2c2c2e] border-b border-white/10 shrink-0">
        <span className="font-semibold text-sm">Zacus V3 Editor</span>
        <nav className="flex gap-2 ml-auto">
          <button onClick={handleImport} className="text-xs px-3 py-1.5 bg-white/10 rounded-lg hover:bg-white/20">Importer YAML</button>
          <button onClick={handleExport} className="text-xs px-3 py-1.5 bg-[#0071e3] rounded-lg hover:bg-[#0077ed]">Exporter YAML</button>
          <button onClick={handleValidate} className="text-xs px-3 py-1.5 bg-white/10 rounded-lg hover:bg-white/20">Valider</button>
        </nav>
      </header>

      {/* Main area */}
      <div className="flex flex-1 min-h-0">
        {/* Blockly Workspace */}
        <div ref={containerRef} className="flex-1" />

        {/* YAML Preview panel */}
        <aside className="w-80 flex flex-col border-l border-white/10 bg-[#2c2c2e]">
          <div className="px-3 py-2 text-xs font-medium text-white/60 border-b border-white/10">Aperçu YAML</div>
          <pre className="flex-1 overflow-auto text-xs p-3 text-green-300 font-mono">{yamlPreview}</pre>
        </aside>
      </div>

      {/* Console Bar */}
      <footer className="h-8 px-4 flex items-center text-xs bg-[#2c2c2e] border-t border-white/10 shrink-0">
        {validation ? (
          <span className={validation.valid ? 'text-green-400' : 'text-red-400'}>{validation.summary}</span>
        ) : (
          <span className="text-white/40">Cliquer sur Valider pour analyser le scénario</span>
        )}
        {validation && !validation.valid && (
          <ul className="ml-4 flex gap-3">
            {validation.errors.map((err, i) => (
              <li key={i} className="text-red-400">• {err}</li>
            ))}
          </ul>
        )}
      </footer>
    </div>
  );
}
```

**File:** `frontend-v3/apps/editor/src/components/toolbox.ts`

```typescript
export const TOOLBOX_XML = `
<xml>
  <category name="📦 Puzzles" colour="#FF6B35">
    <block type="puzzle_sequence_sonore"/>
    <block type="puzzle_circuit_led"/>
    <block type="puzzle_qr_treasure"/>
    <block type="puzzle_radio"/>
    <block type="puzzle_morse"/>
    <block type="puzzle_symboles_nfc"/>
    <block type="puzzle_coffre_final"/>
  </category>
  <category name="🤖 NPC" colour="#5B4FD6">
    <block type="npc_profiling"/>
    <block type="npc_duration"/>
    <block type="npc_adaptive_rule"/>
  </category>
  <category name="🔀 Flow" colour="#2ECC71">
    <block type="phase_container"/>
    <block type="puzzle_selector"/>
  </category>
</xml>
`;
```

**Test:** `frontend-v3/apps/editor/src/validation/validator.test.ts`

```typescript
import { describe, it, expect } from 'vitest';
import { validateScenario } from './validator.js';
import { parseScenarioYaml } from '@zacus/shared';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

const yamlRaw = readFileSync(resolve('../../../../game/scenarios/zacus_v3_complete.yaml'), 'utf-8');

describe('validateScenario', () => {
  it('validates the canonical scenario without errors', () => {
    const scenario = parseScenarioYaml(yamlRaw);
    const result = validateScenario(scenario);
    expect(result.errors).toHaveLength(0);
    expect(result.valid).toBe(true);
  });

  it('fails when code digits do not sum to 8', () => {
    const scenario = parseScenarioYaml(yamlRaw);
    // Remove all digits
    for (const p of scenario.puzzles) {
      p.code_digits = undefined;
      p.code_digit = undefined;
    }
    const result = validateScenario(scenario);
    expect(result.errors.some((e) => e.includes('digits'))).toBe(true);
  });

  it('fails when CLIMAX phase has no P7_COFFRE', () => {
    const scenario = parseScenarioYaml(yamlRaw);
    const climax = scenario.phases.find((p) => p.type === 'CLIMAX');
    if (climax) climax.puzzles = [];
    const result = validateScenario(scenario);
    expect(result.errors.some((e) => e.includes('CLIMAX'))).toBe(true);
  });
});
```

---

## Phase 3: Dashboard Game Master (20h)

### Task 3.1 — apps/dashboard scaffold + WebSocket store (4h)

**Directory:** `frontend-v3/apps/dashboard/`

`frontend-v3/apps/dashboard/package.json`
```json
{
  "name": "@zacus/dashboard",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite --port 5174",
    "build": "tsc -b && vite build",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  },
  "dependencies": {
    "@zacus/shared": "workspace:*",
    "@zacus/ui": "workspace:*",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "zustand": "^5.0.3"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@vitejs/plugin-react": "^4.3.4",
    "typescript": "^5.7.3",
    "vite": "^6.0.11",
    "vitest": "^2.1.8"
  }
}
```

**File:** `frontend-v3/apps/dashboard/src/store/gameStore.ts`

```typescript
import { create } from 'zustand';
import type { GameEvent, GameCommand, EngineState, NpcMood, Phase, GroupProfile } from '@zacus/shared';
import { BOX3_MDNS_HOST, BOX3_WS_PORT, BOX3_WS_RECONNECT_MS } from '@zacus/shared';

interface DashboardState {
  // Connection
  wsUrl: string;
  connected: boolean;
  ws: WebSocket | null;

  // Game state (mirrored from BOX-3 events)
  sessionId: string | null;
  elapsedMs: number;
  targetDuration: number;
  phase: Phase;
  groupProfile: GroupProfile | null;
  solvedPuzzles: string[];
  skippedPuzzles: string[];
  hintsGiven: Record<string, number>;
  npcMood: NpcMood;
  npcLastPhrase: string | null;
  codeAssembled: string;
  events: GameEvent[];

  // Actions
  connect: (url?: string) => void;
  disconnect: () => void;
  sendCommand: (cmd: GameCommand) => void;
  setTargetDuration: (minutes: number) => void;
}

export const useGameStore = create<DashboardState>((set, get) => ({
  wsUrl: `ws://${BOX3_MDNS_HOST}:${BOX3_WS_PORT}`,
  connected: false,
  ws: null,
  sessionId: null,
  elapsedMs: 0,
  targetDuration: 60,
  phase: 'INTRO',
  groupProfile: null,
  solvedPuzzles: [],
  skippedPuzzles: [],
  hintsGiven: {},
  npcMood: 'neutral',
  npcLastPhrase: null,
  codeAssembled: '',
  events: [],

  connect(url) {
    const wsUrl = url ?? get().wsUrl;
    const ws = new WebSocket(wsUrl);

    ws.onopen = () => set({ connected: true, ws });

    ws.onmessage = (msg) => {
      try {
        const event = JSON.parse(msg.data as string) as GameEvent;
        get().#handleEvent(event);
      } catch {
        // Ignore malformed messages
      }
    };

    ws.onclose = () => {
      set({ connected: false, ws: null });
      // Auto-reconnect
      setTimeout(() => get().connect(wsUrl), BOX3_WS_RECONNECT_MS);
    };

    ws.onerror = () => ws.close();
  },

  disconnect() {
    get().ws?.close();
    set({ connected: false, ws: null });
  },

  sendCommand(cmd) {
    const { ws, connected } = get();
    if (!ws || !connected) return;
    ws.send(JSON.stringify(cmd));
  },

  setTargetDuration(minutes) {
    set({ targetDuration: minutes });
    get().sendCommand({ type: 'set_duration', data: { minutes } });
  },

  // Private — not in interface (called internally)
  // @ts-expect-error — private method pattern in Zustand
  '#handleEvent'(event: GameEvent) {
    set((state) => {
      const events = [...state.events, event].slice(-200); // Keep last 200

      switch (event.type) {
        case 'timer_update':
          return { events, elapsedMs: event.data['elapsed_ms'] as number };
        case 'puzzle_solved':
          return { events, solvedPuzzles: [...state.solvedPuzzles, event.data['puzzle_id'] as string] };
        case 'puzzle_skipped':
          return { events, skippedPuzzles: [...state.skippedPuzzles, event.data['puzzle_id'] as string] };
        case 'hint_given': {
          const pid = event.data['puzzle_id'] as string;
          return { events, hintsGiven: { ...state.hintsGiven, [pid]: (state.hintsGiven[pid] ?? 0) + 1 } };
        }
        case 'npc_spoke':
          return { events, npcLastPhrase: event.data['phrase'] as string };
        case 'phase_changed':
          return { events, phase: event.data['phase'] as Phase };
        case 'profile_detected':
          return { events, groupProfile: event.data['profile'] as GroupProfile };
        case 'game_start':
          return { events, sessionId: event.data['session_id'] as string };
        default:
          return { events };
      }
    });
  },
}));
```

### Task 3.2 — Expert Mode UI (8h)

**File:** `frontend-v3/apps/dashboard/src/components/ExpertMode.tsx`

```typescript
import { useGameStore } from '../store/gameStore.js';
import { NPC_MOOD_COLORS, PUZZLE_IDS } from '@zacus/shared';
import type { PuzzleId } from '@zacus/shared';

export function ExpertMode() {
  const {
    connected, sessionId, elapsedMs, targetDuration, phase,
    groupProfile, solvedPuzzles, skippedPuzzles, hintsGiven,
    npcMood, npcLastPhrase, events, sendCommand,
  } = useGameStore();

  const formatTime = (ms: number) => {
    const s = Math.floor(ms / 1000);
    return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
  };

  const progress = Math.min(1, elapsedMs / (targetDuration * 60_000));

  const puzzleStatus = (id: PuzzleId) => {
    if (solvedPuzzles.includes(id)) return 'solved';
    if (skippedPuzzles.includes(id)) return 'skipped';
    return 'pending';
  };

  const moodEmoji: Record<string, string> = {
    neutral: '😐', impressed: '😄', worried: '😟', amused: '😂',
  };

  return (
    <div className="flex flex-col h-screen bg-[#1c1c1e] text-white text-sm">
      {/* Header */}
      <header className="flex items-center gap-6 px-6 h-14 bg-[#2c2c2e] border-b border-white/10 shrink-0">
        <span className="font-mono text-xs text-white/40">SESSION {sessionId ?? '—'}</span>
        <span className="font-mono text-2xl font-bold">{formatTime(elapsedMs)}</span>
        <span className="text-white/40">/ {targetDuration} min</span>
        <div className="flex-1 mx-4 h-2 bg-white/10 rounded-full overflow-hidden">
          <div className="h-full bg-[#0071e3] transition-all" style={{ width: `${progress * 100}%` }} />
        </div>
        <span className={`text-xs px-2 py-0.5 rounded-full ${connected ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
          {connected ? 'Connecté' : 'Déconnecté'}
        </span>
        <span className="text-xs text-white/40">Phase: {phase}</span>
      </header>

      <div className="flex flex-1 min-h-0 gap-0">
        {/* Puzzle Grid */}
        <section className="w-72 p-4 border-r border-white/10 overflow-y-auto">
          <h2 className="text-xs font-semibold text-white/60 mb-3 uppercase tracking-wide">Puzzles</h2>
          <div className="flex flex-col gap-2">
            {PUZZLE_IDS.map((id) => {
              const status = puzzleStatus(id);
              const hints = hintsGiven[id] ?? 0;
              return (
                <div
                  key={id}
                  className={`p-3 rounded-xl border ${
                    status === 'solved' ? 'border-green-500/30 bg-green-500/10' :
                    status === 'skipped' ? 'border-white/10 bg-white/5 opacity-50' :
                    'border-white/10 bg-[#2c2c2e]'
                  }`}
                >
                  <div className="flex items-center justify-between">
                    <span className="font-mono text-xs">{id}</span>
                    <span>{status === 'solved' ? '✅' : status === 'skipped' ? '⏭' : '⬜'}</span>
                  </div>
                  {hints > 0 && <div className="text-xs text-orange-400 mt-1">{hints} indice(s)</div>}
                </div>
              );
            })}
          </div>
        </section>

        {/* NPC Panel */}
        <section className="w-56 p-4 border-r border-white/10 overflow-y-auto">
          <h2 className="text-xs font-semibold text-white/60 mb-3 uppercase tracking-wide">Professeur Zacus</h2>
          <div
            className="w-16 h-16 rounded-full mx-auto mb-3 flex items-center justify-center text-3xl"
            style={{ backgroundColor: NPC_MOOD_COLORS[npcMood] + '33', boxShadow: `0 0 20px ${NPC_MOOD_COLORS[npcMood]}66` }}
          >
            {moodEmoji[npcMood]}
          </div>
          <div className="text-center text-xs text-white/60 mb-2">Humeur: {npcMood}</div>
          {groupProfile && (
            <div className="text-center text-xs text-[#0071e3] mb-3">Profil détecté: {groupProfile}</div>
          )}
          {npcLastPhrase && (
            <div className="bg-[#2c2c2e] rounded-xl p-3 text-xs italic text-white/80">
              "{npcLastPhrase}"
            </div>
          )}
        </section>

        {/* Timeline */}
        <section className="flex-1 p-4 overflow-y-auto">
          <h2 className="text-xs font-semibold text-white/60 mb-3 uppercase tracking-wide">Événements</h2>
          <ul className="flex flex-col gap-1.5">
            {[...events].reverse().map((ev, i) => (
              <li key={i} className="flex items-start gap-2 text-xs">
                <span className="text-white/30 font-mono shrink-0">
                  {new Date(ev.timestamp).toLocaleTimeString('fr-FR')}
                </span>
                <span className="text-white/80">{ev.type.replace(/_/g, ' ')}</span>
                {ev.data['puzzle_id'] && <span className="text-[#0071e3]">{ev.data['puzzle_id'] as string}</span>}
              </li>
            ))}
          </ul>
        </section>

        {/* Controls */}
        <section className="w-56 p-4 border-l border-white/10 flex flex-col gap-3">
          <h2 className="text-xs font-semibold text-white/60 uppercase tracking-wide">Contrôles</h2>
          {[
            { label: 'Forcer indice', cmd: 'force_hint' },
            { label: 'Passer puzzle', cmd: 'skip_puzzle' },
            { label: 'Bonus +100', cmd: 'add_bonus' },
            { label: 'Sonner téléphone', cmd: 'ring_phone' },
            { label: 'Pause', cmd: 'pause' },
            { label: 'Reprendre', cmd: 'resume' },
            { label: 'Fin de partie', cmd: 'end_game' },
          ].map(({ label, cmd }) => (
            <button
              key={cmd}
              onClick={() => sendCommand({ type: cmd as any, data: {} })}
              disabled={!connected}
              className="w-full py-2 px-3 text-xs rounded-xl bg-[#2c2c2e] hover:bg-[#3a3a3c] disabled:opacity-40 text-left"
            >
              {label}
            </button>
          ))}

          <div className="mt-2">
            <label className="text-xs text-white/60">TTS manuel</label>
            <input
              type="text"
              placeholder="Texte à prononcer..."
              className="w-full mt-1 px-2 py-1.5 text-xs rounded-lg bg-[#2c2c2e] border border-white/10 focus:border-[#0071e3] outline-none"
              onKeyDown={(e) => {
                if (e.key === 'Enter') {
                  sendCommand({ type: 'manual_tts', data: { text: (e.target as HTMLInputElement).value } });
                  (e.target as HTMLInputElement).value = '';
                }
              }}
            />
          </div>
        </section>
      </div>
    </div>
  );
}
```

### Task 3.3 — Simple Mode UI (3h)

**File:** `frontend-v3/apps/dashboard/src/components/SimpleMode.tsx`

```typescript
import { useGameStore } from '../store/gameStore.js';
import { PUZZLE_IDS } from '@zacus/shared';

export function SimpleMode() {
  const { elapsedMs, targetDuration, solvedPuzzles, connected, sendCommand } = useGameStore();

  const formatTime = (ms: number) => {
    const s = Math.floor(ms / 1000);
    return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
  };

  const progress = Math.min(1, solvedPuzzles.length / PUZZLE_IDS.length);

  return (
    <div className="flex flex-col items-center justify-center h-screen bg-[#1c1c1e] text-white gap-8">
      {/* Timer */}
      <div className="font-mono text-8xl font-bold tabular-nums">{formatTime(elapsedMs)}</div>

      {/* Progress */}
      <div className="w-96">
        <div className="flex justify-between text-sm text-white/60 mb-2">
          <span>Progression</span>
          <span>{solvedPuzzles.length} / {PUZZLE_IDS.length} puzzles</span>
        </div>
        <div className="h-3 bg-white/10 rounded-full overflow-hidden">
          <div className="h-full bg-[#0071e3] transition-all" style={{ width: `${progress * 100}%` }} />
        </div>
      </div>

      {/* Status */}
      <p className="text-white/60 text-lg">Le Professeur gère tout automatiquement</p>

      {/* Controls */}
      <div className="flex gap-4">
        <button
          onClick={() => sendCommand({ type: 'pause', data: {} })}
          disabled={!connected}
          className="px-8 py-4 rounded-2xl bg-[#2c2c2e] hover:bg-[#3a3a3c] disabled:opacity-40 text-lg font-medium"
        >
          Pause
        </button>
        <button
          onClick={() => sendCommand({ type: 'resume', data: {} })}
          disabled={!connected}
          className="px-8 py-4 rounded-2xl bg-[#0071e3] hover:bg-[#0077ed] disabled:opacity-40 text-lg font-medium"
        >
          Reprendre
        </button>
        <button
          onClick={() => sendCommand({ type: 'end_game', data: {} })}
          disabled={!connected}
          className="px-8 py-4 rounded-2xl bg-[#ff3b30] hover:bg-[#ff453a] disabled:opacity-40 text-lg font-medium"
        >
          Terminer
        </button>
      </div>

      {/* Emergency */}
      <a
        href="tel:+33XXXXXXXXX"
        className="text-sm text-white/40 hover:text-white/80 underline"
      >
        Appeler le support L'Electron Rare
      </a>

      {/* Connection indicator */}
      <div className={`fixed top-4 right-4 text-xs px-2 py-1 rounded-full ${connected ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
        {connected ? '● Connecté à BOX-3' : '● Déconnecté'}
      </div>
    </div>
  );
}
```

### Task 3.4 — Mode toggle + App root + connection setup (3h)

**File:** `frontend-v3/apps/dashboard/src/App.tsx`

```typescript
import { useEffect, useState } from 'react';
import { useGameStore } from './store/gameStore.js';
import { ExpertMode } from './components/ExpertMode.js';
import { SimpleMode } from './components/SimpleMode.js';
import { ConnectionSetup } from './components/ConnectionSetup.js';

type ViewMode = 'expert' | 'simple';

export function App() {
  const { connected, connect } = useGameStore();
  const [mode, setMode] = useState<ViewMode>(() => {
    const params = new URLSearchParams(window.location.search);
    return (params.get('mode') as ViewMode) ?? 'expert';
  });
  const [setupDone, setSetupDone] = useState(false);

  const handleConnect = (url: string) => {
    connect(url);
    setSetupDone(true);
  };

  if (!setupDone) {
    return <ConnectionSetup onConnect={handleConnect} />;
  }

  return (
    <div>
      {/* Mode toggle (top-right corner) */}
      <div className="fixed top-2 left-1/2 -translate-x-1/2 z-50 flex gap-1 bg-[#2c2c2e] rounded-full p-1">
        <button
          onClick={() => setMode('expert')}
          className={`text-xs px-4 py-1.5 rounded-full transition-all ${mode === 'expert' ? 'bg-[#0071e3] text-white' : 'text-white/40 hover:text-white'}`}
        >
          Expert
        </button>
        <button
          onClick={() => setMode('simple')}
          className={`text-xs px-4 py-1.5 rounded-full transition-all ${mode === 'simple' ? 'bg-[#0071e3] text-white' : 'text-white/40 hover:text-white'}`}
        >
          Simple
        </button>
      </div>
      {mode === 'expert' ? <ExpertMode /> : <SimpleMode />}
    </div>
  );
}
```

**File:** `frontend-v3/apps/dashboard/src/components/ConnectionSetup.tsx`

```typescript
import { useState } from 'react';
import { BOX3_MDNS_HOST, BOX3_WS_PORT } from '@zacus/shared';

interface Props { onConnect: (url: string) => void; }

export function ConnectionSetup({ onConnect }: Props) {
  const defaultUrl = `ws://${BOX3_MDNS_HOST}:${BOX3_WS_PORT}`;
  const [url, setUrl] = useState(defaultUrl);

  return (
    <div className="flex flex-col items-center justify-center h-screen bg-[#1c1c1e] text-white gap-6">
      <h1 className="text-2xl font-bold">Dashboard Zacus V3</h1>
      <p className="text-white/60">Connecter au BOX-3</p>
      <div className="flex gap-2">
        <input
          type="text"
          value={url}
          onChange={(e) => setUrl(e.target.value)}
          className="w-80 px-4 py-2 rounded-xl bg-[#2c2c2e] border border-white/10 focus:border-[#0071e3] outline-none"
        />
        <button
          onClick={() => onConnect(url)}
          className="px-6 py-2 rounded-xl bg-[#0071e3] hover:bg-[#0077ed] font-medium"
        >
          Connecter
        </button>
      </div>
      <button
        onClick={() => onConnect(defaultUrl)}
        className="text-sm text-white/40 hover:text-white/80"
      >
        Utiliser mDNS par défaut ({BOX3_MDNS_HOST})
      </button>
    </div>
  );
}
```

**Test:** `frontend-v3/apps/dashboard/src/store/gameStore.test.ts`

```typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';

// Mock WebSocket
const mockWs = {
  send: vi.fn(),
  close: vi.fn(),
  onopen: null as any,
  onmessage: null as any,
  onclose: null as any,
  onerror: null as any,
};
vi.stubGlobal('WebSocket', vi.fn(() => mockWs));

// Import after mock is set up
const { useGameStore } = await import('./gameStore.js');

describe('gameStore', () => {
  beforeEach(() => {
    useGameStore.setState({
      connected: false, ws: null, solvedPuzzles: [], skippedPuzzles: [],
      hintsGiven: {}, events: [], sessionId: null,
    });
    vi.clearAllMocks();
  });

  it('sets connected=true on WebSocket open', () => {
    useGameStore.getState().connect('ws://localhost:81');
    mockWs.onopen();
    expect(useGameStore.getState().connected).toBe(true);
  });

  it('sends JSON command via WebSocket', () => {
    useGameStore.setState({ connected: true, ws: mockWs as any });
    useGameStore.getState().sendCommand({ type: 'force_hint', data: {} });
    expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({ type: 'force_hint', data: {} }));
  });

  it('updates solvedPuzzles on puzzle_solved event', () => {
    useGameStore.getState().connect('ws://localhost:81');
    const msg = JSON.stringify({ type: 'puzzle_solved', timestamp: Date.now(), data: { puzzle_id: 'P1_SON' } });
    mockWs.onmessage({ data: msg });
    expect(useGameStore.getState().solvedPuzzles).toContain('P1_SON');
  });
});
```

---

## Phase 4: 3D Simulation (35h)

### Task 4.1 — apps/simulation scaffold (2h)

**Directory:** `frontend-v3/apps/simulation/`

`frontend-v3/apps/simulation/package.json`
```json
{
  "name": "@zacus/simulation",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite --port 5175",
    "build": "tsc -b && vite build",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  },
  "dependencies": {
    "@react-three/fiber": "^8.17.14",
    "@react-three/drei": "^9.122.0",
    "@zacus/scenario-engine": "workspace:*",
    "@zacus/shared": "workspace:*",
    "@zacus/ui": "workspace:*",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "three": "^0.171.0",
    "zustand": "^5.0.3"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@types/three": "^0.171.0",
    "@vitejs/plugin-react": "^4.3.4",
    "typescript": "^5.7.3",
    "vite": "^6.0.11",
    "vitest": "^2.1.8"
  }
}
```

### Task 4.2 — 3D Room scene (8h)

**File:** `frontend-v3/apps/simulation/src/scene/RoomScene.tsx`

```typescript
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment, Fog, Sparkles } from '@react-three/drei';
import { Suspense } from 'react';
import { PuzzleStations } from './PuzzleStations.js';
import { RtcPhone } from './RtcPhone.js';
import { NpcBubble } from './NpcBubble.js';
import { useSimStore } from '../store/simStore.js';

export function RoomScene() {
  const mode = useSimStore((s) => s.mode);
  return (
    <Canvas
      camera={{ position: [0, 3, 6], fov: 60 }}
      shadows
      gl={{ antialias: true }}
    >
      <Suspense fallback={null}>
        {/* Lighting */}
        <ambientLight intensity={0.3} />
        <spotLight
          position={[0, 5, 0]}
          angle={0.6}
          penumbra={0.8}
          intensity={1.2}
          castShadow
          shadow-mapSize={[1024, 1024]}
        />
        {/* Atmospheric particles */}
        <Sparkles count={80} scale={[6, 4, 6]} size={1.5} speed={0.2} opacity={0.3} color="#ffccaa" />

        {/* Room geometry */}
        <Room />

        {/* Central phone */}
        <RtcPhone position={[0, 0, 0]} />

        {/* 7 puzzle stations */}
        <PuzzleStations />

        {/* NPC speech bubbles */}
        <NpcBubble />

        {/* Camera control */}
        {mode === 'sandbox' && <OrbitControls makeDefault />}
        {mode === 'demo' && <CinematicCamera />}

        <Environment preset="night" />
        <fog attach="fog" args={['#0a0a0f', 8, 20]} />
      </Suspense>
    </Canvas>
  );
}

function Room() {
  return (
    <group>
      {/* Floor */}
      <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow>
        <planeGeometry args={[12, 8]} />
        <meshStandardMaterial color="#1a1a1a" roughness={0.9} metalness={0.1} />
      </mesh>
      {/* Ceiling */}
      <mesh rotation={[Math.PI / 2, 0, 0]} position={[0, 4, 0]}>
        <planeGeometry args={[12, 8]} />
        <meshStandardMaterial color="#111111" />
      </mesh>
      {/* Walls */}
      {[
        { pos: [0, 2, -4] as [number,number,number], rot: [0, 0, 0] as [number,number,number], size: [12, 4] as [number,number] },
        { pos: [0, 2, 4] as [number,number,number], rot: [0, Math.PI, 0] as [number,number,number], size: [12, 4] as [number,number] },
        { pos: [-6, 2, 0] as [number,number,number], rot: [0, Math.PI / 2, 0] as [number,number,number], size: [8, 4] as [number,number] },
        { pos: [6, 2, 0] as [number,number,number], rot: [0, -Math.PI / 2, 0] as [number,number,number], size: [8, 4] as [number,number] },
      ].map((wall, i) => (
        <mesh key={i} position={wall.pos} rotation={wall.rot} receiveShadow>
          <planeGeometry args={wall.size} />
          <meshStandardMaterial color="#1e1e2e" roughness={1} />
        </mesh>
      ))}
    </group>
  );
}

function CinematicCamera() {
  // Animated camera that moves between puzzle stations in demo mode
  // Uses useFrame from @react-three/fiber for lerping
  return null; // Placeholder — implement with useFrame + keyframe sequence
}
```

### Task 4.3 — 7 Puzzle station 3D models + interactions (16h)

**File:** `frontend-v3/apps/simulation/src/scene/PuzzleStations.tsx`

```typescript
import { useRef, useState } from 'react';
import { useFrame } from '@react-three/fiber';
import { Text } from '@react-three/drei';
import * as THREE from 'three';
import { useSimStore } from '../store/simStore.js';

// Positions arranged around the central table (polar layout)
const STATION_POSITIONS: Record<string, [number, number, number]> = {
  P1_SON:      [-4, 0, -2],
  P2_CIRCUIT:  [-4, 0,  2],
  P3_QR:       [ 0, 0, -3.5],
  P4_RADIO:    [ 4, 0, -2],
  P5_MORSE:    [ 4, 0,  2],
  P6_SYMBOLES: [ 0, 0,  3.5],
  P7_COFFRE:   [ 5, 0,  0],
};

export function PuzzleStations() {
  return (
    <group>
      <P1SequenceSonore position={STATION_POSITIONS['P1_SON']!} />
      <P2CircuitLed position={STATION_POSITIONS['P2_CIRCUIT']!} />
      <P3QrTreasure position={STATION_POSITIONS['P3_QR']!} />
      <P4Radio position={STATION_POSITIONS['P4_RADIO']!} />
      <P5Morse position={STATION_POSITIONS['P5_MORSE']!} />
      <P6SymbolesNfc position={STATION_POSITIONS['P6_SYMBOLES']!} />
      <P7CoffreFinal position={STATION_POSITIONS['P7_COFFRE']!} />
    </group>
  );
}

// ---- P1: Séquence Sonore ----
// Box with 4 colored buttons
function P1SequenceSonore({ position }: { position: [number, number, number] }) {
  const { solvePuzzle, mode } = useSimStore();
  const colors = ['#ff3b30', '#34c759', '#0071e3', '#ffcc00'];
  const [pressed, setPressed] = useState<number[]>([]);
  const TARGET = [0, 2, 1, 3]; // melody pattern to reproduce

  const handleButtonPress = (i: number) => {
    if (mode === 'demo') return;
    const newPressed = [...pressed, i];
    setPressed(newPressed);
    // Play Web Audio tone
    playTone(220 + i * 110);
    if (newPressed.length === TARGET.length) {
      if (newPressed.every((v, j) => v === TARGET[j])) {
        solvePuzzle('P1_SON');
      }
      setPressed([]);
    }
  };

  return (
    <group position={position}>
      <mesh castShadow>
        <boxGeometry args={[0.8, 0.3, 0.8]} />
        <meshStandardMaterial color="#2c2c2e" />
      </mesh>
      {colors.map((color, i) => (
        <mesh
          key={i}
          position={[(i % 2) * 0.3 - 0.15, 0.2, Math.floor(i / 2) * 0.3 - 0.15]}
          onClick={() => handleButtonPress(i)}
        >
          <cylinderGeometry args={[0.08, 0.08, 0.06, 16]} />
          <meshStandardMaterial color={color} emissive={color} emissiveIntensity={0.3} />
        </mesh>
      ))}
      <StationLabel label="P1 Son" position={[0, 0.5, 0]} />
    </group>
  );
}

// ---- P4: Radio ----
// Retro radio with rotating dial
function P4Radio({ position }: { position: [number, number, number] }) {
  const { solvePuzzle, mode } = useSimStore();
  const dialRef = useRef<THREE.Mesh>(null);
  const [freq, setFreq] = useState(88.0);
  const TARGET_FREQ = 93.5;

  const handleDialDrag = (delta: number) => {
    if (mode === 'demo') return;
    const newFreq = Math.max(88, Math.min(108, freq + delta * 0.1));
    setFreq(parseFloat(newFreq.toFixed(1)));
    if (Math.abs(newFreq - TARGET_FREQ) < 0.2) {
      solvePuzzle('P4_RADIO');
    }
  };

  return (
    <group position={position}>
      {/* Radio body */}
      <mesh castShadow>
        <boxGeometry args={[1.2, 0.6, 0.5]} />
        <meshStandardMaterial color="#8B4513" roughness={0.8} />
      </mesh>
      {/* Dial */}
      <mesh
        ref={dialRef}
        position={[0.3, 0.1, 0.26]}
        onPointerMove={(e) => {
          if (e.buttons === 1) handleDialDrag(e.movementX);
        }}
      >
        <cylinderGeometry args={[0.12, 0.12, 0.05, 16]} />
        <meshStandardMaterial color="#c0c0c0" metalness={0.9} roughness={0.1} />
      </mesh>
      {/* Frequency display */}
      <Text position={[-0.1, 0.05, 0.26]} fontSize={0.08} color="#00ff00">
        {freq.toFixed(1)} MHz
      </Text>
      <StationLabel label="P4 Radio" position={[0, 0.5, 0]} />
    </group>
  );
}

// ---- P5: Morse ----
// Telegraph key (spacebar = press)
function P5Morse({ position }: { position: [number, number, number] }) {
  const { solvePuzzle, mode } = useSimStore();
  const [active, setActive] = useState(false);

  return (
    <group position={position}>
      <mesh castShadow>
        <boxGeometry args={[0.6, 0.1, 0.4]} />
        <meshStandardMaterial color="#3a3a3a" metalness={0.7} />
      </mesh>
      {/* Telegraph key arm */}
      <mesh
        position={[0, active ? 0.05 : 0.1, 0]}
        onPointerDown={() => { if (mode !== 'demo') { setActive(true); playTone(800, 0.1); } }}
        onPointerUp={() => setActive(false)}
      >
        <boxGeometry args={[0.4, 0.04, 0.08]} />
        <meshStandardMaterial color="#c0c0c0" metalness={0.9} />
      </mesh>
      <StationLabel label="P5 Morse" position={[0, 0.3, 0]} />
    </group>
  );
}

// ---- P7: Coffre Final ----
// Keypad box
function P7CoffreFinal({ position }: { position: [number, number, number] }) {
  const { solvePuzzle, mode } = useSimStore();
  const [input, setInput] = useState('');
  const CORRECT_CODE = '12345678'; // assembled from all puzzles

  const handleKey = (digit: string) => {
    if (mode === 'demo') return;
    const newInput = input + digit;
    setInput(newInput);
    if (newInput.length === 8) {
      if (newInput === CORRECT_CODE) solvePuzzle('P7_COFFRE');
      setInput('');
    }
  };

  return (
    <group position={position}>
      <mesh castShadow>
        <boxGeometry args={[1, 1, 0.8]} />
        <meshStandardMaterial color="#2c2c2e" metalness={0.5} />
      </mesh>
      {/* Keypad grid 3×4 */}
      {['1','2','3','4','5','6','7','8','9','*','0','#'].map((k, i) => (
        <mesh
          key={k}
          position={[(i % 3) * 0.22 - 0.22, 0.6 - Math.floor(i / 3) * 0.18, 0.41]}
          onClick={() => handleKey(k)}
        >
          <boxGeometry args={[0.18, 0.14, 0.04]} />
          <meshStandardMaterial color="#3a3a3a" />
        </mesh>
      ))}
      <Text position={[0, 0.95, 0.41]} fontSize={0.07} color="#00ff00">
        {input.padEnd(8, '_')}
      </Text>
      <StationLabel label="P7 Coffre" position={[0, 1.2, 0]} />
    </group>
  );
}

// Stubs for P2, P3, P6 — implement same pattern
function P2CircuitLed({ position }: { position: [number, number, number] }) {
  const { solvePuzzle } = useSimStore();
  return (
    <group position={position}>
      <mesh castShadow onClick={() => solvePuzzle('P2_CIRCUIT')}>
        <boxGeometry args={[0.9, 0.1, 0.7]} />
        <meshStandardMaterial color="#1a2a1a" />
      </mesh>
      <StationLabel label="P2 Circuit" position={[0, 0.3, 0]} />
    </group>
  );
}

function P3QrTreasure({ position }: { position: [number, number, number] }) {
  const { solvePuzzle } = useSimStore();
  return (
    <group position={position}>
      {[0,1,2,3,4,5].map((i) => (
        <mesh key={i} position={[(i % 3) * 0.3 - 0.3, 1.5, -3.4 + Math.floor(i/3) * 0.4]} onClick={() => { if (i === 5) solvePuzzle('P3_QR'); }}>
          <boxGeometry args={[0.25, 0.25, 0.02]} />
          <meshStandardMaterial color="#f5f5dc" />
        </mesh>
      ))}
      <StationLabel label="P3 QR" position={[0, 2.2, -3.4]} />
    </group>
  );
}

function P6SymbolesNfc({ position }: { position: [number, number, number] }) {
  const { solvePuzzle } = useSimStore();
  return (
    <group position={position}>
      <mesh castShadow onClick={() => solvePuzzle('P6_SYMBOLES')}>
        <boxGeometry args={[0.8, 0.05, 0.6]} />
        <meshStandardMaterial color="#5a3a1a" roughness={0.9} />
      </mesh>
      <StationLabel label="P6 Symboles" position={[0, 0.3, 0]} />
    </group>
  );
}

// Shared label component
function StationLabel({ label, position }: { label: string; position: [number, number, number] }) {
  return (
    <Text position={position} fontSize={0.1} color="white" anchorX="center">
      {label}
    </Text>
  );
}

// Web Audio helper
function playTone(hz: number, duration = 0.2) {
  const ctx = new AudioContext();
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.frequency.value = hz;
  gain.gain.setValueAtTime(0.3, ctx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
  osc.connect(gain).connect(ctx.destination);
  osc.start();
  osc.stop(ctx.currentTime + duration);
}
```

### Task 4.4 — RTC Phone + NPC visualization (4h)

**File:** `frontend-v3/apps/simulation/src/scene/RtcPhone.tsx`

```typescript
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useSimStore } from '../store/simStore.js';
import { NPC_MOOD_COLORS } from '@zacus/shared';

export function RtcPhone({ position }: { position: [number, number, number] }) {
  const npcMood = useSimStore((s) => s.npcMood);
  const glowRef = useRef<THREE.PointLight>(null);

  const moodColor = NPC_MOOD_COLORS[npcMood] ?? '#0071e3';

  useFrame(({ clock }) => {
    if (!glowRef.current) return;
    // Pulsing glow effect
    glowRef.current.intensity = 0.5 + 0.3 * Math.sin(clock.elapsedTime * 2);
  });

  return (
    <group position={position}>
      {/* Table */}
      <mesh castShadow position={[0, -0.02, 0]}>
        <cylinderGeometry args={[0.8, 0.8, 0.04, 32]} />
        <meshStandardMaterial color="#3a2a1a" roughness={0.8} />
      </mesh>
      {/* Phone body */}
      <mesh castShadow position={[0, 0.15, 0]}>
        <boxGeometry args={[0.3, 0.25, 0.15]} />
        <meshStandardMaterial color="#1a1a1a" metalness={0.4} />
      </mesh>
      {/* Handset */}
      <mesh castShadow position={[0, 0.32, 0]} rotation={[0, 0, -0.3]}>
        <boxGeometry args={[0.2, 0.06, 0.06]} />
        <meshStandardMaterial color="#111111" />
      </mesh>
      {/* Mood glow */}
      <pointLight ref={glowRef} color={moodColor} distance={2} />
      <mesh position={[0, 0.1, 0]}>
        <sphereGeometry args={[0.08, 16, 16]} />
        <meshStandardMaterial color={moodColor} emissive={moodColor} emissiveIntensity={1} transparent opacity={0.7} />
      </mesh>
    </group>
  );
}
```

**File:** `frontend-v3/apps/simulation/src/scene/NpcBubble.tsx`

```typescript
import { Html } from '@react-three/drei';
import { useSimStore } from '../store/simStore.js';

export function NpcBubble() {
  const npcLastPhrase = useSimStore((s) => s.npcLastPhrase);
  if (!npcLastPhrase) return null;

  return (
    <Html position={[0, 1.2, 0]} center>
      <div
        style={{
          background: 'rgba(0,0,0,0.85)',
          color: 'white',
          padding: '8px 14px',
          borderRadius: 12,
          fontSize: 13,
          maxWidth: 220,
          textAlign: 'center',
          border: '1px solid rgba(255,255,255,0.15)',
          backdropFilter: 'blur(8px)',
          pointerEvents: 'none',
        }}
      >
        {npcLastPhrase}
      </div>
    </Html>
  );
}
```

### Task 4.5 — Simulation store + 3 modes (3h)

**File:** `frontend-v3/apps/simulation/src/store/simStore.ts`

```typescript
import { create } from 'zustand';
import type { NpcMood, EngineState } from '@zacus/shared';
import { ZacusScenarioEngine } from '@zacus/scenario-engine';

export type SimMode = 'demo' | 'sandbox' | 'test';

interface SimState {
  mode: SimMode;
  npcMood: NpcMood;
  npcLastPhrase: string | null;
  solvedPuzzles: string[];
  engineState: EngineState | null;
  engine: ZacusScenarioEngine | null;

  setMode: (mode: SimMode) => void;
  solvePuzzle: (id: string) => void;
  loadScenario: (yaml: string) => void;
  tickEngine: (nowMs: number) => void;
  speakNpc: (phrase: string) => void;
}

export const useSimStore = create<SimState>((set, get) => ({
  mode: 'sandbox',
  npcMood: 'neutral',
  npcLastPhrase: null,
  solvedPuzzles: [],
  engineState: null,
  engine: null,

  setMode(mode) { set({ mode }); },

  loadScenario(yaml) {
    const engine = new ZacusScenarioEngine();
    engine.load(yaml);
    engine.start({ targetDuration: 60, mode: '60' });
    set({ engine, engineState: engine.getState() });
  },

  tickEngine(nowMs) {
    const { engine } = get();
    if (!engine) return;
    const state = engine.tick(nowMs);
    set({ engineState: state, npcMood: state.npcMood });
  },

  solvePuzzle(id) {
    const { engine } = get();
    set((s) => ({ solvedPuzzles: [...s.solvedPuzzles, id] }));
    if (engine) {
      const decisions = engine.onEvent({
        type: 'puzzle_solved',
        timestamp: Date.now(),
        data: { puzzle_id: id },
      });
      for (const d of decisions) {
        if (d.action === 'speak') {
          const phrase = `Excellent ! Puzzle ${id} résolu !`;
          get().speakNpc(phrase);
        }
      }
    }
  },

  speakNpc(phrase) {
    set({ npcLastPhrase: phrase });
    // TTS via Web Speech API
    if ('speechSynthesis' in window) {
      const utt = new SpeechSynthesisUtterance(phrase);
      utt.lang = 'fr-FR';
      window.speechSynthesis.speak(utt);
    }
    // Clear bubble after 5s
    setTimeout(() => set({ npcLastPhrase: null }), 5000);
  },
}));
```

### Task 4.6 — Simulation App root + mode selector (2h)

**File:** `frontend-v3/apps/simulation/src/App.tsx`

```typescript
import { useState, useEffect } from 'react';
import { RoomScene } from './scene/RoomScene.js';
import { useSimStore } from './store/simStore.js';
import type { SimMode } from './store/simStore.js';

export function App() {
  const { mode, setMode, loadScenario } = useSimStore();
  const [yamlLoaded, setYamlLoaded] = useState(false);

  // Load the canonical scenario on mount
  useEffect(() => {
    fetch('/scenarios/zacus_v3_complete.yaml')
      .then((r) => r.text())
      .then((yaml) => {
        loadScenario(yaml);
        setYamlLoaded(true);
      })
      .catch(() => {
        // In dev: scenario not served, use empty engine
        setYamlLoaded(true);
      });
  }, [loadScenario]);

  return (
    <div className="relative w-full h-screen bg-black">
      {/* Mode selector */}
      <div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 flex gap-1 bg-black/60 rounded-full p-1 backdrop-blur-md">
        {(['demo', 'sandbox', 'test'] as SimMode[]).map((m) => (
          <button
            key={m}
            onClick={() => setMode(m)}
            className={`text-xs px-4 py-1.5 rounded-full capitalize transition-all ${mode === m ? 'bg-[#0071e3] text-white' : 'text-white/60 hover:text-white'}`}
          >
            {m}
          </button>
        ))}
      </div>

      {/* 3D Scene */}
      {yamlLoaded && <RoomScene />}

      {/* Test mode overlay */}
      {mode === 'test' && <TestModeOverlay />}
    </div>
  );
}

function TestModeOverlay() {
  const { engineState, solvedPuzzles } = useSimStore();
  return (
    <div className="absolute bottom-4 left-4 bg-black/80 text-white rounded-2xl p-4 text-xs font-mono backdrop-blur-md">
      <div className="font-bold mb-2 text-[#0071e3]">Test Mode</div>
      <div>Phase: {engineState?.phase ?? '—'}</div>
      <div>Profile: {engineState?.groupProfile ?? 'detecting...'}</div>
      <div>Solved: {solvedPuzzles.join(', ') || 'none'}</div>
      <div>Code: {engineState?.codeAssembled || '________'}</div>
      <div>Mood: {engineState?.npcMood ?? '—'}</div>
    </div>
  );
}
```

---

## Phase 5: UI Design System (8h)

### Task 5.1 — Full component implementations (5h)

Complete the stub components from Task 1.4. Each component follows the Theme FER design language.

**File:** `frontend-v3/packages/ui/src/components/Card.tsx`

```typescript
import type { ComponentProps } from 'react';

export interface CardProps extends ComponentProps<'div'> {
  variant?: 'default' | 'glass' | 'elevated';
}

export function Card({ variant = 'default', className = '', children, ...props }: CardProps) {
  const variants = {
    default: 'bg-[#2c2c2e] border border-white/10',
    glass: 'bg-white/5 backdrop-blur-xl border border-white/10',
    elevated: 'bg-[#3a3a3c] border border-white/10 shadow-xl',
  };
  return (
    <div className={`rounded-2xl p-4 ${variants[variant]} ${className}`} {...props}>
      {children}
    </div>
  );
}
```

**File:** `frontend-v3/packages/ui/src/components/Badge.tsx`

```typescript
export interface BadgeProps {
  children: React.ReactNode;
  color?: 'blue' | 'green' | 'orange' | 'red' | 'purple' | 'neutral';
  className?: string;
}

const COLOR_MAP = {
  blue:    'bg-[#0071e3]/20 text-[#0071e3]',
  green:   'bg-[#34c759]/20 text-[#34c759]',
  orange:  'bg-[#ff9500]/20 text-[#ff9500]',
  red:     'bg-[#ff3b30]/20 text-[#ff3b30]',
  purple:  'bg-[#af52de]/20 text-[#af52de]',
  neutral: 'bg-white/10 text-white/60',
};

export function Badge({ children, color = 'neutral', className = '' }: BadgeProps) {
  return (
    <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${COLOR_MAP[color]} ${className}`}>
      {children}
    </span>
  );
}
```

**File:** `frontend-v3/packages/ui/src/components/Progress.tsx`

```typescript
export interface ProgressProps {
  value: number; // 0–100
  className?: string;
  color?: string;
}

export function Progress({ value, className = '', color = '#0071e3' }: ProgressProps) {
  const clamped = Math.max(0, Math.min(100, value));
  return (
    <div className={`h-2 bg-white/10 rounded-full overflow-hidden ${className}`}>
      <div
        className="h-full rounded-full transition-all duration-300"
        style={{ width: `${clamped}%`, backgroundColor: color }}
      />
    </div>
  );
}
```

### Task 5.2 — Tailwind config + dark mode + CSS tokens (3h)

**File:** `frontend-v3/packages/ui/tailwind.config.ts`

```typescript
import type { Config } from 'tailwindcss';

export default {
  content: ['./src/**/*.{ts,tsx}', '../../apps/*/src/**/*.{ts,tsx}'],
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        fer: {
          accent:   '#0071e3',
          surface:  '#1c1c1e',
          card:     '#2c2c2e',
          elevated: '#3a3a3c',
          border:   'rgba(255,255,255,0.1)',
        },
        puzzle: {
          p1: '#FF6B35',
          p2: '#34c759',
          p3: '#0071e3',
          p4: '#ff9500',
          p5: '#af52de',
          p6: '#ff2d55',
          p7: '#5856d6',
        },
        npc: {
          neutral:  '#0071e3',
          impressed:'#34c759',
          worried:  '#ff9500',
          amused:   '#af52de',
        },
      },
      fontFamily: {
        sans: ['-apple-system', 'BlinkMacSystemFont', 'SF Pro Display', 'system-ui', 'sans-serif'],
        mono: ['SF Mono', 'JetBrains Mono', 'monospace'],
      },
      borderRadius: {
        xl: '12px',
        '2xl': '16px',
        '3xl': '24px',
      },
      boxShadow: {
        'fer-glow': '0 0 20px rgba(0, 113, 227, 0.4)',
        'fer-card':  '0 4px 24px rgba(0, 0, 0, 0.4)',
      },
    },
  },
  plugins: [],
} satisfies Config;
```

**File:** `frontend-v3/packages/ui/src/styles.css`

```css
/* Theme FER — base CSS tokens */
:root {
  --fer-accent: #0071e3;
  --fer-surface: #1c1c1e;
  --fer-card: #2c2c2e;
  --fer-elevated: #3a3a3c;
  --fer-border: rgba(255, 255, 255, 0.1);
  --fer-text: #ffffff;
  --fer-text-secondary: rgba(255, 255, 255, 0.6);
  --fer-text-tertiary: rgba(255, 255, 255, 0.3);

  /* NPC mood colors */
  --npc-neutral: #0071e3;
  --npc-impressed: #34c759;
  --npc-worried: #ff9500;
  --npc-amused: #af52de;
}

* {
  box-sizing: border-box;
  -webkit-font-smoothing: antialiased;
}

body {
  background: var(--fer-surface);
  color: var(--fer-text);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
  margin: 0;
}
```

---

## Phase 6: Integration + Testing (12h)

### Task 6.1 — Editor → simulation wire (3h)

In the editor app, add a "Preview in 3D" button that:
1. Exports the current workspace to YAML
2. Opens `apps/simulation` (running on port 5175) with the YAML encoded as a URL param
3. The simulation reads the `?yaml=<base64>` param on load and calls `loadScenario(yaml)`

**File:** `frontend-v3/apps/editor/src/components/EditorLayout.tsx` — add to the menu bar:

```typescript
const handlePreview3D = useCallback(() => {
  const ws = workspaceRef.current;
  if (!ws) return;
  const yaml = exportWorkspaceToYaml(ws);
  const encoded = btoa(encodeURIComponent(yaml));
  window.open(`http://localhost:5175?yaml=${encoded}`, '_blank');
}, []);
```

**File:** `frontend-v3/apps/simulation/src/App.tsx` — read URL param in `useEffect`:

```typescript
useEffect(() => {
  const params = new URLSearchParams(window.location.search);
  const encoded = params.get('yaml');
  if (encoded) {
    try {
      const yaml = decodeURIComponent(atob(encoded));
      loadScenario(yaml);
      setYamlLoaded(true);
      return;
    } catch { /* fall through to fetch */ }
  }
  // ... existing fetch fallback
}, [loadScenario]);
```

### Task 6.2 — Dashboard mock server for testing (2h)

**File:** `frontend-v3/apps/dashboard/mock/ws-server.ts`

```typescript
import { WebSocketServer } from 'ws';
import type { GameEvent } from '@zacus/shared';

const wss = new WebSocketServer({ port: 81 });

const EVENTS: GameEvent[] = [
  { type: 'game_start', timestamp: Date.now(), data: { session_id: 'test-session-001' } },
  { type: 'profile_detected', timestamp: Date.now(), data: { profile: 'TECH' } },
  { type: 'puzzle_solved', timestamp: Date.now(), data: { puzzle_id: 'P1_SON' } },
  { type: 'hint_given', timestamp: Date.now(), data: { puzzle_id: 'P2_CIRCUIT' } },
  { type: 'npc_spoke', timestamp: Date.now(), data: { phrase: 'Excellent travail ! Continuez !' } },
];

wss.on('connection', (ws) => {
  console.log('[mock] Dashboard connected');
  // Replay events at 2s intervals
  let i = 0;
  const interval = setInterval(() => {
    if (i >= EVENTS.length) { clearInterval(interval); return; }
    const event = { ...EVENTS[i++], timestamp: Date.now() };
    ws.send(JSON.stringify(event));
    // Also send a timer update every interval
    ws.send(JSON.stringify({ type: 'timer_update', timestamp: Date.now(), data: { elapsed_ms: i * 2000 } }));
  }, 2000);

  ws.on('message', (data) => {
    console.log('[mock] Command received:', data.toString());
  });

  ws.on('close', () => clearInterval(interval));
});

console.log('[mock] BOX-3 mock WS server on ws://localhost:81');
```

Add to `frontend-v3/apps/dashboard/package.json`:
```json
"mock": "tsx mock/ws-server.ts"
```

### Task 6.3 — End-to-end tests (4h)

**File:** `frontend-v3/apps/editor/src/yaml/round-trip.test.ts`

```typescript
import { describe, it, expect } from 'vitest';
import { parseScenarioYaml } from '@zacus/shared';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

// Full round-trip test: canonical YAML → parse → serialize → parse again
describe('YAML round-trip', () => {
  it('canonical scenario survives parse → serialize → parse', () => {
    const raw = readFileSync(resolve('../../../../game/scenarios/zacus_v3_complete.yaml'), 'utf-8');
    const { serializeScenarioYaml } = await import('@zacus/shared');
    const scenario1 = parseScenarioYaml(raw);
    const serialized = serializeScenarioYaml(scenario1);
    const scenario2 = parseScenarioYaml(serialized);
    expect(scenario2.hardware.puzzles).toHaveLength(7);
    expect(scenario2.id).toBe(scenario1.id);
    expect(scenario2.duration.target_minutes).toBe(scenario1.duration.target_minutes);
  });
});
```

**File:** `frontend-v3/packages/scenario-engine/src/scoring.test.ts`

```typescript
import { describe, it, expect } from 'vitest';
import { ZacusScenarioEngine } from './engine.js';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

const yamlRaw = readFileSync(resolve('../../../../game/scenarios/zacus_v3_complete.yaml'), 'utf-8');

describe('scoring', () => {
  it('awards S rank for fast perfect play', () => {
    const engine = new ZacusScenarioEngine();
    engine.load(yamlRaw);
    engine.start({ targetDuration: 60, mode: '60' });
    // No hints, no overtime
    const score = engine.getScore();
    expect(score.rank).toBe('S');
    expect(score.total).toBe(1000);
  });

  it('deducts hint penalties correctly', () => {
    const engine = new ZacusScenarioEngine();
    engine.load(yamlRaw);
    engine.start({ targetDuration: 60, mode: '60' });
    // 3 hints given
    for (let i = 0; i < 3; i++) {
      engine.onEvent({ type: 'hint_given', timestamp: Date.now(), data: { puzzle_id: 'P1_SON' } });
    }
    const score = engine.getScore();
    expect(score.hintPenalty).toBe(150); // 3 × 50
    expect(score.total).toBe(850);
  });
});
```

### Task 6.4 — CI configuration (3h)

**File:** `frontend-v3/.github/workflows/ci.yml`

```yaml
name: Frontend V3 CI

on:
  push:
    paths:
      - 'frontend-v3/**'
  pull_request:
    paths:
      - 'frontend-v3/**'

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: frontend-v3

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
          cache-dependency-path: frontend-v3/pnpm-lock.yaml

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Typecheck all packages
        run: pnpm turbo typecheck

      - name: Build all packages
        run: pnpm turbo build

      - name: Run all tests
        run: pnpm turbo test

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        if: always()
        with:
          directory: frontend-v3
```

---

## Dependency Graph

```
packages/shared
    ↑
packages/scenario-engine
    ↑
packages/ui          ← (peer: react)
    ↑
apps/editor          → imports: shared, ui, blockly
apps/dashboard       → imports: shared, ui, zustand
apps/simulation      → imports: shared, scenario-engine, ui, three, @react-three/fiber
```

Build order enforced by Turborepo `dependsOn: ["^build"]`.

## File Creation Checklist

For each task, the worker must create these files in order:

- [ ] **P1.1** `frontend-v3/pnpm-workspace.yaml`, `package.json`, `turbo.json`, `tsconfig.base.json`
- [ ] **P1.2** `packages/shared/package.json`, `src/types.ts`, `src/constants.ts`, `src/yaml-parser.ts`, `src/index.ts`, `tsconfig.json`, `src/yaml-parser.test.ts`
- [ ] **P1.3** `packages/scenario-engine/package.json`, `src/engine.ts`, `src/index.ts`, `tsconfig.json`, `src/engine.test.ts`
- [ ] **P1.4** `packages/ui/package.json`, `src/index.ts`, `src/components/Button.tsx`, `src/components/Card.tsx` (stub), `src/components/Badge.tsx` (stub), `src/components/Progress.tsx` (stub), `tsconfig.json`
- [ ] **P2.1** `apps/editor/package.json`, `vite.config.ts`, `src/main.tsx`, `src/App.tsx`
- [ ] **P2.2** `apps/editor/src/blocks/definitions.ts`
- [ ] **P2.3** `apps/editor/src/blocks/generators.ts`
- [ ] **P2.4** `apps/editor/src/yaml/export.ts`, `apps/editor/src/yaml/import.ts`
- [ ] **P2.5** `apps/editor/src/validation/validator.ts`, `validator.test.ts`
- [ ] **P2.6** `apps/editor/src/components/EditorLayout.tsx`, `toolbox.ts`
- [ ] **P3.1** `apps/dashboard/package.json`, `src/store/gameStore.ts`, `gameStore.test.ts`
- [ ] **P3.2** `apps/dashboard/src/components/ExpertMode.tsx`
- [ ] **P3.3** `apps/dashboard/src/components/SimpleMode.tsx`
- [ ] **P3.4** `apps/dashboard/src/App.tsx`, `src/components/ConnectionSetup.tsx`
- [ ] **P4.1** `apps/simulation/package.json`, `vite.config.ts`, `src/main.tsx`
- [ ] **P4.2** `apps/simulation/src/scene/RoomScene.tsx`
- [ ] **P4.3** `apps/simulation/src/scene/PuzzleStations.tsx`
- [ ] **P4.4** `apps/simulation/src/scene/RtcPhone.tsx`, `NpcBubble.tsx`
- [ ] **P4.5** `apps/simulation/src/store/simStore.ts`
- [ ] **P4.6** `apps/simulation/src/App.tsx`
- [ ] **P5.1** `packages/ui/src/components/Card.tsx`, `Badge.tsx`, `Progress.tsx` (full implementations)
- [ ] **P5.2** `packages/ui/tailwind.config.ts`, `src/styles.css`
- [ ] **P6.1** Editor preview button + simulation URL param reader
- [ ] **P6.2** `apps/dashboard/mock/ws-server.ts`
- [ ] **P6.3** Round-trip test, scoring test
- [ ] **P6.4** `.github/workflows/ci.yml`

## Verification Commands

After each phase, run:

```bash
cd frontend-v3

# After Phase 1
pnpm install
pnpm turbo build --filter=@zacus/shared --filter=@zacus/scenario-engine
pnpm turbo test --filter=@zacus/shared --filter=@zacus/scenario-engine

# After Phase 2
pnpm turbo build --filter=@zacus/editor
pnpm turbo test --filter=@zacus/editor

# After Phase 3
pnpm turbo build --filter=@zacus/dashboard
pnpm turbo test --filter=@zacus/dashboard

# After Phase 4
pnpm turbo build --filter=@zacus/simulation

# Full suite
pnpm turbo build
pnpm turbo test
pnpm turbo typecheck
```

Expected: all packages build cleanly, all tests pass, no TypeScript errors.
