diff --git a/src/app/globals.css b/src/app/globals.css
index 3e4b84a..e3cc195 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -41,10 +41,22 @@
--font-sans: var(--font-manrope);
}
+/* Intensité du dégradé "mer" du fond, en pourcentage — enregistrée via
+ @property pour qu'un navigateur qui la supporte anime une transition
+ fluide entre les deux valeurs de ambient-glow (sinon dégradation sans
+ casse : la valeur du keyframe s'applique quand même, juste sans lissage
+ intermédiaire). */
+@property --sea-glow {
+ syntax: "
";
+ inherits: true;
+ initial-value: 30%;
+}
+
body {
+ --sea-glow: 30%;
background:
radial-gradient(ellipse 80% 50% at 20% -10%, color-mix(in srgb, var(--ink-2) 75%, transparent) 0%, transparent 60%),
- radial-gradient(ellipse 70% 60% at 100% 110%, color-mix(in srgb, var(--sea) 30%, transparent) 0%, transparent 65%),
+ radial-gradient(ellipse 70% 60% at 100% 110%, color-mix(in srgb, var(--sea) var(--sea-glow), transparent) 0%, transparent 65%),
var(--ink);
background-attachment: fixed;
color: var(--foreground);
@@ -112,6 +124,42 @@ body {
}
@media (prefers-reduced-motion: no-preference) {
+ /* Mer de nuit qui respire très lentement, plutôt qu'un fond figé — reste
+ imperceptible comme un vrai mouvement, juste une ambiance qui vit. */
+ @keyframes ambient-glow {
+ from {
+ --sea-glow: 22%;
+ }
+ to {
+ --sea-glow: 42%;
+ }
+ }
+
+ body {
+ animation: ambient-glow 40s ease-in-out infinite alternate;
+ }
+
+ /* Léger halo doré respirant sur les cartes marbre, façon lueur de
+ torche — un outline (jamais box-shadow/border, déjà utilisés par les
+ classes Tailwind de chaque carte) pour ne jamais entrer en conflit
+ avec l'ombre/la bordure propres à chaque usage. N'affecte pas la mise
+ en page (outline ne prend pas de place). */
+ @keyframes card-flicker {
+ 0%,
+ 100% {
+ outline-color: transparent;
+ }
+ 50% {
+ outline-color: color-mix(in srgb, var(--gold) 35%, transparent);
+ }
+ }
+
+ .marble-surface {
+ outline: 1px solid transparent;
+ outline-offset: 2px;
+ animation: card-flicker 7s ease-in-out infinite;
+ }
+
@keyframes podium-rise {
0% {
opacity: 0;
diff --git a/src/app/icare/icarus-game.tsx b/src/app/icare/icarus-game.tsx
index c9ec850..c864795 100644
--- a/src/app/icare/icarus-game.tsx
+++ b/src/app/icare/icarus-game.tsx
@@ -43,11 +43,11 @@ import {
stepIcarus,
} from "@/lib/icarus/physics";
import type { Boost, Column, IcarusState, ShatterEffect } from "@/lib/icarus/types";
+import { playBoostSound } from "@/lib/icarus/sound";
+import { Confetti } from "@/components/confetti";
type Status = "idle" | "flying" | "dead";
-const CONFETTI_COLORS = ["#E7C560", "#C9A227", "#F4ECD8", "#A5342A", "#5E6B3B", "#2E6E7E", "#B08D57"];
-
// 0 avant DIFFICULTY_START_SCORE, monte jusqu'à 1 sur DIFFICULTY_RAMP_SPAN
// points, puis reste à 1 (palier, pour ne pas devenir injouable).
function difficultyProgress(score: number): number {
@@ -499,6 +499,7 @@ export function IcarusGame({
const collectedIndex = boostsRef.current.findIndex((b) => collidesWithBoost(icarusRef.current.y, b));
if (collectedIndex !== -1) {
boostsRef.current.splice(collectedIndex, 1);
+ playBoostSound();
// Active le bouclier pour les N prochaines colonnes (distance
// aléatoire, voir plus bas) et déclenche l'animation de bond en
// avant. Icare se fige (vitesse verticale annulée, plus de
@@ -637,56 +638,6 @@ export function IcarusGame({
);
}
-type ConfettiPiece = {
- id: number;
- left: number;
- color: string;
- delay: number;
- duration: number;
- spin: number;
- drift: number;
-};
-
-function generateConfettiPieces(): ConfettiPiece[] {
- return Array.from({ length: 26 }, (_, index) => ({
- id: index,
- left: Math.random() * 100,
- color: CONFETTI_COLORS[index % CONFETTI_COLORS.length],
- delay: Math.random() * 250,
- duration: 900 + Math.random() * 700,
- spin: (Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 360),
- drift: (Math.random() - 0.5) * 70,
- }));
-}
-
-function Confetti() {
- // Initialiseur paresseux de useState (n'exécute Math.random() qu'une
- // seule fois, au montage — Confetti est remonté à chaque nouvelle partie
- // terminée) plutôt qu'un appel impur dans le corps du composant.
- const [pieces] = useState(generateConfettiPieces);
-
- return (
-
- {pieces.map((piece) => (
-
- ))}
-
- );
-}
-
function render(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
diff --git a/src/app/journal/journal-view.tsx b/src/app/journal/journal-view.tsx
index 2dd7de4..cc5343e 100644
--- a/src/app/journal/journal-view.tsx
+++ b/src/app/journal/journal-view.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
+import { EmptyState } from "@/components/empty-state";
type Entry = {
id: number;
@@ -56,7 +57,7 @@ export function JournalView({
}, []);
if (entries.length === 0) {
- return Aucun décret pour l'instant.
;
+ return ;
}
return (
@@ -72,7 +73,11 @@ export function JournalView({
className="marble-surface flex gap-3 rounded-lg border border-gold/25 px-4 py-3 text-sm shadow-sm"
>
= 0
+ ? "bg-olive shadow-[0_0_0_2px_rgba(94,107,59,0.25)]"
+ : "bg-oxblood shadow-[0_0_0_2px_rgba(165,52,42,0.25)]"
+ }`}
aria-hidden
/>
diff --git a/src/app/leaderboard/leaderboard-view.tsx b/src/app/leaderboard/leaderboard-view.tsx
index cfe0075..2a34d45 100644
--- a/src/app/leaderboard/leaderboard-view.tsx
+++ b/src/app/leaderboard/leaderboard-view.tsx
@@ -5,6 +5,7 @@ import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar";
import { Podium } from "@/components/podium";
import { JudgePointControls } from "@/components/judge-point-controls";
+import { EmptyState } from "@/components/empty-state";
import { computeRanks, computeProgress } from "@/lib/ranking";
type Profile = {
@@ -94,7 +95,7 @@ export function LeaderboardView({
const rest = hasScores ? ranked.filter((p) => p.rank > 3) : ranked;
if (profiles.length === 0) {
- return
Personne n'est encore inscrit.
;
+ return
;
}
return (
@@ -107,6 +108,10 @@ export function LeaderboardView({
onApplyDelta={applyOptimisticDelta}
/>
+ {/* Frise à méandre : marque la transition podium -> reste du
+ classement, seulement quand il y a bien un podium au-dessus. */}
+ {podiumProfiles.length > 0 &&
}
+
{rest.map((profile) => {
const progress = computeProgress(profile.rank, profile.previous_rank);
diff --git a/src/app/leaderboard/loading.tsx b/src/app/leaderboard/loading.tsx
new file mode 100644
index 0000000..c8086aa
--- /dev/null
+++ b/src/app/leaderboard/loading.tsx
@@ -0,0 +1,5 @@
+import { PageLoading } from "@/components/page-loading";
+
+export default function Loading() {
+ return ;
+}
diff --git a/src/app/mur/loading.tsx b/src/app/mur/loading.tsx
new file mode 100644
index 0000000..c8086aa
--- /dev/null
+++ b/src/app/mur/loading.tsx
@@ -0,0 +1,5 @@
+import { PageLoading } from "@/components/page-loading";
+
+export default function Loading() {
+ return ;
+}
diff --git a/src/app/mur/mur-view.tsx b/src/app/mur/mur-view.tsx
index c35551c..a7c2b68 100644
--- a/src/app/mur/mur-view.tsx
+++ b/src/app/mur/mur-view.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState, type ChangeEvent, type FormEvent } fr
import Image from "next/image";
import { Avatar } from "@/components/avatar";
import { IconCamera, IconClose } from "@/components/icons";
+import { EmptyState } from "@/components/empty-state";
import { resizeImageToBlob } from "@/lib/image";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import type { MyReactionRow, ReactionCountRow, WallHistoryRow, WallNoteRow } from "./page";
@@ -434,9 +435,7 @@ export function MurView({
{pastLoading ? (
Chargement…
) : displayedNotes.length === 0 ? (
-
- {viewedDate === null ? "Le mur est encore vide aujourd'hui." : "Le mur était vide ce jour-là."}
-
+
) : (
{displayedNotes.map((note) => (
@@ -558,9 +557,10 @@ export function MurView({
)}
{filteredHistory.length === 0 ? (
-
- {history.length === 0 ? "Aucune note publiée pour l'instant." : "Aucune note ce jour-là."}
-
+
) : (
{filteredHistory.map((note) => (
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
index 2ccd436..dbd1942 100644
--- a/src/app/profile/page.tsx
+++ b/src/app/profile/page.tsx
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
+import { DiamondDivider } from "@/components/diamond-divider";
import { ProfileForm } from "./profile-form";
import { ChariotSubmissionForm } from "./chariot-submission-form";
@@ -58,12 +59,19 @@ export default async function ProfilePage() {
{!isJudge && (
-
-
- Ma question pour Le Char
-
-
-
+ <>
+ {/* Carte secondaire (bordure plus fine, pas de grande ombre) :
+ une action ponctuelle annexe, pas le cœur de la page comme la
+ carte profil au-dessus — distincte sans casser la cohérence
+ marble-surface. */}
+
+
+
+ Ma question pour Le Char
+
+
+
+ >
)}
);
diff --git a/src/components/confetti.tsx b/src/components/confetti.tsx
new file mode 100644
index 0000000..25f1bcb
--- /dev/null
+++ b/src/components/confetti.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { useState, type CSSProperties } from "react";
+
+const CONFETTI_COLORS = ["#E7C560", "#C9A227", "#F4ECD8", "#A5342A", "#5E6B3B", "#2E6E7E", "#B08D57"];
+
+type ConfettiPiece = {
+ id: number;
+ left: number;
+ color: string;
+ delay: number;
+ duration: number;
+ spin: number;
+ drift: number;
+};
+
+function generateConfettiPieces(): ConfettiPiece[] {
+ return Array.from({ length: 26 }, (_, index) => ({
+ id: index,
+ left: Math.random() * 100,
+ color: CONFETTI_COLORS[index % CONFETTI_COLORS.length],
+ delay: Math.random() * 250,
+ duration: 900 + Math.random() * 700,
+ spin: (Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 360),
+ drift: (Math.random() - 0.5) * 70,
+ }));
+}
+
+// Partagé entre Le Vol d'Icare et La Corne d'Abondance (fin de partie) —
+// à l'appelant de ne le monter que si prefers-reduced-motion ne le
+// désactive pas, comme déjà fait sur les deux jeux.
+export function Confetti() {
+ // Initialiseur paresseux de useState (n'exécute Math.random() qu'une
+ // seule fois, au montage — Confetti est remonté à chaque nouvelle partie
+ // terminée) plutôt qu'un appel impur dans le corps du composant.
+ const [pieces] = useState(generateConfettiPieces);
+
+ return (
+
+ {pieces.map((piece) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/empty-state.tsx b/src/components/empty-state.tsx
new file mode 100644
index 0000000..bed40b8
--- /dev/null
+++ b/src/components/empty-state.tsx
@@ -0,0 +1,13 @@
+// Petit ornement (◆, comme diamond-divider.tsx) au-dessus des messages
+// "rien à afficher" — sans ça ce sont des états qui ressemblent à une
+// erreur/un oubli plutôt qu'un vrai état normal de l'app.
+export function EmptyState({ message, className = "text-marble/60" }: { message: string; className?: string }) {
+ return (
+
+ );
+}
diff --git a/src/components/page-loading.tsx b/src/components/page-loading.tsx
new file mode 100644
index 0000000..2d47314
--- /dev/null
+++ b/src/components/page-loading.tsx
@@ -0,0 +1,14 @@
+import { AgoraLogo } from "@/components/agora-logo";
+
+// Utilisé par les loading.tsx des pages les plus consultées : sans ça, le
+// temps de la requête d'un Server Component se traduit par un écran vide,
+// ce qui a déjà fait croire à un vrai bug une fois (/signup, voir
+// l'historique du projet) — même un indicateur minimal évite ce doute.
+export function PageLoading() {
+ return (
+
+ );
+}
diff --git a/src/lib/icarus/sound.ts b/src/lib/icarus/sound.ts
new file mode 100644
index 0000000..cd39bf7
--- /dev/null
+++ b/src/lib/icarus/sound.ts
@@ -0,0 +1,44 @@
+// Petit effet synthétisé via Web Audio, même principe que melon/sound.ts
+// (pas de fichier audio dans le projet, cohérent avec le reste de l'app) :
+// joué à la cueillette de l'éclair de Zeus — une montée rapide de
+// fréquence en dents de scie, pour évoquer une charge d'énergie plutôt
+// qu'un choc (le "pop" de fusion de la Corne, en onde sinusoïdale plus
+// douce, joue un rôle différent).
+let audioContext: AudioContext | null = null;
+
+function getAudioContext(): AudioContext | null {
+ if (typeof window === "undefined") return null;
+ if (!audioContext) {
+ const Ctor =
+ window.AudioContext ??
+ (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+ if (!Ctor) return null;
+ audioContext = new Ctor();
+ }
+ if (audioContext.state === "suspended") {
+ audioContext.resume().catch(() => {});
+ }
+ return audioContext;
+}
+
+export function playBoostSound(): void {
+ const ctx = getAudioContext();
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+
+ const oscillator = ctx.createOscillator();
+ const gain = ctx.createGain();
+ oscillator.type = "sawtooth";
+ oscillator.frequency.setValueAtTime(280, now);
+ oscillator.frequency.exponentialRampToValueAtTime(1400, now + 0.16);
+
+ gain.gain.setValueAtTime(0.0001, now);
+ gain.gain.exponentialRampToValueAtTime(0.18, now + 0.02);
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
+
+ oscillator.connect(gain);
+ gain.connect(ctx.destination);
+ oscillator.start(now);
+ oscillator.stop(now + 0.24);
+}