Site personnel courcellematthi.eu

This commit is contained in:
lrdlg
2026-08-05 22:40:03 +02:00
commit df12d21165
39 changed files with 4503 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
/* ==========================================================================
base.css — Reset moderne et styles des éléments HTML natifs
--------------------------------------------------------------------------
Uniquement des sélecteurs d'éléments, aucune classe. Objectif : un HTML
sémantique écrit sans classe doit déjà être présentable et accessible.
========================================================================== */
@layer base {
/* ======================================================================
RESET
Reset ciblé plutôt que « * { margin:0; padding:0 } » aveugle : on
neutralise ce qui gêne, on garde ce qui est utile (styles de listes,
comportements natifs des formulaires).
====================================================================== */
*,
*::before,
*::after {
box-sizing: border-box; /* La largeur inclut padding et bordure */
}
/* Suppression des marges par défaut : elles varient d'un navigateur à
l'autre et provoquent des fusions de marges imprévisibles. Les
espacements sont pilotés explicitement par les composants. */
body, h1, h2, h3, h4, h5, h6,
p, figure, blockquote, dl, dd {
margin-block: 0;
}
html {
/* Empêche iOS d'agrandir le texte en paysage sans demande explicite */
-webkit-text-size-adjust: 100%;
/* Défilement fluide pour les ancres — neutralisé plus bas si
l'utilisateur a demandé la réduction des animations */
scroll-behavior: smooth;
/* Décale la cible des ancres pour qu'elle ne passe pas sous
l'en-tête collant. Indispensable dès qu'un header est sticky. */
scroll-padding-block-start: calc(var(--header-height) + var(--space-md));
}
body {
min-block-size: 100svh; /* svh : hauteur stable malgré la barre d'URL mobile */
background-color: var(--color-bg);
color: var(--color-ink);
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
/* Lissage : rend Inter plus fidèle sur écrans macOS */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Ligatures et crénage activés pour le texte courant */
text-rendering: optimizeLegibility;
font-kerning: normal;
}
/* ======================================================================
MÉDIAS
====================================================================== */
img, picture, svg, video, canvas {
display: block; /* Supprime l'espace fantôme sous les images
(dû à l'alignement sur la ligne de base) */
max-inline-size: 100%; /* Jamais de débordement horizontal */
block-size: auto; /* Préserve le rapport d'aspect */
}
/* ======================================================================
TYPOGRAPHIE
====================================================================== */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-mono);
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
letter-spacing: -0.02em; /* Compense l'aération naturelle du monospace
aux grandes tailles */
text-wrap: balance; /* Répartit les mots sur des lignes de longueur
comparable — évite le mot orphelin */
}
p, li, dd, figcaption {
text-wrap: pretty; /* Évite les orphelins en fin de paragraphe */
}
/* Les listes porteuses de role="list" sont des listes sémantiques dont on
ne veut pas la puce native. Le rôle explicite est nécessaire : Safari
retire la sémantique de liste quand list-style vaut none. */
ul[role="list"],
ol[role="list"] {
list-style: none;
padding-inline-start: 0;
}
/* Texte mis en avant */
strong, b { font-weight: var(--weight-semibold); }
/* Abréviations : curseur d'aide + soulignement pointillé */
abbr[title] {
text-decoration: underline dotted;
cursor: help;
}
/* ======================================================================
LIENS
====================================================================== */
a {
color: var(--color-accent-dark);
text-decoration-thickness: 1px;
text-underline-offset: 0.2em; /* Le soulignement ne coupe pas les jambages */
transition: color var(--transition-fast);
}
a:hover { color: var(--color-accent); }
/* ======================================================================
ACCESSIBILITÉ
====================================================================== */
/* :focus-visible plutôt que :focus — l'anneau apparaît à la navigation
clavier mais pas au clic souris. Ne jamais faire « outline: none »
sans remplacement visible : c'est la faute d'accessibilité la plus
répandue sur le web. */
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 3px;
border-radius: var(--radius-sm);
}
/* Marge lors du saut vers une ancre (complément de scroll-padding) */
:target {
scroll-margin-block-start: calc(var(--header-height) + var(--space-md));
}
/* ======================================================================
FORMULAIRES ET BOUTONS
====================================================================== */
button, input, select, textarea {
font: inherit; /* Les navigateurs n'héritent pas la police par défaut */
color: inherit;
}
button {
background: none;
border: none;
cursor: pointer;
}
/* ======================================================================
PRÉFÉRENCES SYSTÈME
Respecter les réglages d'accessibilité de l'utilisateur n'est pas
optionnel : les animations peuvent déclencher nausées et migraines
chez les personnes sensibles aux troubles vestibulaires.
====================================================================== */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Mode contraste élevé (Windows, préférence système) */
@media (prefers-contrast: more) {
:root {
--color-ink-faint: #4c5a56;
--color-line: #9aa5a2;
--color-line-strong: #6b7674;
}
}
}
+455
View File
@@ -0,0 +1,455 @@
/* ==========================================================================
components.css — Blocs réutilisables
--------------------------------------------------------------------------
Nomenclature BEM : .bloc, .bloc__element, .bloc--modificateur.
Intérêt : la spécificité reste plate (une classe = 0,1,0), le nom dit
à quoi appartient l'élément, et aucun style ne fuit hors de son bloc.
========================================================================== */
@layer components {
/* ======================================================================
EYEBROW — étiquette au-dessus d'un titre
====================================================================== */
.eyebrow {
display: block;
font-family: var(--font-mono);
font-size: var(--text-xs);
letter-spacing: 0.04em;
color: var(--color-accent-dark);
margin-block-end: var(--space-md);
}
/* Le prompt de terminal est purement décoratif : généré en CSS, il
n'est donc pas lu par les lecteurs d'écran ni copié avec le texte. */
.eyebrow::before {
content: ">_ ";
color: var(--color-accent);
}
/* ======================================================================
TITRES
====================================================================== */
.page-title {
font-size: var(--text-2xl);
letter-spacing: -0.03em;
margin-block-end: var(--space-sm);
}
.page-subtitle {
font-family: var(--font-mono);
font-size: var(--text-md);
font-weight: var(--weight-regular);
line-height: var(--leading-snug);
color: var(--color-ink-soft);
max-inline-size: 40ch;
margin-block-end: var(--space-lg);
}
.section-title {
font-size: var(--text-xl);
margin-block-end: var(--space-xl);
scroll-margin-block-start: calc(var(--header-height) + var(--space-lg));
}
.section-title::before {
content: ">_ ";
color: var(--color-accent);
}
/* ======================================================================
LISTE DE CONTACTS
====================================================================== */
.contact-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs) var(--space-lg);
list-style: none;
margin: 0;
padding: 0;
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-ink-faint);
}
.contact-list li {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
}
.contact-list__icon {
inline-size: 0.95rem;
block-size: 0.95rem;
stroke: var(--color-accent);
stroke-width: 1.8;
fill: none;
flex-shrink: 0;
}
/* ======================================================================
BOUTONS D'ACTION
Deux niveaux seulement : principal (plein) et secondaire (contour).
Au-delà, la hiérarchie visuelle devient illisible.
====================================================================== */
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
margin-block-start: var(--space-lg);
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
min-block-size: var(--tap-target);
padding-inline: var(--space-md);
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
text-decoration: none;
border-radius: var(--radius-md);
border: 1px solid transparent;
transition: background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.button__icon {
inline-size: 1rem;
block-size: 1rem;
stroke: currentcolor;
stroke-width: 1.9;
fill: none;
flex-shrink: 0;
}
.button--primary {
background-color: var(--color-accent-dark);
color: var(--color-ink-inverse);
}
.button--primary:hover {
background-color: var(--color-ink);
color: var(--color-ink-inverse);
}
.button--secondary {
background-color: var(--color-surface);
border-color: var(--color-line-strong);
color: var(--color-ink-soft);
}
.button--secondary:hover {
border-color: var(--color-accent);
color: var(--color-accent-dark);
}
/* ======================================================================
INTRODUCTION
====================================================================== */
.intro { padding-block: var(--space-2xl) var(--space-xl); }
.intro__heading {
font-size: var(--text-lg);
line-height: var(--leading-snug);
max-inline-size: 34ch;
margin-block-end: var(--space-lg);
}
.intro__text {
color: var(--color-ink-soft);
max-inline-size: var(--width-prose);
margin-block-end: var(--space-md);
}
.intro__text strong {
color: var(--color-ink);
font-weight: var(--weight-semibold);
}
/* Dernier paragraphe mis en exergue par un filet latéral plutôt que par
un fond coloré : plus sobre, et imprimable sans consommer d'encre. */
.intro__text--highlight {
border-inline-start: 3px solid var(--color-accent);
padding-inline-start: var(--space-lg);
color: var(--color-ink);
}
/* ======================================================================
ENTRÉE DE PARCOURS (timeline)
====================================================================== */
.entry {
position: relative;
padding-inline-start: var(--space-lg);
padding-block-end: var(--space-xl);
border-inline-start: 2px solid var(--color-line);
}
/* La dernière entrée ne prolonge pas le trait dans le vide */
.entry:last-child {
padding-block-end: 0;
border-inline-start-color: transparent;
}
/* Pastille de jalon. Décorative : aucune information n'en dépend. */
.entry::before {
content: "";
position: absolute;
inset-inline-start: -7px;
inset-block-start: 0.55rem;
inline-size: 12px;
block-size: 12px;
border-radius: 50%;
background-color: var(--color-bg);
border: 2px solid var(--color-accent);
}
.entry__date {
display: block;
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--color-accent-dark);
margin-block-end: var(--space-2xs);
}
.entry__role {
font-size: var(--text-md);
margin-block-end: var(--space-2xs);
}
.entry__org {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-ink-faint);
margin-block-end: var(--space-md);
}
.entry__note {
color: var(--color-ink-soft);
font-size: var(--text-sm);
max-inline-size: var(--width-prose);
}
/* ======================================================================
FICHE DE COURS
====================================================================== */
.course {
background-color: var(--color-surface);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
padding: var(--space-md);
margin-block-end: var(--space-xs);
transition: border-color var(--transition-base);
}
.course:last-child { margin-block-end: 0; }
.course:hover { border-color: var(--color-line-strong); }
.course__name {
display: block;
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: var(--weight-bold);
margin-block-end: var(--space-2xs);
}
.course__name::before {
content: "▪ ";
color: var(--color-accent);
}
.course__desc {
font-size: var(--text-sm);
color: var(--color-ink-soft);
line-height: 1.65;
}
/* ======================================================================
LISTE À PUCES PERSONNALISÉE
Puce en ::before plutôt que list-style-image : couleur pilotable par
variable, alignement contrôlé au pixel.
====================================================================== */
.marker-list {
list-style: none;
margin: 0;
padding: 0;
color: var(--color-ink-soft);
font-size: var(--text-sm);
}
.marker-list li {
position: relative;
padding-inline-start: var(--space-md);
margin-block-end: var(--space-xs);
}
.marker-list li:last-child { margin-block-end: 0; }
.marker-list li::before {
content: "▪";
position: absolute;
inset-inline-start: 0;
color: var(--color-accent);
}
/* ======================================================================
CARTE
====================================================================== */
.card {
background-color: var(--color-surface);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
padding: var(--space-lg);
box-shadow: var(--shadow-sm);
transition: border-color var(--transition-base),
transform var(--transition-base),
box-shadow var(--transition-base);
}
.card:hover {
border-color: var(--color-accent);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.card__title {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-base);
margin-block-end: var(--space-sm);
}
.card__icon {
inline-size: 1.1rem;
block-size: 1.1rem;
stroke: var(--color-accent);
stroke-width: 1.8;
fill: none;
flex-shrink: 0;
}
/* ======================================================================
PROJET
====================================================================== */
.project {
background-color: var(--color-surface);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-block-end: var(--space-sm);
transition: border-color var(--transition-base);
}
.project:last-child { margin-block-end: 0; }
.project:hover { border-color: var(--color-line-strong); }
.project__header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2xs) var(--space-sm);
margin-block-end: var(--space-xs);
}
.project__title { font-size: var(--text-base); }
.project__desc {
color: var(--color-ink-soft);
font-size: var(--text-sm);
max-inline-size: var(--width-prose);
}
.project__desc + .project__desc { margin-block-start: var(--space-xs); }
.project__stack {
display: flex;
flex-wrap: wrap;
gap: var(--space-2xs);
list-style: none;
margin: var(--space-sm) 0 0;
padding: 0;
}
.project__stack li {
font-family: var(--font-mono);
font-size: var(--text-2xs);
color: var(--color-ink-faint);
background-color: var(--color-surface-alt);
border-radius: var(--radius-sm);
padding: 2px var(--space-xs);
}
/* ======================================================================
BADGE
====================================================================== */
.badge {
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--color-accent-dark);
background-color: var(--color-accent-wash);
border: 1px solid var(--color-accent-border);
border-radius: var(--radius-sm);
padding: 2px var(--space-xs);
white-space: nowrap;
}
/* Variante neutre : signale un état non abouti sans l'emphase de l'accent */
.badge--muted {
color: var(--color-ink-faint);
background-color: var(--color-surface-alt);
border-color: var(--color-line-strong);
}
/* ======================================================================
FORMATION
====================================================================== */
.education {
border-inline-start: 2px solid var(--color-line);
padding-inline-start: var(--space-lg);
}
.education__degree {
font-size: var(--text-base);
margin-block-end: var(--space-2xs);
}
.education__school {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-ink-faint);
}
/* ======================================================================
BLOC DE CONTACT (appel à l'action de fin de page)
====================================================================== */
.contact-block {
background-color: var(--color-surface);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
padding: var(--space-xl);
}
.contact-block__text {
color: var(--color-ink-soft);
max-inline-size: var(--width-prose);
}
}
+330
View File
@@ -0,0 +1,330 @@
/* ==========================================================================
layout.css — Structure de page
--------------------------------------------------------------------------
Conteneurs, en-tête collant, navigation, sections, grilles, pied de page.
Ce fichier positionne ; components.css habille.
========================================================================== */
@layer layout {
/* ======================================================================
CONTENEUR
Un seul conteneur réutilisé partout : la largeur de contenu est décidée
à un seul endroit.
====================================================================== */
.container {
inline-size: 100%;
max-inline-size: var(--width-content);
margin-inline: auto;
padding-inline: var(--gutter);
}
/* ======================================================================
LIEN D'ÉVITEMENT
Premier élément focusable de la page. Permet à un utilisateur au
clavier ou au lecteur d'écran de sauter la navigation pour atteindre
directement le contenu. Critère WCAG 2.4.1 (Contourner des blocs).
Masqué visuellement hors focus, mais jamais display:none — sinon il
ne serait pas focusable.
====================================================================== */
.skip-link {
position: fixed;
inset-block-start: var(--space-xs);
inset-inline-start: var(--space-xs);
z-index: var(--z-skip-link);
padding: var(--space-xs) var(--space-md);
background-color: var(--color-ink);
color: var(--color-ink-inverse);
font-family: var(--font-mono);
font-size: var(--text-sm);
text-decoration: none;
border-radius: var(--radius-sm);
transform: translateY(-200%);
transition: transform var(--transition-fast);
}
.skip-link:focus {
transform: translateY(0);
color: var(--color-ink-inverse);
}
/* ======================================================================
EN-TÊTE COLLANT
====================================================================== */
.site-header {
position: sticky;
inset-block-start: 0;
z-index: var(--z-sticky);
background-color: var(--color-bg-translucent);
border-block-end: 1px solid var(--color-line);
/* Flou d'arrière-plan : le contenu qui défile dessous reste deviné
sans nuire à la lisibilité du menu. Dégradation propre si non
supporté (le fond translucide suffit). */
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.site-header__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
min-block-size: var(--header-height);
}
.site-header__brand {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: var(--weight-bold);
color: var(--color-ink);
text-decoration: none;
letter-spacing: -0.01em;
white-space: nowrap;
}
.site-header__brand:hover { color: var(--color-ink); }
.site-header__logo {
inline-size: 1.6rem;
block-size: 1.6rem;
flex-shrink: 0;
}
/* ======================================================================
NAVIGATION PRINCIPALE
====================================================================== */
.site-nav__list {
display: flex;
align-items: center;
gap: var(--space-lg);
list-style: none;
margin: 0;
padding: 0;
}
.site-nav__link {
display: inline-block;
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-ink-faint);
text-decoration: none;
padding-block: var(--space-2xs);
border-block-end: 2px solid transparent;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.site-nav__link:hover { color: var(--color-ink); }
/* Section courante. aria-current n'est pas qu'un sélecteur pratique :
il annonce la position aux lecteurs d'écran. Le repère visuel repose
sur la couleur ET sur une bordure — jamais sur la couleur seule
(WCAG 1.4.1, information non véhiculée par la couleur uniquement). */
.site-nav__link[aria-current="true"] {
color: var(--color-ink);
border-block-end-color: var(--color-accent);
}
/* ======================================================================
BOUTON MENU (mobile)
====================================================================== */
.nav-toggle {
display: none; /* Affiché uniquement sous le point de rupture */
align-items: center;
gap: var(--space-xs);
min-block-size: var(--tap-target);
padding-inline: var(--space-xs);
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-ink-soft);
}
.nav-toggle__icon {
inline-size: 1.15rem;
block-size: 1.15rem;
stroke: currentcolor;
stroke-width: 2;
fill: none;
}
/* Les deux tracés de l'icône se transforment en croix à l'ouverture */
.nav-toggle__line {
transform-origin: center;
transition: transform var(--transition-base), opacity var(--transition-fast);
}
.nav-toggle[aria-expanded="true"] .nav-toggle__line--top { transform: translateY(5px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] .nav-toggle__line--middle { opacity: 0; }
.nav-toggle[aria-expanded="true"] .nav-toggle__line--bottom { transform: translateY(-5px) rotate(-45deg); }
/* ======================================================================
RÉGIONS DE CONTENU
====================================================================== */
.masthead {
padding-block: var(--space-3xl) var(--space-2xl);
border-block-end: 1px solid var(--color-line);
}
.section {
padding-block: var(--space-3xl);
border-block-start: 1px solid var(--color-line);
}
/* ======================================================================
GRILLE AUTO-ADAPTATIVE
auto-fit + minmax : le nombre de colonnes s'ajuste seul à la largeur
disponible, sans media query. Le min() interne évite le débordement
sur les très petits écrans (une colonne ne peut pas être plus large
que son conteneur).
====================================================================== */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(17.5rem, 100%), 1fr));
gap: var(--space-md);
}
.grid--wide {
grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
}
/* Empilement vertical régulier */
.stack > * + * { margin-block-start: var(--space-md); }
/* ======================================================================
PIED DE PAGE
====================================================================== */
.site-footer {
border-block-start: 1px solid var(--color-line);
padding-block: var(--space-xl) var(--space-2xl);
}
.site-footer__inner {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: var(--space-sm) var(--space-lg);
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-ink-faint);
}
.site-footer__links {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
list-style: none;
margin: 0;
padding: 0;
}
/* ======================================================================
BOUTON RETOUR EN HAUT
====================================================================== */
.to-top {
position: fixed;
inset-block-end: var(--space-lg);
inset-inline-end: var(--space-lg);
z-index: var(--z-overlay);
display: grid;
place-items: center;
inline-size: var(--tap-target);
block-size: var(--tap-target);
background-color: var(--color-surface);
border: 1px solid var(--color-line-strong);
border-radius: var(--radius-full);
box-shadow: var(--shadow-md);
color: var(--color-ink-soft);
/* Masqué par défaut : révélé par JS après défilement.
visibility (et non display) pour permettre la transition. */
opacity: 0;
visibility: hidden;
transform: translateY(0.5rem);
transition: opacity var(--transition-base),
transform var(--transition-base),
visibility var(--transition-base);
}
.to-top[data-visible="true"] {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.to-top:hover {
color: var(--color-accent-dark);
border-color: var(--color-accent);
}
/* ======================================================================
POINT DE RUPTURE MOBILE
Une seule media query pour toute la mise en page : le reste est fluide.
Écrite en syntaxe de plage (width <= 46rem), lisible et sans le
traditionnel « max-width: 45.999rem ».
====================================================================== */
@media (width <= 46rem) {
.nav-toggle { display: inline-flex; }
.site-header__inner { position: relative; }
/* Le menu devient un panneau déroulant sous l'en-tête */
.site-nav {
position: absolute;
inset-block-start: 100%;
inset-inline: calc(var(--gutter) * -1);
display: none;
background-color: var(--color-surface);
border-block-end: 1px solid var(--color-line);
box-shadow: var(--shadow-md);
}
.site-nav[data-open="true"] { display: block; }
.site-nav__list {
flex-direction: column;
align-items: stretch;
gap: 0;
padding: var(--space-xs) var(--gutter) var(--space-md);
}
.site-nav__link {
/* Hauteur de cible tactile conforme (44px minimum) */
display: flex;
align-items: center;
min-block-size: var(--tap-target);
padding-inline-start: var(--space-xs);
border-block-end: 1px solid var(--color-line);
border-inline-start: 2px solid transparent;
}
/* Le repère de section active passe du bas au côté */
.site-nav__link[aria-current="true"] {
border-block-end-color: var(--color-line);
border-inline-start-color: var(--color-accent);
background-color: var(--color-accent-glow);
}
.masthead { padding-block: var(--space-2xl) var(--space-xl); }
.section { padding-block: var(--space-2xl); }
.to-top {
inset-block-end: var(--space-md);
inset-inline-end: var(--space-md);
}
}
}
+225
View File
@@ -0,0 +1,225 @@
/* ==========================================================================
print.css — Feuille de style d'impression
--------------------------------------------------------------------------
Chargée via media="print" : elle n'est pas téléchargée en priorité pour
l'affichage écran et ne pénalise donc pas le rendu initial.
Enjeu réel : un CV est imprimé et exporté en PDF. Une page web imprimée
sans feuille dédiée sort avec la navigation, des fonds gris qui vident
les cartouches, des liens dont on ne voit pas la cible, et des blocs
coupés au milieu.
Ce fichier n'utilise pas @layer : chargé en media="print", il doit
surcharger toutes les couches sans discussion.
========================================================================== */
/* --------------------------------------------------------------------------
FORMAT DE PAGE
-------------------------------------------------------------------------- */
@page {
size: A4 portrait;
margin: 15mm 14mm;
}
/* Première page : marge haute réduite, le nom fait office d'en-tête */
@page :first {
margin-block-start: 12mm;
}
/* --------------------------------------------------------------------------
MASQUAGE DES ÉLÉMENTS D'INTERFACE
Rien de ce qui sert à naviguer n'a de sens sur papier.
-------------------------------------------------------------------------- */
.site-header,
.skip-link,
.nav-toggle,
.site-nav,
.to-top,
.actions,
.screen-only {
display: none !important;
}
/* Éléments réservés à l'impression */
.print-only { display: block !important; }
/* --------------------------------------------------------------------------
REMISE À PLAT DES COULEURS
Noir sur blanc : lisible sur toute imprimante, y compris monochrome.
-------------------------------------------------------------------------- */
body {
background: #fff;
color: #000;
font-size: 10.5pt;
line-height: 1.45;
}
.container {
max-inline-size: none;
padding-inline: 0;
}
/* --------------------------------------------------------------------------
GESTION DES SAUTS DE PAGE
Arbitrage important : on autorise une entrée de parcours (.entry) à se
couper entre deux pages, mais jamais une fiche individuelle (.course,
.project, .card).
Pourquoi ? Une entrée « Enseignement » contient quatre fiches de cours et
dépasse la demi-page. Si on interdit sa coupure, elle bascule en entier
sur la page suivante et laisse un vide de plusieurs centimètres. Coupée
entre deux fiches, elle reste parfaitement lisible et le document gagne
une à deux pages.
-------------------------------------------------------------------------- */
.course,
.project,
.card,
.education,
.contact-block {
break-inside: avoid;
}
/* Les titres ne restent jamais seuls en bas de page, séparés de leur contenu */
.section-title,
.entry__role,
.entry__org,
.project__title,
.card__title,
.education__degree {
break-after: avoid;
}
/* Une entrée ne démarre pas en toute fin de page pour n'y poser que sa date */
.entry__date {
break-after: avoid;
}
/* Limite les lignes isolées en haut ou en bas de page (veuves et orphelines) */
p, li {
orphans: 3;
widows: 3;
}
/* --------------------------------------------------------------------------
MISE EN PAGE
-------------------------------------------------------------------------- */
.masthead {
padding-block: 0 10pt;
border-block-end: 1pt solid #000;
}
.section {
padding-block: 10pt;
border-block-start: 0.5pt solid #ccc;
}
.intro { padding-block: 10pt; }
.intro__text--highlight {
border-inline-start: 2pt solid #666;
padding-inline-start: 8pt;
}
/* Grilles : deux colonnes fixes, plus compact que l'auto-fit écran */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8pt;
}
/* --------------------------------------------------------------------------
CARTES ET FICHES
Fonds supprimés, ombres supprimées, bordures fines.
-------------------------------------------------------------------------- */
.course,
.project,
.card,
.contact-block {
background: transparent;
border: 0.5pt solid #bbb;
box-shadow: none;
padding: 6pt 8pt;
margin-block-end: 5pt;
transform: none;
}
.entry {
border-inline-start: 0.5pt solid #999;
padding-block-end: 10pt;
}
.entry::before {
background: #fff;
border-color: #000;
}
.education { border-inline-start: 0.5pt solid #999; }
/* --------------------------------------------------------------------------
ACCENTS EN NIVEAUX DE GRIS
Le vert #05BC8A imprimé en monochrome donne un gris très clair,
quasi invisible. On force le noir sur tous les éléments porteurs
d'information.
-------------------------------------------------------------------------- */
.eyebrow,
.eyebrow::before,
.entry__date,
.section-title::before,
.course__name::before,
.marker-list li::before {
color: #000;
}
.badge {
background: transparent;
border: 0.5pt solid #999;
color: #333;
}
.project__stack li {
background: transparent;
border: 0.5pt solid #ccc;
color: #444;
}
/* Icônes SVG inline */
.card__icon,
.contact-list__icon {
stroke: #000;
}
/* --------------------------------------------------------------------------
LIENS
Sur papier, un lien dont on ne voit pas la cible est inutile.
On affiche l'URL entre parenthèses — sauf pour mailto et les ancres
internes, où l'adresse est déjà visible ou sans objet.
-------------------------------------------------------------------------- */
a {
color: #000;
text-decoration: none;
}
.contact-list a[href^="http"]::after,
.site-footer a[href^="http"]::after {
content: " (" attr(href) ")";
font-size: 8pt;
color: #555;
word-break: break-all;
}
a[href^="#"]::after,
a[href^="mailto:"]::after,
a[href^="tel:"]::after {
content: "";
}
/* --------------------------------------------------------------------------
PIED DE PAGE
-------------------------------------------------------------------------- */
.site-footer {
border-block-start: 0.5pt solid #ccc;
padding-block: 6pt 0;
}
.site-footer__inner { font-size: 8pt; }
.site-footer__links { display: none; }
+146
View File
@@ -0,0 +1,146 @@
/* ==========================================================================
tokens.css — Jetons de conception (design tokens)
--------------------------------------------------------------------------
Source unique de vérité pour les couleurs, la typographie et les espacements.
Aucune règle de style ici : uniquement des variables CSS.
Pourquoi séparer ? Changer l'identité visuelle du site ne doit demander
la modification que d'un seul fichier. Toute valeur littérale (#05BC8A,
1.5rem…) écrite ailleurs que dans ce fichier est une dette technique.
========================================================================== */
/* --------------------------------------------------------------------------
ORDRE DE CASCADE
Déclaré ici en premier, avant toute règle. Une couche listée plus bas
l'emporte sur celles du dessus, quelle que soit la spécificité des
sélecteurs et quel que soit l'ordre des balises <link> dans le <head>.
Cela supprime la cause n°1 des conflits CSS : la course à la spécificité.
-------------------------------------------------------------------------- */
@layer tokens, base, layout, components, utilities;
@layer tokens {
:root {
/* ====================================================================
COULEURS
Palette volontairement restreinte. Contrastes vérifiés WCAG 2.1 :
- ink sur bg : 14,8:1 (AAA, seuil 7:1)
- ink-soft sur bg : 7,4:1 (AAA)
- ink-faint sur bg : 4,1:1 (AA sur texte non essentiel ≥ 14px)
- accent-dark sur bg : 4,3:1 (AA, utilisé pour les liens)
L'accent pur (#05BC8A) n'est jamais utilisé pour du texte sur fond
clair : son contraste (2,3:1) est insuffisant. Il sert uniquement de
couleur de trait, de bordure et de puce décorative.
==================================================================== */
/* ---- Surfaces ---- */
--color-bg: #f7f8f8; /* Fond de page */
--color-bg-translucent: rgb(247 248 248 / 85%); /* En-tête collant */
--color-surface: #ffffff; /* Cartes, fiches */
--color-surface-alt: #f1f3f3; /* Surfaces secondaires, badges neutres */
/* ---- Lignes ---- */
--color-line: #e3e6e6; /* Séparateurs, bordures de cartes */
--color-line-strong: #cfd4d4; /* Bordures nécessitant plus de présence */
/* ---- Texte ---- */
--color-ink: #10201c; /* Titres, texte principal */
--color-ink-soft: #4c5a56; /* Corps de texte, descriptions */
--color-ink-faint: #7b8783; /* Métadonnées, mentions secondaires */
--color-ink-inverse: #f7f8f8; /* Texte sur fond sombre */
/* ---- Accent : identité underscorelab ---- */
--color-accent: #05bc8a; /* Traits, puces, bordures */
--color-accent-dark: #03996f; /* Texte et liens (contraste AA) */
--color-accent-wash: #eafaf4; /* Fond de badge */
--color-accent-border: rgb(5 188 138 / 28%);
--color-accent-glow: rgb(5 188 138 / 12%);
/* ---- États ---- */
--color-focus: #03996f; /* Anneau de focus clavier */
/* ====================================================================
TYPOGRAPHIE
Deux familles seulement :
- JetBrains Mono : titres, métadonnées, éléments d'interface.
Choisie parce que c'est une police de développeur — le sujet du
site est l'informatique, la forme dit la même chose que le fond.
- Inter : corps de texte. Optimisée pour la lecture à l'écran,
hauteur d'x généreuse.
Les piles de repli listent des polices système présentes partout,
pour que la page reste lisible avant le chargement des webfonts.
==================================================================== */
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono",
Menlo, Consolas, "Liberation Mono", monospace;
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
/* ---- Échelle typographique fluide ----
clamp(min, préféré, max) : la taille s'adapte à la largeur du viewport
sans point de rupture. Le terme central mêle rem (respecte le zoom
utilisateur et la taille de police du navigateur) et vw (fluidité).
Un vw seul casserait l'accessibilité : le zoom n'aurait plus d'effet. */
--text-2xs: 0.7rem;
--text-xs: 0.75rem;
--text-sm: 0.85rem;
--text-base: 1rem;
--text-md: clamp(1rem, 0.96rem + 0.2vw, 1.1rem);
--text-lg: clamp(1.15rem, 1.05rem + 0.5vw, 1.4rem);
--text-xl: clamp(1.35rem, 1.15rem + 1vw, 1.75rem);
--text-2xl: clamp(2.1rem, 1.5rem + 3vw, 3.4rem);
/* ---- Graisses ---- */
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* ---- Interlignes ---- */
--leading-tight: 1.15; /* Grands titres */
--leading-snug: 1.4; /* Sous-titres */
--leading-normal: 1.7; /* Corps de texte : confort de lecture */
/* ====================================================================
ESPACEMENTS
Échelle géométrique basée sur 0,25rem (4px). Utiliser une échelle
plutôt que des valeurs arbitraires produit un rythme vertical
cohérent sur toute la page.
==================================================================== */
--space-2xs: 0.25rem;
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4.5rem;
/* ====================================================================
DIMENSIONS
==================================================================== */
--width-content: 60rem; /* Largeur maximale du contenu */
--width-prose: 68ch; /* ~68 caractères : optimum de lisibilité
(au-delà, l'œil perd la ligne au retour) */
--gutter: clamp(1rem, 4vw, 1.5rem); /* Marge latérale fluide */
--header-height: 3.5rem; /* Sert au calcul du scroll-padding */
--tap-target: 2.75rem; /* 44px : cible tactile minimale (WCAG 2.5.5) */
/* ---- Rayons ---- */
--radius-sm: 3px;
--radius-md: 6px;
--radius-full: 999px;
/* ---- Ombres : très légères, le design repose sur les bordures ---- */
--shadow-sm: 0 1px 2px rgb(16 32 28 / 4%);
--shadow-md: 0 4px 12px rgb(16 32 28 / 6%);
/* ---- Transitions ---- */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
/* ---- Plans d'empilement : centralisés pour éviter la surenchère
de z-index dispersés dans le code ---- */
--z-sticky: 100;
--z-overlay: 150;
--z-skip-link: 200;
}
}
+67
View File
@@ -0,0 +1,67 @@
/* ==========================================================================
utilities.css — Classes utilitaires
--------------------------------------------------------------------------
Dernière couche de la cascade : ces classes doivent pouvoir surcharger
un composant sans recourir à !important.
Volontairement peu nombreuses — une bibliothèque utilitaire complète
n'aurait pas de sens sans outil de purge.
========================================================================== */
@layer utilities {
/* ======================================================================
MASQUAGE VISUEL ACCESSIBLE
Retire l'élément de l'affichage tout en le laissant lisible par les
lecteurs d'écran. À utiliser pour les libellés, les intitulés de
sections de navigation, les annonces de contexte.
Ne PAS utiliser display:none ni visibility:hidden pour cet usage :
ces propriétés retirent aussi l'élément de l'arbre d'accessibilité.
====================================================================== */
.visually-hidden {
position: absolute !important;
inline-size: 1px;
block-size: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* ======================================================================
RÉGION D'ANNONCE (live region)
Conteneur des messages dynamiques destinés aux lecteurs d'écran
(confirmation de copie d'adresse, par exemple).
====================================================================== */
.sr-status {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
}
/* ======================================================================
FLUX D'IMPRESSION
====================================================================== */
/* Élément affiché uniquement à l'écran */
.screen-only { }
/* Élément affiché uniquement à l'impression (révélé par print.css) */
.print-only { display: none; }
/* ======================================================================
DIVERS
====================================================================== */
/* Empêche la coupure d'un groupe de mots (« Bac 3 », « Office 365 ») */
.nowrap { white-space: nowrap; }
/* Retire la marge basse résiduelle du dernier enfant d'un bloc */
.flow-end { margin-block-end: 0 !important; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+6
View File
@@ -0,0 +1,6 @@
<!-- Variante monochrome : Safari applique sa propre couleur (mask-icon) -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M11 44 V21 L20.5 33.5 L30 21 V44M50.39 23.69 A11.5 11.5 0 1 0 50.39 41.31M11 52 H53"
fill="none" stroke="#000" stroke-width="5.5"
stroke-linecap="butt" stroke-linejoin="miter"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

+74
View File
@@ -0,0 +1,74 @@
/**
* main.js — Point d'entrée de l'application
* =============================================================================
* Orchestre l'initialisation des modules. Chaque module est indépendant :
* si l'un échoue, les autres continuent de fonctionner.
*
* PRINCIPE DIRECTEUR — amélioration progressive
* ---------------------------------------------
* Le site est intégralement lisible, navigable et imprimable sans
* JavaScript. Tout ce que ce fichier ajoute relève du confort :
*
* - navigation.js : replie le menu sur petit écran
* - scroll-spy.js : surligne la section en cours de lecture
* - back-to-top.js : raccourci de remontée
* - copy-email.js : copie de l'adresse en un clic
*
* Aucun contenu n'est injecté par script : le HTML servi est complet.
* C'est ce qui garantit l'indexation par les moteurs de recherche et la
* lecture par les agents conversationnels, dont beaucoup n'exécutent pas
* JavaScript.
*
* CHARGEMENT
* ----------
* Ce fichier est appelé avec type="module", donc :
* - différé par défaut (équivalent à defer), il ne bloque pas le rendu
* - exécuté en mode strict
* - portée isolée, aucune variable globale
*/
import { initNavigation } from "./modules/navigation.js";
import { initScrollSpy } from "./modules/scroll-spy.js";
import { initBackToTop } from "./modules/back-to-top.js";
import { initCopyEmail } from "./modules/copy-email.js";
/**
* Démarre les modules de la page.
* @returns {void}
*/
function init() {
/* --- Signale que JS est actif : permet au CSS de n'appliquer certains
comportements (menu replié) que lorsqu'ils sont pilotables ------- */
document.documentElement.dataset.js = "true";
initNavigation({
toggle: document.querySelector("[data-nav-toggle]"),
nav: document.querySelector("[data-nav]"),
});
initScrollSpy({
sections: document.querySelectorAll("[data-section]"),
links: document.querySelectorAll("[data-nav-link]"),
});
initBackToTop({
button: document.querySelector("[data-to-top]"),
sentinel: document.querySelector("[data-scroll-sentinel]"),
});
initCopyEmail({
button: document.querySelector("[data-copy]"),
status: document.querySelector("[data-status]"),
});
}
/* -----------------------------------------------------------------------
Les modules sont différés, le DOM est donc normalement prêt. On teste
malgré tout readyState : cela rend le script robuste s'il est un jour
réutilisé dans un contexte de chargement différent.
----------------------------------------------------------------------- */
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
+58
View File
@@ -0,0 +1,58 @@
/**
* back-to-top.js — Bouton de retour en haut de page
* =============================================================================
* Le bouton n'apparaît qu'une fois l'utilisateur descendu au-delà d'une
* hauteur d'écran : afficher un raccourci de retour alors qu'on est déjà
* en haut n'a aucune utilité et encombre l'interface.
*
* Détection par IntersectionObserver sur une sentinelle placée en haut du
* document, plutôt que par écouteur de scroll — même raison que pour le
* scroll-spy : pas de recalcul de mise en page à chaque pixel parcouru.
*
* Le bouton est un vrai <button> injecté par ce script : il n'a aucun sens
* sans JavaScript, il ne doit donc pas exister dans le HTML servi.
*/
/**
* Initialise le bouton de retour en haut.
*
* @param {object} options
* @param {HTMLElement} options.button - Bouton à piloter.
* @param {HTMLElement} options.sentinel - Élément repère en haut de page.
* @returns {void}
*/
export function initBackToTop({ button, sentinel }) {
if (!button || !sentinel) return;
/* --- Action : remonter en haut -------------------------------------- */
button.addEventListener("click", () => {
// Respecte la préférence système de réduction des animations :
// un défilement animé sur plusieurs milliers de pixels peut
// provoquer un malaise chez les personnes photosensibles.
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
window.scrollTo({
top: 0,
behavior: prefersReducedMotion ? "auto" : "smooth",
});
// Le focus doit suivre le déplacement visuel, sinon un utilisateur
// clavier se retrouve à naviguer depuis le bas de page.
document.getElementById("haut-de-page")?.focus({ preventScroll: true });
});
/* --- Visibilité conditionnelle -------------------------------------- */
if (!("IntersectionObserver" in window)) return;
const observer = new IntersectionObserver(
([entry]) => {
// La sentinelle est visible => on est en haut => bouton masqué.
button.dataset.visible = String(!entry.isIntersecting);
},
{ threshold: 0 }
);
observer.observe(sentinel);
}
+64
View File
@@ -0,0 +1,64 @@
/**
* copy-email.js — Copie de l'adresse e-mail dans le presse-papiers
* =============================================================================
* Détail d'ergonomie qui compte sur un site de contact : tout le monde
* n'a pas de client mail configuré, et un lien mailto: qui ouvre un
* logiciel inattendu est une impasse. Le bouton de copie offre la voie
* alternative sans supprimer le lien mailto.
*
* Le retour à l'utilisateur passe par deux canaux :
* - visuel : le libellé du bouton change temporairement
* - vocal : une région aria-live annonce la copie aux lecteurs d'écran
*
* L'API Clipboard exige un contexte sécurisé (HTTPS ou localhost). En cas
* d'échec, on ne laisse pas l'utilisateur sans réponse : on lui indique
* de copier manuellement.
*/
/** Durée d'affichage du message de confirmation, en millisecondes. */
const FEEDBACK_DURATION = 2200;
/**
* Initialise le bouton de copie.
*
* @param {object} options
* @param {HTMLElement} options.button - Bouton portant data-copy="<valeur>".
* @param {HTMLElement} options.status - Région aria-live pour l'annonce.
* @returns {void}
*/
export function initCopyEmail({ button, status }) {
if (!button) return;
const value = button.dataset.copy;
if (!value) return;
const label = button.querySelector("[data-copy-label]");
const originalLabel = label?.textContent ?? "";
let resetTimer;
/**
* Affiche un retour temporaire, visuel et vocal.
* @param {string} message
* @returns {void}
*/
const announce = (message) => {
if (label) label.textContent = message;
if (status) status.textContent = message;
clearTimeout(resetTimer);
resetTimer = setTimeout(() => {
if (label) label.textContent = originalLabel;
if (status) status.textContent = "";
}, FEEDBACK_DURATION);
};
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(value);
announce("Adresse copiée");
} catch {
// Contexte non sécurisé, permission refusée ou API absente.
announce("Copie impossible");
}
});
}
+107
View File
@@ -0,0 +1,107 @@
/**
* navigation.js — Menu de navigation mobile
* =============================================================================
* Gère l'ouverture et la fermeture du panneau de navigation sous le point de
* rupture mobile.
*
* Principe d'amélioration progressive : sans JavaScript, le menu reste une
* liste de liens visible et fonctionnelle (le CSS ne la masque que si le
* bouton, lui-même conditionné à ce script, est présent et actif).
*
* Accessibilité couverte :
* - aria-expanded reflète l'état réel du panneau
* - aria-controls relie le bouton au panneau qu'il commande
* - la touche Échap ferme le menu et rend le focus au bouton
* - le focus est piégé dans le panneau tant qu'il est ouvert
* - le défilement de la page est bloqué pendant l'ouverture
*/
/** Point de rupture, aligné sur celui de layout.css. */
const MOBILE_QUERY = "(width <= 46rem)";
/** Sélecteur des éléments pouvant recevoir le focus clavier. */
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* Initialise le menu mobile.
*
* @param {object} options
* @param {HTMLElement} options.toggle - Bouton d'ouverture/fermeture.
* @param {HTMLElement} options.nav - Conteneur <nav> à afficher/masquer.
* @returns {void}
*/
export function initNavigation({ toggle, nav }) {
// Garde défensive : si le balisage change, le script ne casse pas la page.
if (!toggle || !nav) return;
const mediaQuery = window.matchMedia(MOBILE_QUERY);
/**
* Applique l'état d'ouverture au DOM.
* @param {boolean} isOpen
* @returns {void}
*/
const setOpen = (isOpen) => {
nav.dataset.open = String(isOpen);
toggle.setAttribute("aria-expanded", String(isOpen));
// Empêche le défilement de l'arrière-plan pendant que le panneau
// recouvre l'écran — évite l'effet « scroll fantôme » sur mobile.
document.body.style.overflow = isOpen && mediaQuery.matches ? "hidden" : "";
};
const isOpen = () => toggle.getAttribute("aria-expanded") === "true";
const close = () => setOpen(false);
/* --- Bascule au clic sur le bouton ---------------------------------- */
toggle.addEventListener("click", () => setOpen(!isOpen()));
/* --- Fermeture après navigation vers une ancre ---------------------- */
nav.addEventListener("click", (event) => {
if (event.target.closest("a")) close();
});
/* --- Clavier : Échap ferme, Tab reste piégé dans le panneau --------- */
document.addEventListener("keydown", (event) => {
if (!isOpen()) return;
if (event.key === "Escape") {
close();
toggle.focus(); // Le focus ne doit jamais être perdu dans le vide
return;
}
if (event.key !== "Tab") return;
// Piège de focus : la tabulation boucle entre le bouton et le dernier
// lien du menu, au lieu de partir dans le contenu masqué derrière.
const focusables = [toggle, ...nav.querySelectorAll(FOCUSABLE)];
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
/* --- Fermeture au clic hors du panneau ------------------------------ */
document.addEventListener("click", (event) => {
if (!isOpen()) return;
if (nav.contains(event.target) || toggle.contains(event.target)) return;
close();
});
/* --- Retour en affichage large : on réinitialise --------------------- */
mediaQuery.addEventListener("change", (event) => {
if (!event.matches) close();
});
// État initial explicite plutôt qu'implicite dans le HTML.
close();
}
+92
View File
@@ -0,0 +1,92 @@
/**
* scroll-spy.js — Mise en évidence de la section courante
* =============================================================================
* Marque le lien de navigation correspondant à la section actuellement lue.
*
* Choix technique : IntersectionObserver plutôt qu'un écouteur « scroll ».
* Un écouteur de scroll se déclenche des dizaines de fois par seconde et
* impose de recalculer getBoundingClientRect() à chaque appel, ce qui force
* le navigateur à recalculer la mise en page (layout thrashing) et fait
* chuter la fluidité. IntersectionObserver délègue ce travail au moteur de
* rendu, hors du fil principal, et ne notifie qu'aux franchissements de seuil.
*
* Le lien actif reçoit aria-current="true" : ce n'est pas qu'un crochet de
* style, c'est l'annonce de la position aux lecteurs d'écran (WCAG 2.4.8).
*/
/**
* Initialise la surveillance des sections.
*
* @param {object} options
* @param {NodeListOf<HTMLElement>} options.sections - Sections observées.
* @param {NodeListOf<HTMLAnchorElement>} options.links - Liens de navigation.
* @returns {void}
*/
export function initScrollSpy({ sections, links }) {
if (!sections?.length || !links?.length) return;
// Dégradation propre sur navigateur ancien : pas de surbrillance,
// mais la navigation reste parfaitement utilisable.
if (!("IntersectionObserver" in window)) return;
/* --- Index des liens par identifiant de section ciblée --------------- */
const linksByTargetId = new Map();
links.forEach((link) => {
const targetId = link.getAttribute("href")?.replace("#", "");
if (targetId) linksByTargetId.set(targetId, link);
});
/**
* Active un lien et désactive tous les autres.
* @param {string} sectionId
* @returns {void}
*/
const setActiveLink = (sectionId) => {
links.forEach((link) => link.removeAttribute("aria-current"));
linksByTargetId.get(sectionId)?.setAttribute("aria-current", "true");
};
/** Identifiants des sections actuellement dans la zone de détection. */
const visibleSectionIds = new Set();
/** Sections dans l'ordre du document, pour départager les ex æquo. */
const orderedSections = [...sections];
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(({ target, isIntersecting }) => {
if (isIntersecting) visibleSectionIds.add(target.id);
else visibleSectionIds.delete(target.id);
});
// Aucune section dans la bande : on conserve le dernier état actif
// plutôt que de tout éteindre, ce qui produirait un clignotement.
if (visibleSectionIds.size === 0) return;
// Plusieurs sections visibles : on retient la première dans l'ordre
// du document, c'est-à-dire la plus haute à l'écran.
const current = orderedSections.find((section) =>
visibleSectionIds.has(section.id)
);
if (current) setActiveLink(current.id);
},
{
/*
* Bande de détection étroite, positionnée dans le tiers supérieur de
* la fenêtre, juste sous l'en-tête collant.
*
* -20% en haut : ignore ce qui passe derrière l'en-tête
* -70% en bas : ignore ce qui n'est encore qu'en bas d'écran
*
* Résultat : la section considérée « courante » est celle que
* l'utilisateur lit réellement, pas celle qui pointe le nez.
*/
rootMargin: "-20% 0px -70% 0px",
threshold: 0,
}
);
sections.forEach((section) => observer.observe(section));
}