/* ============================================================
   sofalost.tech — components
   ============================================================ */

/* ============ nav: translucent layer, content scrolls under ============ */
.nav {
  position: fixed;
  inset: 0 0 auto;
  z-index: 50;
  height: 68px;
  display: flex;
  align-items: center;
  background: rgba(11, 10, 9, 0.55);
  backdrop-filter: blur(20px) saturate(160%);
  -webkit-backdrop-filter: blur(20px) saturate(160%);
  border-bottom: 1px solid rgba(242, 236, 228, 0.06);
  transition: background var(--dur-base) var(--ease-both);
}
@media (prefers-reduced-transparency: reduce) {
  .nav { background: var(--ink-000); backdrop-filter: none; -webkit-backdrop-filter: none; }
}

.nav__inner {
  width: 100%;
  max-width: var(--maxw);
  margin-inline: auto;
  padding-inline: var(--gutter);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1.5rem;
}

/* ---- brand: wordmark closed by a full stop that doubles as a status light ---- */
.mark {
  display: inline-flex;
  align-items: baseline;          /* the dot rides the text baseline itself */
  gap: 0.13rem;
  font-family: 'Clash Display', sans-serif;
  font-weight: 700;
  font-size: 1.12rem;
  letter-spacing: -0.025em;
  color: var(--bone);
  flex: none;
}
/* Le mot et le point sont deux liens distincts, groupés ici pour garder
   l'alignement sur la ligne de base que le `::after` avait par héritage. */
.markwrap {
  display: inline-flex;
  align-items: baseline;
  gap: 0.13rem;
  flex: none;
}

/* Tucked under the tail of the final "t", below the baseline — a status light
   hung off the wordmark rather than a full stop level with it. The pulse is
   the server-online tell: a light that never changes says nothing.

   Cible de 5px assumée : c'est un œuf de Pâques, il DOIT être difficile. La
   règle habituelle des 44px ne s'applique pas — rien d'utile n'est derrière, et
   la page reste joignable par son URL. Un padding transparent l'agrandirait à
   l'insu de tous et ferait rater le mot « sofalost » à côté. */
.mark__dot {
  width: 5px; height: 5px;
  flex: none;
  align-self: baseline;
  transform: translateY(3px);
  border-radius: 50%;
  background: var(--ember);
  box-shadow: 0 0 7px rgba(255, 106, 31, 0.75);
  animation: mark-pulse 2.4s var(--ease-both) infinite;
  transition: box-shadow var(--dur-base) var(--ease-both), transform var(--dur-base) var(--ease-out);
}
.mark__dot:hover {
  box-shadow: 0 0 14px rgba(255, 106, 31, 0.95);
  transform: translateY(3px) scale(1.25);
}
/* Le focus clavier, lui, doit se voir : sans anneau, la tabulation traverse un
   lien de 5px sans que rien ne bouge à l'écran. */
.mark__dot:focus-visible {
  outline: 2px solid var(--ember);
  outline-offset: 3px;
}
@keyframes mark-pulse {
  0%, 100% { opacity: 1;    box-shadow: 0 0 7px  rgba(255, 106, 31, 0.75); }
  50%      { opacity: 0.45; box-shadow: 0 0 3px  rgba(255, 106, 31, 0.35); }
}
/* A blinking light is exactly what motion sensitivity flags: hold it steady. */
@media (prefers-reduced-motion: reduce) {
  .mark__dot { animation: none; }
}

/* ---- eight sections: dot jumps to the home preview, label opens the page ---- */
.nav__links {
  display: flex;
  align-items: center;
  gap: clamp(0.5rem, 1.4vw, 1.15rem);
  min-width: 0;
}

/* Label only, back on one centred line. */
.navitem {
  display: inline-flex;
  align-items: center;
  white-space: nowrap;
}

.navitem__label {
  font-size: 0.83rem;
  color: var(--bone-mid);
  font-weight: 500;
  position: relative;
  padding-block: 0.3rem;
  transition: color var(--dur-fast) var(--ease-out);
}
.navitem__label::after {
  content: "";
  position: absolute;
  left: 0; bottom: 0;
  width: 100%; height: 1px;
  background: var(--ember);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur-base) var(--ease-out);
}
.navitem__label:hover { color: var(--bone); }
.navitem__label:hover::after { transform: scaleX(1); }

.navitem.is-current .navitem__label { color: var(--bone); }

.langpick { position: relative; flex: none; line-height: 1; }
/* A <summary> only loses its disclosure triangle while it stays a list-item
   box: display:inline-flex makes Chrome draw the marker again. So the summary
   keeps list-item display, and the flex row lives in an inner span. */
.langpick__btn {
  display: list-item;
  list-style: none;
  width: max-content;
  padding: 0.26rem 0.5rem;
  border-radius: var(--r-pill);
  border: 1px solid rgba(242, 236, 228, 0.16);
  color: var(--bone-dim);
  cursor: pointer;
  transition: border-color var(--dur-base) var(--ease-both), color var(--dur-fast) var(--ease-out);
}
.langpick__btn::-webkit-details-marker { display: none; }
.langpick__btn::marker { content: ""; font-size: 0; }
.langpick__face {
  display: inline-flex;
  align-items: center;
  gap: 0.34rem;
  vertical-align: middle;
}
.langpick__btn:hover { border-color: rgba(242, 236, 228, 0.4); color: var(--bone); }
.langpick__caret { transition: transform var(--dur-base) var(--ease-out); }
.langpick[open] .langpick__caret { transform: rotate(180deg); }

.flag {
  display: block;
  border-radius: 2px;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
}

.langpick__menu {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  min-width: 148px;
  padding: 0.3rem;
  border-radius: var(--r-inset);
  background: rgba(18, 17, 16, 0.97);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  border: 1px solid rgba(242, 236, 228, 0.12);
  box-shadow: 0 18px 46px -18px rgba(0, 0, 0, 0.9);
  z-index: 60;
  animation: langdrop 200ms var(--ease-out);
}
.langpick__menu a {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.52rem 0.6rem;
  border-radius: 8px;
  font-size: 0.86rem;
  color: var(--bone-mid);
}
.langpick__menu a:hover { background: rgba(255, 106, 31, 0.1); color: var(--bone); }
@keyframes langdrop {
  from { opacity: 0; transform: translateY(-5px); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) { .langpick__menu { animation: none; } }

.navsheet__langs {
  display: flex;
  gap: 0.5rem;
  margin-top: 0.7rem;
  padding-top: 0.9rem;
  border-top: 1px solid rgba(242, 236, 228, 0.08);
}
/* Sélecteur descendant volontaire (0,3,1) : `.navsheet a` plus bas pose un
   `border-bottom` de séparation à 0.09 et sortait à égalité de spécificité
   (0,2,1), donc l'ordre du fichier tranchait — le bas du contour prenait la
   valeur du séparateur pendant que les trois autres côtés gardaient la leur,
   et le contour se lisait comme un dégradé vers le bas. Un cran de
   spécificité suffit, pas besoin d'`!important`. */
.navsheet .navsheet__langs a {
  flex: 1;
  justify-content: center;
  border: 1px solid rgba(242, 236, 228, 0.14);
  border-radius: var(--r-pill);
  padding: 0.6rem;
  font-size: 0.85rem;
}
.navsheet .navsheet__langs a[aria-current="true"] {
  border-color: rgba(255, 106, 31, 0.45);
  color: var(--ember-soft);
}

/* ---- mobile: a sheet, because eight items never fit on one line ----
   Two bare white bars floating in the corner read as unfinished. The same
   pill outline as the language picker turns them into a deliberate control,
   and the bars are asymmetric so the shape is not a generic hamburger. */
/* The site's own mark inside the circle, not two abstract dots: the toggle
   should look like it belongs to sofalost rather than to a UI kit. mark.svg is
   the round-plated variant of the favicon, sized to the button exactly, so its
   rim and the button's border land on the same circle. */
.nav__toggle {
  display: none;
  width: 40px; height: 40px;
  padding: 0;
  /* Carré arrondi, pas un cercle (demandé le 24/08/2026) : la même forme que
     la plaque du favicon, pour que le bouton et l'onglet du navigateur se
     lisent comme un seul objet. Le favicon arrondit à rx=15 sur une grille de
     64, soit 23,4 % — 9.4px les reproduit exactement sur 40px de côté. */
  border-radius: 9.4px;
  /* Bordure transparente, pas `border: none` : elle occupe la même place dans
     la boîte, donc le bouton ne change pas d'un pixel quand elle s'allume à
     l'ouverture. Avec `none`, l'orange ferait grandir le bouton de 2 px et
     décalerait la barre entière. */
  border: 1px solid transparent;
  background: rgba(242, 236, 228, 0.03);
  place-content: center;
  justify-items: center;
  overflow: hidden;
  /* Depth for the mark's turn below. Without it rotateY is an orthographic
     squash — the coin reads as a flat card being flattened, not as one turning
     through space. */
  perspective: 340px;
  transition:
    border-color var(--dur-base) var(--ease-both),
    background var(--dur-base) var(--ease-both),
    transform var(--dur-fast) var(--ease-out);
}
/* Survol seulement là où le survol existe. Sans cette requête, un doigt qui
   ferme le menu laisse le bouton en :hover — un anneau ambré à demi allumé
   reste collé alors que le menu est fermé, et rien ne l'efface avant le
   prochain appui ailleurs. C'est visible depuis que le cercle gris de repos a
   disparu : il n'y a plus rien pour masquer la différence. */
@media (hover: hover) {
  .nav__toggle:hover {
    border-color: rgba(255, 106, 31, 0.45);
    background: rgba(255, 106, 31, 0.09);
  }
}
.nav__toggle:active { transform: scale(0.94); }
/* La plaque de favicon.svg remplit son viewBox bord à bord, donc l'image est
   dessinée à la taille pleine du bouton : sa plaque et la bordure du bouton
   décrivent le même carré arrondi, et le S garde l'échelle qu'il a dans son
   propre dessin. Le réduire réduit le S avec lui — la plaque continue de
   paraître correcte pendant que le glyphe rapetisse en silence.

   `border-radius` hérité de 1px de moins que le bouton : le rayon du bouton est
   mesuré sur sa boîte de bordure, celui de l'image sur l'intérieur. Sans ça les
   quatre coins de la plaque débordent d'un cheveu sur la bordure. */
.nav__toggle img {
  display: block;
  width: 100%; height: 100%;
  border-radius: 8.4px;
}
/* Ouvert : l'anneau ambré, franc et constant. C'est le seul cercle qui reste —
   celui de repos était gris et permanent, et se lisait comme une bordure de kit
   d'interface autour d'un mark qui a déjà son propre bord.
   Opacité pleine plutôt que 0.55 : sans cercle gris en dessous, il n'y a plus
   rien à « remplacer », l'anneau doit se suffire à lui-même. */
.nav__toggle[aria-expanded="true"] {
  border-color: var(--ember);
  background: rgba(255, 106, 31, 0.12);
}

/* Open: the mark turns like a coin and stays turned. Closing turns it back.

   A TRANSITION, not an @keyframes animation, and that distinction is the whole
   fix. A keyframe animation only exists while its selector matches: it played
   on open, and on close the rule stopped applying, so the mark cut back to its
   start position in a single frame — the spin had no ending, it just vanished.
   A transition interpolates in both directions from whatever value is on screen
   right now, so closing unwinds the turn, and a click mid-spin is picked up from
   where the mark actually is instead of snapping first.

   One full turn, not two. At 720deg most of the duration was a blur nobody can
   read, which is what made it feel busy rather than smooth; a single 360 is
   slow enough to follow the whole way round. It has to be a whole number of
   turns, too: 180 would leave the S sitting mirrored for as long as the menu
   stayed open, and a backwards logo is not a state.

   The `perspective` this needs is set on the button itself, above. The S is
   flat art, so the back of the turn reads as the mark seen through its own
   face, which is exactly right. */
.nav__toggle img {
  /* Soft in, soft out, no overshoot: --ease-out is tuned for things arriving
     from off screen and would snap the last third of the turn. */
  transition: transform 560ms cubic-bezier(0.45, 0.05, 0.2, 1);
}
.nav__toggle[aria-expanded="true"] img { transform: rotateY(360deg); }
/* Reduced motion: the state still has to be legible without the turn, so the
   mark simply brightens into the open colour. */
@media (prefers-reduced-motion: reduce) {
  .nav__toggle img { transition: none; }
  .nav__toggle[aria-expanded="true"] img { transform: none; }
}

/* Full-height overlay below the header. It is a SIBLING of .nav in the markup,
   not a descendant — see the comment there. Two consequences, both wanted:
     - `position: fixed` resolves against the viewport, so `top: 68px` means
       "under the header" and not "68px below a 68px-tall containing block";
     - backdrop-filter composites against the PAGE, not against the header's
       own blurred layer, so the blur is real.
   The background is deliberately near-transparent: the ask is a blurred page
   behind the menu, not a darkened one. Legibility comes from the blur plus a
   text-shadow on the links, not from a scrim. */
.navsheet {
  position: fixed;
  top: 68px;
  left: 0; right: 0;
  height: calc(100dvh - 68px);
  z-index: 45;
  padding: 0.6rem var(--gutter) 1.4rem;
  background: rgba(11, 10, 9, 0.06);
  backdrop-filter: blur(30px) saturate(135%);
  -webkit-backdrop-filter: blur(30px) saturate(135%);
  border-top: 1px solid rgba(242, 236, 228, 0.08);
  /* Flex column, not grid: the sheet is pinned top-to-bottom, and a grid was
     stretching its rows to fill that height. Here the items keep their natural
     size and simply stack from the top. */
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  overflow-y: auto;
  animation: sheet-in 260ms var(--ease-out);
}
@keyframes sheet-in {
  from { opacity: 0; transform: translateY(-10px); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) { .navsheet { animation: none; } }
/* No blur available: the links would sit on the raw page and be unreadable.
   Opacity is the only fallback left. */
@media (prefers-reduced-transparency: reduce) {
  .navsheet { background: var(--ink-000); backdrop-filter: none; -webkit-backdrop-filter: none; }
}
.navsheet[hidden] { display: none; }
.navsheet a {
  display: flex;
  align-items: center;
  gap: 0.8rem;
  padding: 0.78rem 0.4rem;
  /* Full-strength bone, not --bone-mid: the page behind is only blurred, not
     dimmed, so the labels carry their own contrast. The shadow is what keeps
     them legible when a bright part of the hero blurs underneath. */
  color: var(--bone);
  font-weight: 600;
  text-shadow: 0 1px 10px rgba(0, 0, 0, 0.85), 0 0 3px rgba(0, 0, 0, 0.6);
  border-bottom: 1px solid rgba(242, 236, 228, 0.09);
}
.navsheet a[aria-current="page"] { color: var(--ember-soft); }
.navsheet__icon { font-size: 1.05rem; width: 1.5rem; text-align: center; }
.navsheet__lang { border-bottom: 0; color: var(--bone-dim); font-size: 0.88rem; }

@media (max-width: 1120px) {
  .nav__links { display: none; }
  .nav__toggle { display: grid; }
}
@media (min-width: 1121px) {
  .navsheet { display: none !important; }
}

/* ============ hero: image-as-canvas, text in lower-left safe area ============ */
.hero {
  position: relative;
  min-height: 100dvh;
  display: flex;
  align-items: flex-end;
  padding-top: 68px;
  overflow: hidden;
}

.hero__media {
  position: absolute;
  inset: 0;
  z-index: 0;
}
/* A very slow drift across the embers: 34 s for a 3% pan, which reads as the
   image being alive rather than as an animation. Composited on the GPU
   (transform only, no layout), so it costs nothing to keep running. */
.hero__media img {
  width: 100%; height: 100%;
  object-fit: cover;
  object-position: 62% 42%;
  transform: scale(1.06);
  will-change: transform;
  animation: hero-drift 34s ease-in-out infinite alternate;
}
@keyframes hero-drift {
  from { transform: scale(1.06) translate3d(0, 0, 0); }
  to   { transform: scale(1.10) translate3d(-1.6%, -1.1%, 0); }
}
/* A second layer of heat that swells over the image, out of phase with the
   drift so the two never line up into an obvious loop. */
.hero__media::before {
  content: "";
  position: absolute;
  inset: -10%;
  z-index: 1;
  background: radial-gradient(ellipse 46% 40% at 68% 46%, rgba(255, 122, 16, 0.20), transparent 70%);
  mix-blend-mode: screen;
  pointer-events: none;
  animation: hero-heat 11s ease-in-out infinite alternate;
}
@keyframes hero-heat {
  from { opacity: 0.45; transform: scale(1); }
  to   { opacity: 0.85; transform: scale(1.08); }
}
@media (prefers-reduced-motion: reduce) {
  .hero__media img,
  .hero__media::before { animation: none; }
}
/* tonal overlay keeps text at AAA contrast over the ember texture */
.hero__media::after {
  content: "";
  position: absolute;
  inset: 0;
  background:
    linear-gradient(to top, rgba(11,10,9,0.97) 6%, rgba(11,10,9,0.72) 38%, rgba(11,10,9,0.30) 72%),
    linear-gradient(to right, rgba(11,10,9,0.88) 0%, rgba(11,10,9,0.28) 52%, transparent 78%);
}

.hero__inner {
  position: relative;
  z-index: 1;
  width: 100%;
  padding-bottom: clamp(3.5rem, 8vh, 6rem);
}

.hero__eyebrow {
  color: var(--ember-soft);
  margin-bottom: clamp(1rem, 2vw, 1.5rem);
  display: block;
  /* Un cran au-dessus du --fs-micro (0.75rem) que .mono impose à toutes les
     lignes en capitales du site. « À peine un peu » plus gros, demandé le
     24/08/2026 : 0.82rem se lit mieux sous le titre sans venir concurrencer le
     sous-titre juste en dessous. La surcharge vit ici et pas dans .mono — les
     autres mentions monospace du site gardent leur échelle. */
  font-size: 0.82rem;
}

/* The <br> sets the break; no ch cap fighting it into a third line. */
.hero__title { max-width: 22ch; }
@media (max-width: 560px) { .hero__title { font-size: clamp(2.4rem, 11vw, 3.4rem); } }
/* "sur mesure" as an animated gradient, in the manner of reactbits.dev's
   GradientText — but as plain CSS: the effect is a clipped background sliding
   under the glyphs, and pulling in React to move a background-position would
   be absurd on a page with no React.
   The colour still falls back to a solid ember: background-clip:text without a
   transparent fill shows the ember, and with `color: transparent` unsupported
   the text would vanish — hence the @supports guard. */
.hero__title em {
  font-style: normal;
  color: var(--ember);
  /* Two things on this right edge, both from the same cause.
     .display sets letter-spacing: -0.035em, applied AFTER the last glyph too:
     the box ends ~1.5px short of the "e"'s own ink. So the comma that follows
     sat on the glyph (margin gives that tracking back on this side only), and
     background-clip:text — which paints only inside the box — left the last
     column of the "e" unpainted, which reads as a flat cut.
     padding widens the paint box, the matching negative margin takes the width
     straight back, so nothing shifts.
     The <span> needs its own copy: the clip does not cascade, and once React
     has mounted the island it is the span that carries it. */
  padding-right: 0.06em;
  margin-right: -0.025em; /* 0.035em of tracking, less the 0.06em padding */
}
.hero__title em > span { padding-right: 0.06em; margin-right: -0.06em; }
@supports (background-clip: text) or (-webkit-background-clip: text) {
  /* Le dégradé est porté par le <em> tant que l'îlot n'a pas monté, et par la
     PIÈCE qui pivote une fois qu'il a monté. Les deux sélecteurs, une seule
     déclaration : c'est la même peinture, à deux moments de la vie de la page.

     Pourquoi la pièce et pas le parent. `background-clip: text` peint un fond
     puis le découpe à la forme des glyphes. Si le fond appartient à un ANCÊTRE
     de l'élément qui tourne, il reste immobile pendant que les lettres partent
     en rotation 3D : le mot se décolle de sa propre couleur le temps du pli, et
     traverse la moitié de l'animation en transparent — donc invisible. Sur la
     pièce elle-même, fond et glyphes sont la même boîte et subissent la même
     transformation ; ils ne peuvent plus se désolidariser.

     `.fold-text-piece` est un inline-block (imposé par l'amont, c'est ce qui
     rend la rotation possible), donc il a bien une boîte à peindre. */
  .hero__title em,
  .hero__title em .fold-text-piece {
    background-image: linear-gradient(
      90deg,
      #c2410c, #ff6a1f, #ffb347, #ff8f4d, #c2410c
    );
    background-size: 300% 100%;
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    animation: gradient-text 7s linear infinite;
  }
  /* Le <em> hôte cesse de peindre dès que la pièce prend le relais : sinon deux
     dégradés se superposent, décalés d'un pixel par le padding de compensation,
     et le mot double ses bords. `:has()` ne s'applique que lorsque l'îlot a
     effectivement monté — sans JS, la règle au-dessus reste seule en vigueur. */
  .hero__title em:has(.fold-text-piece) {
    background-image: none;
    animation: none;
  }
}
@keyframes gradient-text {
  to { background-position: 300% 50%; }
}
/* A shimmer sliding through the headline is exactly what motion sensitivity
   flags: hold the gradient still rather than removing it. */
@media (prefers-reduced-motion: reduce) {
  .hero__title em,
  .hero__title em .fold-text-piece { animation: none; }
}

.hero__sub {
  margin-top: clamp(1.1rem, 2.2vw, 1.6rem);
  max-width: 46ch;
  color: var(--bone-mid);
  font-size: clamp(1.02rem, 1.25vw, 1.18rem);
}

.hero__cta {
  margin-top: clamp(1.8rem, 3.4vw, 2.6rem);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.9rem 1.4rem;
}

/* ============ buttons ============ */
.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.82rem 1.55rem;
  border-radius: var(--r-pill);
  font-weight: 700;
  font-size: 0.95rem;
  letter-spacing: -0.008em;
  white-space: nowrap;
  transition:
    transform var(--dur-fast) var(--ease-out),
    background var(--dur-base) var(--ease-both),
    border-color var(--dur-base) var(--ease-both);
}
.btn:active { transform: scale(0.975); }

.btn--primary {
  background: var(--ember);
  color: #1a0a02;                       /* dark on ember: 8.1:1 */
  box-shadow: 0 8px 30px -8px rgba(255, 106, 31, 0.55);
}
.btn--primary:hover { background: var(--ember-soft); }

.btn--ghost {
  color: var(--bone);
  border: 1px solid rgba(242, 236, 228, 0.22);
  background: rgba(242, 236, 228, 0.04);
}
.btn--ghost:hover { border-color: rgba(242, 236, 228, 0.45); background: rgba(242, 236, 228, 0.08); }
/* Icône à l'orange d'accent, libellé inchangé. Pour un bouton secondaire dont
   le glyphe doit attirer l'œil sans que le bouton entier ne devienne un second
   appel à l'action concurrent du principal (l'enveloppe de « Envoyer » sur la
   case contact de l'accueil, 24/08/2026). */
.btn--ico-ember svg { color: var(--ember); }

.btn__arrow { transition: transform var(--dur-base) var(--ease-out); }
.btn:hover .btn__arrow { transform: translateX(3px); }

/* ============ section headers ============ */
.section { padding-block: var(--section-y); }
/* Adjacent sections would otherwise stack two full paddings into a void. */
.section + .section { padding-top: 0; }

.section__head { margin-bottom: clamp(2.5rem, 5vw, 4rem); max-width: 30ch; }
.section__head p { margin-top: 0.9rem; }

/* ============ work: the plate grid (signature) ============ */
.plates {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
  gap: clamp(0.9rem, 1.5vw, 1.15rem);
}

.plate {
  --lx: 50%;
  --ly: 50%;
  position: relative;
  display: flex;
  flex-direction: column;
  min-height: 232px;
  padding: clamp(1.4rem, 2vw, 1.75rem);
  border-radius: var(--r-surface);
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.07);
  overflow: hidden;
  isolation: isolate;
  transition:
    transform var(--dur-base) var(--ease-out),
    border-color var(--dur-base) var(--ease-both);
}

/* light catching the metal, following the cursor */
.plate::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  opacity: 0;
  background: radial-gradient(
    440px circle at var(--lx) var(--ly),
    rgba(255, 106, 31, 0.17),
    transparent 62%
  );
  transition: opacity var(--dur-base) var(--ease-both);
}

.plate:hover, .plate:focus-within {
  transform: translateY(-3px);
  border-color: rgba(255, 106, 31, 0.34);
}
.plate:hover::before, .plate:focus-within::before { opacity: 1; }

.plate__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 0.75rem;
}

.plate__title {
  font-family: 'Satoshi', sans-serif;
  font-weight: 700;
  font-size: 1.14rem;
  letter-spacing: -0.014em;
}
/* The title link stretches over the whole plate: clicking anywhere but the
   download button opens the repo. */
.plate__link::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: var(--r-surface);
}
/* Sits right after the title, before the space-between gap pushes the
   external-link arrow to the far edge. */
.plate__pin {
  color: var(--ember);
  font-size: 0.78rem;
  line-height: 1;
  flex: none;
  margin-inline: -0.5rem auto;
}
.plate__ext {
  color: var(--bone-dim);
  flex: none;
  transform: rotate(-45deg);
  transition: color var(--dur-fast) var(--ease-out), transform var(--dur-base) var(--ease-out);
}
.plate:hover .plate__ext { color: var(--ember-soft); transform: rotate(-45deg) translateX(2px); }

.plate__desc {
  color: var(--bone-mid);
  font-size: 0.945rem;
  line-height: 1.55;
  margin: 0 0 auto;
  max-width: 46ch;
}

.plate__meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem 1.15rem;
  margin-top: 1.4rem;
  padding-top: 1.1rem;
  border-top: 1px solid rgba(242, 236, 228, 0.08);
  color: var(--bone-dim);
  /* Un cran au-dessus du --fs-micro (0.75rem) que la classe `mono`, portée sur
     le même élément, impose à ces mentions — langage, étoiles, forks, date,
     licence (demandé le 24/08/2026, « grossis un peu »).

     La surcharge est ICI et surtout PAS dans `.mono` : cette classe habille
     tout le site, y compris `.plate__dl` juste en dessous (l'extension et le
     poids) et le pied de page. L'agrandir globalement emporterait le bouton
     Télécharger voisin, explicitement exclu de la demande. Ce bouton, lui, ne
     descend pas de `.plate__meta` — il vit dans `.plate__act`, un frère — donc
     cette ligne ne peut pas l'atteindre. */
  font-size: 0.84rem;
}
.plate__meta b {
  color: var(--bone);
  font-weight: 500;
}

.plate__lang {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
}
.plate__lang::before {
  content: "";
  width: 7px; height: 7px;
  border-radius: 50%;
  background: var(--lang, var(--bone-dim));
}

.plate__stars b { color: var(--ember-soft); }

/* skeleton while the GitHub API answers */
.plate--skeleton { pointer-events: none; }
.plate--skeleton .sk {
  display: block;
  height: 0.7rem;
  border-radius: 4px;
  background: linear-gradient(
    90deg,
    var(--ink-200) 0%, var(--ink-300) 40%, var(--ink-200) 80%
  );
  background-size: 220% 100%;
  animation: sheen 1.5s var(--ease-both) infinite;
}
.plate--skeleton .sk + .sk { margin-top: 0.6rem; }
.sk--title { height: 1.05rem; width: 52%; margin-bottom: 1.1rem; }
.sk--w70 { width: 70%; } .sk--w90 { width: 90%; } .sk--w40 { width: 40%; }
@keyframes sheen { to { background-position: -120% 0; } }
@media (prefers-reduced-motion: reduce) { .sk { animation: none; } }

/* empty + error states */
.plates__note {
  grid-column: 1 / -1;
  padding: 2rem;
  border-radius: var(--r-surface);
  border: 1px dashed rgba(242, 236, 228, 0.16);
  color: var(--bone-mid);
  text-align: center;
}
.plates__note a { color: var(--ember-soft); text-decoration: underline; text-underline-offset: 3px; }

/* ============ stats strip: oversized numerals, no card boxes ============ */
.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: clamp(1.5rem, 3vw, 2.5rem);
  padding-block: clamp(2rem, 4vw, 3rem);
  border-block: 1px solid rgba(242, 236, 228, 0.09);
}
.stat__num {
  font-family: 'Clash Display', sans-serif;
  font-weight: 600;
  font-size: clamp(2.3rem, 4.4vw, 3.4rem);
  line-height: 1;
  letter-spacing: -0.03em;
  color: var(--bone);
  font-variant-numeric: tabular-nums;
}
.stat__label { color: var(--bone-dim); margin-top: 0.55rem; display: block; }

/* ============ about: editorial side-image, 40/60 ============ */
.about__grid {
  display: grid;
  grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
  gap: clamp(2rem, 5vw, 4.5rem);
  align-items: center;
}
@media (max-width: 900px) { .about__grid { grid-template-columns: 1fr; } }

.about__media {
  position: relative;
  border-radius: var(--r-surface);
  overflow: hidden;
  aspect-ratio: 4 / 5;
}
/* Same idea as the hero, slower still: a portrait that breathes rather than
   one that moves. The zoom stays under 4% so nothing important drifts out. */
.about__media img {
  width: 100%; height: 100%;
  object-fit: cover;
  will-change: transform;
  animation: about-breathe 26s ease-in-out infinite alternate;
}
@keyframes about-breathe {
  from { transform: scale(1)    translate3d(0, 0, 0); }
  to   { transform: scale(1.04) translate3d(0.8%, -1%, 0); }
}
.about__media::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(200deg, transparent 40%, rgba(11,10,9,0.55));
}
/* Warm light sliding across the frame, as if something off-screen were glowing. */
.about__media::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  background: linear-gradient(115deg, transparent 34%, rgba(255, 122, 16, 0.13) 50%, transparent 66%);
  pointer-events: none;
  animation: about-sheen 14s ease-in-out infinite alternate;
}
@keyframes about-sheen {
  from { transform: translateX(-22%); opacity: 0.5; }
  to   { transform: translateX(22%);  opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .about__media img,
  .about__media::before { animation: none; }
}
@media (max-width: 900px) { .about__media { aspect-ratio: 16 / 9; } }

.about__body p + p { margin-top: 1em; }

.stack {
  display: flex;
  flex-wrap: wrap;
  gap: 0.55rem;
  margin-top: 1.8rem;
}
/* Group title for the Technologies section: quiet label above each cluster. */
.about__g {
  margin: 1.8rem 0 0;
  font-size: 0.8rem;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--bone-dim, #8f867c);
}
/* The cluster hugs its title instead of taking .stack's own top margin. */
.about__g + .stack { margin-top: 0.7rem; }
.stack li {
  padding: 0.4rem 0.85rem;
  border-radius: var(--r-pill);
  border: 1px solid rgba(242, 236, 228, 0.14);
  color: var(--bone-mid);
  background: rgba(242, 236, 228, 0.03);
}

/* ============ contact: full-bleed surface + centered close ============ */
.contact {
  position: relative;
  overflow: hidden;
  text-align: center;
  padding-top: var(--section-y);   /* full-bleed surface keeps its own top */
}
.contact__media { position: absolute; inset: 0; z-index: 0; }
.contact__media img { width: 100%; height: 100%; object-fit: cover; opacity: 0.5; }
.contact__media::after {
  content: "";
  position: absolute; inset: 0;
  background:
    radial-gradient(120% 80% at 50% 105%, rgba(255,106,31,0.16), transparent 58%),
    linear-gradient(to bottom, var(--ink-000) 2%, rgba(11,10,9,0.86) 45%, var(--ink-000) 98%);
}
.contact__inner { position: relative; z-index: 1; }
.contact h2 { max-width: 18ch; margin-inline: auto; }
.contact .lede { max-width: 48ch; margin-inline: auto; }

.links {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.75rem;
  margin-top: 2.4rem;
}
.links a {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.7rem 1.3rem;
  border-radius: var(--r-pill);
  border: 1px solid rgba(242, 236, 228, 0.18);
  background: rgba(242, 236, 228, 0.04);
  font-weight: 500;
  font-size: 0.94rem;
  transition:
    border-color var(--dur-base) var(--ease-both),
    background var(--dur-base) var(--ease-both),
    transform var(--dur-fast) var(--ease-out);
}
.links a:hover { border-color: rgba(255,106,31,0.5); background: rgba(255,106,31,0.09); }
.links a:active { transform: scale(0.975); }
.links svg { width: 17px; height: 17px; flex: none; }

/* ============ footer ============ */
/* Surface, border and glow are set further down, next to the ember dome. */
/* One row: the facts, and nothing above them. The three link columns went
   first — every page they listed is one tap away in the top bar — and the row
   of social marks after them, for the same reason. */
.footer {
  padding-block: 1.15rem 1.05rem;
  color: var(--bone-dim);
}

/* ---- the facts ----
   Two blocks pushed apart, each stacking its own lines: identity on the left,
   live state and fixed facts on the right. `align-items: start` so both start
   at the top edge whatever number of lines they end up with. */
.footer__legal {
  display: flex;
  flex-wrap: wrap;
  align-items: start;
  justify-content: space-between;
  gap: 0.75rem 1.5rem;
  /* No rule above these lines: the hairline separated them from the row of
     social marks, and with that row gone it had nothing left on the other side
     of it. The footer already has its own top border, so a second line a few
     pixels below it read as a stray divider. */
  font-size: 0.72rem;
}
.footer__id,
.footer__meta {
  display: flex;
  flex-direction: column;
  gap: 0.28rem;
  min-width: 0;
  /* Part égale pour les deux colonnes, sinon la bande de réseaux entre elles
     n'est centrée que par rapport à leurs largeurs réelles : « Hosted in
     France » étant plus court que « 5 en ligne · 113 visiteurs », elle tombait
     24 px à gauche du milieu de la page. */
  flex: 1 1 0;
}
/* Right-hand block reads right-aligned so its lines share an edge with the bar
   rather than floating mid-width. */
.footer__meta { text-align: right; align-items: flex-end; }
/* …mais jamais plus étroit que ses chiffres. `min-width: 0` ci-dessus laissait
   la colonne descendre sous la largeur de « 11 en ligne · 270 visiteurs » :
   mesuré en français à 740 px, la ligne faisait 209 px dans une colonne de
   166 px et débordait de 42 px À GAUCHE, par-dessus la bande de réseaux.
   `min-content` = la largeur de la plus longue mention insécable. La colonne
   ne peut plus passer dessous, et quand la barre manque de place c'est
   `.footer__legal` qui enveloppe — la bande descend d'une ligne, rien ne se
   superpose. L'écart entre le français et l'anglais disparaît du même coup :
   la contrainte se calcule sur le texte réellement rendu. */
.footer__meta { min-width: min-content; }
/* The figures stay side by side — "7 online" and "23 visitors" are one thought,
   and stacking them would read as two unrelated counters.

   `nowrap`, et non `wrap` (24/08/2026) : mesuré entre 730 et 760 px de largeur
   utile — ce que donne un zoom à 200 % sur un écran de 1440 — le conteneur
   passait à 52 px de haut et les deux mentions tombaient l'une sous l'autre,
   décalées de 92 px. C'était exactement le « chevauchement au zoom » signalé.

   Le débordement est impossible : `.footer__legal` enveloppe déjà (`flex-wrap:
   wrap`), donc quand les chiffres ne tiennent plus à droite de la bande de
   réseaux, c'est le bloc ENTIER qui passe à la ligne — jamais un compteur
   par-dessus l'autre. `white-space: nowrap` fait la même promesse à l'intérieur
   de chaque mention : « 2 en ligne » ne se coupe pas après le chiffre. */
.footer__figures {
  display: inline-flex; align-items: baseline;
  gap: 0.75rem; flex-wrap: nowrap; justify-content: flex-end;
}
.footer__now, .footer__count { white-space: nowrap; }
.footer__domain { font-size: 0.78rem; color: var(--bone); }
.footer__hosted { color: var(--bone-dim); opacity: 0.75; }
.footer__copy { opacity: 0.55; }

@media (max-width: 720px) {
  /* One column: side by side, the two blocks are narrow enough that both wrap
     internally, which costs more lines than stacking them does.

     Tout est centré ici, contrairement au desktop : en colonne il n'y a plus
     deux blocs qui s'équilibrent de part et d'autre, juste une pile étroite, et
     un axe central lui donne une forme. C'est aussi ce qui fait que la bande de
     réseaux, qui prend toute la largeur, n'a pas l'air décalée par rapport au
     texte au-dessus et en dessous. */
  .footer__legal { flex-direction: column; align-items: center; gap: 0.7rem; font-size: 0.66rem; }
  .footer__id,
  .footer__meta { text-align: center; align-items: center; }
  .footer__figures { justify-content: center; gap: 0.55rem; }
  .footer__domain { font-size: 0.74rem; }
  /* La bande repasse SOUS le bloc d'identité (19/09/2026) : le domaine ouvre
     la barre et la bande la referme. L'ordre du DOM reste celui du desktop
     (identité, bande, chiffres) et donc celui que lit un lecteur d'écran —
     plus de `order`, l'empilement naturel suffit. */
  .footer__loop { order: 0; }

  /* Sur téléphone la barre garde deux choses : la bande de réseaux et
     l'animation du domaine. Les quatre mentions — hébergement, en ligne,
     visiteurs, copyright — disparaissent : empilées et centrées elles font
     quatre lignes de texte gris de même poids, et la barre finit plus haute
     que ce qu'elle annonce.
     display:none et pas une astuce de masquage visuel : ces lignes ne sont ni
     une navigation ni une information dont la page dépend, et les laisser
     lisibles aux seuls lecteurs d'écran ferait annoncer une barre que personne
     ne voit. Tout reste sur desktop. */
  .footer__hosted,
  .footer__meta { display: none; }
}

/* ============ réseaux qui défilent (barre du bas) ============
   Ce qui suit jusqu'à la fin du bloc est le CSS amont de LogoLoop
   (https://reactbits.dev/animations/logo-loop), recopié tel quel : le composant
   est en mode librairie côté Vite, donc son `import './LogoLoop.css'` a été
   retiré et les styles doivent voyager avec la feuille du site. Ne pas
   retoucher ces règles — l'adaptation au site est dans le bloc marqué
   « propre au site » juste après. */
.logoloop {
  position: relative;

  --logoloop-gap: 32px;
  --logoloop-logoHeight: 28px;
  --logoloop-fadeColorAuto: #ffffff;
}

.logoloop--vertical {
  height: 100%;
  display: inline-block;
}

.logoloop--scale-hover {
  padding-top: calc(var(--logoloop-logoHeight) * 0.1);
  padding-bottom: calc(var(--logoloop-logoHeight) * 0.1);
}

@media (prefers-color-scheme: dark) {
  .logoloop {
    --logoloop-fadeColorAuto: #0b0b0b;
  }
}

.logoloop__track {
  display: flex;
  width: max-content;
  will-change: transform;
  user-select: none;
  position: relative;
  z-index: 0;
}

.logoloop--vertical .logoloop__track {
  flex-direction: column;
  height: max-content;
  width: 100%;
}

.logoloop__list {
  display: flex;
  align-items: center;
}

.logoloop--vertical .logoloop__list {
  flex-direction: column;
}

.logoloop__item {
  flex: 0 0 auto;
  margin-right: var(--logoloop-gap);
  font-size: var(--logoloop-logoHeight);
  line-height: 1;
}

.logoloop--vertical .logoloop__item {
  margin-right: 0;
  margin-bottom: var(--logoloop-gap);
}

.logoloop__item:last-child {
  margin-right: var(--logoloop-gap);
}

.logoloop--vertical .logoloop__item:last-child {
  margin-right: 0;
  margin-bottom: var(--logoloop-gap);
}

.logoloop__node {
  display: inline-flex;
  align-items: center;
}

.logoloop__item img {
  height: var(--logoloop-logoHeight);
  width: auto;
  display: block;
  object-fit: contain;
  image-rendering: -webkit-optimize-contrast;
  -webkit-user-drag: none;
  pointer-events: none;
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.logoloop--scale-hover .logoloop__item {
  overflow: visible;
}

.logoloop--scale-hover .logoloop__item:hover img,
.logoloop--scale-hover .logoloop__item:hover .logoloop__node {
  transform: scale(1.2);
  transform-origin: center center;
}

.logoloop--scale-hover .logoloop__node {
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.logoloop__link {
  display: inline-flex;
  align-items: center;
  text-decoration: none;
  border-radius: 4px;
  transition: opacity 0.2s ease;
}

.logoloop__link:hover {
  opacity: 0.8;
}

.logoloop__link:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

.logoloop--fade::before,
.logoloop--fade::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  width: clamp(24px, 8%, 120px);
  pointer-events: none;
  z-index: 10;
}

.logoloop--fade::before {
  left: 0;
  background: linear-gradient(
    to right,
    var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,
    rgba(0, 0, 0, 0) 100%
  );
}

.logoloop--fade::after {
  right: 0;
  background: linear-gradient(
    to left,
    var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,
    rgba(0, 0, 0, 0) 100%
  );
}

.logoloop--vertical.logoloop--fade::before,
.logoloop--vertical.logoloop--fade::after {
  left: 0;
  right: 0;
  width: 100%;
  height: clamp(24px, 8%, 120px);
}

.logoloop--vertical.logoloop--fade::before {
  top: 0;
  bottom: auto;
  background: linear-gradient(
    to bottom,
    var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,
    rgba(0, 0, 0, 0) 100%
  );
}

.logoloop--vertical.logoloop--fade::after {
  bottom: 0;
  top: auto;
  background: linear-gradient(
    to top,
    var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,
    rgba(0, 0, 0, 0) 100%
  );
}

@media (prefers-reduced-motion: reduce) {
  .logoloop__track {
    transform: translate3d(0, 0, 0) !important;
  }

  .logoloop__item img,
  .logoloop__node {
    transition: none !important;
  }
}

/* ---- propre au site ----
   Largeur fixée, et c'est elle qui décide combien de marques on voit : à 20 px
   de logo et 42 px d'écart, 300 px en laissent quatre à cinq à l'écran. Sans
   cette borne la bande prendrait tout l'espace libre entre les deux colonnes et
   en afficherait le double.
   `flex: 0 1 300px` : elle peut rétrécir si la barre manque de place, jamais
   grandir. */
/* overflow: hidden est ICI et pas dans le bloc amont : la source de react-bits
   ne clippe rien, elle compte sur un ancêtre pour le faire. Sans ça la piste,
   qui est en `width: max-content` et fait trois copies de la liste, dépasse de
   ~470 px à droite et met toute la page en défilement horizontal. */
.footer__loop { flex: 0 1 300px; min-width: 0; align-self: center; overflow: hidden; }

/* Le SVG hérite la couleur de marque posée en style inline par l'îlot, la même
   que sur /reseaux/. opacity plutôt qu'un gris : la teinte reste reconnaissable
   au repos et le survol la rend pleine. */
.sloop__icon { display: inline-flex; opacity: 0.78; transition: opacity 0.2s var(--ease-out); }
.footer__loop .logoloop__link:hover .sloop__icon { opacity: 1; }
/* Le lien amont baisse son opacité au survol ; ici c'est l'icône qui s'éclaire,
   les deux ensemble donnaient un logo plus terne au survol qu'au repos. */
.footer__loop .logoloop__link:hover { opacity: 1; }

@media (max-width: 720px) {
  /* La bande est raccourcie (19/09/2026) : 220 px au lieu de 300, la barre du
     bas d'un téléphone gagne en respiration. `min()` borne le cas large, et
     en dessous c'est la largeur réelle qui s'applique — le nombre de logos
     baisse tout seul. */
  .footer__loop { flex: 0 1 auto; width: min(220px, 100%); }

  /* Zone tactile. Le logo reste à 20 px, mais viser 20 px au doigt sur une
     bande qui défile est une loterie : le lien est rembourré pour atteindre
     40x40, seuil tactile courant.
     L'écart est réduit d'autant, sinon le pas entre deux marques passe de 62 à
     82 px et il n'en reste que trois à l'écran. 40 + 20 = 60 px de pas, soit
     cinq marques dans les 300 px.
     Deux détails, tous deux payés en mesurant : la variable se redéfinit sur
     `.logoloop` et pas sur `.footer__loop` — le composant la pose en style
     inline sur SON élément racine, qui est un enfant de l'îlot, donc une
     déclaration sur le parent est seulement héritée puis écrasée. Et il faut
     !important là, parce qu'une règle de feuille ne bat pas un style inline
     autrement. Sur le parent, !important ne servait à rien. */
  .footer__loop .logoloop { --logoloop-gap: 20px !important; }
  .footer__loop .logoloop__link { padding: 10px; }
}

/* ============ scroll reveal ============
   Hidden only once JS has confirmed it can reveal them again.
   Without JS (or if the script fails) the content stays visible. */
.js [data-reveal] {
  opacity: 0;
  transform: translateY(22px);
  transition:
    opacity var(--dur-slow) var(--ease-out),
    transform var(--dur-slow) var(--ease-out);
}
.js [data-reveal].is-in { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .js [data-reveal] { opacity: 1; transform: none; transition: none; }
  .hero__media img { transform: none; }
  * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

/* ============================================================
   Sub-pages
   ============================================================ */

/* Sub-pages have no hero image, so the fixed nav needs breathing room. */
/* 68px of fixed nav, plus a gap so nothing ever touches the blurred edge. */
.is-sub main { padding-top: calc(68px + clamp(0.75rem, 2vw, 1.5rem)); }

/* The fixed nav is 68px of blurred glass. main already clears that height;
   this is the breathing room below it, so the title never sits against the
   blur. */
.phead {
  padding-block: clamp(3.5rem, 7vw, 6rem) clamp(1.6rem, 3vw, 2.4rem);
  position: relative;
}
.phead::after {
  content: "";
  position: absolute;
  left: var(--gutter); right: var(--gutter);
  bottom: 0; height: 1px;
  background: linear-gradient(90deg, rgba(255,106,31,0.5), rgba(242,236,228,0.07) 42%, transparent);
}
.phead__icon { font-size: clamp(1.6rem, 3vw, 2.1rem); display: block; margin-bottom: 0.9rem; }
/* Title left, optional action hard right. Wraps rather than squeezing the
   title: at 4.4rem the h1 has no room to give on a phone. */
.phead__row {
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: 1rem; flex-wrap: wrap;
}
.phead__aside { flex: 0 0 auto; padding-bottom: 0.35rem; }
.ghbtn { display: inline-flex; align-items: center; gap: 0.5rem; }
.ghbtn svg { flex: 0 0 auto; }
/* Gris clair, pas orange (19/09/2026) : le bouton renvoie vers le profil
   GitHub, ce n'est plus l'action principale de la page. Texte sombre dessus,
   comme btn--primary le faisait sur l'orange. */
.ghbtn { background: #d9d3ca; color: #26221e; box-shadow: none; }
.ghbtn:hover { background: #e6e1d9; }
/* The hero aside can hold more than one control (projects: GitHub + refresh). */
.phead__aside { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.refresh { margin: 0; }
.refresh button { display: inline-flex; align-items: center; gap: 0.45rem; }
.refresh svg { flex: 0 0 auto; }
@media (max-width: 520px) { .refresh span { display: none; } }
/* .display sets line-height:0.95; below 1 the glyph box is shorter than the
   letters, so accented caps (À, É) overflow upward into the fixed nav. Here
   the title is the first thing on the page, so it needs a full line box. */
.phead__title { font-size: clamp(2.4rem, 6vw, 4.4rem); line-height: 1.05; }
.phead__lede { margin-top: 1rem; max-width: 54ch; }

/* The header rule already separates; a full section pad on top doubles it. */
.phead + .section { padding-top: clamp(2rem, 4vw, 3rem); }

.sec-h {
  font-family: 'Satoshi', sans-serif;
  font-size: 1.02rem;
  font-weight: 700;
  letter-spacing: 0.01em;
  color: var(--bone);
  margin: 0 0 1.4rem;
  display: flex;
  align-items: center;
  gap: 0.8rem;
}
.sec-h--sp { margin-top: clamp(2.4rem, 5vw, 3.6rem); }
.sec-h__c { color: var(--bone-dim); font-weight: 400; }

.note {
  padding: clamp(1.6rem, 3vw, 2.2rem);
  border-radius: var(--r-surface);
  border: 1px dashed rgba(242, 236, 228, 0.16);
  color: var(--bone-mid);
  text-align: center;
}
.note p { margin: 0 auto; max-width: 52ch; }
.note__extra { margin-top: 0.8rem; }
.note a { color: var(--ember-soft); text-decoration: underline; text-underline-offset: 3px; }

.about__h {
  font-family: 'Satoshi', sans-serif;
  font-size: 0.95rem;
  font-weight: 700;
  margin: 2rem 0 0;
  color: var(--bone);
}

/* ============ projects: richer plates ============ */
.plate--rich { min-height: 268px; }
.plate__topics {
  display: flex; flex-wrap: wrap; gap: 0.35rem;
  margin: 0.9rem 0 0;
}
.plate__topics li {
  font-size: 0.72rem;
  padding: 0.16rem 0.55rem;
  border-radius: var(--r-pill);
  border: 1px solid rgba(242,236,228,0.13);
  color: var(--bone-dim);
}
/* Above the stretched title link, so the download button stays clickable. */
.plate__act {
  display: flex; flex-wrap: wrap; gap: 0.5rem;
  align-items: center;          /* the file facts sit level with the button */
  margin-top: auto;             /* buttons line up across a row of cards */
  padding-top: 1.1rem;
  position: relative;
  z-index: 2;
}
.btn--sm { padding: 0.5rem 1rem; font-size: 0.84rem; }
/* Bouton réduit à son icône (le bouton actualiser depuis le 24/08/2026).
   Le padding de `.btn--sm` est asymétrique — pensé pour du texte, plus large
   que haut — et laissait un rectangle allongé autour d'un glyphe carré. Ici la
   boîte redevient carrée autour de l'icône.

   2,25rem de côté, soit 36px : sous la barre des 44px recommandée pour le
   tactile, mais ce bouton est toujours accolé à un bouton principal de la même
   rangée, et l'agrandir décalerait la ligne du titre. Le geste reste large :
   c'est une commande de confort, jamais le seul chemin vers une action —
   recharger la page fait la même chose. */
.btn--icon {
  padding: 0;
  width: 2.25rem;
  height: 2.25rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
}

/* ============ links list ============ */
.links--stack { flex-direction: column; align-items: stretch; max-width: 620px; }
.links--stack a {
  justify-content: flex-start;
  gap: 0.9rem;
  padding: 1rem 1.2rem;
  text-align: left;
}
.links--stack span { display: grid; gap: 0.1rem; }
.links--stack b { font-weight: 700; font-size: 0.97rem; }
.links--stack small { color: var(--bone-dim); font-size: 0.82rem; }

/* ============ socials grid ============ */
.netgrid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
  gap: 0.85rem;
}
.net a, .net__static {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 1.1rem 1.25rem;
  border-radius: var(--r-surface);
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.07);
  transition: border-color var(--dur-base) var(--ease-both), transform var(--dur-fast) var(--ease-out);
}
/* Hover borrows the network's own colour rather than the site ember: it is
   the fastest confirmation that you are about to open the right service. */
.net a:hover { border-color: color-mix(in srgb, var(--brand, var(--ember)) 45%, transparent); transform: translateY(-2px); }
/* The glyph carries its brand colour at rest, dimmed just enough not to shout
   over the page, and comes to full strength on hover. */
.net__icon {
  color: var(--brand, var(--bone-mid));
  opacity: 0.82;
  flex: none;
  display: grid;
  place-items: center;
  transition: opacity var(--dur-base) var(--ease-both), transform var(--dur-base) var(--ease-out);
}
.net a:hover .net__icon { opacity: 1; transform: scale(1.08); }
.net__body { display: grid; gap: 0.12rem; min-width: 0; flex: 1; }
.net__body b { font-size: 0.98rem; font-weight: 700; }
.net__body small {
  color: var(--bone-dim); font-size: 0.8rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.net__go { color: var(--bone-dim); flex: none; }
.net a:hover .net__go { color: var(--ember-soft); }

/* ============ clips ============ */
.clipgrid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr));
  gap: clamp(1rem, 2vw, 1.5rem);
}

/* ---- clips rail: one row, scrolled sideways, looping ---- */
.rail { position: relative; }
.rail__track {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  /* The loop works by jumping scrollLeft; CSS smooth scrolling would animate
     that jump and make it visible, so it is explicitly off here. */
  scroll-behavior: auto;
  padding-block: 0.25rem;
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.rail__track::-webkit-scrollbar { display: none; }
/* Big enough to actually watch without opening anything: roughly two thirds
   of the viewport on a desktop, nearly full width on a phone. */
.rail .clip {
  flex: 0 0 min(92vw, 760px);
  scroll-snap-align: center;
}
/* Arrows: floated over the rail edges, out of the way on touch where the
   natural gesture is to swipe. */
.rail__nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 3;
  display: grid;
  place-items: center;
  width: 42px; height: 42px;
  border-radius: 50%;
  border: 1px solid rgba(242, 236, 228, 0.14);
  background: rgba(11, 10, 9, 0.82);
  backdrop-filter: blur(6px);
  color: var(--bone);
  cursor: pointer;
  transition: background var(--dur-base) var(--ease-both), border-color var(--dur-base) var(--ease-both);
}
.rail__nav:hover { background: rgba(255, 106, 31, 0.22); border-color: rgba(255, 106, 31, 0.5); }
.rail__nav--prev { left: -8px; }
.rail__nav--prev svg { transform: rotate(180deg); }
.rail__nav--next { right: -8px; }
@media (hover: none) { .rail__nav { display: none; } }
.clip { border-radius: var(--r-surface); overflow: hidden; background: var(--ink-100); border: 1px solid rgba(242,236,228,0.07); }
.clip video { width: 100%; aspect-ratio: 16 / 9; background: #000; display: block; }
.clip__meta {
  display: flex; align-items: center; justify-content: space-between;
  gap: 1rem; padding: 0.85rem 1.1rem;
}
.clip__meta b { font-size: 0.95rem; font-weight: 600; }
.clip__meta small { color: var(--bone-dim); }

/* ============ music player ============ */
.player {
  padding: clamp(1.3rem, 2.4vw, 1.8rem);
  border-radius: var(--r-surface);
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.08);
  display: grid;
  gap: 1.1rem;
  margin-bottom: 1.6rem;
}
.player__now { display: flex; align-items: center; gap: 1.1rem; }
.player__art {
  width: 58px; height: 58px; flex: none;
  border-radius: var(--r-inset);
  background: linear-gradient(150deg, var(--ember-deep), var(--ink-200));
  display: grid; place-items: center;
}
.bars { display: flex; align-items: flex-end; gap: 3px; height: 20px; }
.bars i {
  width: 3px; height: 6px; border-radius: 2px;
  background: rgba(255, 236, 228, 0.85);
  transition: height var(--dur-base) var(--ease-both);
}
.player.is-playing .bars i { animation: eq 900ms var(--ease-both) infinite alternate; }
.player.is-playing .bars i:nth-child(2) { animation-delay: 140ms; }
.player.is-playing .bars i:nth-child(3) { animation-delay: 280ms; }
.player.is-playing .bars i:nth-child(4) { animation-delay: 90ms; }
@keyframes eq { from { height: 5px; } to { height: 19px; } }

.player__meta { display: grid; gap: 0.15rem; min-width: 0; }
.player__meta b { font-size: 1.05rem; font-weight: 700; letter-spacing: -0.012em; }
.player__meta small { color: var(--bone-mid); font-size: 0.86rem; }

.player__seek { display: flex; align-items: center; gap: 0.8rem; }
.player__seek .mono { color: var(--bone-dim); flex: none; font-size: 0.72rem; }
#pl-seek {
  flex: 1;
  -webkit-appearance: none; appearance: none;
  height: 4px; border-radius: 3px;
  background: var(--ink-300);
  cursor: pointer;
}
#pl-seek::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 13px; height: 13px; border-radius: 50%;
  background: var(--ember);
  box-shadow: 0 0 10px rgba(255,106,31,0.7);
  cursor: grab;
}
#pl-seek::-moz-range-thumb {
  width: 13px; height: 13px; border: 0; border-radius: 50%;
  background: var(--ember);
  box-shadow: 0 0 10px rgba(255,106,31,0.7);
  cursor: grab;
}

.player__ctrl { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; }
.player__ctrl button {
  width: 40px; height: 40px;
  display: grid; place-items: center;
  border-radius: 50%;
  border: 1px solid rgba(242, 236, 228, 0.14);
  color: var(--bone-mid);
  transition: color var(--dur-fast) var(--ease-out),
              border-color var(--dur-base) var(--ease-both),
              transform var(--dur-fast) var(--ease-out);
}
.player__ctrl button:hover { color: var(--bone); border-color: rgba(242,236,228,0.4); }
.player__ctrl button:active { transform: scale(0.94); }
.player__ctrl .is-primary {
  width: 50px; height: 50px;
  background: var(--ember);
  border-color: transparent;
  color: #1a0a02;
  box-shadow: 0 8px 26px -10px rgba(255,106,31,0.75);
}
.player__ctrl .is-primary:hover { background: var(--ember-soft); color: #1a0a02; }
.player__ctrl button.is-on { color: var(--ember-soft); border-color: rgba(255,106,31,0.45); }
.player__note { color: var(--bone-dim); font-size: 0.78rem; margin-left: auto; }

.tracks { display: grid; gap: 0.25rem; counter-reset: t; }
.track {
  width: 100%;
  display: flex; align-items: center; gap: 0.9rem;
  padding: 0.72rem 0.9rem;
  border-radius: var(--r-inset);
  text-align: left;
  border: 1px solid transparent;
  transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-base) var(--ease-both);
}
.track:hover { background: rgba(242, 236, 228, 0.04); }
.track.is-on { background: rgba(255, 106, 31, 0.08); border-color: rgba(255,106,31,0.26); }
.track__n { color: var(--bone-dim); flex: none; }
.track.is-on .track__n { color: var(--ember-soft); }
.track__t { display: grid; gap: 0.08rem; min-width: 0; flex: 1; }
.track__t b { font-size: 0.95rem; font-weight: 600; }
.track__t small { color: var(--bone-dim); font-size: 0.8rem; }
.track__eq { display: none; gap: 2px; align-items: flex-end; height: 13px; }
.track.is-on .track__eq { display: flex; }
.track__eq i { width: 2.5px; height: 5px; border-radius: 2px; background: var(--ember); }
.player.is-playing ~ .tracks .track.is-on .track__eq i { animation: eq 760ms var(--ease-both) infinite alternate; }
.player.is-playing ~ .tracks .track.is-on .track__eq i:nth-child(2) { animation-delay: 130ms; }
.player.is-playing ~ .tracks .track.is-on .track__eq i:nth-child(3) { animation-delay: 260ms; }

/* ============ embeds ============ */
.embeds {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
  gap: 1.1rem;
}
.embed {
  border-radius: var(--r-surface);
  background: var(--ink-050);
  border: 1px solid rgba(242, 236, 228, 0.07);
  padding: 1rem;
  overflow: hidden;
}
.embed__h {
  display: flex; align-items: center; gap: 0.55rem;
  font-size: 0.88rem; font-weight: 700;
  color: var(--bone-mid);
  margin: 0 0 0.85rem;
  min-width: 0;
}
/* L'icône du service à sa couleur de marque, le titre au sien. Les SVG sont
   en `currentColor`, donc `color` sur le seul <svg> suffit ; le titre continue
   d'hériter de --bone-mid du <h3>. `> svg` et non `svg` : le chevron du lien
   plus bas dans la carte ne doit pas virer au vert Spotify.
   Repli sur currentColor si --brand n'est pas posée (service inconnu). */
.embed__h > svg { color: var(--brand, currentColor); flex: none; }
/* Service name, secondary to the title the admin typed. */
.embed__svc {
  margin-left: auto;
  flex: none;
  font-size: 0.68rem;
  font-weight: 500;
  color: var(--bone-dim);
  letter-spacing: 0.03em;
  text-transform: uppercase;
}
.embed iframe {
  width: 100%; border: 0;
  border-radius: var(--r-inset);
  background: var(--ink-200);
  display: block;
}
/* Apple Music serves its embed white-only. The invert+hue-rotate pair turns
   the white chrome dark while keeping the artwork's colours roughly intact —
   the only lever they leave us. */
.embed--apple iframe {
  filter: invert(0.92) hue-rotate(180deg) saturate(0.92);
}
.embed__link {
  display: inline-flex; align-items: center; gap: 0.5rem;
  color: var(--ember-soft); font-size: 0.9rem; font-weight: 600;
}

/* ============ gaming ============ */
/* Deux colonnes exactement, pas `auto-fill` (24/08/2026). En auto-fill la
   rangée passait à trois colonnes au-delà de ~1120px puis retombait à deux,
   laissant une carte seule sur une demi-largeur. Ici FACEIT et LoL partagent
   la rangée à taille égale quelle que soit la largeur, et Leetify prend les
   deux colonnes en dessous. */
.gcards {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(0.9rem, 1.6vw, 1.15rem);
}
.gcard--wide { grid-column: 1 / -1; }

@media (max-width: 760px) {
  .gcards { grid-template-columns: minmax(0, 1fr); }
}

/* Sur toute la largeur, la carte Leetify se déplie en deux colonnes :
   compétences à gauche, historique à droite. Empilés, ils feraient un bandeau
   deux fois plus haut que les deux cartes du dessus réunies. */
@media (min-width: 761px) {
  .gcard--wide {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: clamp(1.25rem, 2.4vw, 2rem);
    align-items: start;
  }
  .gcard--wide > .gcard__h,
  .gcard--wide > .gstats { grid-column: 1 / -1; }
  .gcard--wide > .glist { margin-top: 0; }
}
.gcard {
  --lx: 50%; --ly: 50%;
  position: relative;
  padding: clamp(1.25rem, 2vw, 1.6rem);
  border-radius: var(--r-surface);
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.07);
  overflow: hidden;
  isolation: isolate;
}
.gcard::before {
  content: "";
  position: absolute; inset: 0; z-index: -1; opacity: 0;
  background: radial-gradient(400px circle at var(--lx) var(--ly), rgba(255,106,31,0.12), transparent 60%);
  transition: opacity var(--dur-base) var(--ease-both);
}
.gcard:hover::before { opacity: 1; }
.gcard__h { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.1rem; }
.gcard__h h2 {
  font-family: 'Satoshi', sans-serif;
  font-size: 0.94rem; font-weight: 700;
  letter-spacing: 0.02em; text-transform: uppercase;
  color: var(--bone-mid); margin: 0;
}
.gcard__link { color: var(--bone-dim); }
.gcard__link:hover { color: var(--ember-soft); }
.gcard__note { color: var(--bone-dim); font-size: 0.88rem; margin: 0; }
.gcard__hint { color: var(--bone-dim); font-size: 0.78rem; margin: 0.7rem 0 0; line-height: 1.5; }
.gcard__id { font-weight: 700; font-size: 1.02rem; margin: 0 0 0.9rem; }
/* .gcard__credit est partie avec « Data provided by Leetify » (24/08/2026) :
   plus aucun élément ne portait cette classe. */

.gstats { display: flex; flex-wrap: wrap; gap: 1.6rem; margin-bottom: 1rem; }
.gstat { display: grid; gap: 0.2rem; }
.gstat__v {
  font-family: 'Clash Display', sans-serif;
  font-size: 1.9rem; font-weight: 600; line-height: 1;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
}
.gstat__v--sm { font-size: 1.05rem; font-family: 'Satoshi', sans-serif; }
/* Le badge de niveau FACEIT : un losange à la couleur du rang, chiffre dedans.
   La forme reprend celle de la plateforme — c'est elle, autant que la teinte,
   qui rend le rang reconnaissable au premier coup d'œil.

   Le badge CSS en losange est parti le 19/09/2026 : la carte affiche l'icône
   officielle du niveau (assets/img/faceit/), qui EST le losange FACEIT avec
   le chiffre. */
.fclvlimg { display: inline-grid; place-items: center; }
.fclvlimg img { display: block; width: 2.75rem; height: 2.75rem; }
.gstat__l {
  font-family: 'JetBrains Mono', monospace;
  font-size: 0.68rem; letter-spacing: 0.06em;
  color: var(--bone-dim);
}

.gbars { display: grid; gap: 0.5rem; }
.gbars li { display: grid; grid-template-columns: 5.5rem 1fr 2rem; align-items: center; gap: 0.7rem; }
.gbars__l { font-size: 0.8rem; color: var(--bone-mid); }
.gbars__t { height: 5px; border-radius: 3px; background: var(--ink-300); overflow: hidden; }
/* La barre chauffe avec la note : ambre en bas d'échelle, rouge à l'approche
   de 100 (demandé le 24/08/2026). --pct est posé en ligne par PHP, la même
   valeur que la largeur — la teinte ne peut donc pas mentir sur le chiffre.

   Teinte calculée, pas trois paliers : hsl part de 38° (ambre) et descend vers
   0° (rouge) proportionnellement. Un `calc()` dans le canal de teinte évite
   autant la cascade de media queries que le sac de classes .is-high/.is-low,
   et surtout il n'a aucun seuil où la couleur saute.

   Le dégradé de gauche à droite est conservé : il donne du volume à une barre
   de 5px de haut. Ses deux bouts chauffent ensemble. */
.gbars__t i {
  display: block;
  height: 100%;
  border-radius: 3px;
  background: linear-gradient(
    90deg,
    hsl(calc(38deg - 38deg * var(--pct, 0) / 100) 88% 42%),
    hsl(calc(38deg - 38deg * var(--pct, 0) / 100) 92% 55%)
  );
}
.gbars__v { color: var(--bone-dim); text-align: right; font-size: 0.72rem; }

.glist { display: grid; gap: 0.3rem; margin-top: 1rem; }
.glist li {
  display: flex; align-items: center; gap: 0.75rem;
  padding: 0.5rem 0.7rem;
  border-radius: var(--r-inset);
  background: rgba(242, 236, 228, 0.03);
  font-size: 0.85rem;
}
.glist li > span:nth-child(2) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.glist .mono { color: var(--bone-dim); font-size: 0.72rem; }
.glist__r { font-weight: 700; }
.glist .is-win, .glist li.is-win { border-left: 2px solid #4ade80; }
.glist li.is-loss { border-left: 2px solid #f87171; }
.glist li.is-tie  { border-left: 2px solid var(--bone-dim); }
.glist li.is-win .glist__r { color: #86efac; }
.glist li.is-loss .glist__r { color: #fca5a5; }

/* Match history: won green, lost red. The score itself is tinted rather than
   just the edge, and it stays a score — the result is never carried by the
   colour alone, which is what keeps it readable without colour vision. */
.glist__map { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.glist__sc { font-weight: 700; letter-spacing: 0.01em; }
.glist li.is-win  .glist__sc { color: #86efac; }
.glist li.is-loss .glist__sc { color: #fca5a5; }
.glist li.is-tie  .glist__sc { color: var(--bone-mid); }
/* .glist .mono above dims every mono span; the score opts back out. */
.glist .glist__sc.mono { font-size: 0.78rem; }
.glist li.is-win  { background: color-mix(in srgb, #4ade80 6%, rgba(242, 236, 228, 0.03)); }
.glist li.is-loss { background: color-mix(in srgb, #f87171 6%, rgba(242, 236, 228, 0.03)); }

.grank {
  display: grid; gap: 0.15rem;
  padding: 0.7rem 0;
  border-bottom: 1px solid rgba(242, 236, 228, 0.07);
}
.grank:last-of-type { border-bottom: 0; }
.grank b { font-size: 0.8rem; color: var(--bone-dim); font-weight: 500; }
.grank span { font-size: 0.98rem; font-weight: 700; }
.grank small { color: var(--bone-dim); font-size: 0.72rem; }

/* CS2 inventory: rarity colour is the only decoration each tile needs. */
.inv {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(124px, 1fr));
  gap: 0.6rem;
}

/* Inventory cap. The whole list is in the DOM; only its height is limited,
   and the checkbox lifts the limit. No script: :has() reads the checkbox
   state, and the @supports below is the fallback for browsers without it —
   there the list simply shows in full, which is the correct failure. */
.invsteam { margin-left: auto; display: inline-flex; align-items: center; gap: 0.45rem; }
/* L'icône prend la couleur de la marque, le libellé garde la sienne : le SVG
   est en `currentColor`, donc il suffit de changer `color` sur lui seul.
   Le bleu Steam est trop sombre pour du texte sur ce fond — appliqué au bouton
   entier, il rendrait le libellé illisible. */
.invsteam svg { flex: 0 0 auto; color: var(--brand, currentColor); }
@media (max-width: 520px) { .invsteam span { display: none; } }

.invwrap { position: relative; }
/* Off-screen rather than display:none — see the comment on the input. */
.invall {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
}
@supports selector(:has(*)) {
  .invwrap.is-capped .inv {
    max-height: 30rem;
    overflow: hidden;
    /* The veil IS the mask: the last visible row fades into the page instead
       of being cut mid-tile, which is what says "there is more" without a
       second element drawn on top. */
    -webkit-mask-image: linear-gradient(#000 68%, transparent 100%);
    mask-image: linear-gradient(#000 68%, transparent 100%);
  }
  .invwrap.is-capped:has(.invall:checked) .inv {
    max-height: none;
    -webkit-mask-image: none;
    mask-image: none;
  }
  .invmore { display: inline-flex; margin: 1rem auto 0; }
  .invwrap:has(.invall:checked) .invmore__on,
  .invmore__off { display: none; }
  .invwrap:has(.invall:checked) .invmore__off { display: inline; }
  /* The label is the control: it must look and focus like one. */
  .invmore { cursor: pointer; }
  .invall:focus-visible + .inv + .invmore,
  .invall:focus-visible ~ .invmore { outline: 2px solid var(--ember); outline-offset: 2px; }
}
/* Without :has() the cap never applies, so the button would do nothing. */
@supports not selector(:has(*)) { .invmore { display: none; } }
.invwrap { display: grid; justify-items: stretch; }
.invmore { justify-self: center; }
/* The tile carries its rarity the way the game does: colour washing up from
   the bottom edge, strongest at the base. `color-mix` keeps it tied to the
   real Valve hex in --rc rather than a hand-picked approximation, so a knife
   glows gold and a consumer skin stays almost grey without a second list of
   colours to maintain. */
.inv__i {
  position: relative;
  display: grid; gap: 0.45rem;
  padding: 0.7rem 0.6rem 0.8rem;
  border-radius: var(--r-inset);
  background:
    linear-gradient(to top,
      color-mix(in srgb, var(--rc, #b0aca2) 26%, transparent) 0%,
      color-mix(in srgb, var(--rc, #b0aca2) 8%, transparent) 30%,
      transparent 62%),
    var(--ink-100);
  border: 1px solid color-mix(in srgb, var(--rc, #b0aca2) 20%, transparent);
  border-bottom: 2px solid var(--rc, var(--bone-dim));
  text-align: center;
  transition: transform var(--dur-fast) var(--ease-out), box-shadow var(--dur-base) var(--ease-both);
}
.inv__i:hover {
  transform: translateY(-2px);
  box-shadow: 0 8px 26px -12px color-mix(in srgb, var(--rc, #b0aca2) 65%, transparent);
}
.inv__i img { width: 100%; height: auto; aspect-ratio: 1; object-fit: contain; }
.inv__n {
  font-size: 0.7rem; line-height: 1.35; color: var(--bone-mid);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
}
.inv__q {
  position: absolute; top: 0.4rem; right: 0.5rem;
  font-size: 0.65rem; color: var(--ember-soft);
}
/* Live market price from csfloat, shown under the name. */
.inv__p {
  font-size: 0.68rem;
  color: var(--bone);
  font-weight: 600;
  letter-spacing: 0.01em;
}

/* Wear band: the full 0–1 float scale as the track, the skin's condition range
   highlighted inside it, and a needle on the item's own float — which Steam
   does publish, in asset_properties[] of the inventory response. */
.wear {
  display: block;
  padding-top: 7px;         /* room for the marker sitting above the track */
}
/* The full CS2 wear scale. The five stops are the game's own boundaries —
   FN 0-0.07, MW 0.07-0.15, FT 0.15-0.38, WW 0.38-0.45, BS 0.45-1.00 — so a
   position on this bar means what it means in CS2. Hard stops, because the
   bands are discrete rather than shading into one another. */
.wear__t {
  position: relative;
  display: block;
  height: 4px;
  border-radius: 2px;
  background: linear-gradient(90deg,
    #4b9c4b 0%,    #4b9c4b 7%,      /* Factory New     */
    #8fbf3f 7%,    #8fbf3f 15%,     /* Minimal Wear    */
    #d1a52f 15%,   #d1a52f 38%,     /* Field-Tested    */
    #d1732f 38%,   #d1732f 45%,     /* Well-Worn       */
    #c0392b 45%,   #c0392b 100%);   /* Battle-Scarred  */
}
/* The band this skin's condition guarantees: everything OUTSIDE it is veiled,
   using two panels rather than one clipped overlay, so nothing needs
   `overflow: hidden` on the track — that clipping is what used to swallow the
   marker sitting above it. */
.wear__t i {
  position: absolute;
  top: 0; bottom: 0;
  left: 0; right: 0;
  border-radius: 2px;
  /* Veil left of the band, and right of it, in one background. The band's own
     span is left untouched. */
  background:
    linear-gradient(90deg, rgba(11,10,9,0.72) 0 var(--band-a), transparent var(--band-a) 100%),
    linear-gradient(90deg, transparent 0 var(--band-b), rgba(11,10,9,0.72) var(--band-b) 100%);
}
/* Marker pointing at the float's position on the 0-1 scale. Positioned in real
   float units by the page, so where it sits IS where the value falls.

   It reads as a needle rather than the old floating triangle: the head is the
   ::before, the hairline through the track is the ::after. A float of 0.0003
   and one of 0.06 are both Factory New and 1.4px apart on a 240px tile — a
   4px-wide triangle hovering above the bar could not say which band edge it
   was on. A 1px line that crosses the colours can. */
.wear__m {
  position: absolute;
  top: -7px; bottom: 0;
  width: 1px;
  transform: translateX(-50%);
  filter: drop-shadow(0 0 1.5px rgba(11, 10, 9, 0.9));
}
.wear__m::before {
  content: "";
  position: absolute;
  top: 0; left: 50%;
  transform: translateX(-50%);
  width: 0; height: 0;
  border-left: 3.5px solid transparent;
  border-right: 3.5px solid transparent;
  border-top: 5px solid var(--bone);
}
.wear__m::after {
  content: "";
  position: absolute;
  top: 5px; bottom: 0; left: 0;
  width: 1px;
  background: var(--bone);
}

/* Exact float and paint seed, under the bar. Small and dim on purpose: the
   picture is the bar, these are the numbers you go looking for. */
.invmeta {
  display: flex;
  justify-content: center;
  gap: 0.45rem;
  font-size: 0.6rem;
  line-height: 1;
  color: var(--bone-dim);
}
.invmeta__f { color: var(--bone-mid); }

/* ---- League: most-played champions ---- */
.gcard__sub {
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--bone-dim);
  margin: 1rem 0 0.55rem;
  font-weight: 600;
}
.champs {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(56px, 1fr));
  gap: 0.5rem;
}
.champ { display: grid; justify-items: center; gap: 0.2rem; position: relative; }
.champ img {
  width: 100%; max-width: 46px; height: auto;
  aspect-ratio: 1;
  border-radius: 10px;
  border: 1px solid rgba(242, 236, 228, 0.10);
}
.champ__n {
  font-size: 0.6rem;
  color: var(--bone-mid);
  text-align: center;
  line-height: 1.25;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Mastery level, badged on the portrait: the number that says "a lot". */
.champ__m {
  position: absolute;
  top: -4px; right: 2px;
  font-size: 0.58rem;
  font-weight: 700;
  padding: 0.05rem 0.28rem;
  border-radius: 999px;
  background: var(--ember);
  color: #14100c;
}

/* Inventory total: the one number worth reading before the grid. */
.invtot {
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin: 0 0 1rem;
}
.invtot__v {
  font-size: clamp(1.5rem, 3vw, 2.1rem);
  font-weight: 700;
  color: var(--ember);
  line-height: 1;
}
.invtot__l {
  font-size: 0.78rem;
  color: var(--bone-dim);
  text-transform: uppercase;
  letter-spacing: 0.07em;
}
.invtot__l small { display: block; text-transform: none; letter-spacing: 0; opacity: 0.75; }

/* ============ setup ============ */
.parts {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr));
  gap: 0.7rem;
}
.part {
  display: flex; align-items: center; gap: 1rem;
  padding: 1rem 1.15rem;
  border-radius: var(--r-surface);
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.07);
  transition: border-color var(--dur-base) var(--ease-both);
}
.part:hover { border-color: rgba(255, 106, 31, 0.3); }
.part__g {
  font-size: 1.15rem; color: var(--ember-soft);
  width: 1.9rem; text-align: center; flex: none;
}
.part__b { display: grid; gap: 0.18rem; min-width: 0; }
.part__b small {
  font-family: 'JetBrains Mono', monospace;
  font-size: 0.66rem; letter-spacing: 0.06em;
  color: var(--bone-dim); text-transform: uppercase;
}
.part__b b { font-size: 0.94rem; font-weight: 600; line-height: 1.35; }

.src-note { margin-top: 1.6rem; }
.src-note a {
  display: inline-flex; align-items: center; gap: 0.5rem;
  color: var(--ember-soft); font-size: 0.88rem; font-weight: 600;
}

/* ============ reduced motion ============ */
@media (prefers-reduced-motion: reduce) {
  .bars i, .track__eq i { animation: none !important; }
}

/* ============================================================
   Home previews: a real slice of each page, not an empty card
   ============================================================ */
/* Mosaic, not a stack: eight identical full-width boxes read as a list of
   empty cards. Sections that carry real content take two columns, short ones
   take one, so the page has a rhythm. */
.pvs {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: clamp(0.8rem, 1.4vw, 1.1rem);
  align-items: start;
}
@media (max-width: 780px) {
  .pvs { grid-template-columns: 1fr; }
}

.pv {
  --lx: 50%;
  --ly: 50%;
  position: relative;
  padding: clamp(1.4rem, 2.4vw, 1.9rem);
  border-radius: var(--r-surface);
  background: var(--ink-050);
  border: 1px solid rgba(242, 236, 228, 0.07);
  overflow: hidden;
  isolation: isolate;
  /* Clears the fixed nav when a nav dot jumps to this section. */
  scroll-margin-top: 88px;
  transition: border-color var(--dur-base) var(--ease-both);
}
/* Same cursor light as the project plates, so the two pages feel related. */
.pv::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  opacity: 0;
  background: radial-gradient(460px circle at var(--lx) var(--ly), rgba(255, 106, 31, 0.13), transparent 62%);
  transition: opacity var(--dur-base) var(--ease-both);
}
.pv:hover::before { opacity: 1; }
.pv:hover { border-color: rgba(242, 236, 228, 0.13); }
.pv:target { border-color: rgba(255, 106, 31, 0.45); }

/* Wide: content-heavy sections. Narrow ones simply take one column. */
.pv--wide { grid-column: 1 / -1; }
@media (max-width: 780px) {
  .pv--wide { grid-column: auto; }
}

/* Contact and socials share a row and must read as one pair, so they are the
   two cards that opt out of the grid's align-items:start.

   They are already the same width — one column each — but the socials card is
   only as tall as the number of live accounts makes it, and next to contact's
   two lines plus a button that difference read as a smaller box rather than a
   shorter one. Stretching both to the row height settles it whatever
   social_live() returns.

   Not applied to the mosaic at large on purpose: every other narrow card is a
   <details>, and stretching a collapsed one leaves a tall empty box under a
   single line of summary.

   `#pv-reach` est la carte des réseaux. Le sélecteur a déjà raté ce bloc deux
   fois : il est resté sur `#reseaux` après le renommage de la page
   (25/08/2026), puis l'id `socials` qui l'avait remplacé a dû partir à son
   tour parce que les bloqueurs de contenu masquaient la carte entière
   (26/08/2026). Une règle qui ne sélectionne plus rien ne lève pas d'erreur,
   elle cesse simplement d'agir — d'où le test home_pairing_test, qui relie
   cette ligne aux identifiants que index.php émet vraiment. */
#contact, #pv-reach { align-self: stretch; }

.pv__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 1.05rem;
}
.pv__t {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  font-family: 'Satoshi', sans-serif;
  font-size: 1.06rem;
  font-weight: 700;
  letter-spacing: -0.015em;
  margin: 0;
}
.pv__i { font-size: 1.15rem; line-height: 1; }
.pv__all {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.83rem;
  font-weight: 600;
  color: var(--ember-soft);
  white-space: nowrap;
  flex: none;
}
.pv__all svg { transition: transform var(--dur-base) var(--ease-out); }
.pv__all:hover svg { transform: translateX(3px); }

/* ---- foldable cards (gaming, clips, radio, setup) ----
   A real <details>: the whole toggle is native, so keyboard, find-in-page and
   the open state cost nothing. Only the marker and the chevron are styled. */
.pv--fold > summary { cursor: pointer; list-style: none; margin-bottom: 0; }
.pv--fold > summary::-webkit-details-marker { display: none; }   /* Safari's own triangle */
.pv--fold[open] > summary { margin-bottom: 1.05rem; }
.pv--fold > summary:focus-visible { outline: 2px solid var(--ember); outline-offset: 4px; border-radius: var(--r-inset); }
.pv__chev {
  display: inline-flex;
  flex: none;
  color: var(--bone-dim);
  transition: transform var(--dur-base) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.pv--fold[open] .pv__chev { transform: rotate(180deg); }
.pv--fold > summary:hover .pv__chev { color: var(--bone); }
/* The "see all" link moves to the bottom of the panel: inside a <summary> a
   link toggles as well as navigates, depending on the browser. */
.pv__all--foot { margin-top: 1rem; }
@media (prefers-reduced-motion: reduce) { .pv__chev { transition: none; } }

.pv__lede { color: var(--bone-mid); font-size: 0.95rem; max-width: 68ch; margin: 0; }
.pv__empty { color: var(--bone-dim); font-size: 0.9rem; margin: 0; }

.pv__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
  gap: 0.7rem;
}
/* Clip thumbnails: fixed narrow columns, not 1fr — three of those inside a
   wide preview card grew into small films. 190px keeps them vignettes. */
.pv__grid--3 {
  grid-template-columns: repeat(3, minmax(0, 190px));
  justify-content: start;
}
@media (max-width: 640px) {
  .pv__grid--3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

.mini a {
  display: grid;
  gap: 0.35rem;
  padding: 0.9rem 1rem;
  height: 100%;
  border-radius: var(--r-inset);
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.06);
  transition: border-color var(--dur-base) var(--ease-both), transform var(--dur-fast) var(--ease-out);
}
.mini a:hover { border-color: rgba(255, 106, 31, 0.32); transform: translateY(-2px); }
.mini__t { font-size: 0.94rem; font-weight: 700; letter-spacing: -0.012em; }
.mini__d {
  color: var(--bone-mid);
  font-size: 0.845rem;
  line-height: 1.45;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.mini__m {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  margin-top: auto;
  padding-top: 0.5rem;
  color: var(--bone-dim);
  font-size: 0.7rem;
}
.mini__m .dot {
  display: inline-block;
  width: 7px; height: 7px;
  border-radius: 50%;
  background: var(--c, var(--bone-dim));
  margin-right: 0.35rem;
  vertical-align: middle;
}
.mini--media a { padding: 0; overflow: hidden; }
.mini--media video {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  background: #000;
  display: block;
}
.mini--media .mini__t { padding: 0.6rem 0.75rem 0.7rem; font-size: 0.86rem; }
.mini--media video { aspect-ratio: 16 / 9; }

/* Pinned-project mark on the home previews: same ember star as the page. */
.mini__pin {
  color: var(--ember);
  font-size: 0.72rem;
  line-height: 1;
}

.chips { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.chip {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  padding: 0.38rem 0.8rem;
  border-radius: var(--r-pill);
  border: 1px solid rgba(242, 236, 228, 0.13);
  background: rgba(242, 236, 228, 0.03);
  color: var(--bone-mid);
  font-size: 0.83rem;
}
.chip--link {
  position: relative;
  z-index: 1;
  transition: border-color var(--dur-base) var(--ease-both), color var(--dur-fast) var(--ease-out);
}
.chip--link:hover { border-color: rgba(255, 106, 31, 0.42); color: var(--bone); }

/* Brand chips: the glyph keeps the service colour, the label stays neutral so
   six chips in a row do not turn into a colour chart. */
.chip--brand svg { color: var(--brand); opacity: 0.85; transition: opacity var(--dur-base) var(--ease-both); }
.chip--brand:hover { border-color: color-mix(in srgb, var(--brand) 45%, transparent); }
.chip--brand:hover svg { opacity: 1; }

/* Gaming preview: live figures pulled from the same services as the page. */
.pv__figs {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(1.2rem, 3vw, 2.2rem);
}
.pv__figs li { display: grid; gap: 0.15rem; }
.pv__fig {
  font-family: 'Clash Display', sans-serif;
  font-weight: 700;
  font-size: clamp(1.5rem, 3vw, 2rem);
  line-height: 1;
  color: var(--bone);
}
.pv__figl { font-size: 0.68rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--bone-dim); }

/* Leetify bars beside the FACEIT figures. The bars take the free half; the
   figures keep only what they need, so nothing stretches. */
.pvgame {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: clamp(1.2rem, 3vw, 2.4rem);
}
.pvgame .gbars--sm { flex: 1 1 16rem; min-width: 0; max-width: 24rem; }
/* The figures take the rest of the row and centre in it, rather than sitting
   against the bars with the card's right half empty. */
.pvgame .pv__figs { flex: 1 1 auto; justify-content: center; }
.gbars--sm { gap: 0.34rem; }
.gbars--sm li { grid-template-columns: 4.6rem 1fr 1.6rem; gap: 0.5rem; }
.gbars--sm .gbars__l { font-size: 0.72rem; }
.gbars--sm .gbars__t { height: 4px; }
.gbars--sm .gbars__v { font-size: 0.66rem; }
/* Phones: the bars go full width above the figures rather than squeezing into
   a column too narrow to read. */
@media (max-width: 560px) {
  .pvgame .gbars--sm { flex-basis: 100%; max-width: none; }
}

.pv__cta { margin: 1rem 0 0; position: relative; z-index: 1; }

.pv__tracks { display: grid; gap: 0.3rem; }
.pv__tracks li {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  padding: 0.5rem 0.7rem;
  border-radius: var(--r-inset);
  background: rgba(242, 236, 228, 0.03);
  font-size: 0.9rem;
}
.pv__tracks .mono { color: var(--bone-dim); font-size: 0.7rem; }
.pv__tracks b { font-weight: 600; }
.pv__tracks small { color: var(--bone-dim); font-size: 0.78rem; }

.pv__specs {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
  gap: 0.7rem;
}
.pv__specs li {
  display: grid;
  gap: 0.22rem;
  padding: 0.8rem 0.95rem;
  border-radius: var(--r-inset);
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.06);
}
.pv__specs .mono { font-size: 0.64rem; letter-spacing: 0.1em; color: var(--ember-soft); }
.pv__specs b { font-size: 0.88rem; font-weight: 600; line-height: 1.35; }

.stack--sm li { font-size: 0.8rem; padding: 0.3rem 0.7rem; }
.stack--sm { margin-top: 1rem; }

/* The ember glow that closes every page.
   A wide half-dome rising off the top edge of the black bar: full page width,
   only its upper half visible, and strictly nothing spilling INTO the bar.
   That last part is why it lives on a wrapper above the footer rather than on
   the footer itself — anchored with `bottom: 100%`, its own overflow clipped,
   so the ellipse is cut exactly at the bar's edge.
   The bar underneath is a solid, darker surface: the contrast between page and
   footer is what made the old version read as a real edge. */
.footer {
  position: relative;
  /* Un gris franc, pas un noir teinté. Trois valeurs ont été essayées :
     #121110 lisait comme une dalle claire sous la page, #070606 comme un trou.
     #0e0e0e est plus sombre que la première et plus clair que la seconde, et
     surtout il est NEUTRE — les deux précédentes tiraient vers le brun (canal
     rouge au-dessus du bleu), ce qui les faisait paraître sales plutôt que
     sombres. La chaleur du thème est déjà portée par le halo ambré au-dessus
     de la barre ; la surface, elle, gagne à être grise.

     Déclarée en variable parce que la bande de réseaux a besoin de la même
     valeur pour son dégradé de bord : c'était le seul endroit du site où une
     couleur était recopiée à la main dans du JSX. */
  --footer-bg: #0e0e0e;
  background: var(--footer-bg);
  border-top: 1px solid rgba(242, 236, 228, 0.09);
}
.footer::before {
  content: "";
  position: absolute;
  left: 0; right: 0;
  bottom: 100%;
  /* Was 13rem, sized for the old three-column bar. Against a bar a third as
     tall the dome dwarfed it and read as a glow with a footer stuck underneath. */
  height: 8rem;
  /* Half of a very wide ellipse: 200% tall, anchored at its own bottom, so
     only the top dome shows above the bar. */
  background: radial-gradient(ellipse 62% 100% at 50% 100%,
    rgba(255, 106, 31, 0.30) 0%,
    rgba(255, 106, 31, 0.13) 42%,
    rgba(255, 106, 31, 0.04) 68%,
    transparent 100%);
  pointer-events: none;
  filter: blur(6px);
  /* Alive, not flashing: a slow swell in brightness, nothing more. */
  animation: footer-glow 7s var(--ease-both) infinite;
}
@keyframes footer-glow {
  0%, 100% { opacity: 0.82; }
  50%      { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .footer::before { animation: none; opacity: 0.9; }
}
.footer__inner { position: relative; z-index: 1; }

/* The cycling TLD: fixed width so the footer never reflows as it swaps, and a
   short fade rather than a hard cut. */
.footer__tld {
  display: inline-block;
  min-width: 2.6em;
  color: var(--ember);
  transition: opacity 0.22s var(--ease-both), transform 0.22s var(--ease-out);
}
.footer__tld.is-swapping { opacity: 0; transform: translateY(-3px); }

/* Visitor counts: present, not announced. Layout of .footer__meta itself lives
   with the rest of the footer further up — a second display/gap declaration
   here would win on source order and quietly undo it. */
.footer__count { opacity: 0.55; }
.footer__now { display: inline-flex; align-items: center; gap: 0.35rem; opacity: 0.75; }
.footer__dot {
  width: 6px; height: 6px;
  border-radius: 50%;
  background: #4ade80;                       /* green: a live-status convention */
  box-shadow: 0 0 6px rgba(74, 222, 128, 0.7);
  animation: mark-pulse 2.4s var(--ease-both) infinite;
}
@media (prefers-reduced-motion: reduce) { .footer__dot { animation: none; } }

/* ---- home: About preview with its portrait ---- */
.pvabout { display: grid; grid-template-columns: 88px 1fr; gap: 1rem; align-items: start; }
.pvabout__media {
  display: block;
  border-radius: var(--r-inset);
  overflow: hidden;
  aspect-ratio: 3 / 4;
  border: 1px solid rgba(242, 236, 228, 0.08);
}
.pvabout__media img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pvabout__body { min-width: 0; }
@media (max-width: 560px) { .pvabout { grid-template-columns: 64px 1fr; } }

/* ---- home: gaming preview ---- */
.pvinv__tot { display: flex; align-items: baseline; gap: 0.5rem; margin: 0 0 0.7rem; }
.pvinv__tot .mono { font-size: 1.35rem; font-weight: 700; color: var(--ember); }
.pvinv__tot small { font-size: 0.7rem; color: var(--bone-dim); text-transform: uppercase; letter-spacing: 0.06em; }
.pvinv {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
  gap: 0.5rem;
  margin-bottom: 0.9rem;
}
.pvinv__i {
  display: grid; gap: 0.25rem; justify-items: center; text-align: center;
  padding: 0.5rem 0.35rem 0.55rem;
  border-radius: var(--r-inset);
  background: var(--ink-100);
  border-bottom: 2px solid var(--rc, var(--bone-dim));
}
.pvinv__i img { width: 100%; max-width: 62px; height: auto; aspect-ratio: 1; object-fit: contain; }
.pvinv__n {
  font-size: 0.62rem; line-height: 1.3; color: var(--bone-mid);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.pvinv__p { font-size: 0.64rem; font-weight: 600; color: var(--bone); }

/* ---- home: clips you can actually watch from here ---- */
.pvclips {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
  gap: 0.8rem;
}
.pvclip {
  border-radius: var(--r-inset);
  overflow: hidden;
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.07);
}
.pvclip video {
  width: 100%;
  aspect-ratio: 16 / 9;
  background: #000;
  display: block;
}
.pvclip__t {
  display: block;
  font-size: 0.78rem;
  font-weight: 600;
  padding: 0.55rem 0.7rem 0.65rem;
  color: var(--bone-mid);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.pvclip__pin { color: var(--ember); }

/* ---- home: embedded player, sized by its card rather than the service ---- */
.pvembed {
  border-radius: var(--r-inset);
  overflow: hidden;
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.06);
}
/* Height matched to what Apple Music actually draws: artwork, title block and
   controls, and nothing more. 560px left a band of empty widget below the
   controls; 450px is the service's own full-player height, so the widget fills
   it exactly and still never needs its internal scroll. Width is unconstrained
   — only the vertical was wrong. */
.pvembed iframe { display: block; width: 100%; height: 450px; border: 0; }
@media (max-width: 700px) { .pvembed iframe { height: 420px; } }

/* ---- services + trust + closing CTA ---- */
.svc {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 265px), 1fr));
  gap: clamp(0.8rem, 1.5vw, 1.1rem);
}
.svc__i {
  --lx: 50%;
  --ly: 50%;
  position: relative;
  isolation: isolate;
  overflow: hidden;
  padding: clamp(1.4rem, 2.4vw, 1.8rem);
  border-radius: var(--r-surface);
  background: var(--ink-050);
  border: 1px solid rgba(242, 236, 228, 0.07);
  transition: border-color var(--dur-base) var(--ease-both), transform var(--dur-base) var(--ease-out);
}
.svc__i::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  opacity: 0;
  background: radial-gradient(420px circle at var(--lx) var(--ly), rgba(255, 106, 31, 0.14), transparent 62%);
  transition: opacity var(--dur-base) var(--ease-both);
}
.svc__i:hover::before { opacity: 1; }
.svc__i:hover { border-color: rgba(255, 106, 31, 0.3); transform: translateY(-2px); }
.svc__ic {
  display: grid;
  place-items: center;
  width: 42px; height: 42px;
  border-radius: 12px;
  margin-bottom: 1rem;
  color: var(--ember-soft);
  background: rgba(255, 106, 31, 0.1);
  border: 1px solid rgba(255, 106, 31, 0.22);
}
.svc__t { font-size: 1.02rem; font-weight: 700; margin: 0 0 0.5rem; letter-spacing: -0.012em; }
.svc__d { color: var(--bone-mid); font-size: 0.9rem; line-height: 1.55; margin: 0; }

.trust {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
  gap: clamp(1.2rem, 2.6vw, 2rem);
}
.trust li { display: grid; gap: 0.4rem; align-content: start; }
/* Ember rule above each promise: a quiet checklist mark. */
.trust li::before {
  content: "";
  width: 26px; height: 2px;
  border-radius: 2px;
  background: var(--ember);
  margin-bottom: 0.35rem;
}
.trust b { font-size: 0.98rem; font-weight: 700; letter-spacing: -0.01em; }
.trust p { color: var(--bone-mid); font-size: 0.88rem; line-height: 1.55; margin: 0; }

.endcta {
  position: relative;
  overflow: hidden;                       /* the line grid is inset, not bleeding */
  text-align: center;
  padding: clamp(2.4rem, 5vw, 3.6rem) clamp(1.4rem, 3vw, 2rem);
  border-radius: var(--r-surface);
  background: var(--ink-050);
  border: 1px solid rgba(242, 236, 228, 0.08);
}
.endcta__body { position: relative; z-index: 1; display: grid; justify-items: center; gap: 0.7rem; }
/* Barely there on purpose: the bars should register as texture that moves,
   not as a pattern competing with the heading. Masked towards the middle so
   nothing crosses the text. */
.endcta__lines {
  position: absolute;
  inset: 0;
  z-index: 0;
  opacity: 0.16;
  pointer-events: none;
  -webkit-mask-image: radial-gradient(ellipse 68% 78% at 50% 50%, transparent 12%, #000 78%);
  mask-image: radial-gradient(ellipse 68% 78% at 50% 50%, transparent 12%, #000 78%);
}
@media (max-width: 620px) { .endcta__lines { display: none; } }
.endcta__t {
  font-family: 'Clash Display', sans-serif;
  font-size: clamp(1.6rem, 3.4vw, 2.4rem);
  font-weight: 600;
  letter-spacing: -0.025em;
  line-height: 1.1;
  margin: 0;
}
.endcta__s { color: var(--bone-mid); max-width: 46ch; margin: 0 0 0.6rem; }

/* ============ projects: download button ============ */
.btn__sz {
  font-size: 0.7rem;
  opacity: 0.72;
  font-weight: 500;
  padding-left: 0.15rem;
}
/* What you get, next to the button rather than crammed inside it.
   Extension en MAJUSCULES depuis le 24/08/2026 — « .BAT », « .ZIP ». Le
   commentaire disait ici l'inverse (« reads as one only in lower case ») ;
   c'était un avis, pas une contrainte, et il a été tranché autrement. La casse
   est décidée dans projects/index.php, pas ici : un text-transform CSS
   toucherait aussi le poids rendu par human_size(), qui a déjà les siennes. */
.plate__dl {
  font-size: 0.68rem;
  color: var(--bone-dim);
  letter-spacing: 0.04em;
}

/* ============ contact form ============ */
.contact-grid {
  display: grid;
  grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
  gap: clamp(1.6rem, 4vw, 3.2rem);
  align-items: start;
}
@media (max-width: 900px) { .contact-grid { grid-template-columns: 1fr; } }
.contact-col { display: grid; gap: clamp(1.2rem, 2.5vw, 1.8rem); align-content: start; }

/* Accounts card, under the mail form. Same skin as .formcard (it carries both
   classes); only the list inside is its own. */
.socard__lede { margin: 0 0 0.9rem; font-size: 0.82rem; color: var(--bone-dim); }
.socard__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
.socard__list a,
.socard__static {
  display: flex; align-items: center; gap: 0.7rem;
  padding: 0.55rem 0.7rem;
  border-radius: var(--r-inset);
  background: rgba(242, 236, 228, 0.03);
  border: 1px solid transparent;
  color: var(--bone-mid);
  text-decoration: none;
  transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
}
.socard__list a:hover,
.socard__list a:focus-visible {
  background: color-mix(in srgb, var(--brand, var(--ember)) 9%, transparent);
  border-color: color-mix(in srgb, var(--brand, var(--ember)) 34%, transparent);
}
.socard__i { flex: 0 0 auto; display: inline-flex; color: var(--brand, var(--ember)); }
.socard__b { flex: 1; min-width: 0; display: grid; }
.socard__b b { font-size: 0.85rem; color: var(--bone); font-weight: 600; }
.socard__b small {
  font-size: 0.7rem; color: var(--bone-dim);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.socard__go { flex: 0 0 auto; opacity: 0.45; }
.socard__list a:hover .socard__go { opacity: 1; }

.formcard {
  padding: clamp(1.3rem, 2.4vw, 1.8rem);
  border-radius: var(--r-surface);
  background: var(--ink-050);
  border: 1px solid rgba(242, 236, 228, 0.08);
}
.cform { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
.cform__wide { grid-column: 1 / -1; }
@media (max-width: 620px) { .cform { grid-template-columns: 1fr; } }

.cform label {
  display: grid;
  gap: 0.4rem;
  font-size: var(--fs-small);
  font-weight: 500;
  color: var(--bone-mid);
}
.cform input, .cform textarea {
  width: 100%;
  padding: 0.68rem 0.85rem;
  border-radius: var(--r-inset);
  background: var(--ink-200);
  border: 1px solid rgba(242, 236, 228, 0.14);
  color: var(--bone);
  font: inherit;
  font-size: 0.93rem;
  transition: border-color var(--dur-base) var(--ease-both);
}
.cform textarea { resize: vertical; min-height: 130px; line-height: 1.6; }
.cform input:focus, .cform textarea:focus { border-color: rgba(255, 106, 31, 0.55); }
/* 4.6:1 on --ink-200, above the AA floor. */
.cform ::placeholder { color: #8a8078; }
.cform button { justify-self: start; }

/* Honeypot: off-screen rather than display:none, which some bots detect. */
.hp {
  position: absolute;
  left: -9999px;
  width: 1px; height: 1px;
  overflow: hidden;
}

.alert {
  padding: 0.8rem 1rem;
  border-radius: var(--r-inset);
  font-size: var(--fs-small);
  margin: 0 0 1rem;
  border: 1px solid;
}
.alert--ok  { background: rgba(74, 222, 128, 0.09); border-color: rgba(74, 222, 128, 0.32); color: #86efac; }
.alert--err { background: rgba(248, 113, 113, 0.09); border-color: rgba(248, 113, 113, 0.34); color: #fca5a5; }

/* ============ setup: hero rig + spec rows ============ */
.rig {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
  gap: clamp(0.8rem, 1.5vw, 1.1rem);
  margin: 0.9rem 0 0;
}
.rig__i {
  position: relative;
  display: grid;
  gap: 0.3rem;
  /* Centré (19/09/2026) : quand le détail d'une tuile voisine passe sur deux
     lignes, la rangée grandit et `start` laissait le contenu de la tuile
     courte épinglé en haut — le composant n'était plus centré dans sa case. */
  align-content: center;
  min-height: 190px;
  padding: clamp(1.5rem, 2.6vw, 2.1rem) clamp(1.3rem, 2.1vw, 1.7rem);
  border-radius: var(--r-surface);
  background:
    radial-gradient(120% 100% at 0% 0%, rgba(255, 106, 31, 0.12), transparent 58%),
    var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.08);
  overflow: hidden;
  transition: border-color var(--dur-base) var(--ease-both), transform var(--dur-base) var(--ease-out);
}
.rig__i:hover { border-color: rgba(255, 106, 31, 0.3); transform: translateY(-2px); }
.rig__i::before {
  content: "";
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 2px;
  background: linear-gradient(180deg, var(--ember), transparent);
}
/* Oversized watermark of the part key, so the three tiles read at a glance. */
.rig__i::after {
  content: attr(data-k);
  position: absolute;
  right: -0.2rem; bottom: -1.6rem;
  font-family: 'Clash Display', sans-serif;
  font-weight: 700;
  font-size: 5.5rem;
  line-height: 1;
  letter-spacing: -0.04em;
  color: rgba(242, 236, 228, 0.035);
  pointer-events: none;
}
.rig__k {
  font-size: 0.64rem;
  letter-spacing: 0.14em;
  color: var(--ember-soft);
}
.rig__brand {
  font-size: 0.82rem;
  color: var(--bone-dim);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.rig__model {
  font-family: 'Clash Display', sans-serif;
  font-size: clamp(1.15rem, 1.9vw, 1.5rem);
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.02em;
}
.rig__detail { font-size: 0.72rem; color: var(--bone-mid); margin-top: 0.3rem; }

.specs { display: grid; gap: 0.4rem; margin-top: 0.9rem; }
.specs li {
  display: grid;
  grid-template-columns: 2rem minmax(0, 11rem) minmax(0, 1fr);
  align-items: center;
  gap: 1rem;
  padding: 0.85rem 1.1rem;
  border-radius: var(--r-inset);
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.06);
  transition: border-color var(--dur-base) var(--ease-both);
}
.specs li:hover { border-color: rgba(255, 106, 31, 0.26); }
/* Now holds a drawn SVG rather than a text glyph: grid-centred so every row's
   icon lands on the same optical axis regardless of the shape's bounding box. */
.specs__g {
  color: var(--ember-soft);
  display: grid;
  place-items: center;
  width: 1.6rem;
}
/* 0.78rem, pas 0.66 (24/08/2026) : mesuré, l'ancienne valeur rendait 10,56 px
   — sous le plancher de lisibilité pour du monospace en capitales espacées,
   alors que ces libellés sont ce qui NOMME chaque ligne.

   La colonne passe de 9 à 11 rem, et pas plus : « Le ventirad / watercooling »
   demande 202 px (12,6 rem) sur une ligne, mais l'élargir jusque-là prendrait
   5 rem au nom du produit — la seule information que le visiteur vient lire —
   pour un libellé sur onze. `text-wrap: balance` répartit « Le ventirad / » et
   « watercooling » au lieu de laisser un mot seul en dessous.

   Centré verticalement (19/09/2026), plus `start` : quand le détail du
   composant passe sur deux lignes, la rangée grandit et le libellé épinglé
   en haut donnait un titre flottant au-dessus de sa ligne. */
.specs__l {
  align-self: center;
  text-wrap: balance;
  font-family: 'JetBrains Mono', monospace;
  font-size: 0.78rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--bone-dim);
}
.specs__v { display: grid; gap: 0.15rem; min-width: 0; }
.specs__v b { font-size: 0.94rem; font-weight: 600; line-height: 1.35; }
/* Le détail entre parenthèses — « 4.2 GHz », « 2 x 16 Go 6400 MHz » — monte
   lui aussi : c'est la précision qui distingue deux références du même modèle,
   et à 0,72rem elle passait sous le libellé de catégorie. */
.specs__v small { color: var(--bone-dim); font-size: 0.8rem; }
@media (max-width: 620px) {
  .specs li { grid-template-columns: 1.6rem minmax(0, 1fr); }
  .specs__l { grid-column: 2; }
  .specs__v { grid-column: 2; }
}

/* ============ gaming: op.gg card ============ */
.gcard__links { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
.gcard__reg { color: var(--bone-dim); font-size: 0.72rem; }

/* ============ shop ============ */
.shop {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
  gap: clamp(0.8rem, 1.6vw, 1.2rem);
}
.prod {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
  padding: 1.2rem 1.3rem 1.3rem;
  border-radius: var(--r-surface);
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.07);
}
.prod__head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.prod__t { font-size: 1.05rem; margin: 0; }
.prod__p { font-size: 1.15rem; font-weight: 700; color: var(--ember); white-space: nowrap; }
/* "Gratuit" is not a price: same slot, but it must not shout like one, or the
   free page reads as a shop. */
.prod__p--free {
  font-size: 0.66rem; font-weight: 600; color: var(--bone-dim);
  text-transform: uppercase; letter-spacing: 0.06em;
  padding: 0.15rem 0.45rem; border-radius: 4px;
  background: rgba(242, 236, 228, 0.06);
}
.prod__facts a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.prod__d { color: var(--bone-mid); font-size: 0.9rem; margin: 0; }
.prod__facts {
  display: flex; gap: 0.5rem; flex-wrap: wrap;
  font-size: 0.66rem; color: var(--bone-dim);
  text-transform: uppercase; letter-spacing: 0.05em;
}
.prod__facts li {
  padding: 0.12rem 0.4rem;
  border-radius: 4px;
  background: rgba(242, 236, 228, 0.05);
}
.prod__act { margin-top: auto; padding-top: 0.6rem; }
.prod__soon { margin-top: auto; padding-top: 0.6rem; color: var(--bone-dim); font-size: 0.85rem; }

.shop__note {
  display: flex; align-items: center; gap: 0.45rem;
  margin-top: 1.4rem;
  font-size: 0.8rem;
  color: var(--bone-dim);
}

/* Projects page: the pointer to tools that have no repo, so no plate. */
.looset__lede { color: var(--bone-mid); max-width: 46rem; margin: 0 0 1rem; }

/* Post-payment card. */
.paycard {
  max-width: 46rem;
  padding: 1.6rem 1.7rem;
  border-radius: var(--r-surface);
  background: var(--ink-100);
  border: 1px solid rgba(255, 106, 31, 0.22);
}
.paycard__t { margin: 0 0 0.3rem; font-size: 1.25rem; }
.paycard__d { color: var(--bone-mid); margin: 0 0 1.1rem; }
.paycard__wait { color: var(--bone-mid); margin: 0 0 1rem; }
.paycard__terms { margin: 1rem 0 0.2rem; font-size: 0.82rem; color: var(--bone-dim); }
.paycard__keep { margin: 0; font-size: 0.82rem; color: var(--bone-dim); }

/* ---- setup: free-text blocks from the admin panel ---- */
.notecard { margin-top: 0.4rem; }
.notecard__b {
  display: grid;
  gap: 0.35rem;
  padding: 1rem 1.2rem 1.15rem;
  border-radius: var(--r-surface);
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.07);
}
.notecard__b p { margin: 0; color: var(--bone-mid); font-size: 0.92rem; line-height: 1.6; }

/* Les deux croches qui montaient du mot « Radio » ont été retirées le
   24/08/2026, en nav comme sur la case de l'accueil. */


/* ============================================================
   Nouveautés — la page /news et la case de l'accueil
   ============================================================ */

/* Visually hidden, pas display:none : le lecteur d'écran doit lire le libellé
   d'un lien qui n'affiche qu'une flèche. */
.vh {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.news { list-style: none; margin: 0; padding: 0; }
.news__month {
  margin: 2rem 0 0.6rem;
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.09em;
  color: var(--bone-dim);
}
.news__month:first-child { margin-top: 0; }

.news__i {
  display: grid;
  /* Le type et la date sont de largeur bornée ; le titre prend le reste et
     c'est lui qui doit se tronquer, pas la date. */
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  align-items: center;
  gap: 0.4rem 0.85rem;
  padding: 0.7rem 0.2rem;
  border-bottom: 1px solid rgba(242, 236, 228, 0.07);
}
.news__kind {
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--bone-dim);
  padding: 0.16rem 0.45rem;
  border-radius: 4px;
  background: rgba(242, 236, 228, 0.05);
  white-space: nowrap;
}
.news__main { min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; }
.news__t    { font-weight: 600; overflow-wrap: anywhere; }
.news__note { color: var(--bone-dim); font-size: 0.8rem; }
.news__when { color: var(--bone-dim); font-size: 0.75rem; white-space: nowrap; }
.news__go   { color: var(--bone-dim); display: inline-flex; }
.news__go:hover { color: var(--ember); }

@media (max-width: 560px) {
  /* Sur téléphone la date passe sous le titre : quatre colonnes sur 320 px
     réduisaient le titre à deux mots. */
  .news__i { grid-template-columns: auto minmax(0, 1fr) auto; }
  .news__when { grid-column: 2; justify-self: start; }
}

/* La case de l'accueil : même information, en plus court. */
.pvnews { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }
.pvnews li {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: baseline;
  gap: 0.7rem;
}
.pvnews__k {
  font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--bone-dim); white-space: nowrap;
}
.pvnews__t { font-weight: 600; overflow-wrap: anywhere; }
.pvnews__w { color: var(--bone-dim); font-size: 0.72rem; white-space: nowrap; }

/* ============ /baltrou — the easter egg ============
   One screen: a shader behind, one card on top. Nothing else is on this page,
   so it is styled here rather than in its own file — a stylesheet per page for
   forty lines would be a request the other thirty pages pay for. */

/* Le shader doit partir du HAUT de la fenêtre, sous la barre translucide.
   `.is-sub main` réserve 68px + une marge pour dégager la nav ; ce padding est
   à l'EXTÉRIEUR de `.egg`, donc hors du `inset: 0` du canvas : il peignait une
   bande de fond plat en haut de l'écran, lue comme un vide noir. Ici la page
   EST l'écran, la carte se centre toute seule, et `.egg` porte déjà ses 68px
   de dégagement. Ciblé sur la page, pas sur `.is-sub` : les vingt-neuf autres
   sous-pages ont besoin de ce padding. */
.is-egg main { padding-top: 0; }

/* Pas de halo orange au-dessus du pied de page ici. Il vient de `.footer::before`
   et vaut pour tout le site ; sur cette page la section fait 100dvh, donc le
   dôme atterrit juste sous le shader et lui ajoute une lueur qui n'est pas la
   sienne (retiré le 24/08/2026). */
.is-egg .footer::before { display: none; }

.egg {
  position: relative;
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding: 68px var(--gutter) 3rem;
  overflow: hidden;
}
.egg__gl {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}
/* The card, not the shader, is what has to stay readable. A blurred panel
   rather than a solid one: the animation is the point of the page, so it should
   still be visible behind the words. */
.egg__card {
  position: relative;
  z-index: 1;
  max-width: 46ch;
  padding: clamp(1.5rem, 3vw, 2.2rem);
  border-radius: var(--r-surface);
  background: rgba(11, 10, 9, 0.62);
  border: 1px solid rgba(242, 236, 228, 0.1);
  backdrop-filter: blur(14px);
  text-align: center;
}
.egg__eyebrow {
  display: block;
  font-size: 0.72rem;
  color: var(--ember);
  margin-bottom: 0.9rem;
}
.egg__t {
  font-family: 'Clash Display', sans-serif;
  font-size: clamp(2.4rem, 7vw, 4rem);
  line-height: 0.95;
  margin-bottom: 1rem;
}
.egg__p {
  color: var(--bone-dim);
  margin-bottom: 1.6rem;
}
/* Les deux boutons de la carte, côte à côte et centrés sous le texte
   (25/08/2026). En bloc l'un sous l'autre, le retour vers le site passait sous
   la ligne de flottaison sur un téléphone posé en paysage. */
.egg__acts {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  justify-content: center;
}
/* La secousse au clic. C'est le seul retour dont dispose quelqu'un dont
   l'onglet est muet ou le volume à zéro — sans elle, le bouton a l'air cassé. */
.egg__fart.is-parping { animation: parp 380ms var(--ease-both); }
@keyframes parp {
  0%, 100% { transform: none; }
  25%      { transform: translateX(-3px) rotate(-1.4deg); }
  60%      { transform: translateX(3px) rotate(1.4deg); }
}
/* Sous réduction de mouvement, la secousse cède la place à un flash de la
   bordure : le retour visuel est nécessaire (le son peut ne pas sortir), c'est
   le déplacement qui ne l'est pas. */
@media (prefers-reduced-motion: reduce) {
  .egg__fart.is-parping {
    animation: none;
    border-color: var(--ember);
    color: var(--ember-soft);
  }
}

/* ---- La table de jeu ----
   Elle partage l'écran avec la carte de texte, qui est centrée par le
   `place-items: center` de `.egg`. Le plateau se pose donc dans le même flux :
   la carte en haut, la table dessous, les deux au-dessus du shader.

   `min-height: 100dvh` sur `.egg` devient un `min-height` seulement — la page
   dépasse maintenant la fenêtre, et c'est voulu : une table de cartes écrasée
   pour tenir dans la hauteur restante donne des cartes de 20px. */
.egg:has(.tbl:not([hidden])) { align-content: start; gap: clamp(1rem, 3vh, 2rem); }
.egg:has(.tbl:not([hidden])) .egg__card { margin-inline: auto; }

.tbl {
  position: relative;
  z-index: 1;
  width: min(100%, 640px);
  display: grid;
  gap: clamp(0.7rem, 2vh, 1.2rem);
  text-align: center;
}

/* Les deux places. `is-turn` allume la bordure quand c'est à vous : sur une
   partie à distance, c'est ce qui dit qu'on attend votre clic sans avoir à
   lire la ligne d'état. */
.tbl__seat {
  display: grid;
  gap: 0.5rem;
  justify-items: center;
  padding: 0.6rem;
  border-radius: var(--r-surface);
  border: 1px solid transparent;
  /* Assez de fond pour détacher les cartes du shader sans le masquer : les
     zones de main encadrent le tapis, elles ne sont pas le tapis. */
  background: rgba(11, 10, 9, 0.55);
  backdrop-filter: blur(8px);
  transition: border-color var(--dur-base) var(--ease-both);
}
.tbl__seat.is-turn { border-color: rgba(255, 106, 31, 0.45); }
.tbl__seat.is-empty { opacity: 0.55; }
.tbl__who { font-size: 0.78rem; color: var(--bone-dim); }

/* La chaise, dessinée en CSS et non en SVG : un dossier et une assise, deux
   bordures. Un fichier de plus pour ça coûterait une requête. */
.tbl__chair {
  width: 14px; height: 16px;
  border: 2px solid var(--bone-dim);
  border-radius: 3px 3px 1px 1px;
  border-bottom-width: 5px;
}
/* La chaise du haut est vue de l'autre côté de la table : retournée, sinon les
   deux dossiers pointent dans le même sens et la table n'a plus de haut. */
.tbl__seat--top .tbl__chair { transform: rotate(180deg); }

/* ---- L'accueil : créer, ou entrer un code ---- */
/* `display` l'emporte sur l'attribut `hidden`, dont la valeur par défaut n'est
   qu'un `display: none` de la feuille du navigateur. Une règle `display: grid`
   sur cet élément le rend donc visible même quand le JS l'a masqué : l'accueil
   et la table s'affichaient l'un sur l'autre, et le code du salon se lisait
   sous les cartes (26/08/2026). Chaque conteneur que le script masque redit
   donc la règle ici. */
.tbl__lobby[hidden], [data-board][hidden] { display: none !important; }
.tbl__lobby {
  display: grid;
  justify-items: center;
  gap: 0.9rem;
  padding: clamp(1.1rem, 3vw, 1.7rem);
  border-radius: var(--r-surface);
  background: rgba(11, 10, 9, 0.62);
  border: 1px solid rgba(242, 236, 228, 0.1);
  backdrop-filter: blur(14px);
}
.tbl__joinlbl { display: block; font-size: 0.76rem; color: var(--bone-dim); margin-bottom: 0.35rem; }
.tbl__joinrow { display: flex; gap: 0.45rem; justify-content: center; }
/* Le code fait quatre lettres : le champ fait la largeur de quatre lettres.
   Un champ pleine largeur pour ça suggère qu'on attend une phrase. */
.tbl__codein {
  width: 6.5ch;
  padding: 0.4rem 0.6rem;
  text-align: center;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  font-size: 1rem;
  color: var(--bone);
  background: rgba(0, 0, 0, 0.35);
  border: 1px solid rgba(242, 236, 228, 0.18);
  border-radius: var(--r-inset);
}
.tbl__codein:focus-visible { outline: 2px solid var(--ember); outline-offset: 2px; }
.tbl__lobbyhint { font-size: 0.76rem; color: var(--bone-dim); max-width: 44ch; }

/* Le code du salon, une fois dedans : c'est la seule chose à transmettre pour
   que la partie existe, donc il reste lisible en permanence. */
.tbl__foot {
  display: flex; align-items: center; justify-content: center;
  gap: 0.8rem; margin-top: 0.8rem;
}
.tbl__codeout { font-size: 1rem; letter-spacing: 0.2em; color: var(--ember-soft); }

/* Pendant qu'une requête est en vol : le curseur dit qu'on attend, et les
   clics ne partent pas en double. */
.tbl.is-busy { cursor: progress; }
.tbl.is-busy [data-card] { pointer-events: none; }

/* Le feutre. Un vert désaturé plutôt qu'un vert de casino : la page est
   orange et noire, un tapis saturé y ferait tache. Le radial imite la lumière
   au-dessus d'une table. */
/* Le feutre est OPAQUE, et c'est une correction mesurée (26/08/2026) : à 0.55
   d'alpha le shader traversait le tapis, et « Atout ♦ » sur une volute rouge
   devenait illisible — exactement l'information qu'on relit à chaque pli. La
   carte de texte de cette page a déjà tranché ce point en se donnant un fond
   à 0.62 plus un flou ; le tapis a le même problème et prend la même réponse
   en plus ferme, puisqu'on y lit des chiffres et pas une phrase.

   L'animation reste visible tout autour de la table : elle est le fond de la
   page, pas celui des cartes. */
.tbl__felt {
  position: relative;
  padding: clamp(1rem, 3vw, 1.6rem);
  border-radius: 999px / 64px;
  background:
    radial-gradient(ellipse at 50% 40%, #2f4a3c, #16241d 72%);
  border: 1px solid rgba(242, 236, 228, 0.12);
  box-shadow: inset 0 1px 0 rgba(242, 236, 228, 0.06), 0 18px 40px -22px #000;
  min-height: 140px;
  display: grid;
  align-content: center;
  gap: 0.7rem;
}
.tbl__meta {
  display: flex;
  justify-content: center;
  gap: 1.2rem;
  font-size: 0.74rem;
  color: var(--bone-dim);
}
.tbl__trump.is-red, .tbl__slot.is-red .tbl__pip, .tbl__card.is-red .tbl__pip { color: #e2564f; }

/* Les cartes posées au centre. `min-height` réserve leur place dès le premier
   rendu : sans elle, le feutre grandit au premier pli et toute la page saute. */
.tbl__played {
  display: flex;
  gap: 0.6rem;
  justify-content: center;
  min-height: 72px;
  align-items: center;
}
.tbl__slot, .tbl__card {
  width: 48px; height: 68px;
  border-radius: 6px;
  background: var(--bone);
  color: #14120f;
  display: grid;
  place-content: center;
  gap: 0.05rem;
  font-family: 'Satoshi', sans-serif;
  font-weight: 700;
  line-height: 1;
  border: 1px solid rgba(0, 0, 0, 0.25);
}
.tbl__rank { font-size: 1rem; }
.tbl__pip { font-size: 0.95rem; }
/* La carte du joueur du haut arrive à l'envers : elle est posée depuis l'autre
   bord de la table. Détail gratuit, mais c'est ce qui fait lire les deux
   cartes comme un pli plutôt que comme deux cartes côte à côte. */
.tbl__slot[data-from="top"] { transform: rotate(180deg); }

.tbl__hand {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
  justify-content: center;
  min-height: 68px;
}
/* Le dos des cartes adverses. Leur NOMBRE est une information de jeu — savoir
   qu'il reste deux cartes change ce qu'on joue — leur identité n'en est pas
   une, et le serveur ne l'envoie pas. Ces rectangles sont donc tout ce qu'il y
   a à dessiner, pas une version masquée de vraies cartes.

   Plus petits que les vraies : c'est la main d'en face, elle ne se lit pas. */
.tbl__hand--foe { min-height: 44px; gap: 0.25rem; }
.tbl__back {
  width: 32px; height: 44px;
  border-radius: 5px;
  background: repeating-linear-gradient(45deg, #6b2a14, #6b2a14 3px, #4a1c0d 3px, #4a1c0d 6px);
  border: 1px solid rgba(242, 236, 228, 0.16);
}
.tbl__card {
  cursor: pointer;
  transition: transform var(--dur-fast, 140ms) var(--ease-out), box-shadow var(--dur-fast, 140ms) var(--ease-out);
}
.tbl__card:hover:not(:disabled) { transform: translateY(-6px); box-shadow: 0 8px 18px -8px #000; }
.tbl__card:focus-visible { outline: 2px solid var(--ember); outline-offset: 3px; }
/* Une carte injouable reste LISIBLE, seulement estompée : elle est encore de
   l'information (on doit voir qu'on a un As qu'on n'a pas le droit de jouer),
   pas un bouton mort à masquer. */
.tbl__card:disabled { cursor: default; opacity: 0.42; }

.tbl__status { font-size: 0.86rem; color: var(--bone); min-height: 1.4em; }
.tbl__go { margin-left: 0.5rem; }
/* Les règles sont posées sur le shader nu, sans tapis derrière : à 0.75
   d'opacité elles se noyaient dans les volutes rouges. Elles se lisent une
   fois, mais cette fois-là doit marcher. */
.tbl__rules {
  font-size: 0.76rem;
  color: var(--bone-dim);
  justify-self: center;
  max-width: 52ch;
  padding: 0.45rem 0.9rem;
  border-radius: 999px;
  background: rgba(11, 10, 9, 0.62);
  backdrop-filter: blur(10px);
}

@media (prefers-reduced-motion: reduce) {
  .tbl__card:hover:not(:disabled) { transform: none; }
}

/* ============ Fonds animés reactbits ============
   Sept animations sont arrivées d'un coup le 24/08/2026. Elles partagent la
   même mécanique et sont donc groupées ici plutôt que dispersées dans les
   sections des pages qu'elles décorent.

   Trois règles valent pour toutes, et expliquent la forme de ce bloc :

   1. Chaque animation est rendue dans un conteneur VIDE en position absolue,
      derrière le contenu. React efface l'élément où il se monte : envelopper
      un titre ou une liste reviendrait à les perdre quand le bundle ne charge
      pas. Le contenu reste donc du HTML servi par PHP, l'animation est une
      couche par-dessous.

   2. Le conteneur doit avoir une taille AVANT que le shader n'existe. Les
      composants amont lisent clientWidth/clientHeight du parent au montage ;
      un parent sans hauteur donne un canvas de zéro pixel, qui ne se corrige
      pas tout seul. D'où les `inset: 0` et les hauteurs explicites.

   3. Le fond posé ici doit rester correct SANS l'animation. Le shader ne monte
      pas hors écran, ni sous prefers-reduced-motion, ni si le bundle échoue —
      trois cas courants, pas des accidents. Chaque conteneur garde donc une
      couleur ou un dégradé propre. */

/* ---- /radio/ + accueil : le cadre tramé des lecteurs intégrés ---- */
/* Uniquement sur les intégrations (Spotify, SoundCloud, Apple Music, YouTube).
   Les fichiers MP3/WAV déposés ont leur propre lecteur, dans une autre section,
   et n'en portent pas. */
.embed--dither { position: relative; }
.embed__dither {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  /* Le canvas est tramé et contrasté : sans ce voile il concurrencerait la
     pochette d'album affichée par le lecteur juste au-dessus. */
  opacity: 0.5;
  /* Le creux de la vague n'est plus noir mais gris très foncé (demandé le
     24/08/2026). Le shader écrit `mix(vec3(0.0), waveColor, f)` : son point bas
     est un noir PUR, codé en dur dans la copie amont — qu'on ne modifie jamais,
     les corrections vont en amont.

     Donc c'est la composition qui le relève, pas le shader. `screen` garde la
     valeur la plus claire des deux couches : là où le canvas est noir (0), le
     fond ci-dessous ressort tel quel ; là où la vague est claire, elle domine.
     Le noir pur devient exactement ce fond, et rien d'autre ne bouge.

     #171614 plutôt qu'un gris neutre : c'est la teinte des surfaces du site,
     donc la trame se pose dans la carte au lieu d'y flotter.

     `isolation` borne le mélange à ce conteneur : sans elle, le `screen` du
     canvas remonterait jusqu'au fond de la page et éclaircirait la carte. */
  background: #171614;
  isolation: isolate;
}
/* C'est le CANVAS qui se mélange, pas le conteneur — il doit composer avec le
   fond gris juste au-dessus, pas avec la page. */
.embed__dither canvas { mix-blend-mode: screen; }
.embed__dither canvas { display: block; width: 100%; height: 100%; }
/* Le contenu repasse devant. Les deux hôtes sont des conteneurs différents
   (.embed a un titre, .pvembed n'a que l'iframe), d'où les deux sélecteurs. */
.embed--dither > :not(.embed__dither) { position: relative; z-index: 1; }

/* ---- /setup/ : un encadré par catégorie, chacun avec son grain ---- */
.setupcat {
  position: relative;
  isolation: isolate;
  border-radius: var(--r-surface);
  background: var(--ink-050);
  border: 1px solid rgba(242, 236, 228, 0.07);
  padding: clamp(1.1rem, 2.4vw, 1.8rem);
  overflow: hidden;
}
.setupcat + .setupcat { margin-top: clamp(1.1rem, 2.4vw, 1.8rem); }
/* Le premier titre de l'encadré ne prend pas la marge haute que .sec-h--sp
   pose entre deux sections : l'encadré fournit déjà son padding. */
.setupcat > .sec-h { margin-top: 0; }
.setupcat__gl {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: 0.28;
}
/* ShaderBox insère SON PROPRE div entre le conteneur et le canvas. Sans cette
   règle il est en display:block sans hauteur : les composants amont lisent
   clientHeight du parent au montage, trouvent 0, et ne créent jamais leur
   canvas. C'est ce qui laissait un encadré de /setup/ sans fond alors que les
   deux autres marchaient — le premier rendu arrivait avant que la mise en page
   ne donne sa hauteur à la carte. Les conteneurs d'îlot doivent remplir leur
   parent AVANT que le shader n'existe. */
.setupcat__gl > *,
.embed__dither > * { width: 100%; height: 100%; }
.setupcat__gl canvas { display: block; width: 100%; height: 100%; }

/* ---- accroche de l'accueil : le texte qui se déplie ---- */
/* FoldText met chaque caractère dans un inline-block pour pouvoir le faire
   pivoter. Un inline-block ne se coupe pas en fin de ligne : sans ça la
   première ligne du titre déborderait au lieu de passer à la ligne. */
.hero__title .fold-text { white-space: normal; }
/* `display: inline`, et c'est ce qui décide du nombre de lignes du titre.
   L'amont met `.fold-text` en inline-block — une boîte qui ne se coupe jamais.
   Depuis que la seconde ligne est faite de trois fragments, chacun devenait une
   boîte insécable : « , livré sans surprise. » ne pouvait plus se répartir
   autour du mot en dégradé et basculait en entier sur une troisième ligne.
   En inline, les caractères se réassemblent en un seul flux et le <br> du
   balisage reste seul maître des ruptures. La rotation 3D reste possible : elle
   s'applique aux `.fold-text-piece`, qui gardent leur inline-block. */
.hero__title .fold-text { display: inline; }
/* La fonte, la graisse et la couleur viennent du <h1>, pas des valeurs par
   défaut de l'amont (80px / 800 / #f7f2e8) qui casseraient l'échelle typo. */
.hero__title .fold-text,
.hero__title .fold-text-piece { font: inherit; color: inherit; letter-spacing: inherit; }
/* La pièce a le même défaut de bord droit que le <em> (constaté le 19/09/2026) :
   le letter-spacing négatif s'applique après le dernier glyphe, donc sa boîte
   s'arrête avant l'encre du E — et c'est ELLE qui porte le dégradé une fois
   React monté. background-clip:text ne peint que dans la boîte : la dernière
   colonne du E restait transparente, le E paraissait mangé. Le <em> avait le
   correctif (padding + margin), la pièce non. Même compensation : le padding
   élargit la boîte à peindre, le margin rend la largeur au flux. */
.hero__title em .fold-text-piece { padding-right: 0.06em; margin-right: -0.06em; }
/* La virgule qui suit le mot en dégradé ne doit pas rester seule en début de
   ligne. Elle ouvre le fragment « , livré sans surprise. », et splitBy="word"
   en fait un segment à part : le navigateur avait donc le droit de couper
   entre elle et « livré » — « Fait sur-mesure | , livré sans ».

   L'amont rend, côte à côte : un segment « , », un `.fold-text-whitespace`
   contenant une insécable, puis un segment « livré ». La coupure possible est
   ce blanc-là. `display: inline-block` en fait une boîte atomique, donc plus
   un point de rupture — la virgule et le mot suivant restent solidaires.

   Ciblé sur le PREMIER blanc seulement (`:first-of-type`) : les suivants
   gardent leurs coupures, et la ligne continue de se replier où il faut.

   Une insécable écrite dans le texte ne pouvait pas marcher : en JS, `\s`
   inclut U+00A0, donc le split de l'amont la découpait comme un espace
   ordinaire et reformait exactement le même segment isolé (vérifié). */
.hero__title em + [data-island] .fold-text-whitespace:first-of-type {
  display: inline-block;
}
/* FoldText double le texte : une copie visible découpée en caractères, et une
   copie intacte en `sr-only` pour les lecteurs d'écran — l'animation hache le
   mot en <span>, ce qu'une synthèse vocale lirait lettre par lettre.
   Sauf qu'ici le <h1> porte DÉJÀ le titre complet en clair, dans les deux
   moitiés de la ligne. La copie cachée le faisait donc annoncer deux fois de
   suite. On la retire : la version accessible est le <h1> lui-même. */
.hero__title .fold-text-sr-only { display: none; }

/* ---------- /stats : l'audience publique (25/08/2026) ----------
   Les styles de l'admin (.facts, .statbars) ne sont PAS réutilisés : ils
   vivent dans admin.css, que les pages publiques ne chargent pas. Les copier
   aurait fait deux tables à maintenir pour une page ; ce sont donc des règles
   à part, dessinées pour cette page-là — plus aérées, sans la densité d'un
   tableau de bord. */
.kfig {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
  gap: 0.85rem;
  margin: 0 0 3rem;
}
.kfig__i {
  display: grid;
  gap: 0.3rem;
  padding: 1.5rem 1.4rem;
  border-radius: var(--r-surface);
  background: var(--ink-100);
  border: 1px solid rgba(242, 236, 228, 0.07);
}
.kfig__n {
  font-size: clamp(1.9rem, 4.5vw, 2.6rem);
  font-weight: 700;
  line-height: 1;
  color: var(--bone);
  /* Les trois chiffres sont lus en colonne : sans chasse fixe, les unités ne
     s'alignent pas d'une carte à l'autre. */
  font-variant-numeric: tabular-nums;
}
.kfig__l { font-size: var(--fs-small); color: var(--bone-dim); }

.stats__h {
  font-size: clamp(1.3rem, 3vw, 1.7rem);
  font-weight: 600;
  letter-spacing: -0.02em;
  margin: 0 0 1.1rem;
}
/* Bornée : sur un écran large, une barre de 1500px de long pour dire « 184 »
   n'ajoute aucune information, elle éloigne juste le chiffre de son pays. */
.obars { display: grid; gap: 0.55rem; max-width: 760px; }
.obars__i {
  display: grid;
  /* Le pays prend ce qu'il lui faut, la barre le reste, le chiffre une chasse
     fixe : sans la troisième colonne figée, les valeurs dansaient d'une ligne
     à l'autre selon leur nombre de chiffres. */
  grid-template-columns: minmax(0, 11rem) 1fr 4.5rem;
  align-items: center;
  gap: 1rem;
  font-size: var(--fs-small);
}
.obars__d { color: var(--bone-mid); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.obars__t { height: 9px; border-radius: 5px; background: var(--ink-200); overflow: hidden; }
.obars__t i {
  display: block;
  height: 100%;
  border-radius: 5px;
  background: linear-gradient(90deg, var(--ember), var(--ember-soft));
}
.obars__v { color: var(--bone); text-align: right; font-variant-numeric: tabular-nums; }
@media (max-width: 560px) {
  /* Sur un écran étroit, onze rem pour le nom du pays ne laissent plus rien à
     la barre. Le nom passe seul sur sa ligne, la barre et le chiffre dessous. */
  .obars__i { grid-template-columns: 1fr 4.5rem; }
  .obars__d { grid-column: 1 / -1; }
}

.stats__none, .stats__priv {
  color: var(--bone-dim);
  font-size: var(--fs-small);
  max-width: 68ch;
  line-height: 1.6;
}
.stats__priv { margin-top: 3rem; }
