Site personnel courcellematthi.eu
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 300 B |
|
After Width: | Height: | Size: 383 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -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 |
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||