3 Commits

Author SHA1 Message Date
Tom Boullay 970adf4853 feat(a11y): WCAG AA polish on the site onboarding flow
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
- index.css: add visible :focus-visible rings for .site-card-button
  and .site-button so keyboard users can see where focus lives
- SiteCard: drop outline:none, add aria-pressed and aria-label so
  screen readers announce selection state
- SiteButton: add the .site-button class for the shared focus ring
- SiteDisclaimerScreen: keyboard skip via Enter / Space / Escape, a
  role="region" + aria-label wrapper and aria-live="polite" on the
  message; honour prefers-reduced-motion on the fade
- IntroVideoPlayer: role="region" with a skip hint in aria-label,
  preload="auto", and aria-hidden on the decorative caption span
2026-05-30 18:44:03 +02:00
Tom Boullay 07b09c22af fix(site): repair onboarding audio cleanup, redirect, and manifest fetches
- loadDialogueManifest: cache the resolved manifest at module level and
  dedupe concurrent fetches so each screen no longer re-downloads it
- useGameStore: completeIntroState now also advances intro.currentStep
  to "playing" so callers do not need a separate setIntroStep call
- SiteNamingScreen and SiteTransitionOverlay: replace ref-based guards
  with an isCancelled flag captured per effect. The previous guards
  persisted across StrictMode remounts, leaving mount 2 unable to
  re-run the effect after mount 1's chain was cancelled, which broke
  the fade animations, the second narrator dialogue and the redirect.
  Both screens now also call stopCurrentDialogue on unmount so audio
  cannot bleed across routes, and the transition gets a safety timeout
  in case the dialogue audio fails to fire its "ended" event
- SiteTransitionOverlay: keep the <Subtitles /> mount inside the
  overlay so it renders inside the z-index 1000 stacking context
  (above the black screen); the one in SiteLayout sits behind it
- IntroDialogueOverlay: route through playDialogueById instead of
  AudioManager.playSoundWithCallback so the narrator subtitles play
  in sync, and add the same isCancelled cleanup pattern
- IntroRevealOverlay: rely on completeIntro alone now that it advances
  intro.currentStep, and skip the fade when reduced motion is requested
- SiteMobileBlocker: correct logo path from public/... to /...
2026-05-30 18:43:53 +02:00
Tom Boullay 0f6860f1ae refactor(site): extract shared utilities and centralise dialogue IDs
- new src/hooks/ui/useIsMobile.ts (matchMedia + useSyncExternalStore)
  replacing the resize-handler hook inlined inside pages/site/page.tsx
- new src/hooks/ui/usePrefersReducedMotion.ts
- new src/data/site/dialogueIds.ts so site and intro components stop
  carrying hard-coded narrator IDs
- siteConfig: add SITE_BACKGROUND_STYLE shared by SiteLayout and
  SiteMobileBlocker, rename forcedName to presetPlayerName, fix the
  swapped id/label pairing on situation cards
- useSiteStore: rename selectedExperience/Situation to *Index so the
  stored value (an array index) is obvious in callers
- audioConfig: drop dead AUDIO_PATHS placeholders
- propagate the renames and SITE_BACKGROUND_STYLE through SiteLayout,
  SiteWelcomeScreen, SiteSituationScreen and pages/site/page.tsx
2026-05-30 18:43:35 +02:00
22 changed files with 374 additions and 208 deletions
+1
View File
@@ -21,6 +21,7 @@ export function SiteButton({
onMouseDown={() => setIsPressed(true)} onMouseDown={() => setIsPressed(true)}
onMouseUp={() => setIsPressed(false)} onMouseUp={() => setIsPressed(false)}
onMouseLeave={() => setIsPressed(false)} onMouseLeave={() => setIsPressed(false)}
className="site-button"
style={{ style={{
display: "inline-flex", display: "inline-flex",
padding: "12px 20px", padding: "12px 20px",
+7 -13
View File
@@ -22,23 +22,18 @@ export function SiteCard({
return "#b8b8b8"; return "#b8b8b8";
}; };
const getBorder = (): string => { const borderColor = selected ? "#a8d5a2" : "rgba(255, 255, 255, 0.55)";
if (selected) return "3px solid #a8d5a2";
if (isSituation) return "3px solid rgba(255, 255, 255, 0.55)";
if (disabled) return "3px solid rgba(255, 255, 255, 0.55)";
return "3px solid rgba(255, 255, 255, 0.55)";
};
const getTextColor = (): string => { const textColor = disabled ? "rgba(77, 77, 77, 0.72)" : "#4d4d4d";
if (disabled) return "rgba(77, 77, 77, 0.72)";
return "#4d4d4d";
};
return ( return (
<button <button
type="button" type="button"
onClick={onSelect} onClick={onSelect}
disabled={disabled} disabled={disabled}
aria-pressed={selected}
aria-label={label}
className="site-card-button"
style={{ style={{
width: isSituation width: isSituation
? "clamp(220px, 24vw, 300px)" ? "clamp(220px, 24vw, 300px)"
@@ -46,21 +41,20 @@ export function SiteCard({
height: isSituation height: isSituation
? "clamp(48px, 6vw, 60px)" ? "clamp(48px, 6vw, 60px)"
: "clamp(140px, 18vw, 180px)", : "clamp(140px, 18vw, 180px)",
border: getBorder(), border: `3px solid ${borderColor}`,
background: getBackground(), background: getBackground(),
cursor: disabled ? "not-allowed" : "pointer", cursor: disabled ? "not-allowed" : "pointer",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
transition: "all 0.15s ease", transition: "all 0.15s ease",
outline: "none",
flexShrink: 0, flexShrink: 0,
}} }}
> >
{!imagePath && ( {!imagePath && (
<span <span
style={{ style={{
color: getTextColor(), color: textColor,
fontSize: isSituation fontSize: isSituation
? "clamp(14px, 1.8vw, 22px)" ? "clamp(14px, 1.8vw, 22px)"
: "clamp(10px, 1.5vw, 14px)", : "clamp(10px, 1.5vw, 14px)",
+21 -8
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useSiteStore } from "@/managers/stores/useSiteStore"; import { useSiteStore } from "@/managers/stores/useSiteStore";
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
const DISCLAIMER_TEXT = const DISCLAIMER_TEXT =
"Ce site a été conçu pour être utilisé sur ordinateur.\nPour une meilleure expérience, assurez-vous d'avoir une bonne connexion internet et une machine performante."; "Ce site a été conçu pour être utilisé sur ordinateur.\nPour une meilleure expérience, assurez-vous d'avoir une bonne connexion internet et une machine performante.";
@@ -7,13 +8,15 @@ const DISCLAIMER_TEXT =
const TEXT_DISPLAY_DURATION = 5000; const TEXT_DISPLAY_DURATION = 5000;
const FADE_OUT_DURATION = 1000; const FADE_OUT_DURATION = 1000;
const TRANSITION_DELAY = 250; const TRANSITION_DELAY = 250;
const SKIP_KEYS = new Set(["Enter", " ", "Escape"]);
/** /**
* Screen 0: Disclaimer * Screen 0: Disclaimer
*/ */
export function SiteDisclaimerScreen(): React.JSX.Element { export function SiteDisclaimerScreen(): React.JSX.Element {
const setStep = useSiteStore((state) => state.setStep); const setStep = useSiteStore((state) => state.setStep);
const [textOpacity, setTextOpacity] = useState(0); const prefersReducedMotion = usePrefersReducedMotion();
const [textOpacity, setTextOpacity] = useState(prefersReducedMotion ? 1 : 0);
const hasSkipped = useRef(false); const hasSkipped = useRef(false);
const handleSkip = useCallback(() => { const handleSkip = useCallback(() => {
@@ -23,33 +26,40 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
}, [setStep]); }, [setStep]);
useEffect(() => { useEffect(() => {
// Fade in text
const fadeInTimeout = window.setTimeout(() => { const fadeInTimeout = window.setTimeout(() => {
setTextOpacity(1); setTextOpacity(1);
}, 100); }, 100);
// Start fade out after display duration
const fadeOutTimeout = window.setTimeout(() => { const fadeOutTimeout = window.setTimeout(() => {
setTextOpacity(0); setTextOpacity(0);
}, TEXT_DISPLAY_DURATION); }, TEXT_DISPLAY_DURATION);
// Transition to welcome after fade out + delay
const transitionTimeout = window.setTimeout( const transitionTimeout = window.setTimeout(
() => { handleSkip,
handleSkip();
},
TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY, TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY,
); );
const handleKeyDown = (event: KeyboardEvent): void => {
if (SKIP_KEYS.has(event.key)) {
event.preventDefault();
handleSkip();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => { return () => {
window.clearTimeout(fadeInTimeout); window.clearTimeout(fadeInTimeout);
window.clearTimeout(fadeOutTimeout); window.clearTimeout(fadeOutTimeout);
window.clearTimeout(transitionTimeout); window.clearTimeout(transitionTimeout);
window.removeEventListener("keydown", handleKeyDown);
}; };
}, [handleSkip]); }, [handleSkip]);
return ( return (
<div <div
role="region"
aria-label="Avertissement"
onClick={handleSkip} onClick={handleSkip}
style={{ style={{
position: "fixed", position: "fixed",
@@ -63,6 +73,7 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
}} }}
> >
<p <p
aria-live="polite"
style={{ style={{
color: "#F2F2F2", color: "#F2F2F2",
textAlign: "center", textAlign: "center",
@@ -72,7 +83,9 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
lineHeight: 1.6, lineHeight: 1.6,
maxWidth: 800, maxWidth: 800,
opacity: textOpacity, opacity: textOpacity,
transition: `opacity ${FADE_OUT_DURATION}ms ease-in-out`, transition: prefersReducedMotion
? "none"
: `opacity ${FADE_OUT_DURATION}ms ease-in-out`,
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
}} }}
> >
+2 -6
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { SITE_CONFIG } from "@/data/site/siteConfig"; import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig";
import { Subtitles } from "@/components/ui/Subtitles"; import { Subtitles } from "@/components/ui/Subtitles";
interface SiteLayoutProps { interface SiteLayoutProps {
@@ -16,11 +16,7 @@ export function SiteLayout({ children }: SiteLayoutProps): React.JSX.Element {
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
backgroundColor: "#87CEEB", ...SITE_BACKGROUND_STYLE,
backgroundImage: `url(${SITE_CONFIG.backgroundImage})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
fontFamily: "system-ui, -apple-system, sans-serif", fontFamily: "system-ui, -apple-system, sans-serif",
color: "#fff", color: "#fff",
overflow: "hidden", overflow: "hidden",
+6 -15
View File
@@ -1,37 +1,28 @@
import { SITE_CONFIG } from "@/data/site/siteConfig"; import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig";
const MOBILE_TEXT = const MOBILE_TEXT =
"Ce site a été conçu pour être utilisé sur ordinateur. Veuillez réessayer sur votre ordinateur pour une expérience optimale."; "Ce site a été conçu pour être utilisé sur ordinateur. Veuillez réessayer sur votre ordinateur pour une expérience optimale.";
/**
* Mobile blocker screen
*/
export function SiteMobileBlocker(): React.JSX.Element { export function SiteMobileBlocker(): React.JSX.Element {
return ( return (
<div <div
role="alert"
style={{ style={{
position: "fixed", position: "fixed",
inset: 0, inset: 0,
backgroundColor: "#87CEEB",
backgroundImage: `url(${SITE_CONFIG.backgroundImage})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
padding: 32, padding: 32,
gap: 48, gap: 48,
...SITE_BACKGROUND_STYLE,
}} }}
> >
<img <img
src="public/assets/logo/logo.jpg" src="/assets/logo/logo.jpg"
alt="Logo" alt="Logo Altera"
style={{ style={{ width: 120, height: "auto" }}
width: 120,
height: "auto",
}}
/> />
<p <p
style={{ style={{
+45 -15
View File
@@ -3,52 +3,61 @@ import { useGameStore } from "@/managers/stores/useGameStore";
import { useSiteStore } from "@/managers/stores/useSiteStore"; import { useSiteStore } from "@/managers/stores/useSiteStore";
import { SiteButton } from "@/components/site/SiteButton"; import { SiteButton } from "@/components/site/SiteButton";
import { SITE_CONFIG } from "@/data/site/siteConfig"; import { SITE_CONFIG } from "@/data/site/siteConfig";
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue"; import {
playDialogueById,
stopCurrentDialogue,
} from "@/utils/dialogues/playDialogue";
/** /**
* Screen 3: Name input * Screen 3: Name input
* The displayed name is forced to SITE_CONFIG.presetPlayerName — the
* field reveals one letter per keystroke until the preset name is complete.
*/ */
export function SiteNamingScreen(): React.JSX.Element { export function SiteNamingScreen(): React.JSX.Element {
const setStep = useSiteStore((state) => state.setStep); const setStep = useSiteStore((state) => state.setStep);
const setPlayerName = useGameStore((state) => state.setPlayerName); const setPlayerName = useGameStore((state) => state.setPlayerName);
const [charIndex, setCharIndex] = useState(0); const [charIndex, setCharIndex] = useState(0);
const dialogueStarted = useRef(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const forcedName = SITE_CONFIG.forcedName; const presetPlayerName = SITE_CONFIG.presetPlayerName;
const displayValue = forcedName.slice(0, charIndex); const displayValue = presetPlayerName.slice(0, charIndex);
const isComplete = charIndex >= forcedName.length; const isComplete = charIndex >= presetPlayerName.length;
// Play dialogue when screen appears (with subtitles)
useEffect(() => { useEffect(() => {
if (dialogueStarted.current) return; let cancelled = false;
dialogueStarted.current = true;
void (async () => { void (async () => {
const manifest = await loadDialogueManifest(); const manifest = await loadDialogueManifest();
if (manifest) { if (cancelled || !manifest) return;
await playDialogueById(manifest, "narrateur_intro_prenom"); await playDialogueById(manifest, SITE_DIALOGUE_IDS.naming);
}
})(); })();
return () => {
cancelled = true;
stopCurrentDialogue();
};
}, []); }, []);
// Focus input on mount
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
}, []); }, []);
const handleNameChange = useCallback( const handleNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => { (event: React.ChangeEvent<HTMLInputElement>): void => {
const nextLength = Math.min(event.target.value.length, forcedName.length); const nextLength = Math.min(
event.target.value.length,
presetPlayerName.length,
);
setCharIndex(nextLength); setCharIndex(nextLength);
}, },
[forcedName.length], [presetPlayerName.length],
); );
const handleConfirm = (): void => { const handleConfirm = (): void => {
if (isComplete) { if (isComplete) {
setPlayerName(forcedName); setPlayerName(presetPlayerName);
setStep("transition"); setStep("transition");
} }
}; };
@@ -75,6 +84,7 @@ export function SiteNamingScreen(): React.JSX.Element {
}} }}
> >
<h2 <h2
id="player-name-label"
style={{ style={{
color: "#F2F2F2", color: "#F2F2F2",
textAlign: "center", textAlign: "center",
@@ -97,6 +107,9 @@ export function SiteNamingScreen(): React.JSX.Element {
value={displayValue} value={displayValue}
onChange={handleNameChange} onChange={handleNameChange}
placeholder="Écrivez votre prénom ici" placeholder="Écrivez votre prénom ici"
aria-labelledby="player-name-label"
aria-describedby="player-name-hint"
autoComplete="off"
style={{ style={{
display: "flex", display: "flex",
padding: "clamp(8px, 1.5vw, 10px)", padding: "clamp(8px, 1.5vw, 10px)",
@@ -116,6 +129,23 @@ export function SiteNamingScreen(): React.JSX.Element {
boxSizing: "border-box", boxSizing: "border-box",
}} }}
/> />
<span
id="player-name-hint"
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
Votre personnage s&apos;appelle {presetPlayerName}. Tapez{" "}
{presetPlayerName.length} caractères pour révéler son nom.
</span>
</div> </div>
<SiteButton <SiteButton
+8 -6
View File
@@ -7,13 +7,15 @@ import { SITUATION_CARDS } from "@/data/site/siteConfig";
* Screen 2: Situation selection * Screen 2: Situation selection
*/ */
export function SiteSituationScreen(): React.JSX.Element { export function SiteSituationScreen(): React.JSX.Element {
const selectedSituation = useSiteStore((state) => state.selectedSituation); const selectedSituationIndex = useSiteStore(
const setSelectedSituation = useSiteStore( (state) => state.selectedSituationIndex,
(state) => state.setSelectedSituation, );
const setSelectedSituationIndex = useSiteStore(
(state) => state.setSelectedSituationIndex,
); );
const setStep = useSiteStore((state) => state.setStep); const setStep = useSiteStore((state) => state.setStep);
const canProceed = selectedSituation !== null; const canProceed = selectedSituationIndex !== null;
const handleConfirm = (): void => { const handleConfirm = (): void => {
if (canProceed) { if (canProceed) {
@@ -63,11 +65,11 @@ export function SiteSituationScreen(): React.JSX.Element {
<SiteCard <SiteCard
key={card.id} key={card.id}
config={card} config={card}
selected={selectedSituation === index} selected={selectedSituationIndex === index}
variant="situation" variant="situation"
onSelect={() => { onSelect={() => {
if (!card.disabled) { if (!card.disabled) {
setSelectedSituation(index); setSelectedSituationIndex(index);
} }
}} }}
/> />
+77 -41
View File
@@ -1,72 +1,102 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useSiteStore } from "@/managers/stores/useSiteStore"; import { useSiteStore } from "@/managers/stores/useSiteStore";
import { Subtitles } from "@/components/ui/Subtitles"; import { Subtitles } from "@/components/ui/Subtitles";
import { setSiteVisited } from "@/utils/cookies/siteVisitCookie"; import { setSiteVisited } from "@/utils/cookies/siteVisitCookie";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue"; import {
playDialogueById,
stopCurrentDialogue,
} from "@/utils/dialogues/playDialogue";
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
const FADE_DURATION_MS = 1000; const FADE_DURATION_MS = 1000;
const DIALOGUE_FALLBACK_TIMEOUT_MS = 12000;
const NO_DIALOGUE_FALLBACK_MS = 3000;
/** /**
* Transition overlay: black screen (fade in) + logo (fade in/out) + dialogue with subtitles + redirect to / * Transition overlay: black screen, logo fade-in, transition dialogue
* with subtitles, then redirect to /. A safety timeout guarantees the
* redirect happens even if the dialogue audio fails to fire `ended`.
*/ */
export function SiteTransitionOverlay(): React.JSX.Element { export function SiteTransitionOverlay(): React.JSX.Element {
const navigate = useNavigate(); const navigate = useNavigate();
const reset = useSiteStore((state) => state.reset); const reset = useSiteStore((state) => state.reset);
const prefersReducedMotion = usePrefersReducedMotion();
const [screenOpacity, setScreenOpacity] = useState(0); const [screenOpacity, setScreenOpacity] = useState(0);
const [logoOpacity, setLogoOpacity] = useState(0); const [logoOpacity, setLogoOpacity] = useState(0);
const transitionStarted = useRef(false);
useEffect(() => { useEffect(() => {
if (transitionStarted.current) return;
transitionStarted.current = true;
// Fade in black screen
setScreenOpacity(1);
// Set cookie
setSiteVisited(); setSiteVisited();
// Fade in logo after the black screen transition delay. let isCancelled = false;
setLogoOpacity(1); const timeoutIds: number[] = [];
// Play transition dialogue (with subtitles) then fade out logo and redirect // Defer the opacity flip one tick so the CSS transition has an
void (async () => { // initial frame at opacity 0 before flipping to 1.
const manifest = await loadDialogueManifest(); const fadeInId = window.setTimeout(() => {
if (manifest) { setScreenOpacity(1);
const dialogueAudio = await playDialogueById( setLogoOpacity(1);
manifest, }, 0);
"narrateur_intro_apresprenom", timeoutIds.push(fadeInId);
);
if (dialogueAudio) { const redirectToGame = (): void => {
dialogueAudio.addEventListener( if (isCancelled) return;
"ended",
() => {
// Fade out logo
setLogoOpacity(0); setLogoOpacity(0);
// Redirect after logo fade out const id = window.setTimeout(() => {
setTimeout(() => { if (isCancelled) return;
reset(); reset();
navigate({ to: "/" }); navigate({ to: "/" });
}, FADE_DURATION_MS); }, FADE_DURATION_MS);
timeoutIds.push(id);
};
void (async () => {
const manifest = await loadDialogueManifest();
if (isCancelled) return;
const dialogueAudio = manifest
? await playDialogueById(manifest, SITE_DIALOGUE_IDS.transition)
: null;
if (isCancelled) return;
if (dialogueAudio) {
const safetyId = window.setTimeout(
redirectToGame,
DIALOGUE_FALLBACK_TIMEOUT_MS,
);
timeoutIds.push(safetyId);
dialogueAudio.addEventListener(
"ended",
() => {
window.clearTimeout(safetyId);
redirectToGame();
}, },
{ once: true }, { once: true },
); );
return; return;
} }
}
// Fallback: redirect after 3s if dialogue fails const fallbackId = window.setTimeout(
setTimeout(() => { redirectToGame,
setLogoOpacity(0); NO_DIALOGUE_FALLBACK_MS,
setTimeout(() => { );
reset(); timeoutIds.push(fallbackId);
navigate({ to: "/" });
}, FADE_DURATION_MS);
}, 3000);
})(); })();
return () => {
isCancelled = true;
timeoutIds.forEach(window.clearTimeout);
stopCurrentDialogue();
};
}, [navigate, reset]); }, [navigate, reset]);
const fadeTransition = prefersReducedMotion
? "none"
: `opacity ${FADE_DURATION_MS}ms ease-in-out`;
return ( return (
<div <div
style={{ style={{
@@ -86,12 +116,12 @@ export function SiteTransitionOverlay(): React.JSX.Element {
background: "#000", background: "#000",
zIndex: 0, zIndex: 0,
opacity: screenOpacity, opacity: screenOpacity,
transition: `opacity ${FADE_DURATION_MS}ms ease-in-out`, transition: fadeTransition,
}} }}
/> />
<img <img
src="/assets/logo/logo.jpg" src="/assets/logo/logo.jpg"
alt="Logo" alt="Logo Altera"
style={{ style={{
position: "relative", position: "relative",
zIndex: 1, zIndex: 1,
@@ -99,10 +129,16 @@ export function SiteTransitionOverlay(): React.JSX.Element {
height: "auto", height: "auto",
objectFit: "contain", objectFit: "contain",
opacity: logoOpacity, opacity: logoOpacity,
transition: `opacity ${FADE_DURATION_MS}ms ease-in-out`, transition: fadeTransition,
transitionDelay: logoOpacity === 1 ? `${FADE_DURATION_MS}ms` : "0ms", transitionDelay:
!prefersReducedMotion && logoOpacity === 1
? `${FADE_DURATION_MS}ms`
: "0ms",
}} }}
/> />
{/* Subtitles must live inside this overlay's stacking context
(z-index 1000) so they render above the black screen. The
<Subtitles /> in SiteLayout sits behind this overlay. */}
<Subtitles /> <Subtitles />
</div> </div>
); );
+8 -6
View File
@@ -7,13 +7,15 @@ import { EXPERIENCE_CARDS } from "@/data/site/siteConfig";
* Screen 1: Welcome * Screen 1: Welcome
*/ */
export function SiteWelcomeScreen(): React.JSX.Element { export function SiteWelcomeScreen(): React.JSX.Element {
const selectedExperience = useSiteStore((state) => state.selectedExperience); const selectedExperienceIndex = useSiteStore(
const setSelectedExperience = useSiteStore( (state) => state.selectedExperienceIndex,
(state) => state.setSelectedExperience, );
const setSelectedExperienceIndex = useSiteStore(
(state) => state.setSelectedExperienceIndex,
); );
const setStep = useSiteStore((state) => state.setStep); const setStep = useSiteStore((state) => state.setStep);
const canProceed = selectedExperience !== null; const canProceed = selectedExperienceIndex !== null;
const handleNext = (): void => { const handleNext = (): void => {
if (canProceed) { if (canProceed) {
@@ -104,10 +106,10 @@ export function SiteWelcomeScreen(): React.JSX.Element {
<SiteCard <SiteCard
key={card.id} key={card.id}
config={card} config={card}
selected={selectedExperience === index} selected={selectedExperienceIndex === index}
onSelect={() => { onSelect={() => {
if (!card.disabled) { if (!card.disabled) {
setSelectedExperience(index); setSelectedExperienceIndex(index);
} }
}} }}
/> />
@@ -1,31 +1,63 @@
import { useEffect, useRef } from "react"; import { useEffect } from "react";
import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import {
playDialogueById,
stopCurrentDialogue,
} from "@/utils/dialogues/playDialogue";
const INTRO_DIALOGUE_PATH = "/sounds/dialogue/narrateur_ordreebike.mp3"; const DIALOGUE_FALLBACK_TIMEOUT_MS = 12000;
/** /**
* Black screen overlay with dialogue audio * Black screen overlay that plays the intro dialogue (with synced subtitles)
* - Plays narrateur_ordreebike.mp3 * via the dialogue manifest, then transitions to the reveal step.
* - Transitions to reveal step when dialogue ends
*/ */
export function IntroDialogueOverlay(): React.JSX.Element { export function IntroDialogueOverlay(): React.JSX.Element {
const setIntroStep = useGameStore((state) => state.setIntroStep); const setIntroStep = useGameStore((state) => state.setIntroStep);
const dialogueStarted = useRef(false);
useEffect(() => { useEffect(() => {
if (dialogueStarted.current) return; let cancelled = false;
dialogueStarted.current = true; let safetyTimeoutId: number | null = null;
// Play dialogue then transition to reveal const advance = (): void => {
const audio = AudioManager.getInstance(); if (cancelled) return;
audio.playSoundWithCallback(INTRO_DIALOGUE_PATH, 0.8, () => { if (safetyTimeoutId !== null) window.clearTimeout(safetyTimeoutId);
setIntroStep("reveal"); setIntroStep("reveal");
}); };
void (async () => {
const manifest = await loadDialogueManifest();
if (cancelled) return;
const audio = manifest
? await playDialogueById(manifest, SITE_DIALOGUE_IDS.introOrder)
: null;
if (cancelled) return;
if (!audio) {
advance();
return;
}
safetyTimeoutId = window.setTimeout(
advance,
DIALOGUE_FALLBACK_TIMEOUT_MS,
);
audio.addEventListener("ended", advance, { once: true });
})();
return () => {
cancelled = true;
if (safetyTimeoutId !== null) window.clearTimeout(safetyTimeoutId);
stopCurrentDialogue();
};
}, [setIntroStep]); }, [setIntroStep]);
return ( return (
<div <div
role="region"
aria-label="Dialogue d'introduction"
style={{ style={{
position: "fixed", position: "fixed",
inset: 0, inset: 0,
@@ -37,6 +69,7 @@ export function IntroDialogueOverlay(): React.JSX.Element {
}} }}
> >
<span <span
aria-hidden="true"
style={{ style={{
color: "rgba(255, 255, 255, 0.5)", color: "rgba(255, 255, 255, 0.5)",
fontSize: 16, fontSize: 16,
+10 -10
View File
@@ -1,28 +1,25 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
const REVEAL_DURATION_MS = 2000; const REVEAL_DURATION_MS = 2000;
/** /**
* Fade-out overlay for reveal transition * Fade-out overlay revealing the game world.
* - Starts fully black * Calls completeIntro() when the fade is done — completeIntro also flips
* - Fades out to reveal the game world * intro.currentStep to "playing" so no separate setIntroStep call is needed.
* - Transitions to playing step when done
*/ */
export function IntroRevealOverlay(): React.JSX.Element { export function IntroRevealOverlay(): React.JSX.Element {
const setIntroStep = useGameStore((state) => state.setIntroStep);
const completeIntro = useGameStore((state) => state.completeIntro); const completeIntro = useGameStore((state) => state.completeIntro);
const prefersReducedMotion = usePrefersReducedMotion();
const [opacity, setOpacity] = useState(1); const [opacity, setOpacity] = useState(1);
useEffect(() => { useEffect(() => {
// Start fade out
const fadeTimeout = window.setTimeout(() => { const fadeTimeout = window.setTimeout(() => {
setOpacity(0); setOpacity(0);
}, 100); }, 100);
// Complete intro after fade
const completeTimeout = window.setTimeout(() => { const completeTimeout = window.setTimeout(() => {
setIntroStep("playing");
completeIntro(); completeIntro();
}, REVEAL_DURATION_MS); }, REVEAL_DURATION_MS);
@@ -30,16 +27,19 @@ export function IntroRevealOverlay(): React.JSX.Element {
window.clearTimeout(fadeTimeout); window.clearTimeout(fadeTimeout);
window.clearTimeout(completeTimeout); window.clearTimeout(completeTimeout);
}; };
}, [setIntroStep, completeIntro]); }, [completeIntro]);
return ( return (
<div <div
aria-hidden="true"
style={{ style={{
position: "fixed", position: "fixed",
inset: 0, inset: 0,
background: "#000", background: "#000",
opacity, opacity,
transition: `opacity ${REVEAL_DURATION_MS}ms ease-out`, transition: prefersReducedMotion
? "none"
: `opacity ${REVEAL_DURATION_MS}ms ease-out`,
zIndex: 998, zIndex: 998,
pointerEvents: "none", pointerEvents: "none",
}} }}
+11 -11
View File
@@ -1,13 +1,12 @@
import { useCallback, useRef, useEffect } from "react"; import { useCallback, useEffect, useRef } from "react";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
const INTRO_VIDEO_PATH = "/cinematics/intro.mp4"; const INTRO_VIDEO_PATH = "/cinematics/intro.mp4";
const SKIP_KEYS = new Set(["Enter", " "]);
/** /**
* Full-screen video player for intro cinematic * Full-screen video player for the intro cinematic.
* - Plays intro.mp4 in fullscreen * Advances to the dialogue-intro step when the video ends or the user skips.
* - Automatically advances to dialogue-intro step when video ends
* - Allows skipping with Enter/Space/Click
*/ */
export function IntroVideoPlayer(): React.JSX.Element { export function IntroVideoPlayer(): React.JSX.Element {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
@@ -18,16 +17,13 @@ export function IntroVideoPlayer(): React.JSX.Element {
}, [setIntroStep]); }, [setIntroStep]);
const handleSkip = useCallback(() => { const handleSkip = useCallback(() => {
if (videoRef.current) { videoRef.current?.pause();
videoRef.current.pause();
}
setIntroStep("dialogue-intro"); setIntroStep("dialogue-intro");
}, [setIntroStep]); }, [setIntroStep]);
// Handle keyboard skip (Enter/Space)
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Enter" || event.key === " ") { if (SKIP_KEYS.has(event.key)) {
event.preventDefault(); event.preventDefault();
handleSkip(); handleSkip();
} }
@@ -39,6 +35,8 @@ export function IntroVideoPlayer(): React.JSX.Element {
return ( return (
<div <div
role="region"
aria-label="Vidéo d'introduction. Appuyez sur Entrée pour passer."
onClick={handleSkip} onClick={handleSkip}
style={{ style={{
position: "fixed", position: "fixed",
@@ -56,6 +54,7 @@ export function IntroVideoPlayer(): React.JSX.Element {
src={INTRO_VIDEO_PATH} src={INTRO_VIDEO_PATH}
autoPlay autoPlay
playsInline playsInline
preload="auto"
onEnded={handleVideoEnd} onEnded={handleVideoEnd}
style={{ style={{
width: "100%", width: "100%",
@@ -64,6 +63,7 @@ export function IntroVideoPlayer(): React.JSX.Element {
}} }}
/> />
<span <span
aria-hidden="true"
style={{ style={{
position: "absolute", position: "absolute",
bottom: 32, bottom: 32,
-8
View File
@@ -1,11 +1,3 @@
export const AUDIO_PATHS = {
intro: "/sounds/effect/fa.mp3",
bienvenue: "/sounds/effect/fa.mp3",
alertCentral: "/sounds/effect/fa.mp3",
searching: "/sounds/effect/fa.mp3",
helped: "/sounds/effect/fa.mp3",
} as const;
export type AudioCategory = "music" | "sfx" | "dialogue"; export type AudioCategory = "music" | "sfx" | "dialogue";
export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = { export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
+9
View File
@@ -0,0 +1,9 @@
/**
* Dialogue manifest IDs used by the /site flow and the intro sequence.
* Defined once here so components don't hold magic strings.
*/
export const SITE_DIALOGUE_IDS = {
naming: "narrateur_intro_prenom",
transition: "narrateur_intro_apresprenom",
introOrder: "narrateur_ordreebike",
} as const;
+19 -4
View File
@@ -1,8 +1,23 @@
import type { CSSProperties } from "react";
const BACKGROUND_IMAGE = "/assets/bg-site.png";
export const SITE_CONFIG = { export const SITE_CONFIG = {
backgroundImage: "/assets/bg-site.png", backgroundImage: BACKGROUND_IMAGE,
forcedName: "Danyl", presetPlayerName: "Danyl",
} as const; } as const;
/**
* Shared background style used by SiteLayout and SiteMobileBlocker.
*/
export const SITE_BACKGROUND_STYLE: CSSProperties = {
backgroundColor: "#87CEEB",
backgroundImage: `url(${BACKGROUND_IMAGE})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
};
export interface SiteCardConfig { export interface SiteCardConfig {
id: string; id: string;
label: string; label: string;
@@ -24,10 +39,10 @@ export const EXPERIENCE_CARDS: readonly SiteCardConfig[] = [
* Cards for screen 2: "Quelle est votre situation ?" * Cards for screen 2: "Quelle est votre situation ?"
*/ */
export const SITUATION_CARDS: readonly SiteCardConfig[] = [ export const SITUATION_CARDS: readonly SiteCardConfig[] = [
{ id: "sit-refugie-climat", label: "Sans domicile fixe", disabled: true }, { id: "sit-sans-domicile", label: "Sans domicile fixe", disabled: true },
{ id: "sit-refugie-guerre", label: "Réfugié.e de guerre", disabled: true }, { id: "sit-refugie-guerre", label: "Réfugié.e de guerre", disabled: true },
{ {
id: "sit-sans-domicile", id: "sit-refugie-climat",
label: "Réfugié.e climatique", label: "Réfugié.e climatique",
disabled: false, disabled: false,
}, },
+31
View File
@@ -0,0 +1,31 @@
import { useSyncExternalStore } from "react";
const MOBILE_MEDIA_QUERY =
"(max-width: 767px), (pointer: coarse) and (hover: none)";
function subscribeToMobileQuery(callback: () => void): () => void {
const query = window.matchMedia(MOBILE_MEDIA_QUERY);
query.addEventListener("change", callback);
return () => query.removeEventListener("change", callback);
}
function getMobileSnapshot(): boolean {
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
}
function getServerMobileSnapshot(): boolean {
return false;
}
/**
* True when the device is a phone or a touch-only tablet.
* Uses matchMedia so layout decisions follow CSS conventions
* and avoid resize-handler churn.
*/
export function useIsMobile(): boolean {
return useSyncExternalStore(
subscribeToMobileQuery,
getMobileSnapshot,
getServerMobileSnapshot,
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useSyncExternalStore } from "react";
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeToReducedMotion(callback: () => void): () => void {
const query = window.matchMedia(REDUCED_MOTION_QUERY);
query.addEventListener("change", callback);
return () => query.removeEventListener("change", callback);
}
function getReducedMotionSnapshot(): boolean {
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
}
function getServerReducedMotionSnapshot(): boolean {
return false;
}
/**
* True when the user has requested reduced motion at the OS level.
* UI fades and transitions should collapse to 0ms when this is true.
*/
export function usePrefersReducedMotion(): boolean {
return useSyncExternalStore(
subscribeToReducedMotion,
getReducedMotionSnapshot,
getServerReducedMotionSnapshot,
);
}
+12
View File
@@ -44,6 +44,18 @@ select {
font: inherit; font: inherit;
} }
/* Site onboarding — accessible focus rings (WCAG 2.4.7) */
.site-card-button:focus-visible,
.site-button:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.55);
}
.site-card-button[aria-pressed="true"]:focus-visible {
outline-color: #a8d5a2;
}
canvas { canvas {
display: block; display: block;
} }
+1
View File
@@ -121,6 +121,7 @@ function completeIntroState(state: GameState): GameStateUpdate {
mainState: "ebike", mainState: "ebike",
intro: { intro: {
...state.intro, ...state.intro,
currentStep: "playing",
hasCompleted: true, hasCompleted: true,
isEbikeUnlocked: true, isEbikeUnlocked: true,
}, },
+9 -8
View File
@@ -3,14 +3,14 @@ import type { SiteStep } from "@/types/game";
interface SiteState { interface SiteState {
currentStep: SiteStep; currentStep: SiteStep;
selectedExperience: number | null; selectedExperienceIndex: number | null;
selectedSituation: number | null; selectedSituationIndex: number | null;
} }
interface SiteActions { interface SiteActions {
setStep: (step: SiteStep) => void; setStep: (step: SiteStep) => void;
setSelectedExperience: (index: number) => void; setSelectedExperienceIndex: (index: number) => void;
setSelectedSituation: (index: number) => void; setSelectedSituationIndex: (index: number) => void;
reset: () => void; reset: () => void;
} }
@@ -18,14 +18,15 @@ type SiteStore = SiteState & SiteActions;
const initialState: SiteState = { const initialState: SiteState = {
currentStep: "disclaimer", currentStep: "disclaimer",
selectedExperience: null, selectedExperienceIndex: null,
selectedSituation: null, selectedSituationIndex: null,
}; };
export const useSiteStore = create<SiteStore>()((set) => ({ export const useSiteStore = create<SiteStore>()((set) => ({
...initialState, ...initialState,
setStep: (step) => set({ currentStep: step }), setStep: (step) => set({ currentStep: step }),
setSelectedExperience: (index) => set({ selectedExperience: index }), setSelectedExperienceIndex: (index) =>
setSelectedSituation: (index) => set({ selectedSituation: index }), set({ selectedExperienceIndex: index }),
setSelectedSituationIndex: (index) => set({ selectedSituationIndex: index }),
reset: () => set(initialState), reset: () => set(initialState),
})); }));
+1 -34
View File
@@ -1,4 +1,3 @@
import { useEffect, useState } from "react";
import { useSiteStore } from "@/managers/stores/useSiteStore"; import { useSiteStore } from "@/managers/stores/useSiteStore";
import { SiteDisclaimerScreen } from "@/components/site/SiteDisclaimerScreen"; import { SiteDisclaimerScreen } from "@/components/site/SiteDisclaimerScreen";
import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen"; import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen";
@@ -7,39 +6,7 @@ import { SiteNamingScreen } from "@/components/site/SiteNamingScreen";
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay"; import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker"; import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
import { SiteLayout } from "@/components/site/SiteLayout"; import { SiteLayout } from "@/components/site/SiteLayout";
import { useIsMobile } from "@/hooks/ui/useIsMobile";
/**
* Check if user is on mobile device
*/
function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = (): void => {
const userAgent = navigator.userAgent.toLowerCase();
const mobileKeywords = [
"android",
"webos",
"iphone",
"ipad",
"ipod",
"blackberry",
"windows phone",
];
const isMobileDevice = mobileKeywords.some((keyword) =>
userAgent.includes(keyword),
);
const isSmallScreen = window.innerWidth < 768;
setIsMobile(isMobileDevice || isSmallScreen);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
return isMobile;
}
export function SitePage(): React.JSX.Element { export function SitePage(): React.JSX.Element {
const currentStep = useSiteStore((state) => state.currentStep); const currentStep = useSiteStore((state) => state.currentStep);
+13 -2
View File
@@ -12,6 +12,9 @@ import type { SubtitleCue } from "@/utils/subtitles/parseSrt";
const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json"; const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json";
const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr"; const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr";
let manifestCache: DialogueManifest | null = null;
let manifestPromise: Promise<DialogueManifest | null> | null = null;
export interface DialogueSubtitleCue { export interface DialogueSubtitleCue {
voice: DialogueVoice; voice: DialogueVoice;
cue: SubtitleCue; cue: SubtitleCue;
@@ -28,13 +31,21 @@ export interface DialogueSubtitleCues {
} }
export async function loadDialogueManifest(): Promise<DialogueManifest | null> { export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
const response = await fetch(DIALOGUE_MANIFEST_PATH); if (manifestCache) return manifestCache;
if (manifestPromise) return manifestPromise;
manifestPromise = (async () => {
const response = await fetch(DIALOGUE_MANIFEST_PATH);
if (!response.ok) { if (!response.ok) {
manifestPromise = null;
return null; return null;
} }
const manifest = parseDialogueManifest(await response.json());
manifestCache = manifest;
return manifest;
})();
return parseDialogueManifest(await response.json()); return manifestPromise;
} }
function getDialogueVoice( function getDialogueVoice(