feat(simulations): composants TimerDisplay + WordCountBar

This commit is contained in:
Hermann_Kitio 2026-04-21 00:18:33 +03:00
parent 24968f542d
commit 41d2eec3f7
2 changed files with 112 additions and 0 deletions

View file

@ -0,0 +1,45 @@
/**
* Affichage du minuteur de simulation au format MM:SS.
*
* Règle H : purement présentationnel, reçoit secondesRestantes et isExpired par props.
* Règle L : tokens Direction H uniquement (ink-*, danger).
*
* États visuels :
* - Normal : ink-2
* - Critique (< 120s) : danger + pulse (motion-safe uniquement prefers-reduced-motion respecté)
* - Expiré : danger + bold
*
* A11y : role="timer" + aria-live="polite" pour annoncer les changements critiques.
*/
import { formatTimer } from '../lib/simulationConfig'
interface Props {
secondesRestantes: number
isExpired: boolean
}
const SEUIL_CRITIQUE_SECONDES = 120
export function TimerDisplay({ secondesRestantes, isExpired }: Props) {
const isCritique = !isExpired && secondesRestantes < SEUIL_CRITIQUE_SECONDES
const base = 'tabular-nums text-sm font-medium'
const tone = isExpired
? 'text-danger font-bold'
: isCritique
? 'text-danger motion-safe:animate-pulse'
: 'text-ink-2'
return (
<span
role="timer"
aria-live="polite"
aria-atomic="true"
aria-label={isExpired ? 'Temps écoulé' : `Temps restant ${formatTimer(secondesRestantes)}`}
className={`${base} ${tone}`}
>
{formatTimer(secondesRestantes)}
</span>
)
}

View file

@ -0,0 +1,67 @@
/**
* Barre de progression du nombre de mots avec compteur contextuel.
*
* Règle H : purement présentationnel, reçoit count et config par props.
* Règle L : tokens Direction H uniquement (success, warning, danger, ink-*, line).
*
* Couleurs selon le nombre de mots :
* - < motsCibleMin : warning (orange) en dessous de la cible
* - motsCibleMin count motsCibleMax : success (vert) dans la cible
* - > motsCibleMax : danger (rouge) au-dessus de la cible
*
* La barre est graduée par rapport à motsCibleMax (100% = borne haute cible).
* Au-dessus on reste à 100% de remplissage en rouge.
*/
import type { SimulationConfig } from '../lib/simulationConfig'
interface Props {
count: number
config: SimulationConfig
}
type Tone = 'warning' | 'success' | 'danger'
function computeTone(count: number, config: SimulationConfig): Tone {
if (count > config.motsCibleMax) return 'danger'
if (count >= config.motsCibleMin) return 'success'
return 'warning'
}
const TONE_CLASSES: Record<Tone, { bar: string; text: string }> = {
warning: { bar: 'bg-warning', text: 'text-warning' },
success: { bar: 'bg-success', text: 'text-success' },
danger: { bar: 'bg-danger', text: 'text-danger' },
}
export function WordCountBar({ count, config }: Props) {
const tone = computeTone(count, config)
const classes = TONE_CLASSES[tone]
const pct = Math.min(100, Math.round((count / config.motsCibleMax) * 100))
return (
<div className="space-y-1.5">
<div
role="progressbar"
aria-valuenow={count}
aria-valuemin={0}
aria-valuemax={config.motsCibleMax}
aria-label={`Progression du nombre de mots : ${count} sur une cible de ${config.motsCibleMin} à ${config.motsCibleMax} mots`}
className="h-1.5 w-full overflow-hidden rounded-full bg-canvas-2"
>
<div
className={`h-full rounded-full transition-[width] duration-150 ease-out ${classes.bar}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs">
<span className={`font-medium tabular-nums ${classes.text}`}>
{count.toLocaleString('fr-FR')} mot{count > 1 ? 's' : ''}
</span>
<span className="text-ink-4 tabular-nums">
cible {config.motsCibleMin}{config.motsCibleMax} mots
</span>
</div>
</div>
)
}