From 358bed0c2e402b1c7e58adf620253caa61800db3 Mon Sep 17 00:00:00 2001 From: Valentin ROBIN Date: Wed, 26 Aug 2026 15:03:52 +0200 Subject: [PATCH] Passe de polish visuel sur tout le site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige deux incohérences trouvées en chemin : le compteur de passages dans le char en gold-bright sur marbre (même défaut de contraste déjà vu ailleurs), et le point du Crieur toujours rouge même pour un honneur positif. Réutilise les ornements existants mais jamais appelés (meander-divider entre le podium et le classement, DiamondDivider sur /profile avec une nouvelle convention de carte secondaire). Ajoute une ambiance discrète (fond qui respire très lentement, halo doré sur les cartes marbre, désactivés sous prefers-reduced-motion), étend les confettis et un nouveau son à Icare pour la parité avec la Corne d'Abondance, des loading.tsx sur les pages les plus consultées, et un petit ornement sur les états vides plutôt qu'un texte brut. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 13 ++++-- src/app/char/entry-ranking.tsx | 2 +- src/app/char/loading.tsx | 5 ++ src/app/corne/melon-game.tsx | 6 +++ src/app/globals.css | 50 +++++++++++++++++++- src/app/icare/icarus-game.tsx | 55 ++-------------------- src/app/journal/journal-view.tsx | 9 +++- src/app/leaderboard/leaderboard-view.tsx | 7 ++- src/app/leaderboard/loading.tsx | 5 ++ src/app/mur/loading.tsx | 5 ++ src/app/mur/mur-view.tsx | 12 ++--- src/app/profile/page.tsx | 20 +++++--- src/components/confetti.tsx | 58 ++++++++++++++++++++++++ src/components/empty-state.tsx | 13 ++++++ src/components/page-loading.tsx | 14 ++++++ src/lib/icarus/sound.ts | 44 ++++++++++++++++++ 16 files changed, 245 insertions(+), 73 deletions(-) create mode 100644 src/app/char/loading.tsx create mode 100644 src/app/leaderboard/loading.tsx create mode 100644 src/app/mur/loading.tsx create mode 100644 src/components/confetti.tsx create mode 100644 src/components/empty-state.tsx create mode 100644 src/components/page-loading.tsx create mode 100644 src/lib/icarus/sound.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5ec13b2..fd71e33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,11 +217,16 @@ Polices (`next/font/google`, variables CSS, exposées comme `font-heading`/`font Ornements en SVG/CSS inline (jamais d'image bitmap) : - `components/laurel-wreath.tsx` — couronne de laurier calculée par trigonométrie (deux arcs symétriques de feuilles), utilisée en logo, sur le rang 1 du podium, etc. Le favicon (`src/app/icon.svg`) est une version statique des mêmes coordonnées. -- `.meander-divider` (classe globale) — frise à méandre grecque en `background-image` SVG data-URI, séparateur discret. -- `components/diamond-divider.tsx` — séparateur ◆ avec filets dégradés. -- `.marble-surface` (classe globale) — texture marbre via dégradés (pas d'image de bruit). +- `.meander-divider` (classe globale) — frise à méandre grecque en `background-image` SVG data-URI, séparateur discret. Utilisée sous le header, et depuis la passe de polish visuel entre le podium et le reste du classement sur `/leaderboard` (`leaderboard-view.tsx`) — seulement quand un podium est réellement affiché. +- `components/diamond-divider.tsx` — séparateur ◆ avec filets dégradés. Utilisé sur `/profile` entre la carte profil et la carte suggestion de question (deux actions distinctes, pas la même importance — voir la convention de carte secondaire ci-dessous). +- `.marble-surface` (classe globale) — texture marbre via dégradés (pas d'image de bruit). Porte aussi, depuis la même passe, un léger halo doré respirant (`outline`, jamais `box-shadow`/`border` pour ne jamais entrer en conflit avec l'ombre/la bordure propre à chaque usage) — animation lente (7s), désactivée sous `prefers-reduced-motion`. +- `components/empty-state.tsx` — petit ◆ doré au-dessus d'un message "rien à afficher" (`/journal`, `/leaderboard`, `/mur`), pour qu'un état vide se lise comme un état normal de l'app plutôt qu'un oubli. +- `components/confetti.tsx` — partagé entre Le Vol d'Icare et La Corne d'Abondance (fin de partie), extrait de l'ancien code propre à Icare. À l'appelant de vérifier `prefers-reduced-motion` avant de le monter (comme déjà fait sur les deux jeux). +- Fond (`body`, `globals.css`) : le dégradé "mer" respire lentement (`--sea-glow`, `@property` pour une transition fluide côté navigateurs qui le supportent, dégradation sans casse sinon) plutôt que rester figé — même principe de subtilité, désactivé sous `prefers-reduced-motion`. -Convention de carte : `marble-surface rounded-2xl border border-gold/40 shadow-xl` (formulaires, podium) ou `rounded-lg border-gold/25` pour les lignes de liste. Bouton primaire : `bg-ink-2 text-gold-bright font-heading uppercase hover:bg-ink`. Bouton secondaire : `border border-gold/30 text-text-marble hover:bg-gold/10`. +Convention de carte : `marble-surface rounded-2xl border border-gold/40 shadow-xl` (formulaires, podium) ou `rounded-lg border-gold/25` pour les lignes de liste. Carte secondaire (action annexe, pas le cœur de la page — ex. la suggestion de question sur `/profile`) : `border-gold/20 shadow-sm` plutôt que `border-gold/40 shadow-xl`, pour une hiérarchie visuelle claire sans sortir du langage marble-surface. Bouton primaire : `bg-ink-2 text-gold-bright font-heading uppercase hover:bg-ink`. Bouton secondaire : `border border-gold/30 text-text-marble hover:bg-gold/10`. + +Sons : synthétisés via Web Audio (jamais de fichier audio dans le projet) — `src/lib/melon/sound.ts` (pop de fusion, La Corne d'Abondance) et `src/lib/icarus/sound.ts` (montée en dents de scie à la cueillette de l'éclair de Zeus, Le Vol d'Icare). `AudioContext` créé paresseusement au premier appel, toujours après un vrai geste utilisateur (contrainte d'autoplay des navigateurs). Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code/les commentaires, pour que ça reste maintenable) : juges/admins → **Archontes**, membres → **Citoyens**, leaderboard → **L'Agora**, journal des points → **Le Crieur** (décrets), points positifs/négatifs → **Honneurs**/**Blâmes**, points → **gloires**. Exception volontaire : la page `/admin` s'appelle simplement « Admin » (police monospace, rupture délibérée avec le vocabulaire diégétique et le thème grec, pour marquer que c'est un outil technique). diff --git a/src/app/char/entry-ranking.tsx b/src/app/char/entry-ranking.tsx index 885ac99..30affce 100644 --- a/src/app/char/entry-ranking.tsx +++ b/src/app/char/entry-ranking.tsx @@ -25,7 +25,7 @@ export function EntryRanking({
  • {p.pseudo} - {count > 0 ? `×${count}` : "—"} + {count > 0 ? `×${count}` : "—"}
  • ); })} diff --git a/src/app/char/loading.tsx b/src/app/char/loading.tsx new file mode 100644 index 0000000..c8086aa --- /dev/null +++ b/src/app/char/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoading } from "@/components/page-loading"; + +export default function Loading() { + return ; +} diff --git a/src/app/corne/melon-game.tsx b/src/app/corne/melon-game.tsx index bbfcfe7..00b1b40 100644 --- a/src/app/corne/melon-game.tsx +++ b/src/app/corne/melon-game.tsx @@ -17,6 +17,7 @@ import { WALL_THICKNESS, } from "@/lib/melon/constants"; import { playMergeSound } from "@/lib/melon/sound"; +import { Confetti } from "@/components/confetti"; import type { MemberRow } from "./page"; type Status = "playing" | "dead"; @@ -422,6 +423,9 @@ export function MelonGame({ // eslint-disable-next-line react-hooks/exhaustive-deps -- monté une seule fois ; spawnPiece/maxTier sont stables tant que citizens.length ne change pas }, []); + const prefersReducedMotion = + typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + return (
    @@ -525,6 +529,8 @@ export function MelonGame({ />
    + {status === "dead" && !prefersReducedMotion && } + {status === "dead" && (
    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 ( +
    + + ◆ + +

    {message}

    +
    + ); +} 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 ( +
    + +

    Chargement…

    +
    + ); +} 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); +}