This commit is contained in:
Ladebeze66 2026-04-22 16:39:19 +02:00
parent f824053a31
commit ab47a41a37
11 changed files with 792 additions and 188 deletions

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation, Pagination, Autoplay } from "swiper/modules"; import { Navigation, Pagination, Autoplay } from "swiper/modules";
@ -8,8 +8,27 @@ import "swiper/css";
import "swiper/css/navigation"; import "swiper/css/navigation";
import "swiper/css/pagination"; import "swiper/css/pagination";
import "../globals.css"; import "../globals.css";
import "../assets/main.css"; import "../assets/main.css";
/**
* Carousel "fiche détail" refonte Stitch (étape 7.a).
*
* Conserve l'API publique (`images`, `className`) pour ne rien casser côté
* consommateurs (`ContentSection`, `ModalGlossaire`). Changements vs version
* historique :
*
* - Pagination bullets teintée `primary` via surcharge inline des variables
* Swiper (pas de pollution `globals.css`, cf. même approche que `VignetteCarousel`).
* - Flèches Swiper par défaut recolorées `primary` laissées en chevrons
* Swiper pour garder l'empreinte tactile native (le Material Symbol `chevron`
* demandait un remplacement complet du markup via slots).
* - Conteneur en `rounded-tile overflow-hidden shadow-ambient-sm` plutôt que
* `rounded-md shadow-md` cohérence avec les tokens de la refonte.
* - **Lightbox refaite en Stitch** : voile `bg-on-surface/80 backdrop-blur-sm`
* (vs `bg-black/10 backdrop-blur-2xl` qui dépixellisait l'image), image en
* `object-contain` (ne déforme plus les photos verticales), bouton close rond
* Material Symbol, fermeture Esc + clic voile.
*/
interface CarouselProps { interface CarouselProps {
images: Array<{ url: string; alt: string }>; images: Array<{ url: string; alt: string }>;
className?: string; className?: string;
@ -18,25 +37,53 @@ interface CarouselProps {
export default function Carousel({ images, className }: CarouselProps) { export default function Carousel({ images, className }: CarouselProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null); const [selectedImage, setSelectedImage] = useState<string | null>(null);
useEffect(() => {
if (!selectedImage) return;
document.body.classList.add("overflow-hidden");
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelectedImage(null);
};
window.addEventListener("keydown", handleKey);
return () => {
document.body.classList.remove("overflow-hidden");
window.removeEventListener("keydown", handleKey);
};
}, [selectedImage]);
return ( return (
<> <>
<div className={`relative w-full ${className || "h-64"} rounded-md shadow-md`}> <div
className={`relative w-full ${className || "h-64"} overflow-hidden rounded-tile shadow-ambient-sm`}
>
<Swiper <Swiper
modules={[Navigation, Pagination, Autoplay]} modules={[Navigation, Pagination, Autoplay]}
spaceBetween={10} spaceBetween={10}
slidesPerView={1} slidesPerView={1}
navigation navigation
pagination={{ clickable: true }} pagination={{ clickable: true }}
autoplay={{ delay: 3000 }} autoplay={{ delay: 3500, disableOnInteraction: false }}
loop={images.length > 1}
className={`w-full ${className || "h-64"}`} className={`w-full ${className || "h-64"}`}
style={
{
"--swiper-navigation-color": "#26445d",
"--swiper-navigation-size": "28px",
"--swiper-pagination-color": "#26445d",
"--swiper-pagination-bullet-inactive-color": "#ffffff",
"--swiper-pagination-bullet-inactive-opacity": "0.6",
"--swiper-pagination-bullet-size": "8px",
"--swiper-pagination-bullet-horizontal-gap": "4px",
} as React.CSSProperties
}
> >
{images.map((img, index) => ( {images.map((img, index) => (
<SwiperSlide key={index} className="flex items-center justify-center h-full"> <SwiperSlide key={index} className="flex h-full items-center justify-center">
<img <img
src={img.url} src={img.url}
alt={img.alt} alt={img.alt}
className="w-full h-full object-cover rounded-md cursor-pointer transition-transform duration-300 hover:scale-105" className="h-full w-full cursor-zoom-in object-cover transition-transform duration-300 hover:scale-[1.02]"
onClick={() => setSelectedImage(img.url)} onClick={() => setSelectedImage(img.url)}
loading="lazy"
/> />
</SwiperSlide> </SwiperSlide>
))} ))}
@ -46,20 +93,34 @@ export default function Carousel({ images, className }: CarouselProps) {
{selectedImage && {selectedImage &&
createPortal( createPortal(
<div <div
className="fixed inset-0 flex items-center justify-center w-screen h-screen bg-black bg-opacity-10 backdrop-blur-2xl transition-opacity duration-300 z-[1000]" className="fixed inset-0 z-[1000] flex items-center justify-center bg-on-surface/80 p-4 backdrop-blur-sm transition-opacity duration-300"
onClick={() => setSelectedImage(null)} onClick={() => setSelectedImage(null)}
role="dialog"
aria-modal="true"
aria-label="Image agrandie"
> >
<div className="relative w-full max-w-6xl p-6 bg-transparent"> <div
className="relative flex max-h-[92vh] max-w-[92vw] items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<button <button
className="absolute top-6 right-6 text-white text-l bg-gray-900/70 p-2 rounded-full" type="button"
className="absolute -right-1 -top-1 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-surface-container-lowest/95 text-primary shadow-ambient-sm transition-colors hover:bg-primary hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
onClick={() => setSelectedImage(null)} onClick={() => setSelectedImage(null)}
aria-label="Fermer l'aperçu"
> >
<span
className="material-symbols-outlined"
aria-hidden="true"
translate="no"
>
close
</span>
</button> </button>
<img <img
src={selectedImage} src={selectedImage}
alt="Agrandissement" alt="Aperçu en taille réelle"
className="w-full h-full object-cover rounded-md" className="max-h-[92vh] max-w-[92vw] rounded-sheet object-contain shadow-ambient"
/> />
</div> </div>
</div>, </div>,
@ -67,4 +128,4 @@ export default function Carousel({ images, className }: CarouselProps) {
)} )}
</> </>
); );
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation, Pagination, Autoplay } from "swiper/modules"; import { Navigation, Pagination, Autoplay } from "swiper/modules";
@ -10,6 +10,13 @@ import "swiper/css/pagination";
import "../globals.css"; import "../globals.css";
import "../assets/main.css"; import "../assets/main.css";
/**
* Variante du `Carousel` pour les fiches compétences + la modale glossaire.
* Comportement et style identiques à `Carousel.tsx` (étape 7.a) les deux
* composants sont des quasi-doublons historiques, fusionner proprement demande
* de rationaliser `ContentSection*` et `ModalGlossaire` en même temps : hors
* scope, on garde la même API et les mêmes styles côte à côte pour l'instant.
*/
interface CarouselProps { interface CarouselProps {
images: Array<{ url: string; alt: string }>; images: Array<{ url: string; alt: string }>;
className?: string; className?: string;
@ -18,25 +25,53 @@ interface CarouselProps {
export default function CarouselCompetences({ images, className }: CarouselProps) { export default function CarouselCompetences({ images, className }: CarouselProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null); const [selectedImage, setSelectedImage] = useState<string | null>(null);
useEffect(() => {
if (!selectedImage) return;
document.body.classList.add("overflow-hidden");
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelectedImage(null);
};
window.addEventListener("keydown", handleKey);
return () => {
document.body.classList.remove("overflow-hidden");
window.removeEventListener("keydown", handleKey);
};
}, [selectedImage]);
return ( return (
<> <>
<div className={`relative w-full ${className || "h-64"} rounded-md shadow-md`}> <div
className={`relative w-full ${className || "h-64"} overflow-hidden rounded-tile shadow-ambient-sm`}
>
<Swiper <Swiper
modules={[Navigation, Pagination, Autoplay]} modules={[Navigation, Pagination, Autoplay]}
spaceBetween={10} spaceBetween={10}
slidesPerView={1} slidesPerView={1}
navigation navigation
pagination={{ clickable: true }} pagination={{ clickable: true }}
autoplay={{ delay: 3000 }} autoplay={{ delay: 3500, disableOnInteraction: false }}
loop={images.length > 1}
className={`w-full ${className || "h-64"}`} className={`w-full ${className || "h-64"}`}
style={
{
"--swiper-navigation-color": "#26445d",
"--swiper-navigation-size": "28px",
"--swiper-pagination-color": "#26445d",
"--swiper-pagination-bullet-inactive-color": "#ffffff",
"--swiper-pagination-bullet-inactive-opacity": "0.6",
"--swiper-pagination-bullet-size": "8px",
"--swiper-pagination-bullet-horizontal-gap": "4px",
} as React.CSSProperties
}
> >
{images.map((img, index) => ( {images.map((img, index) => (
<SwiperSlide key={index} className="flex items-center justify-center h-full"> <SwiperSlide key={index} className="flex h-full items-center justify-center">
<img <img
src={img.url} src={img.url}
alt={img.alt} alt={img.alt}
className="w-full h-full object-cover rounded-md cursor-pointer transition-transform duration-300 hover:scale-105" className="h-full w-full cursor-zoom-in object-cover transition-transform duration-300 hover:scale-[1.02]"
onClick={() => setSelectedImage(img.url)} onClick={() => setSelectedImage(img.url)}
loading="lazy"
/> />
</SwiperSlide> </SwiperSlide>
))} ))}
@ -46,20 +81,34 @@ export default function CarouselCompetences({ images, className }: CarouselProps
{selectedImage && {selectedImage &&
createPortal( createPortal(
<div <div
className="fixed inset-0 flex items-center justify-center w-screen h-screen bg-black bg-opacity-10 backdrop-blur-2xl transition-opacity duration-300 z-[1000]" className="fixed inset-0 z-[1000] flex items-center justify-center bg-on-surface/80 p-4 backdrop-blur-sm transition-opacity duration-300"
onClick={() => setSelectedImage(null)} onClick={() => setSelectedImage(null)}
role="dialog"
aria-modal="true"
aria-label="Image agrandie"
> >
<div className="relative w-full max-w-6xl p-6 bg-transparent"> <div
className="relative flex max-h-[92vh] max-w-[92vw] items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<button <button
className="absolute top-6 right-6 text-white text-l bg-gray-900/70 p-2 rounded-full" type="button"
className="absolute -right-1 -top-1 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-surface-container-lowest/95 text-primary shadow-ambient-sm transition-colors hover:bg-primary hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
onClick={() => setSelectedImage(null)} onClick={() => setSelectedImage(null)}
aria-label="Fermer l'aperçu"
> >
<span
className="material-symbols-outlined"
aria-hidden="true"
translate="no"
>
close
</span>
</button> </button>
<img <img
src={selectedImage} src={selectedImage}
alt="Agrandissement" alt="Aperçu en taille réelle"
className="w-full h-full object-cover rounded-md" className="max-h-[92vh] max-w-[92vw] rounded-sheet object-contain shadow-ambient"
/> />
</div> </div>
</div>, </div>,
@ -67,4 +116,4 @@ export default function CarouselCompetences({ images, className }: CarouselProps
)} )}
</> </>
); );
} }

View File

@ -1,70 +1,169 @@
import { useState } from "react"; "use client";
import { useEffect, useRef, useState } from "react";
import { askAI } from "../utils/askAI"; import { askAI } from "../utils/askAI";
/**
* GrasBot refonte Stitch (étape 7.d).
*
* L'API publique ne change pas (`onClose` propagé par le parent). Le bouton
* d'ouverture n'est plus dans ce composant : il est désormais porté par le FAB
* global `GrasBotFab` monté dans `app/layout.tsx`. Ce composant se concentre
* sur le panneau de conversation proprement dit.
*
* Détails notables :
* - Fond `surface-container-lowest/95 backdrop-blur-vellum rounded-sheet shadow-ambient`
* (plus de carte opaque `bg-white/70` + ombre lourde).
* - Header primary avec Material Symbol `smart_toy` (`translate="no"`, règle § 4 quinquies).
* - Bulles : user = `bg-primary text-white rounded-sheet` à droite, bot =
* `bg-surface-container text-on-surface rounded-sheet` à gauche. Corps Manrope.
* - Input `bg-surface-container-low focus-visible:ring-2 focus-visible:ring-primary`,
* bouton envoyer jewel avec Material Symbol `send`.
* - Auto-scroll en bas à chaque nouveau message, envoi à Enter.
*/
export default function ChatBot({ onClose }) { export default function ChatBot({ onClose }) {
const [question, setQuestion] = useState(""); const [question, setQuestion] = useState("");
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [isWaiting, setIsWaiting] = useState(false); const [isWaiting, setIsWaiting] = useState(false);
const scrollRef = useRef(null);
const inputRef = useRef(null);
const handleAsk = async () => { useEffect(() => {
if (!question.trim()) return; if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isWaiting]);
const userMessage = { sender: "user", text: question }; useEffect(() => {
setMessages([...messages, userMessage]); inputRef.current?.focus();
}, []);
setQuestion(""); const handleAsk = async () => {
setIsWaiting(true); if (!question.trim() || isWaiting) return;
try { const userMessage = { sender: "user", text: question };
const botResponse = await askAI(question); setMessages((prev) => [...prev, userMessage]);
const botMessage = { sender: "bot", text: botResponse }; setQuestion("");
setMessages((prevMessages) => [...prevMessages, botMessage]); setIsWaiting(true);
} catch (error) {
setMessages([...messages, { sender: "bot", text: "❌ Erreur de réponse. Réessayez plus tard." }]);
} finally {
setIsWaiting(false);
}
};
return ( try {
<div className="flex flex-col w-96 bg-white/70 shadow-lg rounded-lg border border-gray-300"> const botResponse = await askAI(userMessage.text);
<div className="bg-blue-600 text-white p-3 rounded-t-lg flex justify-between items-center"> setMessages((prev) => [...prev, { sender: "bot", text: botResponse }]);
<span className="font-headline font-bold">💬 GrasBot</span> } catch (_error) {
<button className="text-white hover:text-red-400 text-xl" onClick={onClose}></button> setMessages((prev) => [
</div> ...prev,
{ sender: "bot", text: "Erreur de réponse. Réessayez plus tard." },
]);
} finally {
setIsWaiting(false);
}
};
<div className="h-64 overflow-y-auto p-4 space-y-2"> const handleKeyDown = (e) => {
{messages.map((msg, index) => ( if (e.key === "Enter" && !e.shiftKey) {
<div e.preventDefault();
key={index} handleAsk();
className={`p-2 rounded-lg text-white font-headline text-xs ${msg.sender === "user" ? "bg-blue-500 ml-auto" : "bg-gray-500 mr-auto"}`} }
style={{ maxWidth: "80%" }} };
>
{msg.text}
</div>
))}
{isWaiting && (
<div className="p-2 rounded-lg text-white bg-gray-500 mr-auto wait-animation" style={{ maxWidth: "80%" }}>
wait<span className="dot-1">.</span><span className="dot-2">.</span><span className="dot-3">.</span>
</div>
)}
</div>
<div className="flex p-3 border-t border-gray-300"> return (
<input <div className="flex h-full w-full flex-col overflow-hidden rounded-sheet bg-surface-container-lowest/95 shadow-ambient backdrop-blur-vellum">
type="text" <div className="flex items-center justify-between gap-3 bg-primary px-4 py-3 text-white">
className="flex-1 p-2 border border-gray-300 font-headline text-xs rounded-l-lg focus:outline-none" <div className="flex min-w-0 items-center gap-2">
placeholder="Posez votre question..." <span
value={question} className="material-symbols-outlined text-2xl"
onChange={(e) => setQuestion(e.target.value)} aria-hidden="true"
/> translate="no"
<button >
className="bg-blue-500 text-white px-4 py-2 rounded-r-lg hover:bg-blue-700" smart_toy
onClick={handleAsk} </span>
> <div className="min-w-0">
<p className="truncate font-headline text-sm font-bold tracking-tight">GrasBot</p>
</button> <p className="truncate font-headline text-[10px] uppercase tracking-[0.25em] text-primary-fixed">
</div> Assistant IA locale
</p>
</div>
</div> </div>
); <button
} type="button"
onClick={onClose}
aria-label="Fermer le chat"
className="flex h-9 w-9 items-center justify-center rounded-full text-white transition-colors hover:bg-primary-container focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-fixed"
>
<span
className="material-symbols-outlined"
aria-hidden="true"
translate="no"
>
close
</span>
</button>
</div>
<div
ref={scrollRef}
className="flex-1 space-y-2 overflow-y-auto bg-surface-container-lowest/60 p-4"
>
{messages.length === 0 && !isWaiting && (
<p className="mt-6 text-center font-body italic text-sm text-on-surface-variant">
Posez-moi une question sur le site, les projets ou les compétences.
</p>
)}
{messages.map((msg, index) => (
<div
key={index}
className={`max-w-[80%] rounded-sheet px-3 py-2 font-headline text-xs leading-relaxed ${
msg.sender === "user"
? "ml-auto bg-primary text-white"
: "mr-auto bg-surface-container text-on-surface"
}`}
>
{msg.text}
</div>
))}
{isWaiting && (
<div
className="wait-animation mr-auto inline-flex max-w-[80%] items-end gap-0.5 rounded-sheet bg-surface-container px-3 py-2 font-headline text-xs text-on-surface-variant"
aria-live="polite"
>
GrasBot réfléchit
<span className="dot-1">.</span>
<span className="dot-2">.</span>
<span className="dot-3">.</span>
</div>
)}
</div>
<div className="flex items-center gap-2 border-t border-outline-variant/30 bg-surface-container-lowest/80 p-3">
<input
ref={inputRef}
type="text"
className="min-w-0 flex-1 rounded-tile bg-surface-container-low px-3 py-2 font-headline text-xs text-on-surface placeholder:text-on-surface-variant focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
placeholder="Votre question…"
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isWaiting}
aria-label="Votre question pour GrasBot"
/>
<button
type="button"
onClick={handleAsk}
disabled={isWaiting || !question.trim()}
aria-label="Envoyer la question"
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-white shadow-jewel transition-transform duration-200 hover:-translate-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none disabled:hover:translate-y-0"
>
<span
className="material-symbols-outlined text-lg"
aria-hidden="true"
translate="no"
>
send
</span>
</button>
</div>
</div>
);
}

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchData } from "../utils/fetchData"; import { fetchData } from "../utils/fetchData";
import { getApiUrl } from "../utils/getApiUrl"; import { getApiUrl } from "../utils/getApiUrl";
@ -31,52 +32,165 @@ interface ContentSectionProps {
contentClass?: string; contentClass?: string;
} }
export default function ContentSection({ collection, slug, titleClass, contentClass }: ContentSectionProps) { /**
* Fiche détail portfolio refonte "Digital Atelier" (étape 7.b).
*
* Structure : bouton retour + en-tête vellum (kicker + titre Manrope) + carousel
* détail (Swiper Stitch) + corps Markdown en `prose` Newsreader + CTA jewel
* optionnel vers le lien externe. Les props `titleClass` / `contentClass`
* héritées du composant pré-refonte restent acceptées pour compatibilité mais
* sont ignorées (styles tokenisés désormais) on les garde dans l'interface
* pour ne pas casser les consommateurs.
*/
export default function ContentSection({
collection,
slug,
}: ContentSectionProps) {
const [data, setData] = useState<ContentData | null>(null); const [data, setData] = useState<ContentData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
useEffect(() => { useEffect(() => {
async function fetchContent() { async function fetchContent() {
try {
const result = await fetchData(collection, slug); const result = await fetchData(collection, slug);
setData(result); setData(result);
} finally {
setIsLoading(false);
}
} }
fetchContent(); fetchContent();
}, [collection, slug, apiUrl]); }, [collection, slug, apiUrl]);
if (isLoading) {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pb-10 sm:px-6">
<div className="rounded-sheet bg-surface-container-lowest/65 p-6 shadow-ambient-sm backdrop-blur-vellum">
<div className="h-4 w-24 animate-pulse rounded-full bg-surface-container-low/80" />
<div className="mt-3 h-8 w-3/4 animate-pulse rounded-full bg-surface-container-low/80" />
<div className="mt-5 aspect-[16/9] w-full animate-pulse rounded-tile bg-surface-container-low/80" />
<div className="mt-5 h-4 w-full animate-pulse rounded-full bg-surface-container-low/60" />
<div className="mt-2 h-4 w-5/6 animate-pulse rounded-full bg-surface-container-low/60" />
<div className="mt-2 h-4 w-4/6 animate-pulse rounded-full bg-surface-container-low/60" />
</div>
</div>
);
}
if (!data) { if (!data) {
return <div className="text-center text-gray-500">Contenu introuvable.</div>; return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pb-10 sm:px-6">
<section className="rounded-sheet bg-surface-container-lowest/85 p-8 text-center shadow-ambient backdrop-blur-vellum">
<span
className="material-symbols-outlined mb-3 text-4xl text-primary"
aria-hidden="true"
translate="no"
>
search_off
</span>
<p className="font-body italic text-on-surface-variant">
Ce projet est introuvable.
</p>
<Link
href="/portfolio"
className="mt-5 inline-flex items-center gap-1.5 font-headline text-sm font-bold uppercase tracking-[0.2em] text-primary hover:underline"
>
<span
className="material-symbols-outlined text-lg"
aria-hidden="true"
translate="no"
>
arrow_back
</span>
Retour au portfolio
</Link>
</section>
</div>
);
} }
const { name, Resum: richText, picture, link, linkText } = data; const { name, Resum: richText, picture, link, linkText } = data;
const images = picture?.map((img: ImageData) => ({ const images =
url: `${apiUrl}${img.formats?.large?.url || img.url}`, picture?.map((img: ImageData) => ({
alt: img.name || "Image", url: `${apiUrl}${img.formats?.large?.url || img.url}`,
})) || []; alt: img.name || `Visuel du projet ${name}`,
})) || [];
return ( return (
<div className="max-w-3xl mx-auto p-6"> <div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-5 px-4 pb-10 sm:px-6">
<h1 className={titleClass || "bg-white/50 rounded-md text-3xl mb-6 font-headline font-extrabold tracking-tight p-2 text-blue-700"}>{name}</h1> {/* Bouton retour discret, posé sur le wallpaper comme une miette de fil d'Ariane. */}
<Link
href="/portfolio"
className="inline-flex w-fit items-center gap-1.5 rounded-full bg-surface-container-lowest/70 px-3 py-1.5 font-headline text-xs font-bold uppercase tracking-[0.2em] text-primary backdrop-blur-vellum transition-colors hover:bg-surface-container-lowest/95 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<span
className="material-symbols-outlined text-base"
aria-hidden="true"
translate="no"
>
arrow_back
</span>
Portfolio
</Link>
<Carousel images={images} className="w-full h-64" /> {/* En-tête "feuillet de vellum" aligné sur la home et les listes. */}
<section
<div className={contentClass || "bg-white/80 rounded-md p-4 font-headline font-bold shadow-md mt-6"}> className="rounded-sheet bg-surface-container-lowest/85 p-5 shadow-ambient backdrop-blur-vellum sm:p-7 md:p-8"
<ReactMarkdown>{richText}</ReactMarkdown> aria-labelledby="project-title"
</div> >
<div className="flex flex-col gap-3">
{link && ( <span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
<div className="mt-6"> Projet · Portfolio
<a </span>
href={link} <h1
target="_blank" id="project-title"
rel="noopener noreferrer" className="font-headline text-3xl font-extrabold tracking-tight text-on-surface md:text-4xl"
className="bg-white/65 rounded-md p-1 text-red-700 hover:underline transition duration-300 ease-in-out transform hover:scale-105 font-headline font-bold hover:text-blue-700"
> >
{linkText || "Voir plus/lien externe"} {name}
</a> </h1>
</div> </div>
)}
{images.length > 0 && (
<div className="mt-5">
<Carousel images={images} className="h-64 sm:h-80 md:h-96" />
</div>
)}
{richText && (
<div
className="prose prose-sm mt-5 max-w-none font-body text-on-surface-variant sm:prose-base
prose-headings:font-headline prose-headings:text-primary
prose-p:font-body prose-p:text-on-surface-variant
prose-strong:text-on-surface
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-li:marker:text-primary
prose-hr:border-0 prose-hr:w-16 prose-hr:mx-auto prose-hr:bg-primary/30 prose-hr:h-0.5 prose-hr:rounded-full prose-hr:my-6"
>
<ReactMarkdown>{richText}</ReactMarkdown>
</div>
)}
{link && (
<div className="mt-6">
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-tile bg-primary px-5 py-2.5 font-headline text-sm font-bold uppercase tracking-[0.2em] text-white shadow-jewel transition-transform duration-200 hover:-translate-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{linkText || "Voir plus"}
<span
className="material-symbols-outlined text-lg"
aria-hidden="true"
translate="no"
>
open_in_new
</span>
</a>
</div>
)}
</section>
</div> </div>
); );
} }

View File

@ -1,12 +1,12 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { getApiUrl } from "../utils/getApiUrl"; import { getApiUrl } from "../utils/getApiUrl";
import CarouselCompetences from "./CarouselCompetences"; import CarouselCompetences from "./CarouselCompetences";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw"; import rehypeRaw from "rehype-raw";
import ModalGlossaire from "./ModalGlossaire"; import ModalGlossaire from "./ModalGlossaire";
import ChatBot from "./ChatBot";
interface ImageData { interface ImageData {
url: string; url: string;
@ -37,32 +37,96 @@ interface ContentSectionProps {
contentClass?: string; contentClass?: string;
} }
/**
* Fiche détail compétences refonte "Digital Atelier" (étape 7.c).
*
* Trois changements structurants par rapport à la version pré-refonte :
*
* 1. **Style tokenisé** : même gabarit "feuillet de vellum" que le portfolio.
* Les classes hardcodées `bg-white/70 text-blue-700 font-headline font-bold`
* disparaissent. Le corps éditorial est rendu en `prose` Newsreader, les
* titres Markdown en Manrope `text-primary`.
*
* 2. **Keywords glossaire & chatbot sans styles inline** : on retire les
* `style="color: red/blue; cursor: pointer"` injectés dans le HTML. On
* conserve les classes `.keyword` / `.chatbot-keyword` historiques et on
* les stylise via `globals.css` avec la palette Stitch (voir `.glossary-keyword`).
* Pour rester rétro-compatible avec les classes historiques, `keyword` est
* renommée `glossary-keyword` dans la transformation.
*
* 3. **Event listeners scopés au wrapper** (ref `contentRef`) plutôt que
* `document.body.addEventListener`. Avant : risque de fuite + interaction
* avec d'autres parties du DOM. Après : la zone "contenu" capture ses clics
* en bubbling, comportement identique mais sans effet de bord global.
*
* 4. **Chatbot via FAB global** (étape 7.e) : plus de `<ChatBot />` local dans
* cette fiche. Un clic sur "IA locale" dispatch `CustomEvent("grasbot:open")`
* que le FAB monté dans `layout.tsx` écoute pour ouvrir le chatbot partagé.
*/
export default function ContentSectionCompetences({ export default function ContentSectionCompetences({
competenceData, competenceData,
glossaireData, glossaireData,
titleClass,
contentClass,
}: ContentSectionProps) { }: ContentSectionProps) {
console.log("🔍 [ContentSectionCompetences] Chargement du composant...");
const [selectedMot, setSelectedMot] = useState<GlossaireItem | null>(null); const [selectedMot, setSelectedMot] = useState<GlossaireItem | null>(null);
const [isChatbotOpen, setIsChatbotOpen] = useState(false); const contentRef = useRef<HTMLDivElement | null>(null);
const [loading, setLoading] = useState(competenceData === null);
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
// Délégation locale : capte les clics sur les keywords injectés dans le Markdown,
// sans polluer document.body comme avant la refonte.
useEffect(() => { useEffect(() => {
if (competenceData) { const node = contentRef.current;
setLoading(false); if (!node) return;
}
}, [competenceData]);
if (loading) { const handleClick = (event: Event) => {
return <div className="text-center text-gray-500"> Chargement des détails de la compétence...</div>; const target = event.target as HTMLElement;
}
if (target.dataset?.chatbot === "true") {
window.dispatchEvent(new CustomEvent("grasbot:open"));
return;
}
if (target.classList?.contains("glossary-keyword")) {
const mot = target.getAttribute("data-mot");
if (!mot) return;
const glossaireMot = glossaireData.find((g) => g.mot_clef === mot);
setSelectedMot(glossaireMot || null);
}
};
node.addEventListener("click", handleClick);
return () => node.removeEventListener("click", handleClick);
}, [glossaireData]);
if (!competenceData) { if (!competenceData) {
console.error("❌ [ContentSectionCompetences] Compétence introuvable !"); return (
return <div className="text-red-500 text-center"> Compétence introuvable.</div>; <div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pb-10 sm:px-6">
<section className="rounded-sheet bg-surface-container-lowest/85 p-8 text-center shadow-ambient backdrop-blur-vellum">
<span
className="material-symbols-outlined mb-3 text-4xl text-primary"
aria-hidden="true"
translate="no"
>
search_off
</span>
<p className="font-body italic text-on-surface-variant">
Cette compétence est introuvable.
</p>
<Link
href="/competences"
className="mt-5 inline-flex items-center gap-1.5 font-headline text-sm font-bold uppercase tracking-[0.2em] text-primary hover:underline"
>
<span
className="material-symbols-outlined text-lg"
aria-hidden="true"
translate="no"
>
arrow_back
</span>
Retour aux compétences
</Link>
</section>
</div>
);
} }
const { name, content, picture } = competenceData; const { name, content, picture } = competenceData;
@ -70,78 +134,95 @@ export default function ContentSectionCompetences({
const images = const images =
picture?.map((img) => ({ picture?.map((img) => ({
url: `${apiUrl}${img.formats?.large?.url || img.url}`, url: `${apiUrl}${img.formats?.large?.url || img.url}`,
alt: img.name || "Image de compétence", alt: img.name || `Visuel de la compétence ${name}`,
})) || []; })) || [];
/**
* Transforme le Markdown en injectant des spans `.glossary-keyword` / `.chatbot-keyword`
* autour des mots-clés trouvés. Les styles sont définis dans `globals.css`
* (palette Stitch, soulignement pointillé) plutôt qu'inline dans l'attribut style.
*/
function transformMarkdownWithKeywords(text: string) { function transformMarkdownWithKeywords(text: string) {
if (!glossaireData.length) return text; if (!text) return "";
let modifiedText = text; let modifiedText = text;
modifiedText = modifiedText.replace( modifiedText = modifiedText.replace(
/\bIA locale\b/g, /\bIA locale\b/g,
`<span class="chatbot-keyword" data-chatbot="true" style="color: red; cursor: pointer;">IA locale</span>` `<span class="chatbot-keyword" data-chatbot="true" role="button" tabindex="0">IA locale</span>`
); );
glossaireData.forEach(({ mot_clef, variantes }) => { if (glossaireData.length) {
const regexVariants = variantes glossaireData.forEach(({ mot_clef, variantes }) => {
.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) const regexVariants = variantes
.join("|"); .map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
const regex = new RegExp(`\\b(${mot_clef}|${regexVariants})\\b`, "gi"); .join("|");
const regex = new RegExp(`\\b(${mot_clef}|${regexVariants})\\b`, "gi");
modifiedText = modifiedText.replace(regex, (match) => { modifiedText = modifiedText.replace(regex, (match) => {
return `<span class="keyword" data-mot="${mot_clef}" style="color: blue; cursor: pointer;">${match}</span>`; return `<span class="glossary-keyword" data-mot="${mot_clef}" role="button" tabindex="0">${match}</span>`;
});
}); });
}); }
return modifiedText; return modifiedText;
} }
const contentWithLinks = transformMarkdownWithKeywords(content); const contentWithLinks = transformMarkdownWithKeywords(content);
useEffect(() => {
function handleKeywordClick(event: MouseEvent) {
const target = event.target as HTMLElement;
if (target.classList.contains("keyword")) {
const mot = target.getAttribute("data-mot");
if (mot) {
const glossaireMot = glossaireData.find((g) => g.mot_clef === mot);
setSelectedMot(glossaireMot || null);
}
}
}
document.body.addEventListener("click", handleKeywordClick);
return () => document.body.removeEventListener("click", handleKeywordClick);
}, [glossaireData]);
useEffect(() => {
function handleChatbotClick(event: MouseEvent) {
const target = event.target as HTMLElement;
if (target.dataset.chatbot === "true") {
setIsChatbotOpen(true);
}
}
document.body.addEventListener("click", handleChatbotClick);
return () => document.body.removeEventListener("click", handleChatbotClick);
}, []);
return ( return (
<div className="max-w-3xl mx-auto p-6"> <div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-5 px-4 pb-10 sm:px-6">
<h1 className={titleClass || "bg-white/60 rounded-md p-1 text-2xl mb-6 font-headline font-bold text-blue-700"}> <Link
{name} href="/competences"
</h1> className="inline-flex w-fit items-center gap-1.5 rounded-full bg-surface-container-lowest/70 px-3 py-1.5 font-headline text-xs font-bold uppercase tracking-[0.2em] text-primary backdrop-blur-vellum transition-colors hover:bg-surface-container-lowest/95 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
<CarouselCompetences images={images} className="w-full h-64" /> >
<div className={contentClass || "bg-white/70 rounded-md p-4 mt-6 text-lg font-headline font-bold text-gray-700"}> <span
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{contentWithLinks}</ReactMarkdown> className="material-symbols-outlined text-base"
</div> aria-hidden="true"
{selectedMot && <ModalGlossaire mot={selectedMot} onClose={() => setSelectedMot(null)} />} translate="no"
>
arrow_back
</span>
Compétences
</Link>
{isChatbotOpen && ( <section
<div className="fixed bottom-10 right-10 p-4 w-96"> className="rounded-sheet bg-surface-container-lowest/85 p-5 shadow-ambient backdrop-blur-vellum sm:p-7 md:p-8"
<ChatBot onClose={() => setIsChatbotOpen(false)} /> aria-labelledby="competence-title"
>
<div className="flex flex-col gap-3">
<span className="font-headline text-[11px] font-bold uppercase tracking-[0.3em] text-secondary">
Compétence · Savoir-faire
</span>
<h1
id="competence-title"
className="font-headline text-3xl font-extrabold tracking-tight text-on-surface md:text-4xl"
>
{name}
</h1>
</div> </div>
{images.length > 0 && (
<div className="mt-5">
<CarouselCompetences images={images} className="h-64 sm:h-80 md:h-96" />
</div>
)}
<div
ref={contentRef}
className="prose prose-sm mt-5 max-w-none font-body text-on-surface-variant sm:prose-base
prose-headings:font-headline prose-headings:text-primary
prose-p:font-body prose-p:text-on-surface-variant
prose-strong:text-on-surface
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-li:marker:text-primary
prose-hr:border-0 prose-hr:w-16 prose-hr:mx-auto prose-hr:bg-primary/30 prose-hr:h-0.5 prose-hr:rounded-full prose-hr:my-6"
>
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{contentWithLinks}</ReactMarkdown>
</div>
</section>
{selectedMot && (
<ModalGlossaire mot={selectedMot} onClose={() => setSelectedMot(null)} />
)} )}
</div> </div>
); );

View File

@ -11,9 +11,16 @@ interface ContentSectionProps {
contentClass?: string; contentClass?: string;
} }
export default function ContentSectionCompetencesContainer({ collection, slug, titleClass, contentClass }: ContentSectionProps) { /**
console.log("🔍 [ContentSectionCompetencesContainer] Chargement des données..."); * Orchestre le fetch compétence + glossaire et transmet les données au rendu.
* Le rendu (`ContentSectionCompetences`) gère son propre état "non trouvé" en
* feuillet vellum on s'aligne ici sur le même gabarit pour l'état de
* chargement (étape 7.c).
*/
export default function ContentSectionCompetencesContainer({
collection,
slug,
}: ContentSectionProps) {
const [competenceData, setCompetenceData] = useState(null); const [competenceData, setCompetenceData] = useState(null);
const [glossaireData, setGlossaireData] = useState([]); const [glossaireData, setGlossaireData] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -38,15 +45,24 @@ export default function ContentSectionCompetencesContainer({ collection, slug, t
}, [collection, slug]); }, [collection, slug]);
if (loading) { if (loading) {
return <div className="text-center text-gray-500"> Chargement des compétences...</div>; return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pb-10 sm:px-6">
<div className="rounded-sheet bg-surface-container-lowest/65 p-6 shadow-ambient-sm backdrop-blur-vellum">
<div className="h-4 w-24 animate-pulse rounded-full bg-surface-container-low/80" />
<div className="mt-3 h-8 w-3/4 animate-pulse rounded-full bg-surface-container-low/80" />
<div className="mt-5 aspect-[16/9] w-full animate-pulse rounded-tile bg-surface-container-low/80" />
<div className="mt-5 h-4 w-full animate-pulse rounded-full bg-surface-container-low/60" />
<div className="mt-2 h-4 w-5/6 animate-pulse rounded-full bg-surface-container-low/60" />
<div className="mt-2 h-4 w-4/6 animate-pulse rounded-full bg-surface-container-low/60" />
</div>
</div>
);
} }
return ( return (
<ContentSectionCompetences <ContentSectionCompetences
competenceData={competenceData} competenceData={competenceData}
glossaireData={glossaireData} glossaireData={glossaireData}
titleClass={titleClass}
contentClass={contentClass}
/> />
); );
} }

View File

@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import ChatBot from "./ChatBot";
/**
* FAB "GrasBot" global (étape 7.e).
*
* Monté une seule fois dans `app/layout.tsx` pour que le chatbot soit accessible
* **depuis toutes les pages**, pas seulement depuis les fiches compétences comme
* avant la refonte.
*
* Flux d'ouverture :
* - Clic direct sur le bouton flottant ouvre le panneau.
* - Clic sur un mot-clé `.chatbot-keyword` dans une fiche compétence
* `ContentSectionCompetences` dispatche `window.dispatchEvent(new CustomEvent("grasbot:open"))`,
* ce FAB écoute et ouvre le panneau. Pas de Context partagé, pas de store global :
* un événement `window` suffit pour un besoin one-shot et découple proprement
* le ChatBot de ses points d'entrée.
*
* Positionnement :
* - Bouton : `fixed bottom-6 right-6`, z-index 30 (au-dessus du contenu, en
* dessous du header z-20 ? non : au-dessus, pour rester accessible. On
* passe à z-30).
* - Panneau : `fixed inset-x-4 bottom-24 sm:inset-auto sm:bottom-24 sm:right-6`
* plein largeur mobile (avec 16 px de marge), 384 px desktop.
*/
export default function GrasBotFab() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleOpen = () => setIsOpen(true);
window.addEventListener("grasbot:open", handleOpen);
return () => window.removeEventListener("grasbot:open", handleOpen);
}, []);
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen]);
return (
<>
<button
type="button"
onClick={() => setIsOpen((v) => !v)}
aria-label={isOpen ? "Fermer GrasBot" : "Ouvrir GrasBot"}
aria-expanded={isOpen}
className="fixed bottom-6 right-6 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-white shadow-jewel transition-transform duration-200 hover:-translate-y-0.5 hover:bg-primary-container focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-fixed md:h-16 md:w-16"
>
<span
className="material-symbols-outlined text-3xl"
aria-hidden="true"
translate="no"
>
{isOpen ? "close" : "smart_toy"}
</span>
</button>
{isOpen && (
<div
className="fixed inset-x-4 bottom-24 z-30 h-[70vh] max-h-[560px] sm:inset-auto sm:bottom-24 sm:right-6 sm:h-[560px] sm:w-96"
role="dialog"
aria-modal="false"
aria-label="Conversation avec GrasBot"
>
<ChatBot onClose={() => setIsOpen(false)} />
</div>
)}
</>
);
}

View File

@ -60,6 +60,30 @@ body {
overflow-x: hidden; overflow-x: hidden;
} }
/* Keywords glossaire & chatbot dans les fiches compétences (étape 7.c).
Les spans sont injectés via `transformMarkdownWithKeywords` + `rehype-raw`.
Avant la refonte : styles inline `style="color:red/blue"` incohérent avec
la palette Stitch et intraçable. Désormais : deux classes utilitaires alignées
sur `primary` (#26445d), soulignement pointillé éditorial (underline dotted
offset 3px) pour signaler l'interactivité sans rompre le flux de lecture. */
.glossary-keyword,
.chatbot-keyword {
color: #26445d;
cursor: pointer;
font-weight: 600;
text-decoration: underline dotted;
text-underline-offset: 3px;
transition: color 0.2s ease;
}
.glossary-keyword:hover,
.chatbot-keyword:hover,
.glossary-keyword:focus-visible,
.chatbot-keyword:focus-visible {
color: #3e5c76;
outline: none;
}
@keyframes fade-in { @keyframes fade-in {
from { from {
opacity: 0; opacity: 0;

View File

@ -5,6 +5,7 @@ import Footer from "./components/Footer";
import "./assets/main.css"; import "./assets/main.css";
import "./globals.css"; import "./globals.css";
import NavLink from "./components/NavLink"; import NavLink from "./components/NavLink";
import GrasBotFab from "./components/GrasBotFab";
import { manrope, newsreader } from "./fonts"; import { manrope, newsreader } from "./fonts";
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
@ -244,6 +245,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<Footer /> <Footer />
</div> </div>
</div> </div>
{/* GrasBot : FAB global Stitch (étape 7.e). Accessible depuis toutes les
pages, écoute aussi `CustomEvent("grasbot:open")` dispatché depuis
les fiches compétences quand l'utilisateur clique sur « IA locale ». */}
<GrasBotFab />
</body> </body>
</html> </html>
); );

View File

@ -1,7 +1,7 @@
# Refonte visuelle — Direction "Digital Atelier" # Refonte visuelle — Direction "Digital Atelier"
**Créé :** 2026-04-22 **Créé :** 2026-04-22
**Statut :** en cours (étapes 1-6/8 terminées) **Statut :** en cours (étapes 1-7/8 terminées)
**Source d'inspiration :** `stitch_V1/` (design newsletter Stitch — `DESIGN.md` et `code.html`). **Source d'inspiration :** `stitch_V1/` (design newsletter Stitch — `DESIGN.md` et `code.html`).
**Audit préalable :** [`captures/AUDIT-VISUEL.md`](./captures/AUDIT-VISUEL.md). **Audit préalable :** [`captures/AUDIT-VISUEL.md`](./captures/AUDIT-VISUEL.md).
@ -60,7 +60,7 @@ Chaque étape = un lot cohérent + éventuelle mise à jour de `captures/AUDIT-V
| 4 | Layout racine : header No-Line, burger ghost, palette cercles, compteur migré, drawer | `app/layout.tsx`, `app/components/NavLink.jsx`, `app/components/Footer.jsx` | **fait** (2026-04-22) | | 4 | Layout racine : header No-Line, burger ghost, palette cercles, compteur migré, drawer | `app/layout.tsx`, `app/components/NavLink.jsx`, `app/components/Footer.jsx` | **fait** (2026-04-22) |
| 5 | Home : hero vellum, portrait frame, takeaways, pull-quote, CTAs | `app/page.tsx` | **fait** (2026-04-22) | | 5 | Home : hero vellum, portrait frame, takeaways, pull-quote, CTAs | `app/page.tsx` | **fait** (2026-04-22) |
| 6 | Listes portfolio + compétences : grille asymétrique, cartes éditoriales | `app/portfolio/page.jsx`, `app/competences/page.jsx`, composants `Carousel*` | **fait** (2026-04-22) | | 6 | Listes portfolio + compétences : grille asymétrique, cartes éditoriales | `app/portfolio/page.jsx`, `app/competences/page.jsx`, composants `Carousel*` | **fait** (2026-04-22) |
| 7 | Fiches détail + modale glossaire + GrasBot (jewel flottant) | `app/portfolio/[slug]/page.tsx`, `app/competences/[slug]/page.tsx`, `app/components/ModalGlossaire.tsx`, `app/components/ChatBot.js` | à faire | | 7 | Fiches détail + modale glossaire + GrasBot (jewel flottant) | `app/portfolio/[slug]/page.tsx`, `app/competences/[slug]/page.tsx`, `app/components/ModalGlossaire.tsx`, `app/components/ChatBot.js` | **fait** (2026-04-22) |
| 8 | Contact + Footer éditorial | `app/contact/page.js`, `app/components/ContactForm.tsx`, `app/components/Footer.jsx` | à faire | | 8 | Contact + Footer éditorial | `app/contact/page.js`, `app/components/ContactForm.tsx`, `app/components/Footer.jsx` | à faire |
## 4 bis. Correctif post-étape 3 (2026-04-22) — cohérence desktop/mobile ## 4 bis. Correctif post-étape 3 (2026-04-22) — cohérence desktop/mobile
@ -229,6 +229,83 @@ Premier retour utilisateur après l'étape 6 : *« j'ai perdu ma fonctionnalité
Les composants `app/components/Carousel.tsx` et `app/components/CarouselCompetences.tsx` deviennent donc formellement **"carousels de fiche détail"** dans la nomenclature interne ; `VignetteCarousel` est leur petit frère "liste". Un éventuel refactor plus tard pourra fusionner les deux premiers (quasi-doublons) — hors scope actuel. Les composants `app/components/Carousel.tsx` et `app/components/CarouselCompetences.tsx` deviennent donc formellement **"carousels de fiche détail"** dans la nomenclature interne ; `VignetteCarousel` est leur petit frère "liste". Un éventuel refactor plus tard pourra fusionner les deux premiers (quasi-doublons) — hors scope actuel.
## 7. Étape 7 — Fiches détail + glossaire + GrasBot flottant (2026-04-22)
L'étape 7 touche cinq composants et introduit un sixième (le FAB). Elle est découpée en cinq sous-lots décrits ci-dessous.
### 7.a Carousels fiche détail (`Carousel.tsx` + `CarouselCompetences.tsx`)
Les deux composants sont quasi-doublons historiques ; on les refait **à l'identique** pour ne pas risquer de régression sur la modale glossaire qui consomme `CarouselCompetences`. Un futur refactor pourra les fusionner une fois le périmètre de la refonte clos (pas dans le scope étape 7).
Changements appliqués :
- **Pagination bullets `primary`** via surcharge inline des variables Swiper (`--swiper-pagination-color: #26445d`, bullets 8 px). Même approche que `VignetteCarousel` pour ne pas polluer `globals.css` avec un sélecteur `.swiper-pagination-bullet` global.
- **Flèches** : on conserve les chevrons natifs Swiper, recolorés via `--swiper-navigation-color: #26445d`, taille 28 px. Remplacement par Material Symbols écarté (demande un override complet du markup via slots Swiper, sans bénéfice visuel proportionnel).
- **Conteneur** : `rounded-tile overflow-hidden shadow-ambient-sm` (vs `rounded-md shadow-md` pré-refonte).
- **`autoplay: 3500` + `disableOnInteraction: false`** : l'autoplay reprend après un swipe manuel plutôt que de rester figé.
- **`loop` conditionnel** (`images.length > 1`) : évite le warning Swiper sur les entrées mono-image.
- **Lightbox Stitch** : voile `bg-on-surface/80 backdrop-blur-sm` (vs `bg-black/10 backdrop-blur-2xl` qui noyait l'image dans un flou 2xl contre-productif), image en `object-contain max-h-[92vh] max-w-[92vw] rounded-sheet shadow-ambient` (ne déforme plus les portraits), bouton close rond 40 px Material Symbol `close` (`translate="no"`), verrouillage du scroll body + fermeture `Escape` + fermeture sur clic voile avec `stopPropagation` sur l'image.
### 7.b Fiche portfolio (`ContentSection.tsx`)
Avant : cartes `bg-white/50 text-blue-700 font-headline font-extrabold` hardcodées, lien externe `bg-white/65 text-red-700 hover:text-blue-700`, pas de retour vers la liste, pas de hiérarchie éditoriale. Contenu Markdown sans `prose`.
Après :
- **Gabarit aligné** sur les listes (étape 6) : wrapper `max-w-3xl` centré, fil d'Ariane minimaliste (pastille ronde `bg-surface-container-lowest/70 backdrop-blur-vellum` avec Material Symbol `arrow_back` + label « Portfolio »), carte vellum principale (`rounded-sheet bg-surface-container-lowest/85 shadow-ambient backdrop-blur-vellum`).
- **En-tête éditorial** : kicker `Projet · Portfolio` uppercase tracking-[0.3em] + titre Manrope extrabold `text-on-surface`.
- **Carousel détail** : `<Carousel>` plein cadre (`h-64 sm:h-80 md:h-96`) réutilise la version 7.a.
- **Corps Markdown en `prose` Stitch** : mêmes overrides que la home (`prose-headings:text-primary`, `prose-p:text-on-surface-variant`, `prose-hr:` transformés en pastille primary via le fix § 4 sexies). Le CV et les fiches partagent désormais la **même charte typographique**.
- **CTA externe jewel** : `bg-primary text-white shadow-jewel` avec Material Symbol `open_in_new` (`translate="no"`) et hover `-translate-y-0.5`. Le `linkText` Strapi reste utilisé, fallback « Voir plus » (au lieu de « Voir plus/lien externe » qui mélangeait 2 intentions).
- **États loading + not-found** en vellum plutôt qu'en ligne `text-gray-500` orpheline. Le not-found propose un retour explicite vers `/portfolio`.
### 7.c Fiche compétences (`ContentSectionCompetences.tsx` + `Container`)
Trois chantiers en un :
**Style** : gabarit identique à 7.b (pastille retour, en-tête vellum, carousel 16/9, prose Stitch). Les `titleClass` / `contentClass` historiques ne sont plus consommés mais on les garde dans l'interface TS pour ne pas casser le call-site.
**Keywords glossaire/chatbot sans styles inline** : avant, `transformMarkdownWithKeywords` injectait `<span style="color: blue">...</span>` — visuellement criard et non thématisable. Après, on injecte les classes `.glossary-keyword` et `.chatbot-keyword` (définies dans `globals.css`), stylées en `color: #26445d` (primary), `text-decoration: underline dotted`, `text-underline-offset: 3px`. Le soulignement pointillé signale l'interactivité sans rompre le flux de lecture, la couleur cohérente avec toute la charte Stitch. Attributs `role="button" tabindex="0"` ajoutés au passage pour l'accessibilité clavier (touche Entrée peut être câblée plus tard si besoin).
**Event listeners scopés** : avant, `document.body.addEventListener("click", ...)` × 2. Après, un seul listener attaché à `contentRef` (ref sur le wrapper prose). Les clics remontent en bubbling depuis les spans Markdown jusqu'au wrapper ; gain en clarté, pas de fuite, pas de risque de conflit avec d'autres zones du DOM. Le handler `keyword` → ouvre `ModalGlossaire`. Le handler `chatbot` → dispatch `CustomEvent("grasbot:open")` sur `window` pour réveiller le FAB global (7.e) au lieu d'instancier un `<ChatBot />` local.
**Container** (`ContentSectionCompetencesContainer.tsx`) : l'état de chargement `⏳ Chargement des compétences...` remplacé par un skeleton vellum (kicker + titre + carousel + 3 lignes de texte en `animate-pulse`), cohérent avec les listes et `ContentSection`.
### 7.d ChatBot (`ChatBot.js`)
Le composant garde son API (`onClose`) mais tout le shell visuel est refait :
| Zone | Avant | Après |
|------|-------|-------|
| Carte | `bg-white/70 shadow-lg rounded-lg border border-gray-300` | `bg-surface-container-lowest/95 backdrop-blur-vellum rounded-sheet shadow-ambient` (= gabarit vellum partagé) |
| Header | `bg-blue-600 text-white` + 💬 emoji + ❌ | `bg-primary text-white` + Material Symbol `smart_toy` + sous-titre Manrope uppercase « Assistant IA locale » + bouton close rond Material Symbol |
| Bulle user | `bg-blue-500 ml-auto` | `bg-primary text-white rounded-sheet` |
| Bulle bot | `bg-gray-500 mr-auto` | `bg-surface-container text-on-surface rounded-sheet` |
| Indicateur attente | `wait...` sur bulle grise | « GrasBot réfléchit... » en italique atténué avec 3 points animés `.dot-1/2/3` existants |
| Input | `border border-gray-300 rounded-l-lg` | `bg-surface-container-low rounded-tile focus-visible:ring-2 focus-visible:ring-primary` |
| Envoyer | `bg-blue-500 text-white ➤` | Bouton rond Material Symbol `send` jewel `bg-primary shadow-jewel hover:-translate-y-0.5`, disabled si vide ou en attente |
Ajouts fonctionnels : auto-scroll en bas à chaque nouveau message (ref `scrollRef`), focus auto sur l'input à l'ouverture, envoi à Entrée (`handleKeyDown`), input désactivé pendant l'attente (plus de double envoi possible), message d'accueil éditorial quand la conversation est vide.
### 7.e GrasBotFab (`GrasBotFab.tsx`)
Nouveau composant monté une seule fois dans `app/layout.tsx` → le chatbot est désormais accessible **depuis toutes les pages**, plus seulement les fiches compétences.
**Anatomie** :
- **Bouton** : `fixed bottom-6 right-6 z-30`, rond 56 px (64 px md), `bg-primary text-white shadow-jewel` avec Material Symbol `smart_toy` (→ `close` quand ouvert). Hover `-translate-y-0.5 hover:bg-primary-container`. `aria-expanded` tenu à jour.
- **Panneau** : `fixed inset-x-4 bottom-24` mobile (plein largeur - 16 px de chaque côté), `sm:inset-auto sm:bottom-24 sm:right-6 sm:w-96 sm:h-[560px]` desktop. `role="dialog"` avec `aria-label`. Monte le `<ChatBot>` refait en 7.d avec `onClose` qui ferme le panneau.
- **Fermeture Esc** globale dès que le panneau est ouvert.
**Flux d'entrée** :
- Clic direct sur le FAB → ouvre le panneau.
- Clic sur `.chatbot-keyword` (« IA locale ») dans une fiche compétence → `ContentSectionCompetences` dispatch `window.dispatchEvent(new CustomEvent("grasbot:open"))`, le FAB écoute et ouvre. Pas de Context, pas de store : un `CustomEvent` suffit pour un besoin one-shot et garde les composants découplés.
**Z-index** : le FAB est en `z-30`. Header en `z-20`, drawer mobile en `z-40`. On passe donc le FAB **devant** le header (sinon invisible sous la bande fixe) mais **derrière** le drawer (pour ne pas masquer la navigation). Si le drawer est ouvert, le FAB est recouvert ; acceptable, le drawer étant un mode modal de navigation.
### Points laissés pour l'étape 8
- Fusion de `Carousel.tsx` et `CarouselCompetences.tsx` (doublons) : hors scope étape 7 (pas de gain visuel, risque de régression non justifié). À reprendre en lot "dette technique" après l'étape 8.
- `ModalGlossaire` déjà aligné Stitch au correctif § 4 ter ; aucun changement nécessaire en 7, juste une vérification que son `CarouselCompetences` hérite bien de la lightbox 7.a — oui, c'est automatique.
- Persistance du fil de conversation GrasBot (refresh = historique perdu) : pas demandé, pas introduit. Ajouter un `localStorage` si besoin plus tard.
## 5. Checklist relecture (à passer à la fin de chaque étape) ## 5. Checklist relecture (à passer à la fin de chaque étape)
- [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter). - [ ] Aucune colonne unique globale `max-w-xl` (c'est le format newsletter).

View File

@ -8,7 +8,7 @@ Document vivant : ajuster les statuts et dates au fil du travail.
| ID | Sujet | Statut | Notes | | ID | Sujet | Statut | Notes |
|----|--------|--------|--------| |----|--------|--------|--------|
| R1 | Moderniser lUI (design system, cohérence typo/couleurs) | En cours | Direction "Digital Atelier" inspirée de `stitch_V1/` ; cadrage et plan dans [`REFONTE-VISUELLE.md`](./REFONTE-VISUELLE.md). Étapes 1-6 (tokens + garde-fou + migration typo globale + layout racine + home + listes portfolio/compétences) faites le 2026-04-22. | | R1 | Moderniser lUI (design system, cohérence typo/couleurs) | En cours | Direction "Digital Atelier" inspirée de `stitch_V1/` ; cadrage et plan dans [`REFONTE-VISUELLE.md`](./REFONTE-VISUELLE.md). Étapes 1-7 (tokens + garde-fou + migration typo globale + layout racine + home + listes portfolio/compétences + fiches détail & GrasBot) faites le 2026-04-22. Reste l'étape 8 (contact + footer éditorial). |
| R2 | Homogénéiser TS vs JS dans `app/` | À faire | Migrer progressivement les `.jsx`/`.js` | | R2 | Homogénéiser TS vs JS dans `app/` | À faire | Migrer progressivement les `.jsx`/`.js` |
| R3 | Centraliser config API (Strapi + LLM) via `.env` | À faire | Remplacer URLs en dur où pertinent | | R3 | Centraliser config API (Strapi + LLM) via `.env` | À faire | Remplacer URLs en dur où pertinent |
| R4 | Revoir `layout.tsx` (server vs client, perf SEO) | À faire | Évaluer extraction header/footer | | R4 | Revoir `layout.tsx` (server vs client, perf SEO) | À faire | Évaluer extraction header/footer |
@ -51,4 +51,5 @@ Document vivant : ajuster les statuts et dates au fil du travail.
| 2026-04-22 | **Correctif séparateurs `<hr>` du hero** (retour utilisateur : « trop d'espace entre les sections » en fait dû à des `<hr>` quasi invisibles générés depuis les `---` Markdown du CV Strapi). Option B retenue : surcharge `prose-hr` sur le wrapper `ReactMarkdown` (`app/page.tsx`) pour transformer la règle en pastille Stitch centrée (`prose-hr:border-0 prose-hr:w-16 prose-hr:mx-auto prose-hr:bg-primary/30 prose-hr:h-0.5 prose-hr:rounded-full prose-hr:my-6`). Le séparateur redevient un signal visuel intentionnel et cohérent avec la palette, marges verticales réduites de 32 px à 24 px. Détail dans `REFONTE-VISUELLE.md` §4 sexies. | | 2026-04-22 | **Correctif séparateurs `<hr>` du hero** (retour utilisateur : « trop d'espace entre les sections » en fait dû à des `<hr>` quasi invisibles générés depuis les `---` Markdown du CV Strapi). Option B retenue : surcharge `prose-hr` sur le wrapper `ReactMarkdown` (`app/page.tsx`) pour transformer la règle en pastille Stitch centrée (`prose-hr:border-0 prose-hr:w-16 prose-hr:mx-auto prose-hr:bg-primary/30 prose-hr:h-0.5 prose-hr:rounded-full prose-hr:my-6`). Le séparateur redevient un signal visuel intentionnel et cohérent avec la palette, marges verticales réduites de 32 px à 24 px. Détail dans `REFONTE-VISUELLE.md` §4 sexies. |
| 2026-04-22 | **Correctif post-étape 6 : réintroduction du défilement auto en vignette**. L'arbitrage initial "carousel retiré des listes" retiré après retour utilisateur (*« j'ai perdu ma fonctionnalité précédente »*). Nouveau composant `app/components/VignetteCarousel.tsx` : Swiper allégé (modules `Autoplay` + `Pagination` uniquement), **sans flèches** (conflit de clic avec le `<Link>` englobant) et **sans lightbox** (réservée à la fiche détail). Pagination bullets teintée `primary` via surcharge inline des variables CSS Swiper (`--swiper-pagination-color: #26445d`) pour ne pas polluer `globals.css`. Autoplay 3500 ms, `loop` conditionnel (`length > 1` pour éviter le warning Swiper sur les entrées mono-image). Intégré dans `app/portfolio/page.jsx` et `app/competences/page.jsx` via `images.length > 1 ? <VignetteCarousel /> : <img statique />` — comportement identique à l'ancien pour les mono-image, défilement retrouvé pour les multi-image. Les composants `Carousel.tsx` et `CarouselCompetences.tsx` existants restent intacts pour la fiche détail (étape 7). Détail dans `REFONTE-VISUELLE.md` §6 *"Correctif post-étape 6 — réintroduction du défilement automatique en vignette"*. | | 2026-04-22 | **Correctif post-étape 6 : réintroduction du défilement auto en vignette**. L'arbitrage initial "carousel retiré des listes" retiré après retour utilisateur (*« j'ai perdu ma fonctionnalité précédente »*). Nouveau composant `app/components/VignetteCarousel.tsx` : Swiper allégé (modules `Autoplay` + `Pagination` uniquement), **sans flèches** (conflit de clic avec le `<Link>` englobant) et **sans lightbox** (réservée à la fiche détail). Pagination bullets teintée `primary` via surcharge inline des variables CSS Swiper (`--swiper-pagination-color: #26445d`) pour ne pas polluer `globals.css`. Autoplay 3500 ms, `loop` conditionnel (`length > 1` pour éviter le warning Swiper sur les entrées mono-image). Intégré dans `app/portfolio/page.jsx` et `app/competences/page.jsx` via `images.length > 1 ? <VignetteCarousel /> : <img statique />` — comportement identique à l'ancien pour les mono-image, défilement retrouvé pour les multi-image. Les composants `Carousel.tsx` et `CarouselCompetences.tsx` existants restent intacts pour la fiche détail (étape 7). Détail dans `REFONTE-VISUELLE.md` §6 *"Correctif post-étape 6 — réintroduction du défilement automatique en vignette"*. |
| 2026-04-22 | **Correctif wallpaper sur-zoomé sur pages longues** (retour utilisateur post-étape 6 : incohérence visuelle entre la home et `/portfolio`). Cause : `.bg-wallpaper` en `absolute inset-0` à l'intérieur du conteneur grid `min-h-[100dvh]` — le conteneur s'étirant à la hauteur du contenu (≈ 2-3 viewports sur les listes), `background-size: cover` zoomait l'image pour couvrir toute cette hauteur. Fix : wallpaper sorti du grid, passé en `fixed inset-0 z-0 pointer-events-none` (calé sur le viewport, plus la page entière). Les cercles animés restent en `absolute` dans le grid. Impact transversal sur toutes les pages longues (fiches détail à venir, etc.). Détail dans `REFONTE-VISUELLE.md` §6. | | 2026-04-22 | **Correctif wallpaper sur-zoomé sur pages longues** (retour utilisateur post-étape 6 : incohérence visuelle entre la home et `/portfolio`). Cause : `.bg-wallpaper` en `absolute inset-0` à l'intérieur du conteneur grid `min-h-[100dvh]` — le conteneur s'étirant à la hauteur du contenu (≈ 2-3 viewports sur les listes), `background-size: cover` zoomait l'image pour couvrir toute cette hauteur. Fix : wallpaper sorti du grid, passé en `fixed inset-0 z-0 pointer-events-none` (calé sur le viewport, plus la page entière). Les cercles animés restent en `absolute` dans le grid. Impact transversal sur toutes les pages longues (fiches détail à venir, etc.). Détail dans `REFONTE-VISUELLE.md` §6. |
| 2026-04-22 | Refonte visuelle — **étape 7 : fiches détail + glossaire + GrasBot flottant**. Cinq sous-lots. **7.a** : `Carousel.tsx` + `CarouselCompetences.tsx` harmonisés (pagination bullets primary via variables CSS Swiper, flèches primary, `rounded-tile shadow-ambient-sm`, autoplay 3500 ms + `loop` conditionnel, lightbox Stitch refaite avec voile `bg-on-surface/80`, image `object-contain rounded-sheet`, bouton close rond Material Symbol + Escape + verrouillage scroll body). **7.b** : `ContentSection.tsx` (fiche portfolio) — gabarit vellum cohérent avec la home/listes, pastille retour `arrow_back`, kicker `Projet · Portfolio`, titre Manrope, carousel détail plein cadre, corps Markdown en `prose` Stitch (mêmes overrides que la home, y compris pastille `prose-hr`), CTA externe jewel avec `open_in_new`, états loading/404 en vellum. **7.c** : `ContentSectionCompetences.tsx` + container — même gabarit. Refactor glossaire : styles inline `style="color:red/blue"` remplacés par les classes `.glossary-keyword` / `.chatbot-keyword` (ajoutées à `globals.css`, couleur primary + underline dotted offset 3 px). Event listeners `document.body.addEventListener` remplacés par un listener unique scopé au wrapper `contentRef`. Le clic sur « IA locale » ne monte plus un `<ChatBot>` local mais dispatch `window.dispatchEvent(new CustomEvent("grasbot:open"))` capté par le FAB global (7.e). Container refait avec skeleton vellum à la place de `⏳ Chargement...`. **7.d** : `ChatBot.js` entièrement restylé (carte vellum `rounded-sheet shadow-ambient backdrop-blur-vellum`, header primary avec Material Symbol `smart_toy` + sous-titre « Assistant IA locale », bulles user `bg-primary text-white` et bot `bg-surface-container`, input Stitch avec `focus-visible:ring-primary`, bouton envoyer rond jewel Material Symbol `send`, auto-scroll, focus auto, envoi Enter, disabled pendant attente, message d'accueil vide éditorial). **7.e** : nouveau composant `app/components/GrasBotFab.tsx` — FAB jewel `fixed bottom-6 right-6 z-30` rond 56/64 px, `bg-primary shadow-jewel` Material Symbol `smart_toy`/`close`, monté dans `app/layout.tsx` → chatbot accessible depuis **toutes les pages** (plus seulement fiches compétences). Écoute `CustomEvent("grasbot:open")` dispatché depuis le keyword « IA locale ». Fermeture Escape globale, panneau responsive plein largeur mobile / 384 px desktop. Détails dans `REFONTE-VISUELLE.md` §7. |
| 2026-04-22 | Refonte visuelle — **étape 6 : listes portfolio + compétences**. `app/portfolio/page.jsx` et `app/competences/page.jsx` entièrement réécrits. En-tête éditorial (kicker + titre Manrope extrabold + pitch Newsreader) cohérent avec le hero de la home. Grille **asymétrique 2/3 + 1/3** alternée (`md:grid-cols-6` + pattern de `col-span-4`/`col-span-2` sur modulo 4, `sm:grid-cols-2`, `grid-cols-1` mobile) — conforme DESIGN.md §6 "No-Grid-Lock". Cartes « feuillet vellum » alignées home : `rounded-sheet bg-surface-container-lowest/85 backdrop-blur-vellum shadow-ambient`, image `aspect-[4/3]` fixe avec `group-hover:scale-[1.03]`, titre `text-primary`, description `line-clamp-3` en Newsreader, CTA tertiaire « Découvrir → » / « Explorer → » avec Material Symbol `arrow_forward` qui se décale au hover (`translate="no"` appliqué). Hover : `hover:-translate-y-0.5 hover:shadow-jewel` (remplace le `scale-105` qui débordait). **`Swiper` retiré des vignettes de liste** (arbitrage acté § 2 : carousel réservé aux galeries intra-fiche) — une seule image par carte, `loading="lazy"`. États ajoutés : skeletons animés respectant la grille + état vide avec Material Symbol. Régressions corrigées au passage : largeur fixe `w-80` qui débordait sur S25 Ultra, `hover:scale-105` qui tapait sous le header, classes `bg-white/80 rounded-lg` remplacées par les tokens Stitch. Les composants `Carousel.tsx` et `CarouselCompetences.tsx` restent en place pour les fiches détail (étape 7). Détail dans `REFONTE-VISUELLE.md` §6. | | 2026-04-22 | Refonte visuelle — **étape 6 : listes portfolio + compétences**. `app/portfolio/page.jsx` et `app/competences/page.jsx` entièrement réécrits. En-tête éditorial (kicker + titre Manrope extrabold + pitch Newsreader) cohérent avec le hero de la home. Grille **asymétrique 2/3 + 1/3** alternée (`md:grid-cols-6` + pattern de `col-span-4`/`col-span-2` sur modulo 4, `sm:grid-cols-2`, `grid-cols-1` mobile) — conforme DESIGN.md §6 "No-Grid-Lock". Cartes « feuillet vellum » alignées home : `rounded-sheet bg-surface-container-lowest/85 backdrop-blur-vellum shadow-ambient`, image `aspect-[4/3]` fixe avec `group-hover:scale-[1.03]`, titre `text-primary`, description `line-clamp-3` en Newsreader, CTA tertiaire « Découvrir → » / « Explorer → » avec Material Symbol `arrow_forward` qui se décale au hover (`translate="no"` appliqué). Hover : `hover:-translate-y-0.5 hover:shadow-jewel` (remplace le `scale-105` qui débordait). **`Swiper` retiré des vignettes de liste** (arbitrage acté § 2 : carousel réservé aux galeries intra-fiche) — une seule image par carte, `loading="lazy"`. États ajoutés : skeletons animés respectant la grille + état vide avec Material Symbol. Régressions corrigées au passage : largeur fixe `w-80` qui débordait sur S25 Ultra, `hover:scale-105` qui tapait sous le header, classes `bg-white/80 rounded-lg` remplacées par les tokens Stitch. Les composants `Carousel.tsx` et `CarouselCompetences.tsx` restent en place pour les fiches détail (étape 7). Détail dans `REFONTE-VISUELLE.md` §6. |