diff --git a/frontend/apps/web/package.json b/frontend/apps/web/package.json
index 88c42a7..5abea82 100644
--- a/frontend/apps/web/package.json
+++ b/frontend/apps/web/package.json
@@ -7,17 +7,19 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
- "test": "vitest",
+ "test": "vitest --config vitest.config.ts",
"clean": "rm -rf dist node_modules/.vite"
},
"dependencies": {
"@solidjs/router": "^0.10.0",
"@suid/icons-material": "^0.7.0",
"@suid/material": "^0.16.0",
- "solid-js": "^1.9.0"
+ "solid-js": "^1.9.0",
+ "zod": "^3.25.76"
},
"devDependencies": {
"@solidjs/testing-library": "^0.8.0",
+ "jsdom": "^26.1.0",
"typescript": "^5.5.4",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.8.0",
diff --git a/frontend/apps/web/src/App.tsx b/frontend/apps/web/src/App.tsx
index fb4c173..a885ea4 100644
--- a/frontend/apps/web/src/App.tsx
+++ b/frontend/apps/web/src/App.tsx
@@ -1,11 +1,20 @@
+import { Route, Router } from '@solidjs/router'
import type { Component } from 'solid-js'
+import AppShell from './components/AppShell'
+import GameRoute from './routes/Game'
+import HomeRoute from './routes/Home'
+import LeaderboardRoute from './routes/Leaderboard'
+import ProfileRoute from './routes/Profile'
+
const App: Component = () => {
return (
-
-
Know Foolery
-
Quiz game coming soon...
-
+
+
+
+
+
+
)
}
diff --git a/frontend/apps/web/src/components/AppShell.tsx b/frontend/apps/web/src/components/AppShell.tsx
new file mode 100644
index 0000000..f57bcf3
--- /dev/null
+++ b/frontend/apps/web/src/components/AppShell.tsx
@@ -0,0 +1,50 @@
+import { A, Outlet } from '@solidjs/router'
+import type { Component } from 'solid-js'
+
+import AppBar from '@suid/material/AppBar'
+import Box from '@suid/material/Box'
+import Container from '@suid/material/Container'
+import IconButton from '@suid/material/IconButton'
+import Stack from '@suid/material/Stack'
+import Toolbar from '@suid/material/Toolbar'
+import Typography from '@suid/material/Typography'
+
+import HomeIcon from '@suid/icons-material/Home'
+import SportsEsportsIcon from '@suid/icons-material/SportsEsports'
+import LeaderboardIcon from '@suid/icons-material/Leaderboard'
+import PersonIcon from '@suid/icons-material/Person'
+
+const AppShell: Component = () => {
+ return (
+
+
+
+
+ Know Foolery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default AppShell
diff --git a/frontend/apps/web/src/hooks/useTimer.ts b/frontend/apps/web/src/hooks/useTimer.ts
new file mode 100644
index 0000000..9cc9530
--- /dev/null
+++ b/frontend/apps/web/src/hooks/useTimer.ts
@@ -0,0 +1,35 @@
+import { createSignal, onCleanup } from 'solid-js'
+
+export function useTimer(durationMs: number) {
+ const [remainingMs, setRemainingMs] = createSignal(durationMs)
+ const [isExpired, setIsExpired] = createSignal(false)
+
+ let t: number | undefined
+ let startedAt: number | undefined
+
+ const tick = () => {
+ if (startedAt == null) return
+ const elapsed = Date.now() - startedAt
+ const remain = Math.max(0, durationMs - elapsed)
+ setRemainingMs(remain)
+ if (remain === 0) setIsExpired(true)
+ }
+
+ const start = () => {
+ if (t != null) return
+ startedAt = Date.now()
+ tick()
+ t = window.setInterval(tick, 250)
+ }
+
+ const stop = () => {
+ if (t != null) {
+ window.clearInterval(t)
+ t = undefined
+ }
+ }
+
+ onCleanup(() => stop())
+
+ return { remainingMs, isExpired, start, stop }
+}
diff --git a/frontend/apps/web/src/main.tsx b/frontend/apps/web/src/main.tsx
index 39ce5d7..4a3ab1d 100644
--- a/frontend/apps/web/src/main.tsx
+++ b/frontend/apps/web/src/main.tsx
@@ -1,4 +1,5 @@
import { render } from 'solid-js/web'
+
import App from './App'
import './styles/global.css'
diff --git a/frontend/apps/web/src/routes/Game.tsx b/frontend/apps/web/src/routes/Game.tsx
new file mode 100644
index 0000000..34e0fb7
--- /dev/null
+++ b/frontend/apps/web/src/routes/Game.tsx
@@ -0,0 +1,120 @@
+import { createMemo, createSignal, onCleanup, onMount } from 'solid-js'
+
+import Box from '@suid/material/Box'
+import Button from '@suid/material/Button'
+import Card from '@suid/material/Card'
+import CardContent from '@suid/material/CardContent'
+import Divider from '@suid/material/Divider'
+import Stack from '@suid/material/Stack'
+import TextField from '@suid/material/TextField'
+import Typography from '@suid/material/Typography'
+
+import AttemptIndicator from '../ui/AttemptIndicator'
+import ScoreDisplay from '../ui/ScoreDisplay'
+import Timer from '../ui/Timer'
+import { useTimer } from '../hooks/useTimer'
+
+export default function GameRoute() {
+ // Placeholder game state until backend exists.
+ const [question] = createSignal({ theme: 'Général', text: 'Quel est le plus petit nombre premier ?' })
+ const [answer, setAnswer] = createSignal('')
+ const [attempts, setAttempts] = createSignal(0)
+ const [hintUsed, setHintUsed] = createSignal(false)
+ const [score, setScore] = createSignal(0)
+ const [message, setMessage] = createSignal(null)
+
+ const durationMs = 30 * 60 * 1000
+ const { remainingMs, isExpired, start } = useTimer(durationMs)
+
+ onMount(() => start())
+
+ onCleanup(() => {
+ // no-op; hook cleans itself
+ })
+
+ const attemptsLeft = createMemo(() => Math.max(0, 3 - attempts()))
+
+ const submit = () => {
+ if (isExpired()) {
+ setMessage('Session terminée (timeout).')
+ return
+ }
+
+ const normalized = answer().trim().toLowerCase()
+ const correct = normalized === '2' || normalized === 'deux'
+
+ setAttempts((a) => a + 1)
+
+ if (correct) {
+ const delta = hintUsed() ? 1 : 2
+ setScore((s) => s + delta)
+ setMessage(`Bonne réponse (+${delta}).`)
+ } else {
+ setMessage('Mauvaise réponse.')
+ }
+
+ setAnswer('')
+ }
+
+ const useHint = () => {
+ if (hintUsed()) return
+ setHintUsed(true)
+ setMessage('Indice: c’est le seul nombre premier pair.')
+ }
+
+ return (
+
+
+
+
+
+ Partie
+
+ Thème : {question().theme}
+
+
+
+
+
+
+
+
+
+ {question().text}
+
+
+ setAnswer(e.currentTarget.value)}
+ fullWidth
+ InputLabelProps={{ style: { color: '#cbd5e1' } }}
+ InputProps={{ style: { color: '#e5e7eb' } }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') submit()
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+ {message() && {message()}}
+ {attemptsLeft() === 0 && Plus d'essais.}
+ {isExpired() && Temps écoulé.}
+
+
+
+
+
+ )
+}
diff --git a/frontend/apps/web/src/routes/Home.tsx b/frontend/apps/web/src/routes/Home.tsx
new file mode 100644
index 0000000..5506a61
--- /dev/null
+++ b/frontend/apps/web/src/routes/Home.tsx
@@ -0,0 +1,70 @@
+import { useNavigate } from '@solidjs/router'
+import { createSignal } from 'solid-js'
+
+import Box from '@suid/material/Box'
+import Button from '@suid/material/Button'
+import Card from '@suid/material/Card'
+import CardContent from '@suid/material/CardContent'
+import Stack from '@suid/material/Stack'
+import TextField from '@suid/material/TextField'
+import Typography from '@suid/material/Typography'
+
+import { playerNameSchema } from '../services/validation'
+
+export default function HomeRoute() {
+ const navigate = useNavigate()
+ const [playerName, setPlayerName] = createSignal('')
+ const [error, setError] = createSignal(null)
+
+ const start = () => {
+ const name = playerName().trim()
+ const parsed = playerNameSchema.safeParse(name)
+ if (!parsed.success) {
+ setError(parsed.error.issues[0]?.message ?? 'Nom invalide')
+ return
+ }
+
+ setError(null)
+ // keep it simple: store in localStorage
+ localStorage.setItem('kf.playerName', name)
+ navigate('/game')
+ }
+
+ return (
+
+
+
+
+
+ Bienvenue
+
+
+ Entre ton pseudo et lance une partie.
+
+
+ setPlayerName(e.currentTarget.value)}
+ error={!!error()}
+ helperText={error() ?? '2–50 caractères'}
+ fullWidth
+ variant="outlined"
+ InputLabelProps={{ style: { color: '#cbd5e1' } }}
+ InputProps={{ style: { color: '#e5e7eb' } }}
+ />
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/apps/web/src/routes/Leaderboard.tsx b/frontend/apps/web/src/routes/Leaderboard.tsx
new file mode 100644
index 0000000..d72f11e
--- /dev/null
+++ b/frontend/apps/web/src/routes/Leaderboard.tsx
@@ -0,0 +1,57 @@
+import { For, Show, createResource } from 'solid-js'
+
+import Box from '@suid/material/Box'
+import Button from '@suid/material/Button'
+import Card from '@suid/material/Card'
+import CardContent from '@suid/material/CardContent'
+import CircularProgress from '@suid/material/CircularProgress'
+import Stack from '@suid/material/Stack'
+import Typography from '@suid/material/Typography'
+
+import { leaderboardClient } from '../services/api'
+
+export default function LeaderboardRoute() {
+ const [items, { refetch }] = createResource(async () => leaderboardClient.top10())
+
+ return (
+
+
+
+
+ Leaderboard
+
+
+
+
+
+
+ }>
+ 0}
+ fallback={Aucun score pour le moment.}
+ >
+
+
+ {(row, idx) => (
+
+ #{idx() + 1}
+ {row.player}
+ {row.score}
+
+ )}
+
+
+
+
+
+
+
+
+ Note : cette page utilise l’API si configurée, sinon une version mock.
+
+
+
+ )
+}
diff --git a/frontend/apps/web/src/routes/Profile.test.tsx b/frontend/apps/web/src/routes/Profile.test.tsx
new file mode 100644
index 0000000..a5180c1
--- /dev/null
+++ b/frontend/apps/web/src/routes/Profile.test.tsx
@@ -0,0 +1,11 @@
+import { render } from '@solidjs/testing-library'
+import { describe, expect, it } from 'vitest'
+
+import ProfileRoute from './Profile'
+
+describe('ProfileRoute', () => {
+ it('renders', () => {
+ const { getByText } = render(() => )
+ expect(getByText('Profil')).toBeTruthy()
+ })
+})
diff --git a/frontend/apps/web/src/routes/Profile.tsx b/frontend/apps/web/src/routes/Profile.tsx
new file mode 100644
index 0000000..c6cfd29
--- /dev/null
+++ b/frontend/apps/web/src/routes/Profile.tsx
@@ -0,0 +1,56 @@
+import { createSignal } from 'solid-js'
+
+import Box from '@suid/material/Box'
+import Button from '@suid/material/Button'
+import Card from '@suid/material/Card'
+import CardContent from '@suid/material/CardContent'
+import Stack from '@suid/material/Stack'
+import TextField from '@suid/material/TextField'
+import Typography from '@suid/material/Typography'
+
+export default function ProfileRoute() {
+ const [name, setName] = createSignal(localStorage.getItem('kf.playerName') ?? '')
+
+ const save = () => {
+ localStorage.setItem('kf.playerName', name().trim())
+ }
+
+ return (
+
+
+
+
+
+ Profil
+
+ Paramètres locaux (placeholder).
+
+ setName(e.currentTarget.value)}
+ fullWidth
+ InputLabelProps={{ style: { color: '#cbd5e1' } }}
+ InputProps={{ style: { color: '#e5e7eb' } }}
+ />
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/apps/web/src/services/api.ts b/frontend/apps/web/src/services/api.ts
new file mode 100644
index 0000000..af012bf
--- /dev/null
+++ b/frontend/apps/web/src/services/api.ts
@@ -0,0 +1,33 @@
+type LeaderboardRow = {
+ player: string
+ score: number
+}
+
+const baseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
+
+async function json(path: string): Promise {
+ if (!baseUrl) {
+ throw new Error('API base URL not configured')
+ }
+ const res = await fetch(`${baseUrl}${path}`)
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ return (await res.json()) as T
+}
+
+export const leaderboardClient = {
+ async top10(): Promise {
+ // When the backend exists, this should call the leaderboard-service.
+ // For now, return a mock if API is not set.
+ if (!baseUrl) {
+ return [
+ { player: 'Alice', score: 24 },
+ { player: 'Bob', score: 22 },
+ { player: 'Charlie', score: 20 },
+ ]
+ }
+
+ // Example endpoint; adjust when gateway routes are finalized
+ const resp = await json<{ items?: LeaderboardRow[] }>(`/leaderboard/top10`)
+ return resp.items ?? []
+ },
+}
diff --git a/frontend/apps/web/src/services/validation.ts b/frontend/apps/web/src/services/validation.ts
new file mode 100644
index 0000000..05741d5
--- /dev/null
+++ b/frontend/apps/web/src/services/validation.ts
@@ -0,0 +1,6 @@
+import { z } from 'zod'
+
+export const playerNameSchema = z
+ .string()
+ .min(2, 'Le nom doit faire au moins 2 caractères')
+ .max(50, 'Le nom doit faire au plus 50 caractères')
diff --git a/frontend/apps/web/src/styles/global.css b/frontend/apps/web/src/styles/global.css
index 0537e29..92e5758 100644
--- a/frontend/apps/web/src/styles/global.css
+++ b/frontend/apps/web/src/styles/global.css
@@ -1,26 +1,21 @@
:root {
- font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
- line-height: 1.5;
- font-weight: 400;
-
- color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
-
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+ color-scheme: dark;
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji',
+ 'Segoe UI Emoji';
}
-* {
- box-sizing: border-box;
+html,
+body {
+ height: 100%;
margin: 0;
padding: 0;
}
#root {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
+ min-height: 100%;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
}
diff --git a/frontend/apps/web/src/ui/AttemptIndicator.tsx b/frontend/apps/web/src/ui/AttemptIndicator.tsx
new file mode 100644
index 0000000..1ebe8eb
--- /dev/null
+++ b/frontend/apps/web/src/ui/AttemptIndicator.tsx
@@ -0,0 +1,32 @@
+import type { Component } from 'solid-js'
+
+import Box from '@suid/material/Box'
+import Stack from '@suid/material/Stack'
+import Typography from '@suid/material/Typography'
+
+const AttemptIndicator: Component<{ attemptsUsed: number; attemptsMax: number }> = (props) => {
+ const used = () => Math.max(0, Math.min(props.attemptsUsed, props.attemptsMax))
+
+ return (
+
+ Essais
+
+ {Array.from({ length: props.attemptsMax }).map((_, i) => (
+
+ ))}
+
+
+ {Math.max(0, props.attemptsMax - used())} restant(s)
+
+
+ )
+}
+
+export default AttemptIndicator
diff --git a/frontend/apps/web/src/ui/ScoreDisplay.tsx b/frontend/apps/web/src/ui/ScoreDisplay.tsx
new file mode 100644
index 0000000..5280ed3
--- /dev/null
+++ b/frontend/apps/web/src/ui/ScoreDisplay.tsx
@@ -0,0 +1,9 @@
+import type { Component } from 'solid-js'
+
+import Chip from '@suid/material/Chip'
+
+const ScoreDisplay: Component<{ score: number }> = (props) => {
+ return
+}
+
+export default ScoreDisplay
diff --git a/frontend/apps/web/src/ui/Timer.tsx b/frontend/apps/web/src/ui/Timer.tsx
new file mode 100644
index 0000000..8320e67
--- /dev/null
+++ b/frontend/apps/web/src/ui/Timer.tsx
@@ -0,0 +1,22 @@
+import type { Component } from 'solid-js'
+
+import Chip from '@suid/material/Chip'
+
+function formatMs(ms: number) {
+ const total = Math.ceil(ms / 1000)
+ const m = Math.floor(total / 60)
+ const s = total % 60
+ return `${m}:${s.toString().padStart(2, '0')}`
+}
+
+const Timer: Component<{ remainingMs: number }> = (props) => {
+ const color = () => {
+ if (props.remainingMs <= 10_000) return 'error'
+ if (props.remainingMs <= 60_000) return 'warning'
+ return 'default'
+ }
+
+ return
+}
+
+export default Timer
diff --git a/frontend/apps/web/vitest.config.ts b/frontend/apps/web/vitest.config.ts
new file mode 100644
index 0000000..68e3449
--- /dev/null
+++ b/frontend/apps/web/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ },
+})
diff --git a/frontend/shared/ui-components/src/AttemptDots.tsx b/frontend/shared/ui-components/src/AttemptDots.tsx
new file mode 100644
index 0000000..42517bf
--- /dev/null
+++ b/frontend/shared/ui-components/src/AttemptDots.tsx
@@ -0,0 +1,32 @@
+import type { Component } from 'solid-js'
+
+import Box from '@suid/material/Box'
+import Stack from '@suid/material/Stack'
+import Typography from '@suid/material/Typography'
+
+const AttemptDots: Component<{ attemptsUsed: number; attemptsMax: number }> = (props) => {
+ const used = () => Math.max(0, Math.min(props.attemptsUsed, props.attemptsMax))
+
+ return (
+
+ Essais
+
+ {Array.from({ length: props.attemptsMax }).map((_, i) => (
+
+ ))}
+
+
+ {Math.max(0, props.attemptsMax - used())} restant(s)
+
+
+ )
+}
+
+export default AttemptDots
diff --git a/frontend/shared/ui-components/src/ScoreChip.tsx b/frontend/shared/ui-components/src/ScoreChip.tsx
new file mode 100644
index 0000000..69f6f71
--- /dev/null
+++ b/frontend/shared/ui-components/src/ScoreChip.tsx
@@ -0,0 +1,9 @@
+import type { Component } from 'solid-js'
+
+import Chip from '@suid/material/Chip'
+
+const ScoreChip: Component<{ score: number }> = (props) => {
+ return
+}
+
+export default ScoreChip
diff --git a/frontend/shared/ui-components/src/TimerChip.tsx b/frontend/shared/ui-components/src/TimerChip.tsx
new file mode 100644
index 0000000..c1be83a
--- /dev/null
+++ b/frontend/shared/ui-components/src/TimerChip.tsx
@@ -0,0 +1,22 @@
+import type { Component } from 'solid-js'
+
+import Chip from '@suid/material/Chip'
+
+function formatMs(ms: number) {
+ const total = Math.ceil(ms / 1000)
+ const m = Math.floor(total / 60)
+ const s = total % 60
+ return `${m}:${s.toString().padStart(2, '0')}`
+}
+
+const TimerChip: Component<{ remainingMs: number }> = (props) => {
+ const color = () => {
+ if (props.remainingMs <= 10_000) return 'error'
+ if (props.remainingMs <= 60_000) return 'warning'
+ return 'default'
+ }
+
+ return
+}
+
+export default TimerChip
diff --git a/frontend/shared/ui-components/src/index.ts b/frontend/shared/ui-components/src/index.ts
index ee9afc2..43c5a79 100644
--- a/frontend/shared/ui-components/src/index.ts
+++ b/frontend/shared/ui-components/src/index.ts
@@ -1,3 +1,4 @@
-// UI Components barrel export
-// Components will be added as they are developed
-export {}
+export { default as AttemptDots } from './AttemptDots'
+export { default as ScoreChip } from './ScoreChip'
+export { default as TimerChip } from './TimerChip'
+