diff --git a/docs/1_requirements/non-functional-requirements.md b/docs/1_requirements/non-functional-requirements.md
index 5e8183f..ea2cda1 100644
--- a/docs/1_requirements/non-functional-requirements.md
+++ b/docs/1_requirements/non-functional-requirements.md
@@ -17,7 +17,6 @@
- **Microservices Architecture**: Independent scaling of components
- **Database**: PostgreSQL with read replicas for high availability
- **Caching**: Redis for session state and frequently accessed data
-- **Auto-scaling**: Kubernetes-based horizontal scaling
## Reliability
- **Uptime**: 99.9% availability target
@@ -36,5 +35,7 @@
### Audit & Compliance
- **Audit Trails**: Comprehensive logging of all administrative actions
- **Compliance Reporting**: SOC 2, ISO 27001 compliance capabilities
-- **Data Retention**: Defined policies for data lifecycle management
+- **Data Retent
+## Future Enhancements (do not take into account for now)
+- **Auto-scaling**: Kubernetes-based horizontal scaling
diff --git a/docs/2_architecture/application-architecture.md b/docs/2_architecture/application-architecture.md
index 515d0c3..c868488 100644
--- a/docs/2_architecture/application-architecture.md
+++ b/docs/2_architecture/application-architecture.md
@@ -146,9 +146,9 @@ Communication Patterns:
Asynchronous: asynchronous gRPC calls (Event-driven via message queues in the future)
Service Discovery:
- Registry: Kubernetes DNS
- Health Checks: HTTP /health endpoints
- Load Balancing: Round-robin with health awareness
+ Registry: No separate registry. Use Docker Compose networking as registry. Put services on the same Compose network. Call services by service name (DNS). Keep endpoints in env vars.
+ Health Checks: HTTP /health endpoints. Add healthcheck + depends_on: condition: service_healthy to reduce startup race issues.
+ Load Balancing: No load balancing
Circuit Breaker:
Pattern: Hystrix-style circuit breakers
@@ -164,7 +164,7 @@ Circuit Breaker:
- **Testing**: Comprehensive unit, integration, and E2E testing
### Production Environment
-- **Container Orchestration**: Kubernetes for production deployment
+- **Container Orchestration**: Docker Compose for production deployment
- **Database**: PostgreSQL with high availability configuration
- **Monitoring**: Prometheus, Grafana, Jaeger for observability
- **CI/CD**: GitHub Actions for automated testing and deployment
@@ -178,7 +178,7 @@ Circuit Breaker:
```yaml
Containerization:
Runtime: Docker 24+
- Orchestration: Kubernetes 1.28+
+ Orchestration: Docker Compose
Registry: Private Docker Registry
Observability:
diff --git a/docs/4_work_plan/overall-plan.md b/docs/4_work_plan/overall-plan.md
index d884dc9..eb65c9f 100644
--- a/docs/4_work_plan/overall-plan.md
+++ b/docs/4_work_plan/overall-plan.md
@@ -304,7 +304,7 @@ GET /admin/audit # Audit log viewer
---
-## Phase 4: Frontend (Weeks 7-9)
+## Phase 4: Web Frontend (Weeks 7-9)
### 4.1 Web Application (SolidJS)
@@ -396,34 +396,6 @@ frontend/shared/ui-components/
└── ThemeBadge/ # Question theme display
```
-### 4.3 Desktop/Mobile (Tauri)
-
-**Priority:** LOW
-**Duration:** 3-4 days
-**Depends on:** 4.1
-
-**Project Structure:**
-```
-frontend/apps/cross-platform/
-├── src/ # Inherits from web app
-├── src-tauri/
-│ ├── Cargo.toml
-│ ├── tauri.conf.json
-│ ├── capabilities/
-│ ├── icons/
-│ └── src/
-│ ├── main.rs
-│ ├── lib.rs
-│ └── commands/ # Native commands
-```
-
-**Target Platforms:**
-- macOS (Intel + Apple Silicon)
-- Windows (x64)
-- Linux (x64)
-- iOS (via Tauri 2.x)
-- Android (via Tauri 2.x)
-
---
## Phase 5: Infrastructure (Weeks 8-9)
@@ -474,39 +446,6 @@ services:
ports: ["16686:16686"]
```
-### 5.2 Kubernetes Manifests
-
-**Priority:** MEDIUM
-**Duration:** 3-4 days
-
-**Directory Structure:**
-```
-infrastructure/k8s/
-├── base/
-│ ├── namespace.yaml
-│ ├── configmaps/
-│ │ ├── game-config.yaml
-│ │ └── observability-config.yaml
-│ ├── secrets/
-│ │ └── database-secrets.yaml
-│ └── services/
-│ ├── gateway/
-│ ├── game-session/
-│ ├── question-bank/
-│ ├── user/
-│ ├── leaderboard/
-│ └── admin/
-├── overlays/
-│ ├── dev/
-│ │ └── kustomization.yaml
-│ └── prod/
-│ └── kustomization.yaml
-└── monitoring/
- ├── prometheus/
- ├── grafana/
- └── jaeger/
-```
-
### 5.3 CI/CD Pipeline
**Priority:** HIGH
@@ -532,10 +471,9 @@ infrastructure/k8s/
**CD Pipeline Stages:**
1. Pull latest images
2. Run database migrations
-3. Deploy to Kubernetes
-4. Health checks
-5. Smoke tests
-6. Rollback on failure
+3. Health checks
+4. Smoke tests
+5. Rollback on failure
---
@@ -615,6 +553,36 @@ func TestGameService_StartGame(t *testing.T) {
---
+### Phase 7: Desktop/Mobile (Tauri)
+
+**Priority:** LOW
+**Duration:** 3-4 days
+**Depends on:** 4.1
+
+**Project Structure:**
+```
+frontend/apps/cross-platform/
+├── src/ # Inherits from web app
+├── src-tauri/
+│ ├── Cargo.toml
+│ ├── tauri.conf.json
+│ ├── capabilities/
+│ ├── icons/
+│ └── src/
+│ ├── main.rs
+│ ├── lib.rs
+│ └── commands/ # Native commands
+```
+
+**Target Platforms:**
+- macOS (Intel + Apple Silicon)
+- Windows (x64)
+- Linux (x64)
+- iOS (via Tauri 2.x)
+- Android (via Tauri 2.x)
+
+---
+
## Implementation Order Summary
```
@@ -648,7 +616,6 @@ Week 7-8: Frontend
Week 9: Polish & Deploy
├── Tauri desktop/mobile packaging
-├── Kubernetes deployment
├── Production configuration
├── Documentation updates
└── Performance testing
@@ -751,7 +718,7 @@ Week 9: Polish & Deploy
---
-## Appendix: Technology Stack
+## Appendix A: Technology Stack
### Backend
| Component | Technology | Version |
@@ -780,8 +747,45 @@ Week 9: Polish & Deploy
| Component | Technology | Version |
|-----------|------------|---------|
| Containerization | Docker | 24+ |
-| Orchestration | Kubernetes | 1.28+ |
+| Orchestration | Docker Compose | Latest |
| CI/CD | GitHub Actions | Latest |
| Monitoring | Prometheus + Grafana | Latest |
| Tracing | Jaeger | Latest |
| Logging | Loki | Latest |
+
+---
+
+## Appendix B: Future Enhancements (do not take into account for now)
+
+### Kubernetes Manifests
+
+**Priority:** MEDIUM
+**Duration:** 3-4 days
+
+**Directory Structure:**
+```
+infrastructure/k8s/
+├── base/
+│ ├── namespace.yaml
+│ ├── configmaps/
+│ │ ├── game-config.yaml
+│ │ └── observability-config.yaml
+│ ├── secrets/
+│ │ └── database-secrets.yaml
+│ └── services/
+│ ├── gateway/
+│ ├── game-session/
+│ ├── question-bank/
+│ ├── user/
+│ ├── leaderboard/
+│ └── admin/
+├── overlays/
+│ ├── dev/
+│ │ └── kustomization.yaml
+│ └── prod/
+│ └── kustomization.yaml
+└── monitoring/
+ ├── prometheus/
+ ├── grafana/
+ └── jaeger/
+```
diff --git a/frontend/apps/web/node_modules/.vite/vitest/results.json b/frontend/apps/web/node_modules/.vite/vitest/results.json
index 6d23824..6266107 100644
--- a/frontend/apps/web/node_modules/.vite/vitest/results.json
+++ b/frontend/apps/web/node_modules/.vite/vitest/results.json
@@ -1 +1 @@
-{"version":"1.6.1","results":[[":src/services/validation.test.ts",{"duration":2,"failed":false}],[":src/ui/Timer.test.ts",{"duration":2,"failed":false}]]}
\ No newline at end of file
+{"version":"1.6.1","results":[[":src/services/validation.test.ts",{"duration":3,"failed":false}],[":src/routes/Results.test.tsx",{"duration":62,"failed":false}],[":src/routes/Leaderboard.test.tsx",{"duration":181,"failed":false}],[":src/routes/Game.test.tsx",{"duration":184,"failed":false}]]}
\ No newline at end of file
diff --git a/frontend/apps/web/package.json b/frontend/apps/web/package.json
index 5abea82..da6bdaf 100644
--- a/frontend/apps/web/package.json
+++ b/frontend/apps/web/package.json
@@ -11,6 +11,7 @@
"clean": "rm -rf dist node_modules/.vite"
},
"dependencies": {
+ "@knowfoolery/ui-components": "workspace:*",
"@solidjs/router": "^0.10.0",
"@suid/icons-material": "^0.7.0",
"@suid/material": "^0.16.0",
diff --git a/frontend/apps/web/src/routes/Game.test.tsx b/frontend/apps/web/src/routes/Game.test.tsx
new file mode 100644
index 0000000..f92d281
--- /dev/null
+++ b/frontend/apps/web/src/routes/Game.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@solidjs/testing-library'
+import { describe, expect, it, vi } from 'vitest'
+
+import GameRoute from './Game'
+
+vi.mock('@solidjs/router', () => ({
+ useNavigate: () => vi.fn(),
+}))
+
+describe('GameRoute', () => {
+ it('renders shared game UI components', () => {
+ render(() => )
+
+ expect(screen.getByText('Partie')).toBeTruthy()
+ expect(screen.getByLabelText('Ta réponse')).toBeTruthy()
+ expect(screen.getByText('Essais')).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Indice (score réduit)' })).toBeTruthy()
+ })
+})
diff --git a/frontend/apps/web/src/routes/Game.tsx b/frontend/apps/web/src/routes/Game.tsx
index 6e8f0de..87cdab2 100644
--- a/frontend/apps/web/src/routes/Game.tsx
+++ b/frontend/apps/web/src/routes/Game.tsx
@@ -1,25 +1,21 @@
import { useNavigate } from '@solidjs/router'
import { createEffect, createMemo, createSignal, onMount } from 'solid-js'
-import Box from '@suid/material/Box'
+import {
+ AnswerInput,
+ AttemptIndicator,
+ GameCard,
+ HintButton,
+ ScoreDisplay,
+ Timer,
+} from '@knowfoolery/ui-components'
import Button from '@suid/material/Button'
-import Card from '@suid/material/Card'
-import CardContent from '@suid/material/CardContent'
-import Dialog from '@suid/material/Dialog'
-import DialogActions from '@suid/material/DialogActions'
-import DialogContent from '@suid/material/DialogContent'
-import DialogTitle from '@suid/material/DialogTitle'
-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 { useTimer } from '../hooks/useTimer'
import { leaderboardClient } from '../services/api'
import { appendGameHistory, saveLastResult } from '../services/session'
-import AttemptIndicator from '../ui/AttemptIndicator'
-import ScoreDisplay from '../ui/ScoreDisplay'
-import Timer from '../ui/Timer'
type QuizQuestion = {
theme: string
@@ -35,10 +31,6 @@ const QUESTION: QuizQuestion = {
hint: 'C’est le seul nombre premier pair.',
}
-function readInputValue(event: Event): string {
- return (event.target as HTMLInputElement).value
-}
-
function normalizeAnswer(answer: string): string {
return answer.trim().toLowerCase()
}
@@ -76,7 +68,6 @@ export default function GameRoute() {
const [hintUsed, setHintUsed] = createSignal(false)
const [score, setScore] = createSignal(0)
const [message, setMessage] = createSignal(null)
- const [hintDialogOpen, setHintDialogOpen] = createSignal(false)
const [finished, setFinished] = createSignal(false)
const durationMs = 30 * 60 * 1000
@@ -155,86 +146,53 @@ export default function GameRoute() {
setAnswer('')
}
- const openHintDialog = () => {
- if (hintUsed() || attempts() > 0 || answerLocked()) return
- setHintDialogOpen(true)
- }
-
const confirmHint = () => {
+ if (hintUsed() || attempts() > 0 || answerLocked()) return
setHintUsed(true)
- setHintDialogOpen(false)
setMessage(`Indice: ${question().hint}`)
}
return (
-
-
-
-
-
- Partie
-
- Thème : {question().theme}
-
-
-
-
-
-
-
-
-
- {question().text}
-
-
- setAnswer(readInputValue(e))}
- fullWidth
- disabled={answerLocked()}
- 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é.}
-
-
-
-
-
-
-
+ 0 || answerLocked()}
+ buttonLabel="Indice (score réduit)"
+ confirmTitle="Confirmer l’indice"
+ confirmMessage="Demander un indice réduit le score maximal de la question de 2 à 1 point. Continuer ?"
+ confirmLabel="Oui, utiliser un indice"
+ cancelLabel="Annuler"
+ onConfirm={confirmHint}
+ />
+
+ }
+ feedbackSlot={
+ <>
+ {message() && {message()}}
+ {attemptsLeft() === 0 && Plus d'essais.}
+ {isExpired() && Temps écoulé.}
+ >
+ }
+ />
)
}
diff --git a/frontend/apps/web/src/routes/Leaderboard.test.tsx b/frontend/apps/web/src/routes/Leaderboard.test.tsx
new file mode 100644
index 0000000..a50a9e2
--- /dev/null
+++ b/frontend/apps/web/src/routes/Leaderboard.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen, waitFor } from '@solidjs/testing-library'
+import { describe, expect, it } from 'vitest'
+
+import LeaderboardRoute from './Leaderboard'
+
+describe('LeaderboardRoute', () => {
+ it('renders leaderboard rows via shared table', async () => {
+ render(() => )
+
+ await waitFor(() => {
+ expect(screen.getByText('Alice')).toBeTruthy()
+ })
+
+ expect(screen.getByRole('table', { name: 'top-10-leaderboard' })).toBeTruthy()
+ })
+})
diff --git a/frontend/apps/web/src/routes/Leaderboard.tsx b/frontend/apps/web/src/routes/Leaderboard.tsx
index 4eb7adc..75480a6 100644
--- a/frontend/apps/web/src/routes/Leaderboard.tsx
+++ b/frontend/apps/web/src/routes/Leaderboard.tsx
@@ -1,25 +1,15 @@
-import { For, Show, createResource } from 'solid-js'
+import { createResource } from 'solid-js'
+import { LeaderboardTable } from '@knowfoolery/ui-components'
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 Table from '@suid/material/Table'
-import TableBody from '@suid/material/TableBody'
-import TableCell from '@suid/material/TableCell'
-import TableHead from '@suid/material/TableHead'
-import TableRow from '@suid/material/TableRow'
import Typography from '@suid/material/Typography'
import { leaderboardClient } from '../services/api'
-function formatDuration(durationSec: number): string {
- const minutes = Math.floor(durationSec / 60)
- return `${minutes}m`
-}
-
export default function LeaderboardRoute() {
const [items, { refetch }] = createResource(async () => leaderboardClient.top10())
@@ -37,39 +27,7 @@ export default function LeaderboardRoute() {
- }>
- 0}
- fallback={Aucun score pour le moment.}
- >
-
-
-
- Rang
- Joueur
- Score
- Questions
- Taux de réussite
- Durée
-
-
-
-
- {(row, idx) => (
-
- #{idx() + 1}
- {row.player}
- {row.score}
- {row.questions}
- {row.successRate}%
- {formatDuration(row.durationSec)}
-
- )}
-
-
-
-
-
+
diff --git a/frontend/apps/web/src/routes/Results.test.tsx b/frontend/apps/web/src/routes/Results.test.tsx
new file mode 100644
index 0000000..6cb9b13
--- /dev/null
+++ b/frontend/apps/web/src/routes/Results.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from '@solidjs/testing-library'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import ResultsRoute from './Results'
+
+vi.mock('@solidjs/router', () => ({
+ useNavigate: () => vi.fn(),
+}))
+
+describe('ResultsRoute', () => {
+ beforeEach(() => {
+ localStorage.setItem(
+ 'kf.lastResult',
+ JSON.stringify({
+ playerName: 'Alice',
+ finalScore: 10,
+ answered: 6,
+ correct: 5,
+ successRate: 83,
+ durationSec: 1200,
+ leaderboardPosition: 3,
+ finishedAt: new Date().toISOString(),
+ })
+ )
+ })
+
+ it('renders stored result using shared results card', () => {
+ render(() => )
+
+ expect(screen.getByText('Joueur : Alice')).toBeTruthy()
+ expect(screen.getByText('Score final : 10')).toBeTruthy()
+ expect(screen.getByText('Position leaderboard : #3')).toBeTruthy()
+ })
+})
diff --git a/frontend/apps/web/src/routes/Results.tsx b/frontend/apps/web/src/routes/Results.tsx
index 22fd575..522b833 100644
--- a/frontend/apps/web/src/routes/Results.tsx
+++ b/frontend/apps/web/src/routes/Results.tsx
@@ -1,74 +1,24 @@
import { useNavigate } from '@solidjs/router'
-import { Show, createMemo } from 'solid-js'
+import { createMemo } from 'solid-js'
+import { ResultsCard } from '@knowfoolery/ui-components'
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 Typography from '@suid/material/Typography'
import { loadLastResult } from '../services/session'
-function formatDuration(durationSec: number): string {
- const minutes = Math.floor(durationSec / 60)
- const seconds = durationSec % 60
- return `${minutes}m ${seconds.toString().padStart(2, '0')}s`
-}
-
export default function ResultsRoute() {
const navigate = useNavigate()
const result = createMemo(() => loadLastResult())
return (
-
-
-
-
- Résultats
-
- Aucune partie terminée pour le moment.
- navigate('/game')}>
- Démarrer une partie
-
-
- }
- >
- {(last) => (
-
-
- Résultats
-
- Joueur : {last().playerName}
-
- Score final : {last().finalScore}
-
- Questions répondues / correctes : {last().answered} / {last().correct}
-
- Taux de réussite : {last().successRate}%
- Durée de session : {formatDuration(last().durationSec)}
-
- Position leaderboard :{' '}
- {last().leaderboardPosition != null ? `#${last().leaderboardPosition}` : 'Hors top 10'}
-
-
-
- navigate('/game')}>
- Rejouer
-
- navigate('/leaderboard')}>
- Voir le leaderboard
-
-
-
- )}
-
-
-
+ navigate('/game')}
+ onViewLeaderboard={() => navigate('/leaderboard')}
+ onStartGame={() => navigate('/game')}
+ title="Résultats"
+ />
)
}
diff --git a/frontend/apps/web/src/ui/AttemptIndicator.tsx b/frontend/apps/web/src/ui/AttemptIndicator.tsx
deleted file mode 100644
index 1ebe8eb..0000000
--- a/frontend/apps/web/src/ui/AttemptIndicator.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-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
deleted file mode 100644
index 5280ed3..0000000
--- a/frontend/apps/web/src/ui/ScoreDisplay.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-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
deleted file mode 100644
index 2237c23..0000000
--- a/frontend/apps/web/src/ui/Timer.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Show, type Component } from 'solid-js'
-
-import Chip from '@suid/material/Chip'
-import Stack from '@suid/material/Stack'
-import Typography from '@suid/material/Typography'
-
-export 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')}`
-}
-
-export function getTimerWarning(remainingMs: number): string | null {
- if (remainingMs <= 10_000) return '10 secondes restantes'
- if (remainingMs <= 60_000) return '1 minute restante'
- if (remainingMs <= 300_000) return '5 minutes restantes'
- return null
-}
-
-const Timer: Component<{ remainingMs: number }> = (props) => {
- const color = () => {
- if (props.remainingMs <= 10_000) return 'error'
- if (props.remainingMs <= 60_000) return 'warning'
- if (props.remainingMs <= 300_000) return 'info'
- return 'default'
- }
-
- const warning = () => getTimerWarning(props.remainingMs)
-
- return (
-
-
-
-
- {warning()}
-
-
-
- )
-}
-
-export default Timer
diff --git a/frontend/apps/web/vitest.config.ts b/frontend/apps/web/vitest.config.ts
index 68e3449..468fa38 100644
--- a/frontend/apps/web/vitest.config.ts
+++ b/frontend/apps/web/vitest.config.ts
@@ -1,6 +1,8 @@
import { defineConfig } from 'vitest/config'
+import solid from 'vite-plugin-solid'
export default defineConfig({
+ plugins: [solid()],
test: {
environment: 'jsdom',
},
diff --git a/frontend/shared/ui-components/node_modules/.vite/vitest/results.json b/frontend/shared/ui-components/node_modules/.vite/vitest/results.json
new file mode 100644
index 0000000..929932b
--- /dev/null
+++ b/frontend/shared/ui-components/node_modules/.vite/vitest/results.json
@@ -0,0 +1 @@
+{"version":"1.6.1","results":[[":src/utils/timer.test.ts",{"duration":7,"failed":false}],[":src/components/AttemptIndicator.test.tsx",{"duration":24,"failed":false}],[":src/components/ResultsCard.test.tsx",{"duration":96,"failed":false}],[":src/components/LeaderboardTable.test.tsx",{"duration":38,"failed":false}],[":src/components/AnswerInput.test.tsx",{"duration":58,"failed":false}],[":src/components/HintButton.test.tsx",{"duration":115,"failed":false}]]}
\ No newline at end of file
diff --git a/frontend/shared/ui-components/package.json b/frontend/shared/ui-components/package.json
index ef70ece..00ebf68 100644
--- a/frontend/shared/ui-components/package.json
+++ b/frontend/shared/ui-components/package.json
@@ -5,7 +5,7 @@
"type": "module",
"main": "src/index.ts",
"scripts": {
- "test": "vitest",
+ "test": "vitest --config vitest.config.ts",
"build": "echo 'No build needed for shared package'",
"clean": "echo 'Nothing to clean'"
},
@@ -14,6 +14,10 @@
"solid-js": "^1.9.0"
},
"devDependencies": {
+ "@solidjs/testing-library": "^0.8.0",
+ "jsdom": "^26.1.0",
+ "vite": "^5.0.0",
+ "vite-plugin-solid": "^2.8.0",
"vitest": "^1.0.0"
},
"peerDependencies": {
diff --git a/frontend/shared/ui-components/src/AttemptDots.tsx b/frontend/shared/ui-components/src/AttemptDots.tsx
index 42517bf..8f61013 100644
--- a/frontend/shared/ui-components/src/AttemptDots.tsx
+++ b/frontend/shared/ui-components/src/AttemptDots.tsx
@@ -1,32 +1,9 @@
import type { Component } from 'solid-js'
-import Box from '@suid/material/Box'
-import Stack from '@suid/material/Stack'
-import Typography from '@suid/material/Typography'
+import AttemptIndicator from './components/AttemptIndicator'
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)
-
-
- )
+ return
}
export default AttemptDots
diff --git a/frontend/shared/ui-components/src/ScoreChip.tsx b/frontend/shared/ui-components/src/ScoreChip.tsx
index 69f6f71..6e54d59 100644
--- a/frontend/shared/ui-components/src/ScoreChip.tsx
+++ b/frontend/shared/ui-components/src/ScoreChip.tsx
@@ -1,9 +1,9 @@
import type { Component } from 'solid-js'
-import Chip from '@suid/material/Chip'
+import ScoreDisplay from './components/ScoreDisplay'
const ScoreChip: Component<{ score: number }> = (props) => {
- return
+ return
}
export default ScoreChip
diff --git a/frontend/shared/ui-components/src/TimerChip.tsx b/frontend/shared/ui-components/src/TimerChip.tsx
index c1be83a..3142fae 100644
--- a/frontend/shared/ui-components/src/TimerChip.tsx
+++ b/frontend/shared/ui-components/src/TimerChip.tsx
@@ -1,22 +1,9 @@
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')}`
-}
+import Timer from './components/Timer'
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
+ return
}
export default TimerChip
diff --git a/frontend/shared/ui-components/src/components/AnswerInput.test.tsx b/frontend/shared/ui-components/src/components/AnswerInput.test.tsx
new file mode 100644
index 0000000..78ab339
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/AnswerInput.test.tsx
@@ -0,0 +1,18 @@
+import { fireEvent, render, screen } from '@solidjs/testing-library'
+import { describe, expect, it, vi } from 'vitest'
+
+import AnswerInput from './AnswerInput'
+
+describe('AnswerInput', () => {
+ it('submits on Enter key', async () => {
+ const onSubmit = vi.fn()
+ const onInputValue = vi.fn()
+
+ render(() => )
+
+ const input = screen.getByLabelText('Ta réponse')
+ await fireEvent.keyDown(input, { key: 'Enter' })
+
+ expect(onSubmit).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/frontend/shared/ui-components/src/components/AnswerInput.tsx b/frontend/shared/ui-components/src/components/AnswerInput.tsx
new file mode 100644
index 0000000..05041ca
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/AnswerInput.tsx
@@ -0,0 +1,34 @@
+import type { Component } from 'solid-js'
+
+import TextField from '@suid/material/TextField'
+
+export type AnswerInputProps = {
+ value: string
+ disabled?: boolean
+ label?: string
+ placeholder?: string
+ onInputValue: (nextValue: string) => void
+ onSubmit?: () => void
+}
+
+const AnswerInput: Component = (props) => {
+ return (
+ props.onInputValue((event.target as HTMLInputElement).value)}
+ fullWidth
+ disabled={props.disabled}
+ InputLabelProps={{ style: { color: '#cbd5e1' } }}
+ InputProps={{ style: { color: '#e5e7eb' } }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ props.onSubmit?.()
+ }
+ }}
+ />
+ )
+}
+
+export default AnswerInput
diff --git a/frontend/shared/ui-components/src/components/AttemptIndicator.test.tsx b/frontend/shared/ui-components/src/components/AttemptIndicator.test.tsx
new file mode 100644
index 0000000..e87bbc3
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/AttemptIndicator.test.tsx
@@ -0,0 +1,12 @@
+import { render, screen } from '@solidjs/testing-library'
+import { describe, expect, it } from 'vitest'
+
+import AttemptIndicator from './AttemptIndicator'
+
+describe('AttemptIndicator', () => {
+ it('clamps attempts and shows remaining count', () => {
+ render(() => )
+
+ expect(screen.getByText('0 restant(s)')).toBeTruthy()
+ })
+})
diff --git a/frontend/shared/ui-components/src/components/AttemptIndicator.tsx b/frontend/shared/ui-components/src/components/AttemptIndicator.tsx
new file mode 100644
index 0000000..db1f727
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/AttemptIndicator.tsx
@@ -0,0 +1,42 @@
+import { For, type Component } from 'solid-js'
+
+import Box from '@suid/material/Box'
+import Stack from '@suid/material/Stack'
+import Typography from '@suid/material/Typography'
+
+export type AttemptIndicatorProps = {
+ attemptsUsed: number
+ attemptsMax?: number
+ label?: string
+ remainingFormatter?: (remaining: number) => string
+}
+
+const AttemptIndicator: Component = (props) => {
+ const attemptsMax = () => Math.max(1, props.attemptsMax ?? 3)
+ const used = () => Math.max(0, Math.min(props.attemptsUsed, attemptsMax()))
+ const remaining = () => Math.max(0, attemptsMax() - used())
+ const formatRemaining = () => props.remainingFormatter?.(remaining()) ?? `${remaining()} restant(s)`
+
+ return (
+
+ {props.label ?? 'Essais'}
+
+
+ {(_, i) => (
+
+ )}
+
+
+ {formatRemaining()}
+
+ )
+}
+
+export default AttemptIndicator
diff --git a/frontend/shared/ui-components/src/components/GameCard.tsx b/frontend/shared/ui-components/src/components/GameCard.tsx
new file mode 100644
index 0000000..220652e
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/GameCard.tsx
@@ -0,0 +1,62 @@
+import type { Component, JSXElement } from 'solid-js'
+
+import Box from '@suid/material/Box'
+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 Typography from '@suid/material/Typography'
+
+import ThemeBadge from './ThemeBadge'
+
+export type GameCardProps = {
+ title?: string
+ theme: string
+ themeLabelPrefix?: string
+ questionText: string
+ timerSlot: JSXElement
+ scoreSlot: JSXElement
+ answerSlot: JSXElement
+ attemptSlot: JSXElement
+ actionsSlot: JSXElement
+ feedbackSlot?: JSXElement
+}
+
+const GameCard: Component = (props) => {
+ return (
+
+
+
+
+ {props.title ?? 'Partie'}
+
+
+
+
+
+ {props.timerSlot}
+ {props.scoreSlot}
+
+
+
+
+
+
+ {props.questionText}
+
+
+ {props.answerSlot}
+ {props.attemptSlot}
+ {props.actionsSlot}
+
+
+
+ {props.feedbackSlot}
+
+
+
+
+ )
+}
+
+export default GameCard
diff --git a/frontend/shared/ui-components/src/components/HintButton.test.tsx b/frontend/shared/ui-components/src/components/HintButton.test.tsx
new file mode 100644
index 0000000..a834b22
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/HintButton.test.tsx
@@ -0,0 +1,22 @@
+import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import HintButton from './HintButton'
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('HintButton', () => {
+ it('opens a confirmation dialog then confirms', async () => {
+ const onConfirm = vi.fn()
+
+ render(() => )
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Indice (score réduit)' }))
+ expect(screen.getByText("Confirmer l'indice")).toBeTruthy()
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Oui, utiliser un indice', hidden: true }))
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/frontend/shared/ui-components/src/components/HintButton.tsx b/frontend/shared/ui-components/src/components/HintButton.tsx
new file mode 100644
index 0000000..ffb22cf
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/HintButton.tsx
@@ -0,0 +1,64 @@
+import { createSignal, type Component } from 'solid-js'
+
+import Button from '@suid/material/Button'
+import Dialog from '@suid/material/Dialog'
+import DialogActions from '@suid/material/DialogActions'
+import DialogContent from '@suid/material/DialogContent'
+import DialogTitle from '@suid/material/DialogTitle'
+import Typography from '@suid/material/Typography'
+
+export type HintButtonProps = {
+ disabled?: boolean
+ requiresConfirmation?: boolean
+ buttonLabel?: string
+ confirmTitle?: string
+ confirmMessage?: string
+ cancelLabel?: string
+ confirmLabel?: string
+ onConfirm: () => void
+}
+
+const HintButton: Component = (props) => {
+ const [dialogOpen, setDialogOpen] = createSignal(false)
+
+ const handleClick = () => {
+ if (props.disabled) return
+
+ if (props.requiresConfirmation === false) {
+ props.onConfirm()
+ return
+ }
+
+ setDialogOpen(true)
+ }
+
+ const confirm = () => {
+ setDialogOpen(false)
+ props.onConfirm()
+ }
+
+ return (
+ <>
+
+ {props.buttonLabel ?? 'Indice (score réduit)'}
+
+
+
+ >
+ )
+}
+
+export default HintButton
diff --git a/frontend/shared/ui-components/src/components/LeaderboardTable.test.tsx b/frontend/shared/ui-components/src/components/LeaderboardTable.test.tsx
new file mode 100644
index 0000000..d50e606
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/LeaderboardTable.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from '@solidjs/testing-library'
+import { describe, expect, it } from 'vitest'
+
+import LeaderboardTable from './LeaderboardTable'
+
+describe('LeaderboardTable', () => {
+ it('renders rows', () => {
+ render(() => (
+
+ ))
+
+ expect(screen.getByText('Alice')).toBeTruthy()
+ expect(screen.getByText('Bob')).toBeTruthy()
+ })
+
+ it('renders empty state', () => {
+ render(() => )
+
+ expect(screen.getByText('No data')).toBeTruthy()
+ })
+})
diff --git a/frontend/shared/ui-components/src/components/LeaderboardTable.tsx b/frontend/shared/ui-components/src/components/LeaderboardTable.tsx
new file mode 100644
index 0000000..e3adc16
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/LeaderboardTable.tsx
@@ -0,0 +1,63 @@
+import { For, Show, type Component } from 'solid-js'
+
+import CircularProgress from '@suid/material/CircularProgress'
+import Table from '@suid/material/Table'
+import TableBody from '@suid/material/TableBody'
+import TableCell from '@suid/material/TableCell'
+import TableHead from '@suid/material/TableHead'
+import TableRow from '@suid/material/TableRow'
+import Typography from '@suid/material/Typography'
+
+import type { LeaderboardRow } from '../types'
+
+function formatDuration(durationSec: number): string {
+ const minutes = Math.floor(durationSec / 60)
+ return `${minutes}m`
+}
+
+export type LeaderboardTableProps = {
+ rows: LeaderboardRow[]
+ loading?: boolean
+ emptyMessage?: string
+ maxRows?: number
+ ariaLabel?: string
+}
+
+const LeaderboardTable: Component = (props) => {
+ const maxRows = () => Math.max(1, props.maxRows ?? 10)
+
+ return (
+ }>
+ 0} fallback={{props.emptyMessage ?? 'Aucun score pour le moment.'}}>
+
+
+
+ Rang
+ Joueur
+ Score
+ Questions
+ Taux de réussite
+ Durée
+
+
+
+
+ {(row, idx) => (
+
+ #{idx() + 1}
+ {row.player}
+ {row.score}
+ {row.questions}
+ {row.successRate}%
+ {formatDuration(row.durationSec)}
+
+ )}
+
+
+
+
+
+ )
+}
+
+export default LeaderboardTable
diff --git a/frontend/shared/ui-components/src/components/ResultsCard.test.tsx b/frontend/shared/ui-components/src/components/ResultsCard.test.tsx
new file mode 100644
index 0000000..fe46202
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/ResultsCard.test.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from '@solidjs/testing-library'
+import { describe, expect, it, vi } from 'vitest'
+
+import ResultsCard from './ResultsCard'
+
+describe('ResultsCard', () => {
+ it('renders completed result values', () => {
+ render(() => (
+
+ ))
+
+ expect(screen.getByText('Joueur : Alice')).toBeTruthy()
+ expect(screen.getByText('Score final : 12')).toBeTruthy()
+ expect(screen.getByText('Position leaderboard : #2')).toBeTruthy()
+ })
+
+ it('renders fallback when result is null', () => {
+ render(() => (
+
+ ))
+
+ expect(screen.getByText('Aucune partie terminée pour le moment.')).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Démarrer une partie' })).toBeTruthy()
+ })
+})
diff --git a/frontend/shared/ui-components/src/components/ResultsCard.tsx b/frontend/shared/ui-components/src/components/ResultsCard.tsx
new file mode 100644
index 0000000..5cc73c7
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/ResultsCard.tsx
@@ -0,0 +1,76 @@
+import { Show, type Component } from 'solid-js'
+
+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 Typography from '@suid/material/Typography'
+
+import type { GameResult } from '../types'
+
+function formatDuration(durationSec: number): string {
+ const minutes = Math.floor(durationSec / 60)
+ const seconds = durationSec % 60
+ return `${minutes}m ${seconds.toString().padStart(2, '0')}s`
+}
+
+export type ResultsCardProps = {
+ result: GameResult | null
+ onPlayAgain: () => void
+ onViewLeaderboard: () => void
+ onStartGame: () => void
+ title?: string
+}
+
+const ResultsCard: Component = (props) => {
+ return (
+
+
+
+
+ {props.title ?? 'Résultats'}
+
+ Aucune partie terminée pour le moment.
+
+ Démarrer une partie
+
+
+ }
+ >
+ {(last) => (
+
+
+ {props.title ?? 'Résultats'}
+
+ Joueur : {last().playerName}
+
+ Score final : {last().finalScore}
+
+ Questions répondues / correctes : {last().answered} / {last().correct}
+
+ Taux de réussite : {last().successRate}%
+ Durée de session : {formatDuration(last().durationSec)}
+
+ Position leaderboard : {last().leaderboardPosition != null ? `#${last().leaderboardPosition}` : 'Hors top 10'}
+
+
+
+
+ Rejouer
+
+
+ Voir le leaderboard
+
+
+
+ )}
+
+
+
+ )
+}
+
+export default ResultsCard
diff --git a/frontend/shared/ui-components/src/components/ScoreDisplay.tsx b/frontend/shared/ui-components/src/components/ScoreDisplay.tsx
new file mode 100644
index 0000000..337e41c
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/ScoreDisplay.tsx
@@ -0,0 +1,9 @@
+import type { Component } from 'solid-js'
+
+import Chip from '@suid/material/Chip'
+
+const ScoreDisplay: Component<{ score: number; labelPrefix?: string }> = (props) => {
+ return
+}
+
+export default ScoreDisplay
diff --git a/frontend/shared/ui-components/src/components/ThemeBadge.tsx b/frontend/shared/ui-components/src/components/ThemeBadge.tsx
new file mode 100644
index 0000000..a662b00
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/ThemeBadge.tsx
@@ -0,0 +1,11 @@
+import type { Component } from 'solid-js'
+
+import Chip from '@suid/material/Chip'
+
+const ThemeBadge: Component<{ theme: string; labelPrefix?: string }> = (props) => {
+ const label = () => (props.labelPrefix ? `${props.labelPrefix}${props.theme}` : props.theme)
+
+ return
+}
+
+export default ThemeBadge
diff --git a/frontend/shared/ui-components/src/components/Timer.tsx b/frontend/shared/ui-components/src/components/Timer.tsx
new file mode 100644
index 0000000..77fe806
--- /dev/null
+++ b/frontend/shared/ui-components/src/components/Timer.tsx
@@ -0,0 +1,37 @@
+import { Show, type Component } from 'solid-js'
+
+import Chip from '@suid/material/Chip'
+import Stack from '@suid/material/Stack'
+import Typography from '@suid/material/Typography'
+
+import {
+ DEFAULT_TIMER_WARNING_TEXT,
+ formatMs,
+ getTimerColor,
+ getTimerWarning,
+ type TimerWarningText,
+} from '../utils/timer'
+
+export type TimerProps = {
+ remainingMs: number
+ showWarning?: boolean
+ warningText?: Partial
+}
+
+const Timer: Component = (props) => {
+ const mergedWarningText = () => ({ ...DEFAULT_TIMER_WARNING_TEXT, ...(props.warningText ?? {}) })
+ const warning = () => getTimerWarning(props.remainingMs, mergedWarningText())
+
+ return (
+
+
+
+
+ {warning()}
+
+
+
+ )
+}
+
+export default Timer
diff --git a/frontend/shared/ui-components/src/index.ts b/frontend/shared/ui-components/src/index.ts
index 43c5a79..68fbde7 100644
--- a/frontend/shared/ui-components/src/index.ts
+++ b/frontend/shared/ui-components/src/index.ts
@@ -1,4 +1,24 @@
+export type { GameResult, LeaderboardRow } from './types'
+
+export { default as AnswerInput } from './components/AnswerInput'
+export { default as AttemptIndicator } from './components/AttemptIndicator'
+export { default as GameCard } from './components/GameCard'
+export { default as HintButton } from './components/HintButton'
+export { default as LeaderboardTable } from './components/LeaderboardTable'
+export { default as ResultsCard } from './components/ResultsCard'
+export { default as ScoreDisplay } from './components/ScoreDisplay'
+export { default as ThemeBadge } from './components/ThemeBadge'
+export { default as Timer } from './components/Timer'
+
+export {
+ DEFAULT_TIMER_WARNING_TEXT,
+ formatMs,
+ getTimerColor,
+ getTimerWarning,
+ type TimerWarningText,
+} from './utils/timer'
+
+// Backward-compatible exports for old component names.
export { default as AttemptDots } from './AttemptDots'
export { default as ScoreChip } from './ScoreChip'
export { default as TimerChip } from './TimerChip'
-
diff --git a/frontend/shared/ui-components/src/types.ts b/frontend/shared/ui-components/src/types.ts
new file mode 100644
index 0000000..3cc3aba
--- /dev/null
+++ b/frontend/shared/ui-components/src/types.ts
@@ -0,0 +1,18 @@
+export type LeaderboardRow = {
+ player: string
+ score: number
+ questions: number
+ successRate: number
+ durationSec: number
+}
+
+export type GameResult = {
+ playerName: string
+ finalScore: number
+ answered: number
+ correct: number
+ successRate: number
+ durationSec: number
+ leaderboardPosition: number | null
+ finishedAt: string
+}
diff --git a/frontend/apps/web/src/ui/Timer.test.ts b/frontend/shared/ui-components/src/utils/timer.test.ts
similarity index 50%
rename from frontend/apps/web/src/ui/Timer.test.ts
rename to frontend/shared/ui-components/src/utils/timer.test.ts
index 12eb273..3985e9a 100644
--- a/frontend/apps/web/src/ui/Timer.test.ts
+++ b/frontend/shared/ui-components/src/utils/timer.test.ts
@@ -1,21 +1,19 @@
import { describe, expect, it } from 'vitest'
-import { formatMs, getTimerWarning } from './Timer'
+import { formatMs, getTimerWarning } from './timer'
-describe('Timer helpers', () => {
- it('formats remaining milliseconds as mm:ss', () => {
+describe('timer utils', () => {
+ it('formats milliseconds as mm:ss', () => {
expect(formatMs(65_000)).toBe('1:05')
})
- it('returns the 5-minute warning', () => {
+ it('returns warning messages at thresholds', () => {
expect(getTimerWarning(299_000)).toBe('5 minutes restantes')
- })
-
- it('returns the 1-minute warning', () => {
expect(getTimerWarning(59_000)).toBe('1 minute restante')
+ expect(getTimerWarning(9_000)).toBe('10 secondes restantes')
})
- it('returns the 10-second warning', () => {
- expect(getTimerWarning(9_000)).toBe('10 secondes restantes')
+ it('returns null when no warning should be shown', () => {
+ expect(getTimerWarning(301_000)).toBeNull()
})
})
diff --git a/frontend/shared/ui-components/src/utils/timer.ts b/frontend/shared/ui-components/src/utils/timer.ts
new file mode 100644
index 0000000..a99ebf6
--- /dev/null
+++ b/frontend/shared/ui-components/src/utils/timer.ts
@@ -0,0 +1,35 @@
+export function formatMs(ms: number): string {
+ const total = Math.ceil(ms / 1000)
+ const minutes = Math.floor(total / 60)
+ const seconds = total % 60
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`
+}
+
+export type TimerWarningText = {
+ fiveMinutes: string
+ oneMinute: string
+ tenSeconds: string
+}
+
+export const DEFAULT_TIMER_WARNING_TEXT: TimerWarningText = {
+ fiveMinutes: '5 minutes restantes',
+ oneMinute: '1 minute restante',
+ tenSeconds: '10 secondes restantes',
+}
+
+export function getTimerWarning(
+ remainingMs: number,
+ warningText: TimerWarningText = DEFAULT_TIMER_WARNING_TEXT
+): string | null {
+ if (remainingMs <= 10_000) return warningText.tenSeconds
+ if (remainingMs <= 60_000) return warningText.oneMinute
+ if (remainingMs <= 300_000) return warningText.fiveMinutes
+ return null
+}
+
+export function getTimerColor(remainingMs: number): 'default' | 'info' | 'warning' | 'error' {
+ if (remainingMs <= 10_000) return 'error'
+ if (remainingMs <= 60_000) return 'warning'
+ if (remainingMs <= 300_000) return 'info'
+ return 'default'
+}
diff --git a/frontend/shared/ui-components/vitest.config.ts b/frontend/shared/ui-components/vitest.config.ts
new file mode 100644
index 0000000..468fa38
--- /dev/null
+++ b/frontend/shared/ui-components/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config'
+import solid from 'vite-plugin-solid'
+
+export default defineConfig({
+ plugins: [solid()],
+ test: {
+ environment: 'jsdom',
+ },
+})
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 9dccb72..86376e8 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -631,12 +631,16 @@ __metadata:
languageName: node
linkType: hard
-"@knowfoolery/ui-components@workspace:shared/ui-components":
+"@knowfoolery/ui-components@workspace:*, @knowfoolery/ui-components@workspace:shared/ui-components":
version: 0.0.0-use.local
resolution: "@knowfoolery/ui-components@workspace:shared/ui-components"
dependencies:
+ "@solidjs/testing-library": "npm:^0.8.0"
"@suid/material": "npm:^0.16.0"
+ jsdom: "npm:^26.1.0"
solid-js: "npm:^1.9.0"
+ vite: "npm:^5.0.0"
+ vite-plugin-solid: "npm:^2.8.0"
vitest: "npm:^1.0.0"
peerDependencies:
solid-js: ^1.9.0
@@ -647,6 +651,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@knowfoolery/web@workspace:apps/web"
dependencies:
+ "@knowfoolery/ui-components": "workspace:*"
"@solidjs/router": "npm:^0.10.0"
"@solidjs/testing-library": "npm:^0.8.0"
"@suid/icons-material": "npm:^0.7.0"