/* ═══════════════════════════════════════════════════════════════════════════
   GenPix AI — design tokens + shared components.

   CYBERPUNK. Two themes that are not inversions of each other but ROLE SWAPS:
   the same three hues (acid yellow, cold cyan, hot crimson) play different
   parts. Dark = night: black field, yellow signal. Light = the poster: acid
   yellow field, black ink. Every colour goes through a token — nothing in a
   page file hardcodes a hex.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ═══════════════════════════════════════════════════════════════════════════
   Display faces — Chakra Petch (system) + Warriot Tech Italic (wordmark).

   Chosen by MEASUREMENT, then by reading the result on screen. Eleven candidate
   cyberpunk faces were opened with fontTools and every glyph of the real French
   charset was drawn to a bounds pen. A character present in the cmap is not the
   same as a character that DRAWS: Warriot Tech and Valorax both claim é, è and
   € and render all three as empty boxes; Robotron has no accented vowel at all
   (110 glyphs); SD Glitch and cyberpunk-regular have no digits, so "0,15 €"
   fell back to Times mid-word. Exactly one face passed the whole set —
   Cyberwar — and it then failed on screen for a reason no charset audit can
   catch: its N is drawn as П and its X as H, so the brand name itself read
   "GEПPIH" in the header.

   The conclusion is the architecture: a poster face cannot run a UI. So the two
   jobs are split, and each face is confined to the strings it can survive.

     Chakra Petch  →  --display       headings, nav, labels, prices, French.
                                      Semi-condensed, clipped corners, flat
                                      terminals — an instrument panel that
                                      happens to own every accent and the euro.
     Glitch Goblin →  --display-loud  the WORDMARK, and nothing else.
                                      Slanted, heavy, and cut through by a
                                      horizontal glitch step — the torn edge of
                                      the Cyberpunk 2077 logo itself. It cannot
                                      draw à, ù, û, ô, î or ï, which is
                                      survivable only because "GENPIX AI" is
                                      pure ASCII and is never translated.
                                      Subset to A–Z a–z 0–9 alone: 3.9 KB.

   Licensing decided this, not taste. genpix-ai.com takes payments, so it is a
   commercial use, and most of the folder cannot legally ship: Robotron, Dirt-
   chunk and SD Glitch are personal-use-only, Valorax is explicitly
   non-commercial, and Cyberwar, Bazer and cyberpunk-regular carry "All Rights
   Reserved" with no grant (cyberpunk-regular has no metadata at all). Glitch
   Goblin is the only face in the folder under the SIL Open Font License, with
   the licence written into the font's own name table.

   Self-hosted on purpose, not linked from fonts.googleapis.com: the CNIL and
   the Munich ruling both treat that CDN as sending a French visitor's IP to
   Google without consent.

   Body copy stays Inter — an angular face at 15px over a paragraph is where
   "cyberpunk" turns into "hard to read".
   ═══════════════════════════════════════════════════════════════════════════ */
@font-face {
  font-family: "Chakra Petch";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url(/fonts/chakrapetch-500-latin-ext.woff2) format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Chakra Petch";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url(/fonts/chakrapetch-500-latin.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Chakra Petch";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/fonts/chakrapetch-600-latin-ext.woff2) format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Chakra Petch";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/fonts/chakrapetch-600-latin.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Chakra Petch";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/fonts/chakrapetch-700-latin-ext.woff2) format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Chakra Petch";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/fonts/chakrapetch-700-latin.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Glitch Goblin";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url(/fonts/glitchgoblin-mark.woff2) format("woff2");
}

/* Cyberpunk Regular — the poster face, kept OFF the reading path on purpose.
   Its charset is 56 glyphs: A–Z, a–z, space. No digit, no comma, no accent, no
   euro. And the drawing itself refuses to be read — put the alphabet on screen
   and C comes out as an angle, P loses its bowl and turns into Γ, Q reads as a
   question mark, U as a check mark, G as an F. "GENPIX" renders "GENΓIX": the
   exact failure that disqualified Cyberwar in the audit above.

   So it is not a heading face — it is a GLITCH ALPHABET, and that is a job it
   does better than any legible font could. Two uses, both of them deliberate:
     · the hero watermark, at 200 px, where the eye reads texture not words
       (the string is chosen from the letters that survive: N O L I M I T S) ;
     · the corrupted letters thrown by js/mosh.js — for 80 ms a character in a
       button label is redrawn in this face, which is why the glitch reads as a
       transmission fault instead of a font swap.

   The unicode-range is a FENCE, not an optimisation: anything outside A–Z a–z
   falls through to Chakra Petch by itself, so no string can ever hit a missing
   glyph. Subset to those 56: 9.8 KB.

   Provenance: the file carries no licence, no copyright, no vendor and no
   designer in its name table — Version 001.001 and nothing else. It is fenced
   into decoration partly for that reason too; see the note in the deploy
   audit before this ships anywhere commercial. */
@font-face {
  font-family: "Cyberpunk Poster";
  font-style: normal;
  font-weight: 400;
  font-display: block;
  src: url(/fonts/cyberpunk-poster.woff2) format("woff2");
  unicode-range: U+0020, U+0041-005A, U+0061-007A;
}
:root {
  /* ── NIGHT ── poster 3: the black field, the yellow signal.
     The floor drops from #0f1113 to #08090a. That is not "darker for the sake
     of it": the accent moved from a red-orange to CP2077's acid yellow
     (#fcee0a), and yellow only reads as a light SOURCE if the field behind it
     is genuinely night. On #08090a the yellow measures 17.6:1 — versus 5.9:1
     for the orange it replaces. That single number is the whole renovation:
     nothing on this site is "too dark to see" any more.
     Plane separation stays a surface step of ~6 points, never a drawn line. */
  --bg-0: #08090a;
  --bg-1: #0e1013;
  --bg-2: #14171a;
  --bg-3: #1c2025;
  /* Hairlines carry a trace of the cold hue rather than being neutral grey —
     it is what makes a dark panel read as machined instead of just dim. */
  --line: #222a2f;
  --line-soft: #171d21;

  /* Ink is cooled a few points toward the cyan structure. Never pure white:
     #e8edef on #08090a is ~16.8:1 and still calm. */
  --fg: #e8edef;
  --fg-dim: #939ba0;
  --fg-faint: #676f74;

  /* ── The three hues. One signal, one structure, one alarm. ──
     Yellow is the ONLY loud thing on the page — the poster fires it and
     nothing else. Cyan is never loud: it is the cold line that organises.
     Crimson is reserved for failure, so it must not be reachable by anything
     decorative. */
  --accent: #fcee0a;
  --accent-ink: #000000;
  /* On night, the yellow is already at maximum contrast — big type needs no
     lifted variant, so accent-hi is the same colour. The token stays because
     the light theme genuinely needs it. */
  --accent-hi: #fcee0a;
  --cold: #00e5ff;
  --ok: #22e38a;
  /* Warn cannot be yellow any more — yellow is the brand. It moves to a warm
     orange, which is now free. */
  --warn: #ff8a3d;
  --bad: #ff003c;

  /* Form controls sit ABOVE the page, never below it — a darker well on a dark
     floor reads as a hole. */
  --field: #14171a;
  --field-focus: #1b2025;

  /* Cyberpunk corners are CUT, not rounded. 2px is the largest radius that
     still reads as a chamfer rather than a pill. */
  --radius: 2px;
  --radius-lg: 3px;
  --shadow: 0 18px 50px -26px rgb(0 0 0 / 0.9);
  --grid: rgb(255 255 255 / 0.022);

  /* The accent in parts, for the places that need it with an alpha. */
  --accent-rgb: 252 238 10;
  /* The colour of the floating dust drawn on #dust by js/landing.js. */
  --dust-ink: #fcee0a;

  /* ── LA COULEUR ÉTRANGÈRE — le tiers manquant des affiches ────────────────
     Sur les trois affiches, le jaune ne travaille jamais seul : il y a toujours
     une surface qui n'est ni le champ ni l'encre — un cyan (le col de
     l'affiche 1, l'autocollant « NEXT-GEN UPDATE » de l'affiche 2), un rouge
     (les panneaux de fond de l'affiche 3, le bandeau PEGI de l'affiche 1).
     Sans ça, la page est bicolore et l'œil lit un thème bien réglé, pas une
     affiche.

     LA DOSE, ET C'EST TOUT LE SUJET : on la voit UNE FOIS PAR PAGE au maximum,
     et beaucoup de pages n'en ont pas du tout. La première version peignait
     10 % du cadre de chaque section — vérifié à l'œil : ça recouvrait le titre
     du hero. Un décor qui mange le contenu n'est plus un décor.

       · --focus : le CYAN, et il ne décore rien — il suit le curseur. Un
         document n'a qu'un focus à la fois, donc le « une fois par page » est
         garanti par la STRUCTURE et pas par la discipline. Au repos, la page
         est strictement noir + jaune ; l'accroc n'existe que pendant qu'on
         s'en sert. Pourquoi pas --accent : le jaune est le SIGNAL (ce sur quoi
         il faut cliquer) ; si le champ actif est jaune aussi, le regard a deux
         destinations et n'en a plus aucune.
       · --focus-ring : le même cyan quand il sert d'ANNEAU au lieu de bord.
         Deux jetons parce que les deux rôles n'ont pas les mêmes contraintes
         de contraste — l'explication est dans le bloc du thème jour.
       · --rouge : la marque, posée à la main sur UN bouton d'UNE page
         (`.mark-red`, cf. css/landing.css). Elle ne porte aucune information,
         exactement comme le rouge de l'affiche.

     LA RÈGLE QUI ÉVITE LA COLLISION AVEC L'ALARME : --rouge ne fait que CERNER
     (outline), il n'encre jamais un texte ; --bad ne fait que l'inverse. Un
     rouge de décor et un rouge d'erreur ne peuvent donc pas être confondus,
     parce qu'ils n'occupent jamais le même type de surface. */
  --focus: #00e5ff;
  --focus-ring: #00e5ff;
  --rouge: #e8112d;

  /* Every stack ends with the symbol/emoji faces. Not decoration: Inter and
     Chakra Petch carry no pictographs at all, so a glyph outside their coverage
     (🌳, ⛉, ✦) fell through to the browser's LAST-RESORT font — which on some
     machines draws the empty box Phil saw. Naming the emoji faces gives the
     glyph a real home before that happens. It costs nothing for text: a face
     is only consulted for a codepoint the ones before it do not have.
     The truly rare symbols (the power sign for Sign out) are drawn as SVG
     instead — no font anywhere is guaranteed to have them. */
  --emoji: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "Apple Symbols", "Symbola";
  --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "Apple Symbols";
  --sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "Apple Symbols", sans-serif;
  /* Everything large or bold. Never body copy. */
  --display: "Chakra Petch", "Inter", -apple-system, BlinkMacSystemFont, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "Apple Symbols", sans-serif;
  /* The wordmark, and nothing else. Pure-ASCII controlled string only:
     this face has no accent, no euro, no period. */
  --display-loud: "Glitch Goblin", "Chakra Petch", "Inter", sans-serif;
  /* The glitch alphabet. Decoration and corrupted letters only — never a
     string the reader has to read. See the @font-face note above. */
  --display-poster: "Cyberpunk Poster", "Glitch Goblin", "Chakra Petch", sans-serif;

  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  --dur: 260ms;
}

[data-theme="light"] {
  /* ── DAY ── poster 1 taken at its word: APLAT INTÉGRAL.
     ONE yellow, not four. The first pass stepped the planes down into deeper
     yellows (#f2e400 → #d8ca00), the way a dark theme steps up — and that is
     precisely what went soft on screen: pack cards and the prompt bar sat
     yellow-on-yellow with a mushy seam, and a mustard halo bled around every
     gallery thumbnail. The poster never does that. It holds ONE field and it
     CUTS into it with pure black.
     So separation stops being a matter of surface and becomes a matter of INK.
     Three devices, and no fifth yellow:
       · every rule is black                          → --line
       · what must LIFT casts a hard offset, no blur  → --shadow
       · what must STAND APART becomes a black plate  → .plate, at the foot
     A tint would have been a fourth device, and it is the one the poster
     refuses. */
  --bg-0: #fcee0a;
  --bg-1: #fcee0a;
  --bg-2: #fcee0a;
  --bg-3: #fcee0a;
  /* The rule IS the structure now, so it is ink and not a darker yellow.
     `--line-soft` is that same ink screened back, never a colour of its own:
     a grey hairline on acid reads as dirt on the print. */
  --line: #000000;
  --line-soft: rgb(0 0 0 / 0.34);

  /* Pure black ink, which the dark theme never uses. On the acid field it is
     20.9:1 — the loudest legible pair that exists, and the reason this theme
     answers "too dark to see" better than any grey ever could.
     The dim and faint steps are the FIELD darkened, not black lightened: on a
     saturated yellow, a neutral grey turns muddy within two steps. */
  --fg: #000000;
  --fg-dim: #4a4400;
  --fg-faint: #6b6400;

  /* The accent cannot be yellow here (it is the field) and must not be black:
     106 rules in this codebase use --accent as TEXT colour, and a black accent
     would silently flatten every one of them into body copy. So the cold hue
     takes the lead role, burnt down until it reads on acid — #006f80 is 4.7:1
     on the field, and still unmistakably the same cyan as the night theme.
     Full-strength cyan is not lost: it is allowed INSIDE a black plate, and
     only there — which is exactly where the posters put it. */
  --accent: #006f80;
  --accent-ink: #fcee0a;
  --accent-hi: #00505d;
  --cold: #00404a;
  --ok: #0a6b2e;
  --warn: #8a3d00;
  --bad: #c4002e;

  /* An input is no longer a well: there is no second surface left to sink
     into. It is a rectangle of the field with ink drawn round it. */
  --field: #fcee0a;
  --field-focus: #fcee0a;
  /* No blur, no colour, no spread — a printed offset. This one token turns
     every lifted thing on the site (menus, the prompt bar, tooltips, panels,
     the lightbox) into a plate laid on the poster rather than a soft UI card
     floating over paper. */
  --shadow: 5px 5px 0 rgb(0 0 0 / 0.9);
  --grid: rgb(0 0 0 / 0.055);
  --accent-rgb: 0 111 128;

  /* Le cyan ne touche JAMAIS le champ directement : les affiches ne posent
     jamais du cyan sur du jaune sans une encre entre les deux, et l'aplat
     intégral dit la même chose — ici la séparation est une affaire d'encre.
     Le rouge, lui, ne bouge pas d'un thème à l'autre : rouge sur jaune est un
     couple d'imprimerie, il tient tel quel.

     Les deux rôles du cyan, en revanche, divergent ici, et c'est mesuré, pas choisi :
       · en BORD, il doit SÉPARER — donc il lui faut le contraste d'une encre.
         #00e5ff sur #fcee0a fait 1,3:1, c'est-à-dire rien ; le même cyan
         poussé jusqu'à l'encre (#00414f) fait 8,9:1, et il reste franchement
         plus froid que le teal de l'accent (#006f80), sinon le focus et le
         signal se ressembleraient.
       · en ANNEAU, il est déjà bordé de noir des deux côtés — il n'a donc plus
         à séparer, il n'a plus qu'à se faire voir : le cyan vif reprend sa
         place. */
  --focus: #00414f;
  --focus-ring: #00c2de;
  --rouge: #e8112d;

  /* Print grain, not neon: on the aplat the dust is ink like everything else. */
  --dust-ink: #000000;
}

@media (prefers-reduced-motion: reduce) {
  :root { --dur: 1ms; }
  * { animation-duration: 1ms !important; animation-iteration-count: 1 !important; }
}

/* ── Base ─────────────────────────────────────────────────────────────────── */
* { box-sizing: border-box; }
/* min-height, NOT height. With `height: 100%` the body's box is exactly one
   viewport tall however long the page is — and a `position: sticky` element is
   bounded by its containing block, so the header stopped sticking the moment
   you scrolled past the first screen. Measured on the landing page: header
   getBoundingClientRect().top = -78 at scrollY 700. It looked fine only
   because nobody had scrolled that far while watching the top of the screen. */
html { height: 100%; }
body { min-height: 100%; }

/* ── L'ÉCHELLE DE LA PAGE, SUR TÉLÉPHONE (Phil, 15/09) ───────────────────────
   « désactive le zoom à deux doigts, la page sort du cadre de l'iPhone,
   surtout après avoir envoyé un message ».
   `touch-action: manipulation` retire le double-tap-zoom (et, au passage, le
   délai de 300 ms sur le clic) sans toucher au défilement. Le pincement, lui,
   se refuse en JS (js/nozoom.js) : aucune propriété CSS ne l'arrête sur iOS.
   `-webkit-text-size-adjust: 100%` empêche Safari de regrossir le texte tout
   seul en paysage — un agrandissement qu'on n'a pas demandé est un débordement
   qu'on n'a pas vu. */
html { touch-action: manipulation; -webkit-text-size-adjust: 100%; }
/* Et la page ne se TIRE plus non plus sur le côté : mesuré sur /app.html, on
   pouvait la faire glisser de 26 px vers la droite — l'animation de changement
   de vue déplace la section par `transform`, et un élément transformé compte
   dans la zone défilable. Le zoom coupé, ce glissement DEVIENT le « ça sort du
   cadre ». `clip` plutôt que `hidden` : `hidden` ferait de la racine un
   conteneur de défilement (les en-têtes `sticky` s'y recollent mal), `clip`
   coupe sans rien devenir. */
html, body { overflow-x: clip; }
body { overscroll-behavior-y: none; }

/* LA cause du « après avoir envoyé un message » : Safari iOS agrandit la page
   entière dès qu'un champ reçoit le focus avec une police < 16 px, et il ne la
   remet jamais à 1 ensuite. Ce n'est pas un réglage, c'est une règle du
   moteur : le seul remède est de ne jamais descendre sous 16 px sur un écran
   tactile. `(pointer: coarse)` vise le doigt, pas la largeur — un écran étroit
   à la souris garde ses tailles d'origine. */
@media (pointer: coarse) {
  input[type=text], input[type=email], input[type=password], input[type=number],
  input[type=search], input[type=tel], input[type=url], input[type=date],
  input:not([type]), textarea, select { font-size: 16px !important; }
}
body {
  margin: 0;
  background: var(--bg-0);
  color: var(--fg);
  font: 15px/1.55 var(--sans);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* A faint grid, always there, never noticed — the "instrument panel" floor. */
  background-image:
    linear-gradient(var(--grid) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid) 1px, transparent 1px);
  background-size: 44px 44px, 44px 44px;
}

a { color: inherit; text-decoration: none; }
/* Headings carry the display face. Note the letter-spacing flips sign: a
   grotesk wants tightening at display size, an angular semi-condensed face
   wants the opposite — its flat terminals collide when you pull them together.
   Sizes go up ~6 % because Chakra Petch's x-height is lower than Inter's, so
   the same px value reads smaller. */
h1, h2, h3 {
  margin: 0 0 .4em;
  font-family: var(--display);
  font-weight: 600; letter-spacing: 0.005em; line-height: 1.12;
}
h1 { font-size: clamp(32px, 5.4vw, 62px); font-weight: 700; }
h2 { font-size: clamp(21px, 2.7vw, 30px); }
p  { margin: 0 0 1em; color: var(--fg-dim); }
code, .mono { font-family: var(--mono); font-size: .92em; }
hr { border: 0; border-top: 1px solid var(--line-soft); margin: 22px 0; }

::selection { background: var(--accent); color: var(--accent-ink); }

:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }

/* ── Layout ───────────────────────────────────────────────────────────────── */
.wrap { width: min(1180px, calc(100% - 40px)); margin-inline: auto; }
.stack > * + * { margin-top: var(--gap, 16px); }
.row { display: flex; gap: 12px; align-items: center; }
.row.wrap-row { flex-wrap: wrap; }
.spacer { flex: 1; }
.muted { color: var(--fg-dim); }
.faint { color: var(--fg-faint); }
.small { font-size: 13px; }
.tiny  { font-family: var(--display); font-weight: 600; font-size: 11.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--fg-faint); }
/* Le seul accent TEXTE de la feuille. `.badge.gold` existait déjà, mais une
   pastille est un objet : quand le chiffre doit rester un chiffre (« 24 unused »
   sous un compteur), il lui faut la couleur sans la boîte, sinon la mention
   part en gris et se lit comme du décor. */
.gold  { color: var(--accent); }

/* ── Header ───────────────────────────────────────────────────────────────── */
.top {
  position: sticky; top: 0; z-index: 40;
  display: flex; align-items: center; gap: 18px;
  padding: 12px 20px;
  background: color-mix(in oklab, var(--bg-0) 82%, transparent);
  backdrop-filter: blur(14px) saturate(1.2);
  border-bottom: 1px solid var(--line-soft);
}
.brand { display: flex; align-items: center; gap: 9px; font-family: var(--display-loud); font-weight: 700; font-size: 17.5px; letter-spacing: .015em; text-transform: uppercase; }
.brand .glyph {
  width: 22px; height: 22px; flex: none;
  border: 1px solid var(--accent);
  border-radius: 50%;
  position: relative;
  transition: transform 600ms var(--ease);
}
.brand .glyph::before,
.brand .glyph::after {
  content: ""; position: absolute; inset: 4px;
  border: 1px solid var(--accent); border-radius: 50%; opacity: .55;
}
.brand .glyph::after { inset: 8px; opacity: .9; background: var(--accent); }
.brand:hover .glyph { transform: rotate(180deg); }

.nav { display: flex; gap: 2px; }
.nav a, .nav button {
  position: relative;
  padding: 7px 12px; border: 0; background: none; color: var(--fg-dim);
  font: 600 13px/1 var(--display); letter-spacing: .04em; cursor: pointer; border-radius: var(--radius);
  transition: color var(--dur) var(--ease);
}
.nav a:hover, .nav button:hover { color: var(--fg); }
.nav a.on, .nav button.on { color: var(--accent); }
.nav a.on::after, .nav button.on::after {
  content: ""; position: absolute; left: 12px; right: 12px; bottom: 2px;
  height: 1px; background: var(--accent);
}
/* Le produit sœur en PASTILLE ROSE (Phil, 14/09 : « bgcolor pink so it make
   more attractive ») — l'accent de companion (#c94f7c), pas une teinte de ce
   thème, présent sur la home ET dans le studio (theme.css est partagé). */
.nav a.nav-companion, .nav a.nav-companion:hover {
  background: #c94f7c; color: #fff; border-radius: 999px;
  padding: 7px 14px; margin-left: 4px;
  box-shadow: 0 2px 10px rgba(201, 79, 124, .35);
  transition: filter var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.nav a.nav-companion:hover { filter: brightness(1.12); box-shadow: 0 3px 14px rgba(201, 79, 124, .5); }

/* ── Header menu + account strip ──────────────────────────────────────────── */
/* A dropdown, not a link: "What it does" was one word pointing at a section
   halfway down the page. This lists the actual jobs the site does, so the menu
   answers the question instead of promising an answer further down. It is fed
   from /seo/index.json, the same manifest the SEO pages are built from. */
.menu { position: relative; }
.menu-btn {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 7px 12px; border: 0; background: none; color: var(--fg-dim);
  font: 600 13px/1 var(--display); letter-spacing: .04em; cursor: pointer;
  border-radius: var(--radius);
  transition: color var(--dur) var(--ease);
}
.menu-btn:hover, .menu[data-open] .menu-btn { color: var(--fg); }
.menu-btn .caret { font-size: 9px; opacity: .65; transition: transform 220ms var(--ease); }
.menu[data-open] .menu-btn .caret { transform: rotate(180deg); }

.menu-pop {
  position: absolute; top: calc(100% + 9px); left: -8px; z-index: 50;
  width: min(560px, 88vw);
  padding: 12px;
  background: var(--bg-1);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
  animation: pop-in 180ms var(--ease) both;
}
@keyframes pop-in { from { opacity: 0; transform: translateY(-6px); } }
.menu-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; }
.menu-grid a {
  display: block; padding: 8px 10px; border-radius: var(--radius);
  color: var(--fg-dim); font-size: 13.5px; line-height: 1.3;
  border-left: 2px solid transparent;
  transition: background 160ms var(--ease), color 160ms var(--ease), border-color 160ms var(--ease);
}
.menu-grid a:hover { background: var(--bg-2); color: var(--fg); border-left-color: var(--accent); }
.menu-all {
  display: block; margin-top: 8px; padding: 9px 10px 3px;
  border-top: 1px solid var(--line-soft);
  font: 600 12px/1 var(--display); letter-spacing: .07em; text-transform: uppercase;
  color: var(--accent);
}
@media (max-width: 720px) { .menu-pop { width: min(340px, 92vw); } .menu-grid { grid-template-columns: 1fr; } }

/* The right-hand strip. Empty until /api/auth/me answers - see index.html. */
.acct { display: flex; align-items: center; gap: 8px; }

/* ── Un seul bouton pour les réglages (15/09) ──────────────────────────────
   Phil : « fusion every setting in one buttom that will save me more space on
   the header ». Le bandeau garde ce qui porte une décision d'argent (le
   crédit, le « + » qui recharge) ; le nom, le studio, la bibliothèque, le
   guide, le thème, le « ? » et la déconnexion passent derrière la silhouette
   — le même tracé que `#btn-profile` côté companion. */
.acct .acct-plus {
  padding: 0; width: 28px; height: 28px; display: grid; place-items: center;
  font-size: 16px; line-height: 1; font-weight: 700;
}
.acct-prof { position: relative; }
.acct .btn-icon {
  width: 30px; height: 30px; display: grid; place-items: center; padding: 0;
  border-radius: 9px; border: 1px solid var(--line); background: none;
  color: inherit; cursor: pointer;
}
.acct .btn-icon:hover { border-color: var(--accent); color: var(--accent); }
.acct .btn-icon[aria-expanded="true"] { border-color: var(--accent); color: var(--accent); }
.acct-menu {
  position: absolute; top: calc(100% + 8px); right: 0; z-index: 200;
  min-width: 246px; padding: 6px; display: grid; gap: 1px;
  border-radius: 12px; border: 1px solid var(--line);
  background: var(--bg-1, #14121a);
  box-shadow: 0 18px 40px rgba(0, 0, 0, .45);
}
.acct-menu.hidden { display: none; }
.am-id { padding: 8px 10px 9px; display: grid; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 4px; }
.am-id b { font-size: 13.5px; }
.am-sub { font-size: 11px; opacity: .55; overflow: hidden; text-overflow: ellipsis; }
/* Une seule ligne pour le libellé et le montant, la phrase en dessous. En
   `grid-template-columns: 1fr auto` le montant se retrouvait comprimé et
   « 71,93 € » passait à la ligne AU MILIEU du nombre. */
.am-credit {
  display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; align-items: baseline;
  padding: 7px 10px; border-radius: 8px; text-decoration: none; color: inherit;
}
.am-credit b { color: var(--accent); white-space: nowrap; justify-self: end; }
.am-credit.low b { color: var(--bad); }
.am-credit i { grid-column: 1 / -1; font-style: normal; opacity: .5; font-size: 11px; }
.acct-menu > a, .am-out {
  display: flex; align-items: center; gap: 8px; padding: 7px 10px;
  border-radius: 8px; text-decoration: none; color: inherit;
  font: inherit; font-size: 13px; background: none; border: 0;
  text-align: left; width: 100%; cursor: pointer;
}
.acct-menu > a:hover, .am-out:hover, .am-credit:hover {
  background: color-mix(in srgb, var(--ink) 7%, transparent);
}
.am-companion:hover { background: color-mix(in srgb, #ff4d9d 22%, transparent); }
.am-tools {
  display: flex; gap: 6px; padding: 6px 8px; margin-top: 4px;
  border-top: 1px solid var(--line);
}
/* Les deux boutons ADOPTÉS depuis le <header> : mêmes éléments, nouveau
   parent, donc rien à recâbler — seulement une boîte à leur donner. */
.am-tool {
  width: 30px; height: 30px; display: grid !important; place-items: center;
  border: 1px solid var(--line); border-radius: 8px; padding: 0;
}
.am-out { margin-top: 2px; border-top: 1px solid var(--line); border-radius: 0 0 8px 8px; }
.am-out:hover { color: var(--bad); }
@media (max-width: 560px) {
  .acct .credit .lbl { display: none; }
  .acct-menu { min-width: 208px; }
}
.acct .credit {
  display: inline-flex; align-items: baseline; gap: 6px;
  padding: 5px 11px; border: 1px solid var(--line-soft); border-radius: 999px;
  background: var(--bg-1); font-family: var(--mono); font-size: 13px; color: var(--fg);
}
.acct .credit b { color: var(--accent); font-weight: 600; }
.acct .credit.low b { color: var(--bad); }
/* The chip pulses when the composer spends or refunds — you see the number
   move even if you were looking at the picture. Keyframes live here rather
   than in app.css because the landing header uses them too. */
.acct .credit.bump { animation: bump 620ms var(--ease); }
@keyframes bump {
  30% { transform: scale(1.09); border-color: var(--accent); }
  100% { transform: none; }
}
.acct .who {
  max-width: 148px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: 12.5px; color: var(--fg-faint);
}
/* 860 was too low: with the address visible the bar measures 952px, so between
   861 and 960 it still ran off the right edge (the theme toggle landed at
   x=931 in an 885px window). The address is the first thing that can go —
   you know which account you are in. */
@media (max-width: 960px) { .acct .who, .acct .credit .lbl { display: none; } }

/* ── The header on a phone ─────────────────────────────────────────────────
   `.top` is one flex row that never wrapped, and its contents don't compress:
   the nav links, the credit chip and the buttons all refuse to shrink past
   their text. Intrinsic width came to 744px, so on a 375px screen the account
   block started at x=409 — Top up, Studio and sign-out sat entirely off the
   right edge, and every page scrolled sideways. Measured 17/08, all 11 pages
   that use `.top`.
   It folds in two stages instead of hiding behind a burger: no page needs new
   markup, and nothing ends up more than one tap away.                        */
@media (max-width: 760px) {
  .top { flex-wrap: wrap; gap: 10px; padding: 10px 14px; }
  /* Sent to its own second line. No `overflow` here on purpose — the "What you
     can make" panel is absolutely positioned inside it and a scroll container
     would clip it. It measures 215px, so it fits even a 320px screen. */
  .top .nav { order: 10; width: 100%; }
}
@media (max-width: 430px) {
  /* The glyph already says the name; it keeps its own 22px box. */
  .brand { font-size: 0; gap: 0; }
  /* Top up and the credit chip point at the same place, so the button is the
     one that can go: tapping the amount still lands on the wallet. Matched by
     href rather than by `.ghost`, because a low balance swaps the ghost and
     primary classes between Top up and Studio. */
  .acct .btn[href="/app.html#wallet"] { display: none; }
  /* 27px short of fitting at 320, which pushed the theme toggle onto a third
     row. Help goes rather than the toggle: it is a keyboard affordance ("or
     press ?") on a device with no such key, and /guide.html says the same
     thing. The light/dark switch stays — both themes are the product. */
  .top [data-help-toggle] { display: none; }
}
/* At 320 the first row came to 277px for 277px of room — the theme toggle was
   thrown onto a line of its own and the bar grew to three rows. Nothing needs
   to be removed to gain the missing 38px: the gaps and the button padding were
   sized for a mouse. */
@media (max-width: 360px) {
  .top { gap: 8px; padding: 10px; }
  .acct { gap: 6px; }
  .acct .btn { padding-left: 10px; padding-right: 10px; }
}

/* ── Buttons ──────────────────────────────────────────────────────────────── */
/* ── `hidden` doit vouloir dire hidden ──────────────────────────────────────
   `display: inline-flex` ci-dessous est une valeur EXPLICITE : elle bat le
   `[hidden] { display: none }` de la feuille du navigateur, qui n'a aucune
   spécificité. Résultat, un `btn.hidden = true` en JS ne cachait rien — et la
   maison avait déjà rustiné ce même défaut trois fois localement
   (admin-talent.css ×2, app.css). On le corrige donc à la SOURCE, une fois,
   pour tous les boutons de toutes les pages. */
.btn[hidden] { display: none !important; }

.btn {
  --btn-bg: var(--bg-3);
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  padding: 9px 16px; min-height: 38px;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--btn-bg); color: var(--fg);
  font: 600 13.5px/1 var(--display); letter-spacing: .03em; cursor: pointer;
  transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease), color var(--dur) var(--ease);
}
.btn:hover:not(:disabled) { border-color: color-mix(in oklab, var(--accent) 55%, var(--line)); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn.ghost { background: transparent; }
/* Un bouton qui ne porte qu'un glyphe n'a pas besoin de 16 px de marge de
   chaque côté : c'est de la place prise à ce qu'il y a à côté, et à côté il y a
   souvent un nom écrit par l'utilisateur. Carré, à la hauteur d'un `.pk-btn`
   pour que la rangée reste une ligne droite. */
.btn.sq { flex: none; width: 34px; min-width: 34px; min-height: 34px; padding: 0; font-size: 15px; }
.btn.link { background: none; border-color: transparent; color: var(--fg-dim); padding-inline: 6px; }
.btn.link:hover { color: var(--accent); }

/* The primary button answers the cursor: the light inside it follows the
   pointer (--mx/--my are written by js/ui.js). Not a hover state — a surface
   that is lit from where you are. */
.btn.primary {
  position: relative; overflow: hidden;
  background: var(--accent); color: var(--accent-ink);
  border-color: color-mix(in oklab, var(--accent) 70%, #000);
  font-weight: 600;
  /* The accent is a LIGHT SOURCE on the dark theme, not a paint swatch — it
     spills onto the floor around it (higgsfield does the same with a flat
     0 0 8px). Two layers: a tight rim that reads as emission, a wide bloom that
     only exists as a gradient in the eye. Warms on hover instead of moving. */
  box-shadow:
    0 0 0 1px color-mix(in oklab, var(--accent) 22%, transparent),
    0 0 18px -4px color-mix(in oklab, var(--accent) 45%, transparent);
  transition: box-shadow 320ms var(--ease);
}
.btn.primary:hover:not(:disabled) {
  box-shadow:
    0 0 0 1px color-mix(in oklab, var(--accent) 34%, transparent),
    0 0 26px -2px color-mix(in oklab, var(--accent) 60%, transparent);
}
/* No glow to be had on paper — the light theme keeps the cut. */
[data-theme="light"] .btn.primary,
[data-theme="light"] .btn.primary:hover:not(:disabled) { box-shadow: none; }
.btn.primary::before {
  content: ""; position: absolute; inset: -60%;
  background: radial-gradient(38% 38% at var(--mx, 50%) var(--my, 50%), rgb(255 255 255 / .5), transparent 70%);
  opacity: 0; transition: opacity 320ms var(--ease); pointer-events: none;
}
.btn.primary:hover::before { opacity: 1; }
.btn.danger { color: var(--bad); border-color: color-mix(in oklab, var(--bad) 40%, var(--line)); }

/* ═══ Hold to fire ═══
   Driven by public/js/hold.js — the ORBITE gesture. Everything here is painted
   in `currentColor`, so a .danger button charges red and a plain one charges
   ink without this file knowing which is which.
   ═══════════════════════════════════════════════════════════════════════════ */
.holdable { position: relative; touch-action: none; -webkit-user-select: none; user-select: none; }
.holdable .hold-lbl { position: relative; z-index: 5; display: inline-flex; align-items: center; gap: inherit; }
/* width/height EXPLICIT, not inset:0 alone: an <svg> is a REPLACED element —
   inset:0 with width:auto does not stretch it, it keeps its intrinsic 300×150
   and the contour draws at an unrelated scale in the middle of the button. */
.hold-trace { position: absolute; inset: 0; width: 100%; height: 100%;
  overflow: visible; pointer-events: none; z-index: 4; }
.hold-trace path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round;
  opacity: .92; transition: stroke-width .2s, filter .2s; }
/* The last 15 %. Dark carries it as LIGHT (constant stroke, a glow appears),
   paper carries it as WEIGHT (no glow to be had on paper — the stroke thickens).
   Same rule as .btn.primary above, and as ORBITE's two themes. */
.hold-trace.close path { filter: drop-shadow(0 0 7px currentColor); }
[data-theme="light"] .hold-trace.close path { filter: none; stroke-width: 3.4; }
/* The charge advances by MASK, never scaleX: a scaled layer gives a hard
   vertical edge — a shutter crossing the button instead of a light rising in
   it — and its feather would grow with the charge. The mask keeps a constant
   44 px trailing edge, and starts at -44px so nothing shows at rest. */
.hold-fill { position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  background: currentColor; opacity: .16; z-index: 1; --hold-u: -44px;
  -webkit-mask-image: linear-gradient(90deg, #000 0, #000 var(--hold-u), transparent calc(var(--hold-u) + 44px));
  mask-image: linear-gradient(90deg, #000 0, #000 var(--hold-u), transparent calc(var(--hold-u) + 44px)); }
/* The lock is not a flash on the button: the contour DETACHES from it — grows
   a little, goes out. The light leaves the object rather than lighting it. */
.hold-lock { animation: hold-lock 320ms cubic-bezier(.16, .84, .12, 1) both; }
@keyframes hold-lock { 0% { opacity: 1; transform: scale(1); } 100% { opacity: 0; transform: scale(1.055); } }

/* The sentence under a wipe button: what it is about to take, and what lives.
   It is not help text — it is the only place the blast radius is written. */
.wipe-note { font-size: 11.5px; line-height: 1.45; color: var(--fg-faint); max-width: 56ch; }

/* ═══ The prompt bar ═══
   Shared by the front page and the studio, which is why it lives here and not
   in landing.css. It is the same component in both: the same markup, the same
   docking, the same keys. Someone who made their first image in the hero opens
   the studio and the tool is exactly where they left it.
   ═══════════════════════════════════════════════════════════════════════════ */
/* ── The prompt bar ───────────────────────────────────────────────────────── */
/* This is the page. It sits directly under the headline with nothing between
   them, because every sentence we could put there is a sentence that delays
   the only action that matters. */
.composer { position: relative; max-width: 880px; margin-top: clamp(24px, 4vh, 40px); }
.hero-in > .cmp-slot > .composer { max-width: 880px; }

/* ── Docking ──────────────────────────────────────────────────────────────── */
/* The slot holds the bar's height open so the page does not collapse upward by
   ~120px at the exact moment the bar leaves it. Measured at dock time by
   js/dock.js rather than hardcoded, because the bar's height depends on how
   many reference photos are sitting in it. */
.cmp-slot { position: relative; }

.composer.docked {
  position: fixed;
  left: 50%;
  bottom: max(16px, env(safe-area-inset-bottom));
  width: min(820px, calc(100% - 28px));
  max-width: none;
  margin-top: 0;
  z-index: 60;
  transform: translateX(-50%);
  animation: dock-in 420ms var(--ease) both;
  /* Docked, the stack inverts: the state line goes ABOVE the bar. In the hero
     it sits underneath because there is page below it to read into; at the foot
     of the screen there is nothing below but the edge, and the pill was being
     clipped by it. Flex order, not a DOM move — the markup stays one form. */
  display: flex; flex-direction: column;
}
.composer.docked .cmp-note { order: -1; }
/* The trial chip goes above the state line, not below it: the promise is the
   standing fact, the note is what just happened. Reading order follows. */
.composer.docked .cmp-trial { order: -2; }
/* It arrives from the bottom edge, not from where it was. That is the whole
   message: the bar did not move down the page, it came back from off-screen to
   be within reach. Reversed on undock so the gesture is symmetrical. */
@keyframes dock-in {
  from { transform: translateX(-50%) translateY(58px); opacity: 0; }
  to   { transform: translateX(-50%) translateY(0);    opacity: 1; }
}
.composer.undocking { animation: dock-out 260ms var(--ease) both; }
@keyframes dock-out {
  from { transform: translateX(-50%) translateY(0);    opacity: 1; }
  to   { transform: translateX(-50%) translateY(58px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .composer.docked, .composer.undocking { animation: none; }
}

/* Docked, the bar is a floating instrument: opaque enough to sit over pictures,
   lit from under so it reads as hovering rather than pasted on. */
.composer.docked .cmp-bar {
  background: color-mix(in oklab, var(--bg-1) 94%, transparent);
  backdrop-filter: blur(18px) saturate(1.25);
  border-color: color-mix(in oklab, var(--accent) 22%, var(--line));
  box-shadow:
    0 22px 60px -22px rgb(0 0 0 / .78),
    0 0 0 1px color-mix(in oklab, var(--accent) 9%, transparent);
}
/* Opaque, and cut in ink. Translucency was the right answer when the bar
   floated over a dark gallery on a dark page: the blur read as glass. Over
   BLACK-MOUNTED pictures on an acid field it reads as a dirty yellow smear —
   96 % of the field mixed with whatever photograph happens to be underneath.
   A poster has no glass. The bar is a plate of the paper, laid on top. */
[data-theme="light"] .composer.docked .cmp-bar {
  background: var(--bg-0);
  backdrop-filter: none;
  border-color: var(--line);
  /* La première couche est transparente EXPRÈS, et ce n'est pas décoratif :
     `.cmp-bar` a un `transition: box-shadow`, et le focus ajoute un anneau —
     donc on passait d'une ombre à UNE couche à une ombre à DEUX. Mesuré au
     navigateur : la transition restait bloquée sur l'image de départ et
     l'anneau cyan n'apparaissait jamais (le calcul, lui, était juste — c'est
     l'interpolation entre deux listes de longueurs différentes qui coinçait).
     Les deux états déclarent maintenant le même nombre de couches, dans le
     même ordre : l'anneau naît de rien au lieu de naître d'un décalage. */
  box-shadow: 0 0 0 3px transparent, 5px 5px 0 rgb(0 0 0 / 0.9);
}
/* The explanation line and the drop veil belong to the hero, not to a floating
   bar over the gallery - docked, the bar is a tool, not a pitch. */
.composer.docked .cmp-note { display: none; }
.composer.docked.has-msg .cmp-note {
  display: block; margin: 0 0 9px; text-align: center;
  padding: 6px 12px; border-radius: 999px;
  background: color-mix(in oklab, var(--bg-1) 92%, transparent);
  border: 1px solid var(--line-soft); width: fit-content; margin-inline: auto;
}
/* Two lines can occupy that slot (the error, and the lane counter) and either
   can be empty — an empty pill is a floating grey capsule with nothing in it. */
.composer.docked.has-msg .cmp-note:empty { display: none; }
/* Docked, the chip picks up the same floating-pill treatment as the note — over
   a gallery of pictures a flat translucent background is unreadable. */
.composer.docked .cmp-trial {
  background: color-mix(in oklab, var(--bg-1) 92%, transparent);
  backdrop-filter: blur(14px) saturate(1.2);
}
[data-theme="light"] .composer.docked .cmp-trial {
  background: var(--bg-0);
  backdrop-filter: none;
  border: 1px solid var(--line);
}
.composer.docked .cmp-refs { justify-content: center; }
.composer.docked .cmp-ref { width: 40px; height: 40px; }

/* Room at the foot of the page so the docked bar never covers the last row of
   the footer. Set on <body> only while something is actually docked. */
body.has-dock { padding-bottom: 104px; }

@media (prefers-reduced-motion: reduce) { .cmp-ref { animation: none; } }

.cmp-bar {
  display: flex; align-items: flex-end; gap: 8px;
  padding: 9px 9px 9px 11px;
  background: color-mix(in oklab, var(--bg-1) 90%, transparent);
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow: var(--shadow);
  transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
/* The bar lights from inside when it has the caret — the panel is live, not a
   field that changed colour. */
.composer:focus-within .cmp-bar {
  border-color: var(--focus);
  box-shadow: 0 0 0 1px color-mix(in oklab, var(--focus) 34%, transparent),
              0 0 44px -18px color-mix(in oklab, var(--focus) 75%, transparent),
              var(--shadow);
}
/* Le curseur lui-même. C'est le détail qui fait que ce n'est pas « un champ
   qui a changé de couleur » mais « le cyan est entré dans la page par le
   curseur » : la barre clignotante est cyan, donc la couleur BOUGE. */
.cmp-bar textarea:focus, .cmp-bar input:focus { caret-color: var(--focus); }
/* JOUR — mesuré, pas déduit : `[data-theme="light"] .composer.docked .cmp-bar`
   (plus haut) pèse 0-4-0 et écrasait le focus à 0-3-0 ; la barre restait
   encrée en noir avec le curseur dedans. Il faut donc redire la règle au même
   poids, pas juste plus bas dans le fichier.
   Et le cyan du jour est presque noir (#00414f) exprès : sur l'aplat, la
   séparation reste de l'encre — simplement une encre FROIDE. Un cyan vif sur
   l'acide ferait 1,3:1, c'est-à-dire rien. */
[data-theme="light"] .composer:focus-within .cmp-bar,
[data-theme="light"] .composer.docked:focus-within .cmp-bar {
  /* Le filet noir NE BOUGE PAS. C'est la doctrine de l'aplat : ce qui sépare
     est de l'encre, jamais une nuance. Premier essai — passer la bordure en
     cyan sombre (#00414f) — vu à l'œil : indiscernable du noir, donc rien du
     tout. Le cyan se pose donc À CÔTÉ de l'encre, en anneau extérieur.
     Et c'est le noir qui le rend lisible : cyan contre jaune acide, seul, n'a
     pas de bord ; cyan collé à un filet noir en a un. */
  border-color: var(--line);
  box-shadow: 0 0 0 3px var(--focus-ring), 5px 5px 0 rgb(0 0 0 / 0.9);
}

/* Scoped by structure, not by id. The bar is shared between two pages that
   name their textarea differently (#cmpPrompt on the landing, #prompt in the
   studio) — an id selector here left the studio's box on the global
   `textarea { min-height: 96px }` and it opened three lines tall. */
.cmp-bar textarea {
  flex: 1; min-width: 0;
  /* 176 px = cinq lignes, puis la boite defile. Le plafond etait a 40vh, soit
     325 px sur un portable : charger un look a phrase longue faisait monter la
     barre par-dessus DEUX rangees de la grille, exactement le reproche du plan.
     La regle et l'autogrow de js/app.js portent le meme nombre — deux
     plafonds qui divergent, c'est la barre qui saute au premier caractere. */
  min-height: 42px; max-height: min(23vh, 176px);
  padding: 10px 4px; margin: 0;
  background: none; border: 0; outline: none; resize: none;
  color: var(--fg); font: 400 16px/1.45 var(--sans);
  scrollbar-width: thin;
}
.cmp-bar textarea::placeholder { color: var(--fg-faint); }

/* Narrow: the sentence takes the whole first line and the controls sit under
   it. `order: -1` targets the textarea by element, not by id, because the two
   pages give it two different ids (#cmpPrompt, #prompt) for the same job.
   This block has to sit AFTER `.cmp-bar textarea` above, not before it: same
   specificity, so the later rule wins. It used to live 30 lines higher, which
   left `flex: 1` in charge and squeezed the prompt field to 57px on a 375px
   phone — the bar looked fine, it just had nowhere to type. */
@media (max-width: 620px) {
  .cmp-bar { flex-wrap: wrap; }
  .cmp-bar textarea { flex: 1 1 100%; order: -1; }
  .cmp-go { margin-left: auto; }
}

.cmp-clip, .cmp-pick, .cmp-go { flex: none; }
/* Les TROIS pastilles de la barre partagent ce gabarit — le trombone, le lot et
   le look. Mesure du 13/09 : le lot etait a 34x28 alors que son propre
   commentaire annoncait « meme gabarit que le trombone ». Un commentaire n'est
   pas une regle : la classe est maintenant dans le selecteur. */
.cmp-clip, .cmp-multi, .cmp-look {
  display: grid; place-items: center; width: 38px; height: 38px; margin-bottom: 2px;
  background: none; border: 1px solid transparent; border-radius: 9px;
  color: var(--fg-faint); cursor: pointer;
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.cmp-clip:hover { color: var(--accent); border-color: var(--line); transform: rotate(-12deg); }

/* ── L'interrupteur du lot ────────────────────────────────────────────────────
   Même gabarit que le trombone : ce sont deux frères dans la même barre, et un
   bouton qui aurait sa propre taille dirait qu'il appartient à un autre outil.
   Ce qui change en revanche quand il est ARMÉ n'est pas une couleur de survol :
   le trait du milieu de l'icône se met à respirer et le badge compte les
   phrases. Le geste porte l'information — combien de tours on est en train
   d'écrire — pas seulement « tu as cliqué ». */
.cmp-multi { flex: none; position: relative; }
.cmp-multi:hover { color: var(--accent); border-color: var(--line); transform: none; }
.cmp-multi[aria-pressed="true"] {
  color: var(--accent);
  border-color: color-mix(in oklab, var(--accent) 45%, var(--line-soft));
  background: color-mix(in oklab, var(--accent) 8%, transparent);
}
.cmp-multi[aria-pressed="true"] svg path { stroke-dasharray: 0; animation: multi-stack 2.8s var(--ease) infinite; }
@keyframes multi-stack {
  0%, 100% { opacity: 1; transform: translateY(0); }
  45%      { opacity: .55; transform: translateY(1.2px); }
}
@media (prefers-reduced-motion: reduce) {
  .cmp-multi[aria-pressed="true"] svg path { animation: none; }
}

/* ── Le look, dans la barre ───────────────────────────────────────────────────
   Meme gabarit que le trombone et l'interrupteur du lot : trois freres dans la
   meme barre, et un bouton qui aurait sa propre taille dirait qu'il appartient
   a un autre outil. Il ne grandit QUE quand un look est charge — il cesse
   d'etre une icone pour devenir une etiquette qui dit sous quel nom on
   travaille. Meme principe que le badge du lot : l'etat se lit sur le bouton,
   jamais dans une phrase posee a cote. */
.cmp-look { flex: none; }
.cmp-look:hover { color: var(--accent); border-color: var(--line); transform: none; }
.cmp-look.named {
  grid-auto-flow: column; align-items: center; gap: 7px;
  width: auto; max-width: 196px; padding: 0 10px 0 8px;
  color: var(--accent);
  border-color: color-mix(in oklab, var(--accent) 45%, var(--line-soft));
  background: color-mix(in oklab, var(--accent) 8%, transparent);
}
.cmp-look-name {
  max-width: 142px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: 11.5px; letter-spacing: .06em; text-transform: uppercase;
}
/* Sur un telephone la barre s'enroule deja (voir `.cmp-bar` plus haut) : le nom
   se coupe plutot que de pousser la forme et le compte sur une ligne de plus. */
@media (max-width: 560px) { .cmp-look.named { max-width: 124px; } .cmp-look-name { max-width: 76px; } }

/* ── La file, rendue visible ──────────────────────────────────────────────────
   Une pastille par phrase, dans l'ordre où elles partiront. C'est la seule
   façon honnête de dessiner une file : un pourcentage dit « ça avance », une
   rangée de pastilles dit LAQUELLE avance et combien attendent encore. */
.batch-bar {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  font-size: 11.5px; color: var(--fg-faint); letter-spacing: .04em;
}
.batch-count { color: var(--fg-dim); }
.batch-chips { display: flex; gap: 3px; flex-wrap: wrap; }
.batch-chip {
  width: 14px; height: 5px; border-radius: 2px;
  background: var(--line); transition: background var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.batch-chip.run  { background: var(--accent); animation: batch-pulse 1.4s var(--ease) infinite; }
.batch-chip.done { background: color-mix(in oklab, var(--good, #4caf7d) 80%, transparent); }
.batch-chip.bad  { background: var(--bad); }
.batch-chip.off  { background: transparent; box-shadow: inset 0 0 0 1px var(--line-soft); }
@keyframes batch-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { .batch-chip.run { animation: none; } }
.batch-why { color: var(--accent); }
.batch-bar .btn.tiny { padding: 2px 8px; font-size: 11px; height: auto; min-height: 0; }
/* `.btn` pose `display: inline-flex`, qui l'emporte sur le `[hidden] { display:
   none }` du navigateur (règle d'agent utilisateur, spécificité nulle). Sans
   cette ligne, REPRENDRE reste affiché sur un lot qui tourne — vu à l'écran le
   11/09/2026, `hidden` était bien posé et le bouton bien visible. Le même piège
   est déjà noté trois fois ailleurs (app.css §compte, §lightbox, theme.css
   §cmp-trial) : c'est le bug qu'on refait à chaque nouveau bandeau. */
.batch-bar .btn[hidden] { display: none; }
/* width:auto beats the global `select { width: 100% }` from theme.css — as a
   flex item that rule made the shape picker eat the whole bar. */
.cmp-pick {
  width: auto; height: 38px; margin-bottom: 2px; padding: 0 8px;
  background: var(--bg-2); border: 1px solid var(--line-soft); border-radius: 9px;
  color: var(--fg-dim); font: 500 12px var(--mono); cursor: pointer;
}
/* The resolution picker. Standard is a picker like any other; Max is the one
   choice on this bar that changes what the job costs, so it says so on itself
   instead of leaving the price on the button to explain it after the fact —
   accent text, and a hairline that runs along the bottom edge only, so the
   control reads as "charged" rather than as "selected". */
.cmp-tier { transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); }
.composer[data-tier="max"] .cmp-tier {
  color: var(--accent);
  border-color: color-mix(in oklab, var(--accent) 45%, var(--line-soft));
  box-shadow: inset 0 -2px 0 -1px color-mix(in oklab, var(--accent) 60%, transparent);
}
/* Not on the acid field. There the accent is the cold hue, and the rule this
   theme sets for itself is that cyan never touches the yellow without an ink
   between — a teal "Max" on the field is exactly the mushy pair the token block
   refuses. So the day theme says it the way the poster does: pure ink, and the
   printed offset that lifts a thing off the page. Seen in both themes before
   this was written down. */
[data-theme="light"] .composer[data-tier="max"] .cmp-tier {
  color: var(--fg);
  border-color: var(--line);
  box-shadow: 3px 3px 0 rgb(0 0 0 / .9);
}
/* And the price it moved, on the button that takes it: the number redraws in
   place, so the eye is told WHERE the change landed rather than left to notice
   a different figure later. */
.cmp-go .cost { display: inline-block; }

/* The composer's one message slot says two different things. `.err` is the
   default — a run that failed, a style that needs a photo — and it is red
   because those are failures. `.ask` is the other one: the video confirm, where
   nothing has gone wrong and the button is simply quoting a price. Same slot so
   the layout never shifts, same warm accent as the armed button so the two
   halves of one question read as one thing. */
.cmp-note.ask { color: var(--warn); border-color: color-mix(in oklab, var(--warn) 45%, transparent); }

/* ── Armed: the button has quoted a price and is waiting to be pressed again ──
   Only ever on video (see `armed` in js/app.js). It has to read as a DIFFERENT
   state and not as an error — nothing has gone wrong, the button is simply
   holding a bigger number than usual. So it borrows the warm accent rather than
   the red, and it breathes: a static colour change is the one thing the eye
   skips, and this is the one press that must not be automatic. */
.cmp-go.armed {
  background: color-mix(in oklab, var(--warn, #d98a2b) 82%, var(--bg-0));
  border-color: color-mix(in oklab, var(--warn, #d98a2b) 60%, var(--line-soft));
  color: #fff;
  animation: goArmed 1.6s var(--ease) infinite;
}
@keyframes goArmed {
  0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--warn, #d98a2b) 45%, transparent); }
  55%      { box-shadow: 0 0 0 6px color-mix(in oklab, var(--warn, #d98a2b) 0%, transparent); }
}
@media (prefers-reduced-motion: reduce) { .cmp-go.armed { animation: none; } }
.cmp-go .cost.bump { animation: costBump 340ms var(--ease) both; }
@keyframes costBump {
  0%   { transform: translateY(-4px); opacity: 0; }
  60%  { transform: translateY(1px); }
  100% { transform: none; opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .cmp-go .cost.bump { animation: none; }
}
.cmp-go { min-height: 38px; margin-bottom: 2px; padding-inline: 20px; border-radius: 9px; }

/* Reference thumbs, numbered. The number is not decoration: it is the handle
   you use in the sentence — "the face from 1, the jacket from 2". */
.cmp-refs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 9px; }
.cmp-ref {
  position: relative; width: 62px; height: 62px; border-radius: 10px; overflow: hidden;
  border: 1px solid var(--line); background: var(--bg-2);
  animation: refIn 320ms var(--ease) both;
}
@keyframes refIn { from { opacity: 0; transform: translateY(6px) scale(.94); } }
.cmp-ref img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cmp-ref.pending img { opacity: .4; filter: grayscale(1); }
.cmp-ref .n {
  position: absolute; left: 4px; top: 4px; min-width: 16px; height: 16px; padding: 0 3px;
  display: grid; place-items: center; border-radius: 4px;
  background: color-mix(in oklab, var(--bg-0) 78%, transparent); color: var(--accent);
  font: 600 10px/1 var(--mono);
}
.cmp-ref .x {
  position: absolute; right: 3px; top: 3px; width: 17px; height: 17px;
  display: grid; place-items: center; border: 0; border-radius: 4px; cursor: pointer;
  background: color-mix(in oklab, var(--bg-0) 78%, transparent); color: var(--fg-dim);
  font-size: 11px; opacity: 0; transition: opacity var(--dur) var(--ease);
}
.cmp-ref:hover .x, .cmp-ref:focus-within .x { opacity: 1; }
.cmp-ref .x:hover { color: var(--bad); }

/* How far the photo has got. Thin, at the foot of the thumbnail, and only while
   it is moving — the point is that a wait now has a SHAPE instead of a grey
   square that may or may not be broken. */
.cmp-prog {
  position: absolute; inset: auto 0 0 0; height: 4px;
  background: color-mix(in oklab, var(--bg-0) 70%, transparent);
}
.cmp-prog i {
  position: absolute; inset: 0 auto 0 0; display: block; background: var(--accent);
  transition: width 220ms var(--ease);
}
/* A photo the server refused. It stays in the strip — nothing removes it but
   the person who dropped it — and carries its own retry. */
.cmp-ref.failed { border-color: var(--bad); }
.cmp-ref.failed img { filter: grayscale(1) brightness(.5); }
.cmp-retry {
  position: absolute; inset: auto 0 0 0; height: 18px; border: 0; cursor: pointer;
  background: var(--bad); color: #fff; font: 600 11px/18px var(--sans);
}

/* The trial chip. Sits above the bar, reads as a stamp rather than a banner:
   the moment it looks like an ad, it stops being read. Same accent as the
   "on us" wording in the note under the bar, so the two are obviously the
   same promise said twice — once before you type, once after. */
.cmp-trial {
  margin: 0 auto 10px; width: fit-content;
  display: flex; align-items: center; gap: 8px;
  padding: 5px 12px 5px 10px; border-radius: 999px;
  font-size: 12.5px; color: var(--fg-dim);
  border: 1px solid color-mix(in oklab, var(--accent) 34%, transparent);
  background: color-mix(in oklab, var(--accent) 8%, transparent);
}
/* `display: flex` above beats the browser's `[hidden] { display: none }`, so
   without this the chip stays on screen as an empty pill with a dot in it once
   the visitor has an account. Caught by looking at the page, not at the CSS. */
.cmp-trial[hidden] { display: none; }
.cmp-trial::before {
  content: ""; width: 6px; height: 6px; border-radius: 50%;
  background: var(--accent); flex: none;
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent);
}
.cmp-trial b { color: var(--accent); font-weight: 600; }
.cmp-trial a { color: var(--accent); text-decoration: none; border-bottom: 1px solid color-mix(in oklab, var(--accent) 40%, transparent); }
/* Spent: the promise is over, so the chip stops shouting and becomes a label.
   Keeping the accent here would keep advertising something that is gone. */
.cmp-trial.spent {
  border-color: var(--line); background: none; color: var(--fg-faint);
}
.cmp-trial.spent::before { background: var(--fg-faint); box-shadow: none; }
@media (prefers-reduced-motion: no-preference) {
  .cmp-trial { animation: trial-in 420ms var(--ease) both; }
}
@keyframes trial-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }

.cmp-note {
  margin: 10px 2px 0; min-height: 18px;
  font-size: 12.5px; color: var(--fg-faint);
}
.cmp-note b { color: var(--fg-dim); font-weight: 600; }
.cmp-note .free { color: var(--accent); }
.cmp-note .bad { color: var(--bad); }
.cmp-note a { color: var(--accent); text-decoration: none; border-bottom: 1px solid color-mix(in oklab, var(--accent) 40%, transparent); }
.dotty::after { content: "…"; animation: dotty 1.4s steps(4) infinite; }
@keyframes dotty { 0% { opacity: .3 } 50% { opacity: 1 } 100% { opacity: .3 } }

/* Drag anywhere on the page: the veil appears over the bar, so the answer to
   "where do I drop it" is the same place you were already looking. */
/* 94 %, not 80 %. At 80 % the placeholder ("Describe your image — Cmd+Enter to
   generate") and the Generate button read straight THROUGH the veil and
   collided with its own caption — two sentences on the same line of pixels,
   neither legible. Seen on screen at 1512 px in both themes, which is the only
   way that kind of fault ever gets noticed. The blur is what keeps it from
   looking like a flat panel dropped on the page: the bar stays present
   underneath as a shape, just not as words competing with the message. */
.cmp-veil {
  position: absolute; inset: -6px; z-index: 3;
  display: grid; place-items: center;
  border: 1px dashed color-mix(in oklab, var(--accent) 60%, transparent); border-radius: 16px;
  background: color-mix(in oklab, var(--bg-0) 94%, transparent);
  backdrop-filter: blur(3px);
  opacity: 0; pointer-events: none;
  transition: opacity 160ms var(--ease);
}
.cmp-veil span { font: 500 13px var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--accent); }
.composer.dragging .cmp-veil { opacity: 1; }


/* ── Cards & panels ───────────────────────────────────────────────────────── */
.card {
  background: var(--bg-1);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-lg);
  padding: 18px;
}
.card.tight { padding: 13px 14px; }
.panel { background: var(--bg-1); border: 1px solid var(--line-soft); border-radius: var(--radius-lg); }
.panel > header {
  display: flex; align-items: center; gap: 10px;
  padding: 11px 14px; border-bottom: 1px solid var(--line-soft);
  font: 600 12px/1.2 var(--display); letter-spacing: .09em; text-transform: uppercase; color: var(--fg-faint);
}
.panel > .body { padding: 14px; }

/* ── Forms ────────────────────────────────────────────────────────────────── */
label.field, .field { display: block; }
/* `.field` as well as `label.field`: a group of controls that is not one input
   (the saved-sets row is a select plus two buttons) still needs the same
   caption voice, and a <label> wrapping three controls is wrong markup. */
.field > span { display: block; font: 600 11.5px/1.2 var(--display); letter-spacing: .08em; text-transform: uppercase; color: var(--fg-faint); margin-bottom: 6px; }
/* Small factual caption under a control — the exact pixels a ratio produces. */
.field > .field-note { display: block; margin-top: 5px; font-size: 11px; font-style: normal; letter-spacing: .04em; color: var(--fg-faint); font-family: var(--mono, ui-monospace, monospace); }
/* `input:not([type])` matters: an <input> with no type attribute is a text
   field, and without it here it fell back to the browser's white box. */
input:not([type]), input[type=text], input[type=email], input[type=password], input[type=number], input[type=search], select, textarea {
  width: 100%; padding: 10px 12px;
  background: var(--field); color: var(--fg);
  border: 1px solid var(--line); border-radius: var(--radius);
  font: 14px/1.45 var(--sans);
  transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease);
}
textarea { resize: vertical; min-height: 96px; font-family: var(--sans); }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--focus); background: var(--field-focus); }
input::placeholder, textarea::placeholder { color: var(--fg-faint); }
select { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--fg-faint) 50%), linear-gradient(135deg, var(--fg-faint) 50%, transparent 50%); background-position: right 15px center, right 10px center; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; padding-right: 30px; }

/* ── Badges ───────────────────────────────────────────────────────────────── */
.badge {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 2px 8px; border-radius: 20px;
  font: 500 11.5px/1.7 var(--mono);
  border: 1px solid var(--line); color: var(--fg-dim); background: var(--bg-2);
}
.badge.ok   { color: var(--ok);   border-color: color-mix(in oklab, var(--ok) 35%, var(--line)); }
.badge.warn { color: var(--warn); border-color: color-mix(in oklab, var(--warn) 35%, var(--line)); }
.badge.bad  { color: var(--bad);  border-color: color-mix(in oklab, var(--bad) 35%, var(--line)); }
.badge.gold { color: var(--accent); border-color: color-mix(in oklab, var(--accent) 40%, var(--line)); }

/* ── Tables (built for volume) ────────────────────────────────────────────── */
.table-scroll { overflow: auto; max-height: var(--table-h, 62vh); border-radius: var(--radius); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
  position: sticky; top: 0; z-index: 2;
  background: var(--bg-3); color: var(--fg-faint);
  font: 500 11px/1 var(--sans); letter-spacing: .07em; text-transform: uppercase;
  text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line);
  white-space: nowrap;
}
tbody td { padding: 8px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
tbody tr { transition: background 140ms var(--ease); }
tbody tr:hover { background: var(--bg-2); }
td.num, th.num { text-align: right; font-family: var(--mono); }

/* Une colonne qui se clique doit se VOIR cliquable avant le clic — sinon la
   fonction existe et personne ne la trouve. D'où la flèche double en veille à
   25 % : assez faible pour ne pas bruiter douze en-têtes, assez présente pour
   que l'œil comprenne « ça bouge ». Au survol elle se lève, une fois active
   elle prend l'accent et devient une flèche simple, qui est la seule chose
   disant DANS QUEL SENS la liste est rangée.
   `aria-sort` est l'état, pas une classe : le lecteur d'écran et le style
   lisent le même attribut, ils ne peuvent donc pas diverger. */
th.sortable { cursor: pointer; user-select: none; transition: color 140ms var(--ease); }
th.sortable::after {
  content: '⇅'; margin-left: 6px; font-size: 10px;
  opacity: .25; transition: opacity 140ms var(--ease), color 140ms var(--ease);
}
th.sortable:hover { color: var(--fg-dim); }
th.sortable:hover::after { opacity: .65; }
th.sortable:focus-visible { outline: 1px solid var(--accent); outline-offset: -2px; }
th.sortable[aria-sort="ascending"], th.sortable[aria-sort="descending"] { color: var(--fg); }
th.sortable[aria-sort="ascending"]::after  { content: '↑'; opacity: 1; color: var(--accent); }
th.sortable[aria-sort="descending"]::after { content: '↓'; opacity: 1; color: var(--accent); }
.clip { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ── Toasts ───────────────────────────────────────────────────────────────── */
/* Top centre, not bottom right (Phil, 2026-08-17). Bottom right is where a
   toast goes to die unseen: on the studio the prompt bar is pinned to the
   bottom of the page, so a message about what just happened landed BEHIND the
   thing your eyes and your cursor were already on. The top centre is the one
   spot no panel of ours occupies, on every page and at every width.
   `pointer-events: none` on the stack so a message never eats a click on what
   is underneath it — the toast itself takes none either, it just expires. */
#toasts {
  position: fixed; top: 14px; left: 50%; transform: translateX(-50%);
  z-index: 90; display: flex; flex-direction: column; align-items: center; gap: 8px;
  pointer-events: none; max-width: min(92vw, 460px);
}
/* …and clear of the bar when there is one, rather than sitting on top of the
   navigation for four seconds. Measured, not assumed: header.top is 63 px and
   sticky on every page that has it. */
body:has(header.top) #toasts { top: 77px; }
.toast {
  padding: 11px 15px; max-width: 100%;
  background: var(--bg-3); border: 1px solid var(--line); border-left: 2px solid var(--accent);
  border-radius: var(--radius); box-shadow: var(--shadow); font-size: 13.5px;
  animation: toast-in 320ms var(--ease);
}
.toast.bad { border-left-color: var(--bad); }
.toast.ok  { border-left-color: var(--ok); }
@keyframes toast-in { from { opacity: 0; transform: translateY(-12px); } }

/* ── « Quelque chose s'appelle déjà comme ça » ────────────────────────────────
   Phil, 22/08/2026 (les plaques de la console) puis 26/08/2026 (les photos du
   studio) : trois portes — annuler, remplacer, garder les deux.

   Ici et pas dans admin.css depuis le 26/08 : la MÊME boîte sert aux deux
   écrans (js/askname.js), et deux copies d'une feuille de style, c'est le jour
   où l'une des deux questions se pose dans une fenêtre qui ne ressemble plus au
   reste du site. Le préfixe « hk- » (hooks) est tombé avec le déménagement.

   La boîte est construite autour d'une COMPARAISON, pas autour d'un message.
   « portrait.jpg existe déjà » est une phrase qu'on ne peut pas trancher ; deux
   images côte à côte avec leur poids et leur date se tranchent d'un coup d'œil.
   D'où la mise en page en trois colonnes serrées, le « vs » au milieu, et les
   vignettes assez grandes (150 px) pour distinguer deux prises voisines — à 92
   px, la taille d'un strip, deux photos du même modèle sont identiques.

   L'entrée est brève et vient du BAS : la boîte répond à un geste qui vient de
   se produire (un fichier lâché), elle n'annonce pas un nouvel écran. */
.ask {
  /* 96, et pas le 90 d'origine : dans la console la boîte n'avait qu'un voile
     sous elle, dans le studio elle peut s'ouvrir pendant qu'un dépôt est en
     cours (`.zone`, 90) ou par-dessus le recadreur et l'atelier de jeux
     (app.css, 95). Une question modale doit être au-dessus de ce qui l'a
     déclenchée, sinon elle attend une réponse derrière un voile. */
  position: fixed; inset: 0; z-index: 96;
  display: grid; place-items: center; padding: 20px;
  /* Le MÊME voile que la lightbox du studio (app.css, .lightbox) : la maison a
     déjà une manière de poser une couche par-dessus une page, et une boîte qui
     s'en écarte se lit comme venant d'un autre logiciel. À 62 % la page restait
     lisible dessous et la boîte flottait mollement ; 88 % sépare autant que le
     reste du site. */
  background: color-mix(in oklab, var(--bg-0) 88%, transparent);
  backdrop-filter: blur(8px);
  animation: ask-in 160ms var(--ease) both;
}
.ask-box {
  width: min(560px, 100%); max-height: 90vh; overflow: auto;
  padding: 18px 20px 16px;
  background: var(--bg-1); border: 1px solid var(--line);
  /* `--shadow` et pas une ombre écrite ici. En sombre c'est un halo ; en clair
     le thème est une AFFICHE — fond jaune plat, filets noirs — et son jeton
     rend un décalage net (5px 5px 0). Une ombre douce écrite en dur y fabrique
     une carte d'interface flottante, c'est-à-dire exactement ce que ce thème
     est construit pour ne pas être. Vu à l'écran le 22/08, dans les deux. */
  box-shadow: var(--shadow);
  animation: ask-rise 200ms var(--ease) both;
}
.ask-box h3 { margin: 0 0 4px; font-size: 15px; font-weight: 600; }
.ask-box h3 b { color: var(--accent); font-weight: 700; }
.ask-box > p { margin: 0 0 14px; line-height: 1.45; }
/* La phrase sous le titre est une PHRASE, et elle peut porter le nom d'un jeu
   que quelqu'un a tapé. `.tiny` passe son texte en majuscules avec une chasse
   élargie : juste pour une étiquette (« ADD », « WEIGHT »), faux pour un nom —
   « Portrait client » n'existe pas sous le nom « PORTRAIT CLIENT ». Même règle
   que `.hk-plate figcaption` (admin.css), écrite ici pour la boîte entière. */
.ask-note { font-size: 11.5px; line-height: 1.45; }

.ask-pair {
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px;
  margin-bottom: 16px;
}
.ask-pair figure { margin: 0; display: flex; flex-direction: column; gap: 6px; align-items: center; }
.ask-pair img {
  width: 100%; max-width: 150px; aspect-ratio: 1; object-fit: cover; display: block;
  border: 1px solid var(--line); background: var(--bg-2);
}
.ask-pair figcaption { text-align: center; font-size: 11.5px; line-height: 1.4; }
.ask-vs { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--fg-faint); }

.ask-acts { display: flex; flex-wrap: wrap; gap: 8px; }
.ask-acts button {
  flex: 1 1 auto; padding: 9px 12px; font-size: 12.5px; cursor: pointer;
  border: 1px solid var(--line); background: var(--bg-2); color: var(--fg);
  transition: border-color 140ms var(--ease), background 140ms var(--ease);
}
.ask-acts button i { font-style: normal; color: var(--fg-faint); }
.ask-acts button:hover, .ask-acts button:focus-visible { border-color: var(--accent); }
.ask-acts button.primary { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.ask-acts button.primary:hover i { color: var(--accent); }
/* « Remplacer » réécrit ce qui est déjà en service — les strips d'une règle
   dans la console, les jeux enregistrés dans le studio. C'est la seule porte de
   la boîte qui défait quelque chose, et elle se lit comme telle SANS être le
   bouton le plus visible : un rouge plein inviterait le clic réflexe. */
.ask-acts button.danger { border-color: color-mix(in srgb, var(--bad) 55%, var(--line)); color: var(--bad); }
.ask-acts button.danger:hover, .ask-acts button.danger:focus-visible { background: color-mix(in srgb, var(--bad) 12%, transparent); }

.ask-all { display: flex; align-items: center; gap: 7px; margin-top: 12px; color: var(--fg-faint); cursor: pointer; }
.ask-all input { accent-color: var(--accent); }

@keyframes ask-in  { from { opacity: 0 } to { opacity: 1 } }
@keyframes ask-rise { from { opacity: 0; transform: translateY(14px) } to { opacity: 1; transform: none } }
@media (prefers-reduced-motion: reduce) {
  .ask, .ask-box { animation-duration: 1ms; }
}

/* ── The "?" help system ──────────────────────────────────────────────────── */
/* Every control that could puzzle someone carries data-help. Turning help ON
   marks them; clicking one opens the note. Nobody has to read a manual to find
   out what a button does — the answer is attached to the button. */
body.help-on [data-help] { position: relative; cursor: help; box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 45%, transparent); border-radius: var(--radius); }
body.help-on [data-help]::after {
  content: "?";
  position: absolute; top: -7px; right: -7px; z-index: 5;
  width: 16px; height: 16px; display: grid; place-items: center;
  background: var(--accent); color: var(--accent-ink);
  border-radius: 50%; font: 700 10px/1 var(--sans);
  animation: help-pop 300ms var(--ease) backwards;
}
@keyframes help-pop { from { transform: scale(0); } }

#helpNote {
  position: fixed; z-index: 95; max-width: 340px;
  padding: 13px 15px;
  background: var(--bg-3); border: 1px solid var(--accent); border-radius: var(--radius);
  box-shadow: var(--shadow); font-size: 13.5px; line-height: 1.5;
  opacity: 0; transform: translateY(6px); pointer-events: none;
  transition: opacity 180ms var(--ease), transform 180ms var(--ease);
}
#helpNote.show { opacity: 1; transform: none; pointer-events: auto; }
#helpNote b { color: var(--accent); }

/* ── Loading ──────────────────────────────────────────────────────────────── */
.spinner {
  width: 15px; height: 15px; flex: none;
  border: 1.5px solid color-mix(in oklab, var(--fg) 25%, transparent);
  border-top-color: var(--accent); border-radius: 50%;
  animation: spin 720ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

.skeleton { background: linear-gradient(90deg, var(--bg-2), var(--bg-3), var(--bg-2)); background-size: 200% 100%; animation: sweep 1.4s linear infinite; border-radius: var(--radius); }
@keyframes sweep { to { background-position: -200% 0; } }

/* ── Directional view transitions ─────────────────────────────────────────── */
/* Moving right through the tabs brings the new view in from the right; moving
   left, from the left. The motion tells you WHERE you went, not just that
   something changed. js/ui.js sets data-dir before swapping. */
.view { display: none; }
.view.active { display: block; animation: view-in var(--dur) var(--ease); }
[data-dir="fwd"] .view.active { animation-name: view-in-fwd; }
[data-dir="back"] .view.active { animation-name: view-in-back; }
@keyframes view-in      { from { opacity: 0; } }
@keyframes view-in-fwd  { from { opacity: 0; transform: translateX(26px); } }
@keyframes view-in-back { from { opacity: 0; transform: translateX(-26px); } }

.hidden { display: none !important; }

/* ── ORBITE token bridge ──────────────────────────────────────────────────── */
/* js/orbite-fenetre.js is a verbatim copy of the ORBITE window module, and that
   module writes NO colour of its own — everything goes through --o-*. So the
   whole bridge between the two design systems is this block: the panel inherits
   GenPix's palette, in both themes, without a line of the module being touched.
   Dark carries the glow (the accent is a light source), light carries the cut
   (no glow to be had on paper — the EDGE thickens instead). */
:root {
  --o-sans: var(--sans);
  --o-mono: var(--mono);
  --o-lourd: cubic-bezier(0.22, 0.61, 0.16, 1);   /* ORBITE's own, calibrated with the timings */

  --o-fond: var(--bg-0);
  --o-fond2: var(--bg-1);
  --o-encre: var(--fg);
  --o-encre-fort: #f7f7f8;
  --o-encre-faible: var(--fg-faint);
  --o-trait: var(--line);
  --o-accent: var(--accent);

  --o-ep-plein: 2px;
  /* The glow drops from 0.55 to 0.45: an orange at full chroma bleeds far more
     than the gold did, and on a near-black floor the halo was eating the edge
     it was supposed to draw. */
  --o-lueur: drop-shadow(0 0 6px rgb(var(--accent-rgb) / 0.45));
  --o-lueur-forte: drop-shadow(0 0 13px rgb(var(--accent-rgb) / 0.8));

  --o-voile: rgb(5 6 7 / 0.8);
  --o-hachure: rgb(var(--accent-rgb) / 0.04);
  --o-fen-scan: rgb(var(--accent-rgb) / 0.12);
}

[data-theme="light"] {
  --o-encre-fort: #000000;
  --o-ep-plein: 3px;
  --o-lueur: none;
  --o-lueur-forte: none;
  --o-voile: rgb(30 26 15 / 0.36);
  --o-hachure: rgb(0 0 0 / 0.045);
  --o-fen-scan: rgb(0 0 0 / 0.05);
}

/* ─────────────────────────────────────────────────────────────────────────
   COSMOS — the backdrop layer
   ─────────────────────────────────────────────────────────────────────────
   The image is ours: generated on our own engine account (see
   scripts/make-backdrop.js). Not a stock photo — a licence we don't hold is
   not worth the decoration on a site that takes money.

   The hard part is not showing a nebula, it's showing one UNDER text. Two
   rules follow from that and explain every number below:

   1. The veil is DENSEST WHERE THE CONTENT IS. Our column is centred and
      ~880px wide, so a horizontal gradient seals the middle and lets the
      cosmos breathe in the side margins, where nothing is ever drawn. On a
      phone there are no margins — the layer collapses to almost pure floor,
      which is also exactly what a phone wants for battery and bandwidth.
   2. It is a FIXED layer, not `background-attachment: fixed` — that property
      forces a repaint of the whole viewport on every scroll frame in Safari
      and stutters on iOS. A fixed element with a composited transform does
      not.

   Weight: 202 KB (2560) / 87 KB (1280), webp q88. Quality is deliberately
   high for the size: the picture is mostly smooth dark gradient, and dark
   gradients are the one thing webp bands on when you starve it.

   18/08/2026 — the rubber duck is out of the cast. It was the brightest object
   in the frame by a wide margin, so it was the one thing the eye found while a
   run was in flight, which is exactly when the page has nothing else to show.
   Erased from the master rather than re-rendered (a re-render is a paid job and
   would have changed every other character too); the fill is a normalised
   convolution over the surrounding void — tmp/backdrop/debuck.py, kept next to
   the master it edits. The originals are in backups/backdrop-duck/. */
.cosmos {
  --cosmos-op: .52;
  position: fixed; inset: 0; z-index: -1;
  overflow: hidden; pointer-events: none;
  /* `layout paint`, deliberately NOT `strict`. `strict` adds size containment,
     and a full-viewport fixed layer that also re-composites on every scroll
     frame made Chrome re-sort its layers mid-scroll: the docked composer was
     caught half-rastered, its panel missing and the gallery tiles painting
     over its text. Measured, not guessed — dropping these two hints is what
     made it stop. */
  contain: layout paint;
}
.cosmos-sky {
  position: absolute;
  /* Bled past every edge so the parallax shift and the drift can never pull a
     hard border into frame. */
  inset: -8% -3%;
  background: no-repeat center / cover;
  /* 15/09, Phil : « change the background to be a mixed of many anime the most
     famous serie character on the space ». Rendu par notre propre moteur
     (scripts/make-backdrop.js, variante d) avec des personnages ORIGINAUX de
     style anime — l'ancien ciel photo reste en place (cosmos-1280/2560.webp),
     revenir = deux URL. */
  background-image: url("/img/bg/cosmos-anime-1280.webp");
  /* Two factors, never one: --cosmos-op is the THEME's strength and stays
     under CSS control, --cosmos-fade is the scroll position and is the only
     thing JS writes. Setting `opacity` inline instead would freeze the layer
     at the dark value the first time you scrolled, and switching to the light
     theme afterwards would do nothing.

     --cosmos-page is the THIRD factor and the reason there are three: a page
     that wants the backdrop dimmer (the studio) sets it once on .cosmos and it
     survives, because it MULTIPLIES the theme strength instead of replacing
     it. Setting --cosmos-op inline on .cosmos looked equivalent and was not —
     the `[data-theme="light"]` rule targets .cosmos-sky directly, and a rule
     on the element beats a value inherited from its parent, so the studio
     would have snapped back to full strength the moment you switched to the
     light theme. */
  opacity: calc(var(--cosmos-op) * var(--cosmos-fade, 1) * var(--cosmos-page, 1));
  filter: saturate(.92) contrast(1.03);
  translate: 0 var(--cosmos-y, 0px);
  animation: cosmos-drift 150s ease-in-out infinite alternate;
  /* No `will-change` here on purpose — the animation already promotes this
     layer, and asking for a second promotion is what triggered the re-sort. */
  backface-visibility: hidden;
}
@media (min-width: 900px), (min-resolution: 2x) {
  .cosmos-sky { background-image: url("/img/bg/cosmos-anime-2560.webp"); }
}
/* `scale` and `rotate` are their own properties now, so this animation and the
   JS-driven `translate` above never fight over one transform string. */
@keyframes cosmos-drift {
  from { scale: 1;     rotate: 0deg;     }
  to   { scale: 1.075; rotate: 0.35deg;  }
}

/* The veil. Four layers, painted over the sky:
     1 horizontal — seals the content column, opens the margins
     2 vertical   — opens at the top (the hero looks out), seals at the bottom
     3+4 the grid — the instrument-panel texture, which must sit ON TOP of the
                    image or it disappears the moment the backdrop is on. */
.cosmos::after {
  content: ''; position: absolute; inset: 0;
  background:
    linear-gradient(90deg,
      transparent 0%,
      color-mix(in srgb, var(--bg-0) 76%, transparent) 30%,
      color-mix(in srgb, var(--bg-0) 76%, transparent) 70%,
      transparent 100%),
    linear-gradient(180deg,
      transparent 0%,
      transparent 26%,
      color-mix(in srgb, var(--bg-0) 62%, transparent) 78%,
      color-mix(in srgb, var(--bg-0) 78%, transparent) 100%),
    linear-gradient(var(--grid) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid) 1px, transparent 1px);
  background-size: auto, auto, 44px 44px, 44px 44px;
}

/* Light theme: a dark nebula faded onto paper turns to grey fog — tried it on
   screen at .62 opacity with a 97 % centre seal, and it flattened the whole
   page into haze. So we don't fade it, we INVERT it: the same picture becomes
   a pale engraved star chart, hues roughly held by the hue-rotate, and the
   contrast bump is what keeps it from reading as a stain. Different object,
   same source file, no second download.

   This is the doctrine, not a shortcut: the light theme is not the dark one
   turned down — it holds the same mood in another medium. */
/* …and then the aplat took the argument further than the inversion could go.
   An engraved star chart is still a PICTURE lying across the field, and the
   whole premise of poster 1 is that the field has nothing lying across it. So
   on the day theme the sky is not faded, not inverted — it is not drawn. What
   stays is the 44px grid painted by .cosmos::after and by <body>: structure,
   not weather. The night theme keeps the nebula in full; the two themes hold
   the same mood in different media, which was always the rule. */
[data-theme="light"] .cosmos-sky {
  display: none;
}

/* Phones: no side margins to reveal, and the least reason to spend bandwidth. */
@media (max-width: 760px) { .cosmos-sky { --cosmos-op: .34; } }

@media (prefers-reduced-motion: reduce) {
  .cosmos-sky { animation: none; translate: none; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   SURFACES — frames that are not all the same dark
   ═══════════════════════════════════════════════════════════════════════════
   Phil, 17/08: "the dark theme, all the frame bg shouldn't always be the same
   colour — see how higgsfield plays with theirs so the important part pops."

   Right, and the trap is picking colours. A panel tinted blue and another
   tinted green is a dashboard that looks like a toy. What higgsfield actually
   varies is ELEVATION AND TEMPERATURE around one hue, and it varies them to
   say something: the thing that takes your money is warmer and lifted, the
   reference material is cooler and sunken, the data tables drop away entirely
   so the numbers are the only thing left with contrast.

   So: three modifiers, each with a MEANING, and nothing else.

     .panel.key    what you are meant to act on — warm floor, accent seam, lift
     .panel.cool   context you read once — cooler, recessed, no shadow
     .panel.flat   a table — the floor drops to the page so rows carry contrast

   Tinting is done with color-mix against the existing tokens rather than new
   hex values, so the light theme inherits the same logic instead of needing a
   parallel palette (and so changing --accent moves all of it at once).
   ─────────────────────────────────────────────────────────────────────────── */

.panel.key {
  /* 6 % of accent is deliberately just past the threshold of "is that a tint
     or my monitor" — measured against #16181b it lands about #1d1a19. Any more
     and it competes with the button inside it, which is the actual target. */
  background:
    linear-gradient(180deg,
      color-mix(in oklab, var(--accent) 7%, var(--bg-1)) 0%,
      var(--bg-1) 60%);
  border-color: color-mix(in oklab, var(--accent) 26%, var(--line));
  box-shadow:
    0 0 0 1px color-mix(in oklab, var(--accent) 8%, transparent),
    0 14px 34px -22px color-mix(in oklab, var(--accent) 48%, transparent);
}
/* The seam: one accent hairline across the top edge only. A full accent border
   shouts; a top edge reads as "this one is lit from somewhere". */
.panel.key > header {
  border-top: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
  margin-top: -1px;
  background: color-mix(in oklab, var(--accent) 5%, transparent);
  color: var(--accent-hi);
}

.panel.cool {
  background: color-mix(in oklab, var(--cold) 5%, var(--bg-0));
  border-color: color-mix(in oklab, var(--cold) 14%, var(--line-soft));
  box-shadow: none;
}
.panel.cool > header { color: color-mix(in oklab, var(--cold) 45%, var(--fg-faint)); }

.panel.flat {
  background: var(--bg-0);
  border-color: var(--line-soft);
  box-shadow: none;
}

/* On paper the same three roles cannot use light — they use weight. A warm
   wash and a hairline still work; the glow and the recession do not. */
[data-theme="light"] .panel.key {
  background: color-mix(in oklab, var(--accent) 5%, var(--bg-1));
  box-shadow: none;
  border-color: color-mix(in oklab, var(--accent) 34%, var(--line));
}
[data-theme="light"] .panel.cool { background: var(--bg-1); }
[data-theme="light"] .panel.flat { background: var(--bg-0); }

/* ═══════════════════════════════════════════════════════════════════════════
   APLAT INTÉGRAL — the day theme's structure, drawn entirely in ink
   ═══════════════════════════════════════════════════════════════════════════
   Everything below runs under [data-theme="light"] only, and exists because
   that theme now has ONE surface colour. A UI normally separates two things by
   giving them different floors; there is no second floor left here, so every
   separation has to be made with black. Three devices, in order of loudness:

     1. THE RULE     a 1px black hairline. Free — it is just --line, so every
                     .card, .panel, .btn and input in the codebase already
                     draws one without a single new selector.
     2. THE OFFSET   box-shadow: 5px 5px 0 black, no blur. Reserved for things
                     that genuinely float above the page (menus, the docked
                     prompt bar, tooltips, the lightbox) — it is --shadow, so
                     again nothing new is targeted.
     3. THE PLATE    a black rectangle with acid ink inside it.

   The plate is the interesting one, and it is not a new palette: INSIDE A
   PLATE THE NIGHT THEME IS IN FORCE. The same tokens, the same values as
   :root — so a caption, a badge, a rating row or a primary button dropped
   inside one is styled correctly without knowing it is on a plate. It is the
   poster's own move: a black sticker laid on the acid field, holding yellow
   type and the only full-strength cyan on the page.

   Where plates go is a rule, not a taste: PICTURES and THE ONE THING TO CLICK.
   Everything else stays flat. That is what stops this from becoming a sticker
   album — on the poster there is exactly one black shape, and it is the point.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── The plate ────────────────────────────────────────────────────────────── */
/* `.plate` is the opt-in class for markup written later; the list after it is
   every picture-holding box that exists today. They are mounted on black for
   one measured reason: a photograph on acid yellow has no edge — the 1px rule
   is swallowed by the first bright pixel of the image, and the field bleeds
   into the picture's own shadows. On black, the picture gets a mount.

   THE RULE THIS LIST ENFORCES, and it is not optional: in the day theme,
   ANYTHING THAT TURNS BLACK JOINS THIS LIST. Painting a surface black and
   setting `color` on it is not enough and was the bug — the children keep
   reading the DAY tokens, and the day tokens are ink meant for acid yellow.
   Measured on the treasury screen, 18/08/2026:
     · `.panel.key > header`  black bar, `color: var(--bg-0)` on the header
       itself — but its `.faint.tiny` subtitle stayed `--fg-faint` = #6b6400,
       which is 1.6:1 on #08090a. Invisible, and it is half the title.
     · `.fn-node:hover`       `background: var(--line)`, and in this theme
       --line IS pure black. The node name (--fg = #000) vanished entirely;
       only "make primary" survived, in teal, at 2.1:1.
     · `.node-menu button:hover`  same declaration, same disappearance.
   Joining the list flips the whole token set, so every child is styled for the
   surface it actually sits on without knowing it moved. */
[data-theme="light"] .plate,
[data-theme="light"] .tile,
[data-theme="light"] .cmp-shot,
[data-theme="light"] .cmp-wait,
[data-theme="light"] .cmp-ref,
[data-theme="light"] .pv-shot,
[data-theme="light"] .shot,
[data-theme="light"] .thumb-media,
[data-theme="light"] .lib-item,
[data-theme="light"] .rt,
[data-theme="light"] .rp-cell,
[data-theme="light"] .pending-shot,
[data-theme="light"] .media-cell,
/* L'onglet actif du rail de Media (26/08). Il passe au noir, donc il rejoint
   cette liste : peindre la surface sans basculer les jetons, c'est le bug que
   le commentaire ci-dessus décrit — le compteur resterait en #6b6400 sur du
   noir, soit 1,6:1. */
[data-theme="light"] .mrail-i.on,
[data-theme="light"] .chain-cell,
[data-theme="light"] .wall-card,
[data-theme="light"] .ap-kin-cell,
[data-theme="light"] .btn.primary,
[data-theme="light"] .pack.best,
[data-theme="light"] .pack:hover,
[data-theme="light"] .panel.key > header,
[data-theme="light"] button.fn-node:not(:disabled):hover,
[data-theme="light"] button.fn-node:not(:disabled):focus-visible,
[data-theme="light"] .node-menu button:hover,
[data-theme="light"] .node-menu button:focus-visible {
  /* ── the night theme, scoped. Same values as :root, deliberately. ── */
  --bg-0: #08090a;
  --bg-1: #0e1013;
  --bg-2: #14171a;
  --bg-3: #1c2025;
  --line: #222a2f;
  --line-soft: #171d21;
  --fg: #e8edef;
  --fg-dim: #939ba0;
  --fg-faint: #676f74;
  --accent: #fcee0a;
  --accent-ink: #000000;
  --accent-hi: #fcee0a;
  --cold: #00e5ff;
  --ok: #22e38a;
  --warn: #ff8a3d;
  --bad: #ff003c;
  --field: #14171a;
  --field-focus: #1b2025;
  --focus: #00e5ff;
  --focus-ring: #00e5ff;
  --accent-rgb: 252 238 10;
  --grid: rgb(255 255 255 / 0.022);

  background: var(--bg-0);
  color: var(--fg);
  /* The plate's own edge is black on yellow, which is to say: the plate IS the
     edge. No hairline — a rule drawn around a black shape is a second outline
     nobody asked for. */
  border-color: var(--bg-0);
}

/* Pictures that carry no container of their own still need the mount. */
[data-theme="light"] .ba-refs img,
[data-theme="light"] .ba-after img,
[data-theme="light"] .recent img,
[data-theme="light"] .tr-refs img,
[data-theme="light"] .rp-cell img {
  background: #08090a;
  border-color: #08090a;
}

/* The CTA is the poster's sticker: black plate, acid type. It inherits its
   colours from the plate block above (--accent is yellow in there), so this
   only has to undo the night theme's glow, which does not exist on paper. */
[data-theme="light"] .btn.primary {
  background: #08090a;
  color: #fcee0a;
  box-shadow: none;
}
[data-theme="light"] .btn.primary:hover:not(:disabled) {
  background: #000000;
  box-shadow: none;
}

/* A primary button that has landed INSIDE a plate cannot also be a plate — two
   blacks touching is one black. There it becomes what it is on the night
   theme: the yellow sticker. Handled by the tokens, not by new colours. */
[data-theme="light"] .plate .btn.primary,
[data-theme="light"] .wall-card .btn.primary,
[data-theme="light"] .panel.key .btn.primary {
  background: var(--accent);
  color: var(--accent-ink);
}

/* .wall-card is on the plate list above but does not carry the .plate class, so
   it used to miss this exception: "Put it up" was drawn #08090a on a #08090a
   card and only its yellow label survived — a button with no button around it.
   Seen 19/08/2026 on Activity → Front page, day theme. Anything else that turns
   black without the class has to be named here too, same as on the list. */

/* The two decided piles (refused, taken down) read quieter by being 72/82%
   opaque. That works on night, where what shows through is more near-black. On
   acid it is a fourth device and the one the poster refuses: the field comes UP
   THROUGH the plate, the card turns olive, and every label inside it loses the
   contrast the plate was there to give. Measured 19/08/2026 — the "Put it up"
   row on a taken-down card. So on the day theme the plate stays opaque and the
   dim moves onto the picture, which is where .live.blocked already puts it. */
[data-theme="light"] .wall-card.refused,
[data-theme="light"] .wall-card.pulled { opacity: 1; }
[data-theme="light"] .wall-card.refused img { opacity: .60; }
[data-theme="light"] .wall-card.pulled  img { opacity: .74; }

/* ── The packs ────────────────────────────────────────────────────────────── */
/* The recommended pack is permanently a plate, and hovering any other one
   flips it to ink. The flip is INSTANT, not a 260ms fade: custom properties do
   not interpolate, so a fading background would spend a fifth of a second with
   near-black text on a near-black card. A hard cut is also what a printed
   layer does — it is there or it is not. */
[data-theme="light"] .pack {
  transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
  border-color: var(--line);
}
[data-theme="light"] .pack:hover {
  border-color: #08090a;
  box-shadow: 5px 5px 0 rgb(0 0 0 / 0.9);
}
[data-theme="light"] .pack.best {
  box-shadow: 5px 5px 0 rgb(0 0 0 / 0.9);
}

/* ── Panels: weight, not tint ─────────────────────────────────────────────── */
/* The three panel roles (act / read / tabulate) were carried by a warm wash, a
   cool wash and a dropped floor. None of the three survives a single-surface
   theme, so they move to line weight and offset — which is how print has
   always said the same three things. */
[data-theme="light"] .panel.key {
  background: var(--bg-0);
  border: 2px solid var(--line);
  box-shadow: 5px 5px 0 rgb(0 0 0 / 0.9);
}
/* The header is a plate now (see the list above), so it must NOT repaint: with
   the night tokens in force `var(--line)` is #222a2f and `var(--bg-0)` is
   #08090a — the old pair would have swapped one unreadable combination for
   another. All that is left here is the seam, which a black block does not need. */
[data-theme="light"] .panel.key > header {
  border-top: 0;
  border-bottom-color: var(--bg-0);
  /* Acid type on the black bar — the plate's own `--accent`, so this is the
     same yellow the primary button uses and not a colour invented here. It is
     also what this header looked like before (it read `var(--bg-0)`); only its
     CHILDREN were wrong. */
  color: var(--accent);
}
[data-theme="light"] .panel.cool {
  background: var(--bg-0);
  border-color: var(--line-soft);
}
[data-theme="light"] .panel.flat {
  background: var(--bg-0);
  border-color: transparent;
}

/* ── Focus has to gain weight too ─────────────────────────────────────────── */
/* A 1px accent border was the focus ring, and it was legible only because the
   field behind it used to be a paler yellow. On one surface it has to be a
   ring, not a border. */
[data-theme="light"] input:focus,
[data-theme="light"] select:focus,
[data-theme="light"] textarea:focus {
  border-color: var(--focus);
  box-shadow: 0 0 0 1px var(--focus);
}

/* ── Nothing floats over an aplat ─────────────────────────────────────────── */
/* The hero's breathing light is a radial of --accent at 13 %. On the night
   theme that is a yellow glow behind the headline; here --accent is the teal,
   and teal at 13 % over acid yellow makes a green fog across the top of the
   page — the one thing an aplat cannot have. It goes. */
[data-theme="light"] .hero::before { display: none; }

/* Two loading sweeps were built out of the gap between --bg-1 and --bg-2.
   There is no gap left, so they are re-cut in ink: the same rhythm, drawn as a
   shadow passing over the paper. */
[data-theme="light"] .cmp-wait {
  background: linear-gradient(100deg,
    transparent 30%, rgb(0 0 0 / 0.16) 50%, transparent 70%) 0 0 / 300% 100%,
    #fcee0a;
  border-color: var(--line);
  color: var(--fg-dim);
}
[data-theme="light"] .pending-shot::before {
  background: linear-gradient(105deg,
    transparent 38%, rgb(252 238 10 / 0.13) 50%, transparent 62%);
}

/* ── Chips: an outline, never a wash ──────────────────────────────────────── */
/* Three admin chips filled themselves with part-strength black — `--line` at
   70 %, 60 %, 45 %. On the night theme that is a soft grey step; on the aplat
   it is a dirty yellow with BLACK TEXT ON IT, which is the same failure as a
   full black surface, only slower to notice. There is no fourth yellow in this
   theme, so a chip is drawn the way the poster draws one: a rule around it. */
[data-theme="light"] .risk {
  background: none;
  border: 1px solid currentColor;
  padding: 2px 5px;
}
[data-theme="light"] .risk.normal { color: var(--fg-dim); }
[data-theme="light"] .sub-line code {
  background: none;
  border: 1px solid var(--line-soft);
  color: var(--fg);
}
[data-theme="light"] .fu-bar {
  background: none;
  border: 1px solid var(--line);
}

/* The 18+ pill says WHICH node refuses, so "off" has to stay readable. At 55 %
   over acid yellow the struck-through state washed out to a stain; the ink is
   dimmed, not the whole button. */
[data-theme="light"] .fn-adult { opacity: 1; color: var(--fg-dim); }
[data-theme="light"] .fn-adult[data-on="0"] { color: var(--fg-faint); }

/* The admin's transparency chequerboard was two yellows; it is now ink on the
   field, which is what a chequerboard is supposed to be. */
[data-theme="light"] .ap-shot {
  background-image:
    linear-gradient(45deg, rgb(0 0 0 / 0.13) 25%, transparent 25%, transparent 75%, rgb(0 0 0 / 0.13) 75%),
    linear-gradient(45deg, rgb(0 0 0 / 0.13) 25%, transparent 25%, transparent 75%, rgb(0 0 0 / 0.13) 75%);
}

/* The state line takes the same treatment as the bar it belongs to. */
[data-theme="light"] .composer.docked.has-msg .cmp-note {
  background: var(--bg-0);
  border-color: var(--line);
  box-shadow: 3px 3px 0 rgb(0 0 0 / 0.9);
}

/* The header, likewise: it sits over the gallery as soon as you scroll, and a
   translucent field is a field with pictures in it. */
[data-theme="light"] .top {
  background: var(--bg-0);
  backdrop-filter: none;
}

/* ═══════════════════════════════════════════════════════════════════════════
   BLOCS — le grain « blocs » d'ORBITE, et l'alphabet de glitch
   ═══════════════════════════════════════════════════════════════════════════
   Deux effets, un seul moteur (js/mosh.js), et ils sont volontairement les
   deux moitiés d'une même idée : un défaut de TRANSMISSION, pas un survol.

     · les PAVÉS — des macroblocs de codec. Un rectangle du bouton est recopié
       à côté de lui-même, décalé de quelques pixels et frangé de rouge/cyan.
       C'est ça, un datamosh : un bloc gardé du mauvais endroit. Le CSS ne fixe
       ici qu'une chose — comment un pavé entre et sort : SEC, sans fondu.
       Un fondu, et l'accident redevient une jolie transition.

     · les LETTRES — pendant ~80 ms un caractère de l'étiquette est redessiné
       dans la face poster (--display-poster). Comme cette face ne sait pas
       écrire (C→∠, P→Γ, Q→?, U→√), le mot n'est pas remplacé : il est CORROMPU.
       C'est le seul emploi où ses défauts de dessin travaillent pour nous.

   Porté depuis /Users/phil/Desktop/ORBITE (orbite.js §MOSH, orbite.css). Le
   préfixe passe de `o-` à rien : ici c'est du code maison, pas une dépendance.
   ═══════════════════════════════════════════════════════════════════════════ */
.mosh { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.mosh__p {
  position: absolute; overflow: hidden;
  animation: mosh-vie steps(1, end) both;   /* durée et retard posés en ligne */
}
.mosh__p > * { inset: auto; }               /* la copie est placée en pixels */
@keyframes mosh-vie { 0% { opacity: 0 } 3% { opacity: 1 } 97% { opacity: 1 } 100% { opacity: 0 } }

/* Le caractère corrompu. `inline-block` + largeur bloquée par le JS : la face
   poster n'a pas les mêmes chasses que Chakra Petch, et sans ça l'étiquette
   respire de deux pixels à chaque salve — un tremblement de mise en page, pas
   un défaut de signal. */
.ms-w { display: inline; }
.ms-c { display: inline-block; white-space: pre; letter-spacing: inherit; }
.ms-c.on {
  font-family: var(--display-poster);
  /* L'encre ne CHANGE PAS — et c'est une correction, pas un oubli. La première
     version peignait le caractère corrompu en `--accent` (nuit) et en noir
     (jour) : sur la plaque noire du thème jour, un bouton primaire a déjà son
     texte en jaune, donc de l'encre noire posée dessus a rendu les lettres
     invisibles — vues à l'écran, il ne restait que la frange. `currentColor`
     tient sur n'importe quel support, plaque comprise. La chaleur vient de la
     frange, qui est ADDITIVE et n'a donc pas ce problème. */
  text-shadow: 1px 0 rgb(255 32 80 / .8), -1px 0 rgb(0 238 220 / .62);
}
[data-theme="light"] .ms-c.on { text-shadow: 1.4px 0 rgb(255 32 80 / .62), -1.4px 0 rgb(0 150 200 / .55); }

/* Une surface qui reçoit des pavés doit pouvoir les contenir. Le JS pose
   `position:relative` au besoin, mais les boutons du site sont déjà des
   boîtes : on leur donne juste de quoi découper. */
.btn, .pack, .plate { position: relative; }

/* ── Le filigrane du hero ──────────────────────────────────────────────────
   La face poster à 200 px, DERRIÈRE le titre. À cette échelle l'œil lit une
   texture, pas un mot — c'est exactement la place d'une face qu'on ne peut pas
   lire. La chaîne n'est quand même pas prise au hasard : « NO LIMITS » n'est
   fait que de lettres que cette fonte dessine juste (N O L I M I T S). Un
   « PRICING » ici sortirait « ΓRI∠ING » et le filigrane deviendrait une faute
   de frappe géante. */
.hero-mark {
  position: absolute; z-index: 0; pointer-events: none; user-select: none;
  right: 2.5%; top: 50%; transform: translateY(-50%) rotate(-4deg);
  font-family: var(--display-poster);
  /* Bornée en vw ET par le texte lui-même : la première passe débordait à
     droite et la ligne « NO » sortait de l'écran — un filigrane coupé se lit
     comme une faute de mise en page, pas comme un fond. */
  font-size: clamp(64px, 10.5vw, 150px); line-height: .8;
  letter-spacing: .01em; white-space: pre-line; text-align: right;
  color: transparent;
  -webkit-text-stroke: 1.5px color-mix(in oklab, var(--accent) 30%, transparent);
}
[data-theme="light"] .hero-mark {
  -webkit-text-stroke: 2px rgb(0 0 0 / .15);
}
@media (max-width: 900px) { .hero-mark { display: none; } }

/* Le levier pour juger sur pièce : `<html data-poster="titles">` fait passer
   TOUS les grands titres dans la face poster. Laissé en place exprès — c'est
   la seule façon de trancher en la regardant plutôt qu'en la décrivant. */
[data-poster="titles"] h1,
[data-poster="titles"] h2 { font-family: var(--display-poster); letter-spacing: .02em; }

/* ── The dropdowns ────────────────────────────────────────────────────────────
   Phil, 19/08/2026: "every dropdown is only a default simple from the browser."
   These rules dress the enhancement in js/pick.js — the native <select> is
   still there under `.pk-native`, still holding the value; what is drawn is a
   button and a list beside it.

   Built to match `.cmp-pick`, which is what the composer bar already looked
   like, rather than to invent a fifth shape: mono type, a hairline, the plate
   colour, and width from the content so the bar does not jump when the longest
   option changes. The only thing added is the chevron, and it turns — that is
   the whole animation budget on a control that is pressed twenty times an hour.

   `.pk-native` is `position: absolute; opacity: 0` and NOT `display: none`:
   a hidden select is removed from the accessibility tree and from the form,
   and it is still the element that carries the label and the value. It keeps
   its box, it just stops being painted. */
/* `min-width: 0` : sans lui, un élément flex refuse de descendre sous la
   largeur de son contenu, et le contenu ici est un nom que l'utilisateur écrit
   — donc sans limite. « Emma — portrait studio lumière douce 50mm » poussait
   les boutons voisins hors du panneau. La règle est la même partout : ce qui
   porte un texte de longueur inconnue doit pouvoir rétrécir, et le dire avec
   des points de suspension (`.pk-val`). */
.pk { position: relative; display: inline-flex; min-width: 0; max-width: 100%; }
/* ── Wearing the host's clothes without wearing them twice ────────────────────
   The wrapper INHERITS the select's classes, because those classes are how the
   surrounding layout addresses this control (`.cmp-pick { flex: none }`, the
   accent rule the composer paints on the tier picker, the grid the admin puts
   its fields in). Dropping them would leave every one of those selectors
   pointing at an element that no longer exists.
   But those same classes also carry a BOX — a border, a plate, a padding — and
   `.pk-btn` draws its own. Two boxes is what the first paint of this looked
   like: an empty pill sitting above a filled one, the bar a row taller than it
   should be. So the wrapper keeps the class and gives the paint back to the
   button, which is the element the eye is actually meant to see. */
.pk.cmp-pick, .pk.cmp-tier, .pk.cmp-count {
  background: none; border: 0; padding: 0; height: auto; margin-bottom: 0; border-radius: 0;
}
.pk.cmp-pick .pk-btn { height: 38px; min-height: 38px; padding: 0 10px; border-radius: 9px; background: var(--bg-2); }
/* The "this one costs more" mark on the resolution picker, forwarded from the
   wrapper (which still carries `.cmp-tier`, so the composer's own selectors
   still find it) onto the button that is now the visible thing. Written as a
   forward rather than re-stated, so the two theme variants above stay the only
   place that decides what the mark LOOKS like. */
.composer[data-tier="max"] .pk.cmp-tier .pk-btn {
  color: var(--accent);
  border-color: color-mix(in oklab, var(--accent) 45%, var(--line-soft));
  box-shadow: inset 0 -2px 0 -1px color-mix(in oklab, var(--accent) 60%, transparent);
}
[data-theme="light"] .composer[data-tier="max"] .pk.cmp-tier .pk-btn {
  color: var(--fg); border-color: var(--line); box-shadow: 3px 3px 0 rgb(0 0 0 / .9);
}
.pk[hidden] { display: none; }
.pk-native {
  position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: 0; pointer-events: none; appearance: none; border: 0; padding: 0; margin: 0;
}
.pk-btn {
  display: inline-flex; align-items: center; gap: 7px;
  font: inherit; font-family: var(--mono, inherit); font-size: 12.5px;
  color: var(--fg-dim); background: var(--bg-1);
  border: 1px solid var(--line-soft); border-radius: var(--radius);
  padding: 7px 10px; min-height: 34px; cursor: pointer;
  min-width: 0; max-width: 100%;
  transition: color 140ms var(--ease), border-color 140ms var(--ease), background 140ms var(--ease);
}
.pk-btn:hover:not(:disabled) { color: var(--fg); border-color: var(--line); }
.pk-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.pk-btn:disabled { opacity: .5; cursor: default; }
/* Coupé aux points de suspension plutôt que tronqué net : « Emma — portrait… »
   dit qu'il y a une suite, « Emma — portrait » prétend être le nom entier.
   Le titre du bouton porte le nom complet (js/pick.js le recopie). */
.pk-val { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.pk-chev { flex: none; opacity: .65; transition: transform 180ms var(--ease); }
.pk.open .pk-btn { color: var(--fg); border-color: var(--accent); }
.pk.open .pk-chev { transform: rotate(180deg); opacity: 1; }

/* ── Le panneau est FIXÉ, et posé en pixels par js/pick.js ───────────────────
   Absolu dans son enveloppe, il se faisait découper par le rail des réglages
   (`.controls`, `overflow-y: auto`) : l'étagère des jeux enregistrés s'ouvrait
   derrière le cadre (Phil, 14/09/2026). Fixé, aucun `overflow` d'ancêtre ne le
   coupe ; sorti dans le top layer par l'attribut `popover` (js/pick.js) quand
   le navigateur le sait, aucun contexte d'empilement ne le recouvre non plus.
   Contrepartie : `top: 100%` ne renvoie plus au bouton — `place()` écrit
   `top`/`bottom`/`left`/`min-width`/`max-height` en ligne à chaque ouverture.
   `inset: auto; margin: 0` désarme le style par défaut du navigateur pour un
   popover (`inset: 0; margin: auto`), qui étirerait le panneau aux quatre
   bords de la fenêtre. */
.pk-list {
  position: fixed; inset: auto; margin: 0;
  /* Au-dessus de tout ce que la page numérote (modales : 95, menus : 120) —
     c'est le filet pour le navigateur sans top layer, où le z-index reste le
     seul argument. */
  z-index: 2147483000;
  /* Le panneau a le droit d'être plus large que son bouton — c'est là qu'on
     LIT les noms — mais pas de sortir de l'écran. */
  max-width: min(340px, calc(100vw - 24px));
  max-height: 300px; overflow: hidden auto;
  /* Arrivé en bout de liste, la molette ne repart pas dans la page derrière :
     un scroll de page ferme le panneau (js/pick.js), donc sans ça la dernière
     impulsion de lecture referme ce qu'on est en train de lire. */
  overscroll-behavior: contain;
  padding: 4px; background: var(--bg-0);
  border: 1px solid var(--line); border-radius: var(--radius);
  box-shadow: 0 10px 30px rgb(0 0 0 / .45);
  /* The one movement: the list arrives from the edge it is anchored to, so the
     eye is told WHERE it came from and not merely that something appeared. */
  animation: pkIn 140ms var(--ease) both;
}
/* `up` ne dit plus QUE le sens de l'arrivée : la position exacte est calculée
   en pixels par js/pick.js, un pourcentage ne renvoyant plus à son bouton. */
.pk.up .pk-list { animation-name: pkInUp; }
@keyframes pkIn   { from { opacity: 0; transform: translateY(-5px); } }
@keyframes pkInUp { from { opacity: 0; transform: translateY(5px); } }
@media (prefers-reduced-motion: reduce) {
  .pk-list { animation: none; }
  .pk-chev { transition: none; }
}
.pk-opt {
  padding: 7px 10px; border-radius: calc(var(--radius) - 1px);
  font-family: var(--mono, inherit); font-size: 12.5px; color: var(--fg-dim);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer;
}
.pk-opt.at { background: var(--bg-2, var(--bg-1)); color: var(--fg); }
/* The current value is marked by a rule down its left edge rather than a tick:
   a tick needs a column of its own on every row to stay aligned, which on a
   three-item list is a third of the panel spent on six pixels of glyph. */
.pk-opt.on { color: var(--fg); box-shadow: inset 2px 0 0 var(--accent); }
.pk-opt.off { opacity: .45; cursor: default; }

/* ══ Le cadeau quotidien ═══════════════════════════════════════════════════
   Trois surfaces, un seul état (public/js/daily.js) : la pastille du bandeau,
   la fenêtre qu'elle ouvre, et l'échelle montée à demeure dans le portefeuille.

   L'échelle porte l'information dans sa FORME et pas dans son texte : une
   colonne par jour, une CELLULE par image. Les jours à deux images font deux
   cellules, donc une colonne deux fois plus haute — la courbe de récompense se
   lit d'un coup d'œil ET se compte, ce que la barre pleine ne permettait pas
   (on voyait « plus grand » sans voir « deux »). Elle change toute seule le
   jour où config.js change de paliers.

   Rien ici n'est un état de survol. Les mouvements disent tous quelque chose :
   le crédit qui traverse l'écran au clic (`.daily-fly`, d'où vient l'argent),
   la jauge de la colonne de demain (`.dm.next`, où en est l'attente — la seule
   chose de la page dont l'état dépend de l'heure qu'il est, donc la seule que
   l'œil ne peut pas apprendre par cœur), et le spectre ci-dessous.

   ── Pourquoi de la couleur, ici et nulle part ailleurs ──────────────────────
   Phil, 21/08 : « ça se fond trop avec les autres, alors que c'est des cadeaux ».
   Cause exacte : la pastille portait `--accent`, c'est-à-dire la couleur de
   TOUS les boutons du bandeau. Une pastille accentuée au milieu de boutons
   accentués n'est pas accentuée — l'œil la range avec eux. Ce qui manquait
   n'était pas de l'intensité, c'était de l'ALTÉRITÉ.

   Alors le cadeau est la seule chose du site qui sorte de la palette. Mais un
   arc-en-ciel plaqué pour faire joli s'apprend en deux visites et redevient du
   fond ; celui-ci porte une INFORMATION : la teinte EST la position dans le
   programme. Le jour 1 est aqua, le dernier est ambre, et chaque colonne de
   l'échelle prend la sienne — donc la bande montre d'un coup d'œil le chemin
   parcouru ET la couleur qu'on n'a pas encore atteinte. La page ne choisit
   aucune couleur : elle donne un RANG (`--i` sur `--n`), le CSS en dérive la
   teinte. Le jour où config.js passe à 30 jours, le spectre s'étale tout seul.

   `--dga` est un angle animé (@property) : sans lui un conic-gradient ne peut
   pas tourner. Un navigateur qui ne connaît pas @property garde l'anneau,
   simplement immobile — on perd le geste, jamais la lisibilité. */
@property --dga { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
@keyframes dg-spin { to { --dga: 360deg; } }

:root {
  /* Le spectre du cadeau. Néon plutôt qu'arc-en-ciel scolaire : on traverse
     cyan → bleu → violet → magenta → ambre, on NE passe PAS par le vert-jaune.
     C'est ce qui sépare une lumière d'enseigne d'une guirlande de Noël. */
  --gift-a: #2ff0d2;  --gift-b: #56a6ff;  --gift-c: #a86bff;
  --gift-d: #ff5aa4;  --gift-e: #ffb347;
  --gift-arc: var(--gift-a), var(--gift-b), var(--gift-c), var(--gift-d), var(--gift-e), var(--gift-a);
  /* La matière du spectre, thème par thème : sur fond sombre une teinte doit
     être claire pour briller, sur l'affiche acide elle doit être SOMBRE pour
     exister. Même dessin, autre matière — jamais l'un inversé de l'autre. */
  --dm-l: 62%;  --dm-s: 88%;  --dm-todo-a: .16;  --dm-done-a: .92;
  /* Le chemin qui RESTE a sa propre clarté : sur fond noir il suffit de diluer
     la teinte du jour, sur fond acide il faut l'assombrir (voir le thème clair
     plus bas), sinon les jours chauds de la fin virent à l'ocre et rejoignent
     le fond. Deux réglages, parce que ce sont deux problèmes différents. */
  --dm-todo-l: var(--dm-l);
  /* Le départ et l'amplitude du spectre, en UN seul endroit — la formule est
     reprise par la cellule, l'anneau, le numéro, le fanion et la ligne de
     palier, et cinq copies d'un même nombre finissent toujours par diverger.
     205° et pas 230 : à 230 le dernier jour tombe sur du rouge franc, et un
     rouge en bordure de la dernière ligne se lit comme une ERREUR, pas comme
     une récompense (vu au banc le 21/08). À 205 l'arrivée est un or orangé —
     la même chaleur, sans l'alarme. */
  --gift-h0: 168;  --gift-span: 205;
}
.daily-chip {
  position: relative; isolation: isolate;
  display: inline-flex; align-items: center; gap: 8px;
  /* Bordure TRANSPARENTE et pas absente : c'est elle qui réserve les 1,5 px que
     l'anneau conique vient peindre. Sans elle, l'anneau mangerait le texte. */
  padding: 5px 12px 5px 9px; border: 1.5px solid transparent; border-radius: 999px;
  /* Fond opaque (`--bg-1`, pas un color-mix transparent) : l'anneau et son halo
     passent DERRIÈRE la pastille, et un fond translucide les laissait
     transparaître au milieu du texte — la pastille clignotait de l'intérieur. */
  background: color-mix(in srgb, var(--gift-c) 10%, var(--bg-1, #0b0d10));
  color: var(--fg); font: 600 12.5px/1 var(--display, inherit); letter-spacing: .04em;
  cursor: pointer; transition: background 200ms var(--ease), transform 200ms var(--ease);
}
/* Le texte et le glyphe passent AU-DESSUS des deux couches de lumière. */
.daily-chip > span { position: relative; z-index: 2; }
/* Deux couches, un seul angle : le HALO flou et l'ANNEAU net tournent en phase,
   donc la lueur est toujours la couleur du liseré à cet endroit-là. Deux
   animations indépendantes se seraient déphasées en quelques secondes et la
   pastille aurait eu l'air mal imprimée. */
.daily-chip::before, .daily-chip::after {
  content: ''; position: absolute; border-radius: 999px; pointer-events: none;
  background: conic-gradient(from var(--dga), var(--gift-arc));
  animation: dg-spin 6s linear infinite;
}
/* Le halo. `blur` + `opacity` qui respire — c'est ce qui fait « néon » plutôt
   que « bordure colorée » : une enseigne éclaire ce qu'il y a autour d'elle. */
.daily-chip::before {
  inset: -7px; z-index: 0; filter: blur(9px); opacity: .5;
  animation: dg-spin 6s linear infinite, dg-glow 3.2s ease-in-out infinite;
}
/* L'anneau. Le masque garde la COURONNE et jette le centre : `content-box`
   (= l'intérieur de la bordure) est retiré du masque plein, il ne reste que
   l'épaisseur de bordure. C'est ce qui donne un liseré dégradé sur une pilule,
   qu'aucune propriété `border-image` ne sait faire avec un border-radius. */
.daily-chip::after {
  inset: 0; z-index: 1; padding: 1.5px;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
}
@keyframes dg-glow { 0%, 100% { opacity: .38; } 50% { opacity: .75; } }
/* Le balayage : une lumière traverse la pastille toutes les ~5 s. Rare et
   rapide, sinon l'œil l'apprend et cesse de la voir. Son conteneur porte le
   `overflow` — le mettre sur la pastille couperait l'anneau, qui déborde. */
.dc-shine {
  position: absolute; inset: 0; z-index: 1; border-radius: 999px;
  overflow: hidden; pointer-events: none;
}
/* 22 % de large et pas 40 : MESURÉ au banc le 21/08, une bande large traverse
   la pastille en la DÉLAVANT — pendant 200 ms le texte passe sous un voile
   blanc et la pastille a l'air désactivée. Un reflet est un trait, pas un
   rideau. L'inclinaison forte (115°) achève de le faire lire comme une arête
   de lumière qui glisse plutôt que comme un fondu. */
.dc-shine::before {
  content: ''; position: absolute; top: -50%; bottom: -50%; width: 22%; left: -40%;
  background: linear-gradient(115deg, transparent, rgb(255 255 255 / .42), transparent);
  animation: dc-sweep 5s cubic-bezier(.3,0,.2,1) infinite;
}
@keyframes dc-sweep { 0%, 68% { left: -40%; } 86%, 100% { left: 118%; } }
.daily-chip:hover { background: color-mix(in srgb, var(--gift-c) 22%, var(--bg-1, #0b0d10)); }
.daily-chip:active { transform: scale(.97); }
.daily-chip:hover::before { opacity: .85; }
.daily-chip b {
  margin-left: 7px; font-family: var(--mono);
  /* Le chiffre du cadeau prend la teinte DU JOUR (`--i` posé par daily.js) :
     la même valeur que la colonne correspondante de l'échelle. Ouvrir la
     fenêtre, c'est alors retrouver la couleur qu'on avait dans le bandeau. */
  color: hsl(calc((var(--gift-h0) + (var(--i, 0) / var(--n, 1)) * var(--gift-span)) * 1deg) var(--dm-s) var(--dm-l));
  text-shadow: 0 0 10px currentColor;
}
/* Deux variantes, et pas une de plus. `ghost` est l'affiche pour qui n'a pas de
   compte : elle vend un nombre, donc elle ne doit PAS crier plus fort que la
   vraie pastille de celui qui a un cadeau à prendre. `quiet` est le compteur de
   série une fois le cadeau du jour pris — ni accent ni losange qui respire :
   une pastille pleine qui reste toute la journée transforme un cadeau en
   reproche, une pastille sourde reste un compteur. */
/* `ghost` garde l'anneau — c'est l'affiche du visiteur sans compte, celui à qui
   « 23 images offertes » s'adresse le plus — mais elle tourne PLUS LENTEMENT et
   son halo est plus bas. Elle attire sans crier plus fort que la pastille de
   celui qui a vraiment un cadeau à prendre : la hiérarchie tient par le rythme,
   pas par la couleur (elles ont la même). */
.daily-chip.ghost { background: color-mix(in srgb, var(--gift-c) 7%, var(--bg-1, #0b0d10)); }
.daily-chip.ghost::before, .daily-chip.ghost::after { animation-duration: 10s; }
.daily-chip.ghost::before { opacity: .34; animation: dg-spin 10s linear infinite; }
.daily-chip.ghost::after { padding: 1px; }
.daily-chip.ghost .dc-shine::before { animation-duration: 8s; }
.daily-chip.ghost:hover { background: color-mix(in srgb, var(--gift-c) 16%, var(--bg-1, #0b0d10)); }
.daily-chip.ghost b {
  margin-left: 7px; font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase;
  color: var(--gift-a);
}
/* `quiet` — le cadeau du jour est PRIS. Toute la lumière s'éteint : anneau,
   halo, balayage. Une pastille qui continue de briller une fois le cadeau pris
   transforme un cadeau en reproche, et surtout elle apprend à l'œil que la
   lumière ne veut rien dire. Ici elle veut dire « il y a quelque chose à
   prendre » — donc elle doit disparaître quand il n'y a plus rien. */
.daily-chip.quiet {
  border-color: color-mix(in srgb, var(--fg) 22%, transparent);
  background: none; color: var(--fg-dim);
}
.daily-chip.quiet::before, .daily-chip.quiet::after, .daily-chip.quiet .dc-shine { display: none; }
.daily-chip.quiet:hover { background: color-mix(in srgb, var(--fg) 7%, transparent); color: var(--fg); }
.daily-chip.quiet .dc-gift {
  width: 7px; height: 7px; opacity: .55; animation: none;
  color: var(--fg-dim); filter: none;
}
.daily-chip.quiet b { color: var(--fg); text-shadow: none; }
/* L'étincelle. C'était un losange : à côté d'un anneau qui tourne, un losange
   est une puce de liste. Quatre branches concaves, ça se lit comme une étoile
   même à 11 px — et une étoile dit « cadeau » sans qu'on écrive le mot.
   `background: currentColor` n'est pas un détail de style : c'est ce qui permet
   au `drop-shadow` de suivre la teinte pendant qu'elle parcourt le spectre.
   Deux animations désynchronisées à dessein (2,8 s et 6 s) : leur battement
   commun ne revient qu'au bout de 42 s, donc l'œil ne trouve pas la boucle. */
.dc-gift {
  width: 11px; height: 11px; flex: none;
  color: var(--gift-a); background: currentColor;
  clip-path: polygon(50% 0, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0 50%, 39% 39%);
  filter: drop-shadow(0 0 5px currentColor);
  animation: dc-breathe 2.8s ease-in-out infinite, dc-hue 6s linear infinite;
}
@keyframes dc-breathe {
  0%, 100% { transform: scale(1) rotate(0deg); opacity: .85; }
  50%      { transform: scale(1.3) rotate(90deg); opacity: 1; }
}
@keyframes dc-hue {
  0%   { color: var(--gift-a); }
  20%  { color: var(--gift-b); }
  40%  { color: var(--gift-c); }
  60%  { color: var(--gift-d); }
  80%  { color: var(--gift-e); }
  100% { color: var(--gift-a); }
}

.daily-head { font-size: 14px; color: var(--fg); margin-bottom: 12px; }
.daily-head b { color: var(--fg); }
.daily-strip {
  display: flex; align-items: flex-end; gap: 5px;
  /* Bornée : étalée sur toute la largeur d'un panneau, une colonne fait 85 px
     et l'échelle se lit comme un tableau. À cette largeur elle redevient un
     rythme — c'est ce qu'on veut voir : des battements et une marche au milieu. */
  /* `fit-content` et pas 100 % : le SOL ci-dessous doit s'arrêter à la dernière
     colonne. Un trait qui continue dans le vide dessine un axe plus long que
     les données — l'échelle a l'air inachevée alors qu'elle est complète. */
  width: fit-content; max-width: min(100%, 480px);
  height: 44px; margin-bottom: 14px;
  /* Le SOL. Sans lui, des colonnes de hauteurs différentes flottent et l'œil
     n'a rien contre quoi les comparer — c'est ce qui fait qu'un graphique se
     lit comme un graphique et pas comme une rangée de pavés. */
  border-bottom: 1px solid color-mix(in srgb, var(--fg) 13%, transparent);
  padding-bottom: 3px;
}
.daily-strip .dm {
  position: relative;
  /* Largeur FIXE, pas `flex: 1`. Étalées, les colonnes deviennent des pavés de
     30 px et l'échelle se lit comme un calendrier ; à 14 px c'est une échelle,
     et la marche du 8e jour se voit d'un bout à l'autre. */
  /* Largeur FIXE (`0 0`, pas `0 1`) : sous `width: fit-content`, des colonnes
     rétrécissables voient leur contribution max-content tomber à leur
     `min-width` et l'échelle se réduit à un code-barres de 6 px — mesuré au
     banc le 21/08. Les écrans étroits sont traités par la requête média. */
  flex: 0 0 14px; height: var(--h, 50%); min-height: 6px;
  /* `column-reverse` : les cellules s'empilent depuis le BAS. Une deuxième
     image, c'est un étage de plus posé sur le premier — pas une colonne qui
     grandit par le haut sans qu'on sache d'où. */
  display: flex; flex-direction: column-reverse; gap: 3px;
  transition: height 420ms var(--ease);
}
/* La TEINTE DU JOUR, dérivée du rang que daily.js a posé (`--i` sur `--n`).
   168° = aqua au premier jour, +200° au dernier = ambre. Un seul calcul, réutilisé
   par la cellule, l'anneau, le numéro et le fanion — donc rien ne peut se
   désaccorder. Le `.dm` non renseigné retombe sur 168° : une échelle rendue par
   une version antérieure du JS reste cyan, elle ne devient pas grise. */
.daily-strip .dm { --hue: calc((var(--gift-h0) + (var(--i, 0) / var(--n, 1)) * var(--gift-span)) * 1deg); }
.daily-strip .dm s {
  flex: 1 1 0; min-height: 5px; text-decoration: none; border-radius: 2px;
  /* Les jours PAS ENCORE gagnés portent déjà leur couleur, à 16 % : la bande
     montre le chemin qui reste dans la teinte qu'il aura. C'est ce qu'une
     rangée grise ne peut pas dire — « continue » au lieu de « pas encore ».
     (PAS `--line` : en thème clair c'est du noir pur, et les jours à gagner
     devenaient les marques les plus fortes de la bande — vu à l'œil le 21/08.) */
  background: hsl(var(--hue) var(--dm-s) var(--dm-todo-l) / var(--dm-todo-a));
  transition: background 300ms var(--ease), box-shadow 300ms var(--ease);
}
/* Gagné : la couleur s'allume, et elle ÉCLAIRE. Le halo est ce qui sépare « une
   case remplie » de « une lumière allumée » — sans lui la bande gagnée a l'air
   imprimée, avec lui elle a l'air branchée. */
.daily-strip .dm.done s {
  background: hsl(var(--hue) var(--dm-s) var(--dm-l) / var(--dm-done-a));
  box-shadow: 0 0 7px hsl(var(--hue) var(--dm-s) var(--dm-l) / .45);
  /* L'ONDE. Une lumière remonte les jours gagnés, un jour après l'autre, et
     s'arrête net là où la série s'arrête. Ce n'est pas du scintillement : le
     retard de chaque colonne est son RANG (`--i` × 110 ms), donc la vague ne
     parcourt QUE ce qu'on a gagné et sa longueur EST la série. Une série de
     trois jours fait un éclair, une série de douze fait une remontée — la même
     information que le compteur, mais dans le temps, où l'œil la lit sans lire.
     8 s de cycle : assez rare pour rester un événement. */
  animation: dm-wave 8s ease-in-out infinite;
  animation-delay: calc(var(--i, 0) * 110ms);
}
@keyframes dm-wave {
  0%, 82%, 100% { filter: none; }
  88% {
    filter: brightness(1.55)
            drop-shadow(0 0 8px hsl(var(--hue) var(--dm-s) var(--dm-l) / .9));
  }
}
.daily-strip .dm.now  s {
  background: hsl(var(--hue) var(--dm-s) var(--dm-l));
  box-shadow: 0 0 12px hsl(var(--hue) var(--dm-s) var(--dm-l) / .8);
}
.daily-strip .dm.now { animation: dm-now 2.4s ease-in-out infinite; border-radius: 3px; }
@keyframes dm-now {
  0%, 100% { box-shadow: 0 0 0 0 hsl(var(--hue) var(--dm-s) var(--dm-l) / .5); }
  50%      { box-shadow: 0 0 0 5px hsl(var(--hue) var(--dm-s) var(--dm-l) / 0); }
}
/* La colonne de DEMAIN et sa jauge d'attente : la part de la journée déjà
   écoulée, montée depuis le bas, retouchée toutes les 30 s. C'est ce qui
   remplace « revenez demain » — une phrase morte par une chose qui monte. */
.daily-strip .dm.next s { background: hsl(var(--hue) var(--dm-s) var(--dm-l) / .3); }
.daily-strip .dm.next::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: 0;
  height: var(--wait, 0%); border-radius: 2px; pointer-events: none;
  background: linear-gradient(to top, hsl(var(--hue) var(--dm-s) var(--dm-l) / .38), transparent);
  border-top: 1px solid hsl(var(--hue) var(--dm-s) var(--dm-l) / .75);
  box-shadow: 0 0 8px hsl(var(--hue) var(--dm-s) var(--dm-l) / .3);
  transition: height 900ms linear;
}
/* La marche entre deux paliers : un espace et une frontière, pas une légende. */
.daily-strip .dm.step { margin-left: 13px; }
.daily-strip .dm.step::before {
  content: ''; position: absolute; top: -10px; bottom: -4px; left: -7px;
  border-left: 1px dashed hsl(var(--hue) var(--dm-s) var(--dm-l) / .5);
}
/* Le fanion du palier — `×2`, `×3`, `+1` : DÉRIVÉ des valeurs reçues, jamais
   écrit. Réservé à la grande échelle : au format du portefeuille il tomberait
   sur le texte au-dessus. */
.daily-strip .dm u {
  position: absolute; left: -6px; bottom: calc(100% + 8px);
  font: 700 10px/1 var(--mono); letter-spacing: .06em; text-decoration: none;
  color: hsl(var(--hue) var(--dm-s) var(--dm-l)); white-space: nowrap;
  text-shadow: 0 0 8px hsl(var(--hue) var(--dm-s) var(--dm-l) / .6);
}
/* La grande échelle, dans la fenêtre : plus haute, plus aérée, et sans borne de
   largeur — c'est le sujet du panneau, pas une ligne d'un panneau. */
.daily-strip.big { height: 88px; gap: 8px; max-width: 100%; margin: 0 auto; }
.daily-strip.big .dm { flex: 0 0 20px; min-height: 10px; gap: 4px; }
.daily-strip.big .dm s { min-height: 9px; border-radius: 3px; }
/* Les numéros de jour, sous la grande échelle seulement. Dans le portefeuille
   ils feraient une deuxième ligne de texte sous une bande de 44 px de haut ;
   ici ils sont ce qui transforme « la 9e colonne » en « le jour 9 ». */
.daily-strip.big .dm b {
  position: absolute; top: calc(100% + 8px); left: -3px; right: -3px;
  text-align: center; font: 600 9px/1 var(--mono); color: var(--fg-faint);
}
.daily-strip.big .dm.done b { color: hsl(var(--hue) 45% var(--dm-l) / .8); }
.daily-strip.big .dm.now  b,
.daily-strip.big .dm.next b { color: hsl(var(--hue) var(--dm-s) var(--dm-l)); }

.daily-foot { display: flex; align-items: center; justify-content: space-between; gap: 14px; }

/* ── La fenêtre du cadeau ─────────────────────────────────────────────────── */
.dg-corps { color: var(--fg); }
.dg-lede { margin: 0 0 20px; font-size: 14.5px; line-height: 1.55; color: var(--fg-dim); }
.dg-lede b { color: var(--fg); }
.dg-ladder {
  position: relative; isolation: isolate;
  margin: 0 0 22px; padding: 28px 18px 30px;
  border: 1.5px solid transparent; border-radius: 10px;
  background: color-mix(in srgb, var(--gift-c) 6%, var(--bg-1, transparent));
}
/* Le même anneau que la pastille, à 22 s au lieu de 6 : ouvrir la fenêtre, ce
   n'est pas changer d'objet, c'est s'approcher du même. À cette taille la
   vitesse de la pastille donnerait le tournis — la lenteur, elle, se remarque
   à peine et suffit à dire que le cadre est vivant. Pas de halo ici : un
   panneau de 600 px qui rayonne éclairerait le texte qu'il contient. */
.dg-ladder::before {
  content: ''; position: absolute; inset: 0; z-index: 0; border-radius: inherit;
  padding: 1.5px; pointer-events: none; opacity: .72;
  background: conic-gradient(from var(--dga), var(--gift-arc));
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  animation: dg-spin 22s linear infinite;
}
.dg-ladder > * { position: relative; z-index: 1; }
/* Les paliers, une ligne chacun. Le total du palier est à DROITE, en chiffres
   d'or : c'est la seule colonne que l'œil descend, et elle grossit vers le bas
   — l'argument « reste, ça vaut plus » est dans la colonne, pas dans la phrase. */
.dg-rungs { list-style: none; margin: 0 0 20px; padding: 0; display: grid; gap: 8px; }
.dg-rungs li {
  display: flex; align-items: baseline; gap: 12px; padding: 10px 14px;
  border: 1px solid var(--line); border-radius: 8px;
}
/* Le palier prend la teinte de son DERNIER jour : la ligne « Days 8–15 » est
   donc ambre comme les colonnes qu'elle décrit, et la ligne « Days 1–7 » aqua.
   Les deux surfaces de la fenêtre parlent alors la même langue de couleur —
   sans ça l'échelle serait un arc-en-ciel posé au-dessus d'une liste grise. */
.dg-rungs li { --hue: calc((var(--gift-h0) + (var(--i, 0) / var(--n, 1)) * var(--gift-span)) * 1deg); }
.dg-rungs li.up { border-color: hsl(var(--hue) var(--dm-s) var(--dm-l) / .55); }
.dg-rungs li.at {
  box-shadow: inset 3px 0 0 hsl(var(--hue) var(--dm-s) var(--dm-l)),
              0 0 14px hsl(var(--hue) var(--dm-s) var(--dm-l) / .18);
}
.dg-rungs b { font: 600 13px/1.2 var(--display); letter-spacing: .04em; }
.dg-rungs span { font-size: 13px; color: var(--fg-dim); }
.dg-rungs em {
  margin-left: auto; font: 700 13px/1 var(--mono); font-style: normal;
  color: hsl(var(--hue) var(--dm-s) var(--dm-l));
  text-shadow: 0 0 10px hsl(var(--hue) var(--dm-s) var(--dm-l) / .45);
}
/* L'affiche du bandeau disparaît sous 780 px, et seulement elle. MESURÉ au banc
   le 21/08 dans un cadre de 390 px : sur la page d'accueil elle pousse « Sign
   in » et « Start free » sur deux lignes chacun — on abîme la porte d'entrée
   pour vendre le cadeau qui est derrière. La vraie pastille (« Day 3 gift +1 »,
   125 px) tient, elle, sans changer la hauteur du bandeau du studio : elle reste. */
@media (max-width: 780px) { .daily-chip.ghost { display: none; } }

/* ── Le spectre en thème clair : de la SÉRIGRAPHIE, pas du néon ──────────────
   Le thème clair est un aplat jaune acide traversé d'encre noire pure. Un néon
   y est physiquement impossible : une lueur floue sur du #fcee0a ne fait pas
   briller, elle SALIT — le halo se mélange au jaune et donne un cerne moutarde
   (exactement le défaut que le passage à l'aplat intégral avait supprimé
   partout ailleurs).

   Donc on ne transpose pas l'effet, on transpose l'INTENTION. Les mêmes cinq
   teintes, brûlées à 38 % de clarté : un teal profond, un violet d'encre, un
   magenta soutenu sur jaune acide, c'est la palette d'une affiche sérigraphiée
   — ça claque plus fort ici que le néon ne claque dans le noir. Les halos
   disparaissent, l'aplat les remplace. Même dessin, autre matière : le clair
   n'est jamais le sombre inversé. */
[data-theme="light"] {
  --gift-a: #007a6b;  --gift-b: #1153c4;  --gift-c: #6b1fbf;
  --gift-d: #c2116b;  --gift-e: #a35a00;
  /* Assez sombre pour tenir sur l'acide, assez saturé pour rester le spectre. */
  --dm-l: 34%;  --dm-s: 92%;  --dm-done-a: 1;
  /* Les jours pas encore gagnés : MESURÉ au banc le 21/08, à 26 % d'une teinte
     de 34 % les colonnes chaudes de la fin (11→15) se composaient avec le jaune
     en un ocre indiscernable du fond — le chemin qui reste disparaissait, c'est
     à dire exactement l'argument de l'écran. Sur l'acide, un ton pâle n'est pas
     « plus discret », il est ABSENT : on descend donc la teinte à 18 % de
     clarté et on remonte l'opacité. Le résultat est de l'encre teintée — le
     jour à venir garde sa couleur, dans la matière du poster. */
  --dm-todo-l: 18%;  --dm-todo-a: .46;
}
/* Aucune lueur : ni autour des cellules, ni sous les chiffres, ni sous le
   fanion. Sur l'acide, chacune de ces ombres est un cerne. */
[data-theme="light"] .daily-strip .dm.done s,
[data-theme="light"] .daily-strip .dm.now s,
[data-theme="light"] .daily-strip .dm.next::after { box-shadow: none; }
/* La colonne de DEMAIN doit rester lisible entre les jours gagnés (pleins) et
   les jours à venir (encre) : même traitement que ceux-ci, un cran plus haut,
   sinon à .3 elle vire au saumon et se confond avec l'acide (vu au banc). */
[data-theme="light"] .daily-strip .dm.next s { background: hsl(var(--hue) var(--dm-s) 22% / .58); }
/* L'onde existe aussi sur l'affiche, mais elle n'y est pas faite de lumière :
   `brightness()` sur une encre sombre posée sur du jaune la DÉTEINT vers le
   fond — l'inverse du geste. Ici la vague est une SATURATION et un décalage :
   la colonne mord un peu plus fort dans le papier au passage. */
[data-theme="light"] .daily-strip .dm.done s { animation-name: dm-wave-ink; }
@keyframes dm-wave-ink {
  0%, 82%, 100% { filter: none; transform: none; }
  88% { filter: saturate(1.6) contrast(1.25); transform: translateY(-2px); }
}
[data-theme="light"] .daily-strip .dm u,
[data-theme="light"] .dg-rungs em,
[data-theme="light"] .daily-chip b { text-shadow: none; }
[data-theme="light"] .daily-strip { border-bottom-color: #000; }
[data-theme="light"] .daily-strip .dm.step::before { border-left-color: rgb(0 0 0 / 0.5); }
/* L'anneau qui bat garde son geste mais devient de l'ENCRE : sur l'acide, une
   onde colorée qui s'évanouit est invisible, une onde noire est un tampon. */
[data-theme="light"] .daily-strip .dm.now { animation-name: dm-now-ink; }
@keyframes dm-now-ink {
  0%, 100% { box-shadow: 0 0 0 0 rgb(0 0 0 / .55); }
  50%      { box-shadow: 0 0 0 5px rgb(0 0 0 / 0); }
}
/* La pastille : le halo flou saute, l'anneau conique reste (il est net, donc il
   est de l'encre), et le relief revient par l'ombre DURE du poster — le même
   device que tout le reste du thème clair, pas un cinquième jaune. */
[data-theme="light"] .daily-chip,
[data-theme="light"] .daily-chip.ghost { background: var(--bg-1); box-shadow: 3px 3px 0 #000; }
[data-theme="light"] .daily-chip:hover,
[data-theme="light"] .daily-chip.ghost:hover { background: #fff; }
[data-theme="light"] .daily-chip:active { box-shadow: 1px 1px 0 #000; }
[data-theme="light"] .daily-chip::before { display: none; }
[data-theme="light"] .daily-chip::after { padding: 2px; }
[data-theme="light"] .dc-gift { filter: none; }
/* Le balayage est un éclaircissement : sur l'acide il passe en blanc franc et
   court, sinon on ne le distingue pas du fond. */
[data-theme="light"] .dc-shine::before {
  background: linear-gradient(100deg, transparent, rgb(255 255 255 / .8), transparent);
}
[data-theme="light"] .dg-ladder { background: rgb(0 0 0 / 0.04); }
[data-theme="light"] .dg-ladder::before { opacity: 1; }
[data-theme="light"] .dg-rungs li.at { box-shadow: inset 3px 0 0 hsl(var(--hue) var(--dm-s) var(--dm-l)); }
[data-theme="light"] .daily-chip.quiet { border-color: rgb(0 0 0 / 0.45); box-shadow: none; }
[data-theme="light"] .daily-chip.quiet:hover { box-shadow: none; }
[data-theme="light"] .daily-fly { filter: none; }

/* Sous 520 px, l'échelle large déborderait du panneau (15 × 20 px + gouttières
   = 425 px pour ~290 px disponibles) : on rétrécit la colonne, on ne coupe pas
   l'échelle — un programme de 15 jours dont on ne voit que 11 ne veut rien dire. */
@media (max-width: 520px) {
  .daily-strip.big { height: 74px; gap: 5px; }
  .daily-strip.big .dm { flex-basis: 12px; }
  .daily-strip.big .dm.step { margin-left: 9px; }
  .daily-strip { gap: 4px; }
  .daily-strip .dm { flex-basis: 12px; }
  .daily-strip .dm.step { margin-left: 9px; }
}

.dg-cta { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin: 0 0 14px; }
.dg-wait { display: inline-flex; align-items: center; gap: 10px; font-size: 13.5px; color: var(--fg-dim); }
.dg-wait b { color: var(--fg); font-family: var(--mono); }
.dg-wait i {
  width: 9px; height: 9px; flex: none; border-radius: 50%;
  border: 1px solid color-mix(in srgb, var(--gift-b) 75%, transparent);
  animation: dg-tick 2.6s ease-in-out infinite;
}
@keyframes dg-tick {
  0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--gift-b) 45%, transparent); }
  50%      { box-shadow: 0 0 0 5px color-mix(in srgb, var(--gift-b) 0%, transparent); }
}
.dg-fine { margin: 0; }

/* Le crédit en vol. `fixed` parce qu'il traverse des conteneurs qui ont chacun
   leur propre débordement — un absolute serait coupé par le premier panneau. */
/* Le crédit en vol prend le spectre entier : c'est LE moment du cadeau, le seul
   endroit où l'on a le droit de tout montrer d'un coup. `background-clip: text`
   plutôt qu'une couleur — un chiffre qui traverse l'écran en dégradé se lit
   comme un objet, pas comme un texte qui bouge. Le repli `color` est là pour un
   moteur qui ne clippe pas : on perd le dégradé, pas le chiffre.
   `fixed` parce qu'il traverse des conteneurs qui ont chacun leur propre
   débordement — un absolute serait coupé par le premier panneau. */
.daily-fly {
  position: fixed; z-index: 9999; pointer-events: none;
  font: 700 22px/1 var(--mono); color: var(--gift-a);
  background: linear-gradient(100deg, var(--gift-a), var(--gift-c), var(--gift-e));
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent;
  filter: drop-shadow(0 0 10px color-mix(in srgb, var(--gift-c) 65%, transparent));
}

/* Mouvement coupé, LUMIÈRE gardée. Le spectre n'est pas de l'animation : il
   porte le rang du jour, donc le retirer retirerait une information. Ce qui
   s'arrête, c'est ce qui bouge — la rotation, le battement, le balayage. */
@media (prefers-reduced-motion: reduce) {
  .dc-gift, .daily-strip .dm.now, .daily-strip .dm.done s, .dg-wait i,
  .daily-chip::before, .daily-chip::after, .dg-ladder::before { animation: none; }
  .dc-shine { display: none; }
  .daily-chip::before { opacity: .45; }
  .daily-strip .dm, .daily-strip .dm s, .daily-strip .dm.next::after { transition: none; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   LE CADEAU SURPRISE — la seule fenêtre qui s'ouvre toute seule
   ───────────────────────────────────────────────────────────────────────────
   Elle est bruyante, et c'est délibéré : tout le reste du site est sobre, donc
   la seule chose qui arrive sans qu'on l'ait demandée doit être immédiatement
   reconnue comme un cadeau et pas comme un bandeau de cookies. Le spectre est
   celui du cadeau du jour (`--gift-a … --gift-e`) — le cadeau est déjà la seule
   exception à la palette de marque, une deuxième exception n'en serait plus une.

   Trois couches, trois rôles, et aucune ne fait le travail d'une autre :
     · le VOILE assombrit et coupe le fond (c'est lui qui rend le néon lisible) ;
     · l'AURORE derrière la capsule est la couleur EN MOUVEMENT, floue, lente ;
     · le HALO de la capsule est un dégradé conique qui TOURNE — c'est le seul
       bord net coloré, donc le seul endroit où l'œil se pose.
   Une seule de ces couches ferait soit un aplat, soit une bouillie.

   Ce qui coûte : deux `filter: blur()` sur trois taches et une rotation de
   dégradé conique. Tout est porté par le compositeur (transform/opacity), le
   fil principal ne fait rien après l'insertion. Pas de `backdrop-filter` sur la
   capsule — mesuré ailleurs dans ce fichier, c'est le seul effet qui fasse
   ramer un portable quand il couvre une grande surface.
   ═══════════════════════════════════════════════════════════════════════════ */
html.gdrop-on { overflow: hidden; }        /* le fond ne défile pas sous la fenêtre */

.gdrop {
  position: fixed; inset: 0; z-index: 9998;
  display: grid; place-items: center;
  opacity: 0; transition: opacity .34s ease;
  /* La position de la source lumineuse, en fraction de la capsule. Posée par
     js/gift.js à chaque image ; ces valeurs ne servent que si le JS n'a pas
     encore tourné ou si le mouvement est coupé. */
  --mx: .5; --my: .38;
}
.gdrop.in { opacity: 1; }

.gdrop-veil {
  position: absolute; inset: 0;
  background: radial-gradient(120% 100% at 50% 42%,
              color-mix(in srgb, var(--gift-c) 9%, #04050a) 0%, #04050a 66%);
  opacity: .96;
}
[data-theme="light"] .gdrop-veil {
  background: radial-gradient(120% 100% at 50% 40%,
              color-mix(in srgb, var(--gift-c) 20%, #f4f5f8) 0%, rgba(232,234,240,.97) 72%);
  opacity: .97;
}

/* L'aurore. Trois taches très floues qui dérivent — la couleur bouge sans
   qu'aucune forme ne soit lisible, donc elle ne concurrence pas la capsule. */
/* Le décalage suit la lumière : quand le reflet part à droite sur la capsule,
   le fond part à gauche. C'est ce contre-mouvement qui donne la profondeur —
   deux plans qui bougent ensemble seraient une seule image qui glisse. */
.gdrop-aur {
  position: absolute; inset: 0; overflow: hidden; pointer-events: none;
  translate: calc((var(--mx) - .5) * -30px) calc((var(--my) - .5) * -20px);
}
.gdrop-aur i {
  position: absolute; width: 46vmax; height: 46vmax; border-radius: 50%;
  filter: blur(90px); opacity: .3; mix-blend-mode: screen;
  animation: gdrop-drift 17s ease-in-out infinite alternate;
}
.gdrop-aur i:nth-child(1) { background: var(--gift-a); left: -12vmax; top: -10vmax; }
.gdrop-aur i:nth-child(2) { background: var(--gift-d); right: -14vmax; top: 8vmax;  animation-duration: 21s; animation-delay: -6s; }
.gdrop-aur i:nth-child(3) { background: var(--gift-b); left: 22vmax; bottom: -18vmax; animation-duration: 25s; animation-delay: -11s; }
[data-theme="light"] .gdrop-aur i { opacity: .3; mix-blend-mode: multiply; }
@keyframes gdrop-drift {
  from { transform: translate3d(0,0,0) scale(1); }
  to   { transform: translate3d(6vmax,-4vmax,0) scale(1.18); }
}

/* La capsule. Elle ENTRE par un coin tiré de l'identifiant du cadeau
   (`--in-x` / `--in-y`, posés en JS) : deux cadeaux d'affilée n'arrivent jamais
   du même endroit, ce qui est la seule chose qui empêche l'œil d'apprendre
   l'animation par cœur. */
.gdrop-box {
  position: relative; z-index: 1;
  width: min(92vw, 420px); padding: 30px 26px 22px;
  border-radius: 22px; text-align: center;
  background: color-mix(in srgb, var(--gift-c) 8%, var(--bg-1, #0b0d10));
  border: 1px solid color-mix(in srgb, var(--gift-c) 34%, transparent);
  box-shadow: 0 30px 90px -20px color-mix(in srgb, var(--gift-c) 55%, transparent),
              0 0 0 1px rgba(255,255,255,.04) inset;
  transform: translate3d(var(--in-x, 0), var(--in-y, 20vh), 0) scale(.86) rotate(calc(var(--spin, 1) * -3deg));
  opacity: 0;
  transition: transform .62s cubic-bezier(.16,.9,.24,1), opacity .4s ease;
}
.gdrop.in .gdrop-box { transform: none; opacity: 1; }
/* Le reflet. `z-index: -1` + `isolation` : il passe AU-DESSUS du fond de la
   capsule et SOUS le texte. Un pseudo-élément positionné sans z-index se serait
   posé par-dessus le titre et le bouton — l'ordre de peinture met les éléments
   positionnés après le contenu en flux, quelle que soit la place dans le HTML. */
.gdrop-box { isolation: isolate; }
.gdrop-box::before {
  content: ''; position: absolute; inset: 0; z-index: -1; border-radius: 22px;
  pointer-events: none; mix-blend-mode: screen;
  background:
    radial-gradient(130px 110px at calc(var(--mx) * 100%) calc(var(--my) * 100%),
      color-mix(in srgb, var(--gift-c) 58%, transparent) 0%, transparent 72%),
    radial-gradient(360px 300px at calc(var(--mx) * 100%) calc(var(--my) * 100%),
      color-mix(in srgb, var(--gift-a) 34%, transparent) 0%, transparent 70%);
}
/* Sur l'acide, `screen` ne fait rien (le fond est déjà clair) : la lumière s'y
   lit comme une TEINTE qui se pose, donc `multiply` et une tache plus serrée. */
[data-theme="light"] .gdrop-box::before {
  mix-blend-mode: multiply;
  background: radial-gradient(240px 200px at calc(var(--mx) * 100%) calc(var(--my) * 100%),
              color-mix(in srgb, var(--gift-a) 22%, transparent) 0%, transparent 72%);
}
[data-theme="light"] .gdrop-box {
  background: color-mix(in srgb, var(--gift-c) 7%, #fff);
  box-shadow: 0 30px 80px -24px color-mix(in srgb, var(--gift-c) 45%, transparent);
}

/* Le halo conique. C'est un anneau, pas un fond : `mask` évide le centre, sinon
   la capsule perd son texte sous le dégradé.
   ⚠ CE QUI TOURNE EST L'ANGLE DU DÉGRADÉ, PAS L'ÉLÉMENT. Vu à l'œil le 24/08 :
   un `transform: rotate()` sur la bordure fait tourner le RECTANGLE, et on
   obtient un grand losange arc-en-ciel qui balaie la page — spectaculaire et
   complètement faux. La rotation d'un `from <angle>` est une propriété
   enregistrée (`--dga`, déjà déclarée plus haut pour la pastille du cadeau du
   jour) : la boîte ne bouge pas, seule la lumière fait le tour du cadre.
   Il accélère une fois à l'ouverture (`.opened`) — l'accélération EST la
   récompense, pas un effet permanent qu'on cesserait de voir. */
.gdrop-halo {
  /* Il COUVRE la capsule entière — sans ça il avale le clic du bouton, ce qui
     donne exactement le bug vu le 24/08 : « Open it » ne réagit pas et rien
     dans la console ne le dit, parce qu'il n'y a pas d'erreur, juste un
     élément par-dessus. */
  position: absolute; inset: -1px; border-radius: 23px; padding: 1.5px;
  pointer-events: none;
  background: conic-gradient(from var(--dga), var(--gift-arc));
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude;
  filter: saturate(1.25) drop-shadow(0 0 12px color-mix(in srgb, var(--gift-c) 55%, transparent));
  animation: dg-spin 9s linear infinite;
}
/* Le sens de rotation est une CLASSE et pas un sélecteur sur la variable :
   `[style*="--spin:-1"]` a l'air malin mais le navigateur normalise la valeur
   d'une propriété personnalisée (« --spin: -1 », avec l'espace), donc le
   sélecteur ne mord jamais. */
.gdrop.ccw .gdrop-halo { animation-direction: reverse; }
.gdrop.opened .gdrop-halo { animation-duration: 1.6s; }

.gdrop-x {
  position: absolute; top: 10px; right: 12px; z-index: 2;
  background: none; border: 0; cursor: pointer; font-size: 15px; line-height: 1;
  color: var(--fg-3, #7b8391); padding: 6px; border-radius: 8px;
}
.gdrop-x:hover { color: var(--fg-1, #e6e9ef); }

.gdrop-kicker {
  margin: 0 0 14px; font-size: 13px; letter-spacing: .06em; text-transform: uppercase;
  color: color-mix(in srgb, var(--gift-a) 78%, var(--fg-2, #9aa3b2));
}

/* Le sceau. Le montant existe dès l'ouverture de la fenêtre (il faut sa place
   pour que rien ne saute au moment de la révélation) mais il est COUVERT par la
   feuille. Ce n'est pas un `display:none` remplacé : c'est un objet qui se
   déchire, donc la place ne bouge pas d'un pixel. */
.gdrop-seal { position: relative; height: 96px; display: grid; place-items: center; }

.gdrop-amount {
  font: 800 clamp(38px, 11vw, 56px)/1 var(--mono);
  background: linear-gradient(100deg, var(--gift-a), var(--gift-c), var(--gift-e));
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: var(--gift-a);
  filter: drop-shadow(0 0 18px color-mix(in srgb, var(--gift-c) 55%, transparent));
}
[data-theme="light"] .gdrop-amount { filter: none; }

/* La feuille : un balayage qui passe et repasse — « il y a quelque chose
   dessous ». Elle ne cache pas par de l'opacité, elle cache par du BRUIT
   coloré, ce qui donne envie de l'enlever plutôt que d'attendre.
   Le « ? » est SUR la feuille et non dessous : vu à l'œil le 24/08, un aplat de
   couleur sans glyphe se lit comme un nuancier — rien ne dit qu'il y a quelque
   chose derrière, donc rien ne donne envie de l'ouvrir. */
.gdrop-foil {
  position: absolute; inset: 8px 26px; border-radius: 14px;
  display: grid; place-items: center; pointer-events: none;
  font: 800 34px/1 var(--mono); color: rgba(6,7,10,.62);
  background:
    linear-gradient(115deg, transparent 30%, rgba(255,255,255,.5) 50%, transparent 70%),
    conic-gradient(from 210deg, var(--gift-arc));
  background-size: 260% 100%, 100% 100%;
  box-shadow: 0 8px 26px -10px color-mix(in srgb, var(--gift-c) 80%, transparent),
              0 0 0 1px rgba(255,255,255,.22) inset;
  filter: saturate(.92);
  animation: gdrop-sheen 2.6s ease-in-out infinite;
  transition: clip-path .55s cubic-bezier(.2,.85,.2,1), opacity .5s ease .18s, transform .55s ease;
  clip-path: inset(0 0 0 0);
}
@keyframes gdrop-sheen { 0%,100% { background-position: 130% 0, 0 0; } 50% { background-position: -30% 0, 0 0; } }
/* La déchirure : la feuille s'ouvre EN DEUX depuis le milieu, elle ne s'efface
   pas. Un fondu aurait dit « le voile se lève », une découpe dit « ça s'est
   ouvert » — et c'est le mot juste pour un cadeau. */
.gdrop.opened .gdrop-foil { clip-path: inset(0 50% 0 50%); opacity: 0; transform: scaleY(1.1); }

/* ── Le « ? » vivant ─────────────────────────────────────────────────────────
   Un glyphe immobile sur une feuille qui brille, c'est une étiquette. Ce qu'on
   veut, c'est quelque chose qui ATTEND derrière — donc il respire, il se
   balance, et de temps en temps il sursaute.
   Trois mouvements sur TROIS PROPRIÉTÉS distinctes (`translate`, `rotate`,
   `scale`) et non trois `transform` : deux animations qui visent la même
   propriété ne se composent pas, la dernière déclarée écrase les autres — on
   n'aurait vu qu'un tiers du geste. Les périodes (2,9 / 4,3 / 7,1 s) sont
   premières entre elles : la figure complète ne se répète qu'au bout de ~90 s,
   assez pour que l'œil ne l'apprenne jamais. */
.gdrop-q {
  display: block; font: inherit; font-style: normal; font-size: 42px;
  animation: gq-bob 2.9s ease-in-out infinite,
             gq-tilt 4.3s ease-in-out infinite,
             gq-beat 7.1s ease-in-out infinite,
             gq-glow 3.7s ease-in-out infinite;
  will-change: translate, rotate, scale;
}
@keyframes gq-bob  { 0%,100% { translate: 0 -3px; } 50% { translate: 0 3px; } }
@keyframes gq-tilt { 0%,100% { rotate: -7deg; }    50% { rotate: 8deg; } }
/* Le sursaut : 90 % du temps il ne fait rien, puis il tressaille. C'est le
   silence avant qui le rend vivant — un pouls régulier serait un métronome. */
@keyframes gq-beat { 0%,88%,100% { scale: 1; } 92% { scale: 1.22; } 96% { scale: .93; } }
/* Le halo respire en décalé du reste (3,7 s) : c'est ce qui empêche de lire le
   « ? » comme un seul objet qui pulse — une partie de lui vit à un autre rythme. */
@keyframes gq-glow {
  0%,100% { text-shadow: 0 0 6px rgba(255,255,255,.25), 0 1px 0 rgba(255,255,255,.35); }
  50%     { text-shadow: 0 0 22px rgba(255,255,255,.95), 0 0 40px rgba(255,255,255,.5), 0 1px 0 rgba(255,255,255,.35); }
}

/* ── Le flare ────────────────────────────────────────────────────────────────
   Un point chaud traverse le sceau et traîne derrière lui un trait horizontal :
   c'est le reflet anamorphique des optiques de cinéma, la signature visuelle de
   « il y a une lumière très forte hors champ ». Il passe toutes les ~7 s, il ne
   reste pas — une lueur permanente devient un décor, un passage reste un
   événement. `screen` pour qu'il AJOUTE de la lumière au lieu de couvrir.
   À l'ouverture, le même trait explose (`.opened`) : la lumière hors champ
   vient de rentrer dans la pièce. */
.gdrop-flare {
  position: absolute; inset: 0; z-index: 3; pointer-events: none;
  mix-blend-mode: screen;
  translate: calc((var(--mx) - .5) * 18px) 0;
}
.gdrop-flare b, .gdrop-flare u, .gdrop-flare s {
  position: absolute; left: 50%; top: 50%; opacity: 0;
}
.gdrop-flare b {
  width: 92%; height: 2px; translate: -50% -50%; rotate: -1.6deg;
  background: linear-gradient(90deg, transparent 0%,
              color-mix(in srgb, var(--gift-a) 70%, #fff) 26%, #fff 50%,
              color-mix(in srgb, var(--gift-e) 70%, #fff) 74%, transparent 100%);
  filter: blur(1.1px) drop-shadow(0 0 10px color-mix(in srgb, var(--gift-c) 80%, transparent));
  animation: gdrop-bar 7.3s ease-in-out infinite;
}
.gdrop-flare u {
  width: 30px; height: 30px; border-radius: 50%;
  background: radial-gradient(circle, #fff 0%,
              color-mix(in srgb, var(--gift-c) 85%, transparent) 42%, transparent 70%);
  filter: blur(2px);
  animation: gdrop-hot 7.3s ease-in-out infinite;
}
@keyframes gdrop-bar {
  0%,16% { opacity: 0; scale: .35 1; }
  38%    { opacity: .95; scale: 1 1; }
  62%    { opacity: .95; scale: 1 1; }
  84%,100% { opacity: 0; scale: .35 1; }
}
@keyframes gdrop-hot {
  0%   { translate: calc(-50% - 150px) calc(-50% + 7px); opacity: 0; }
  20%  { opacity: .9; }
  80%  { opacity: .9; }
  100% { translate: calc(-50% + 150px) calc(-50% - 7px); opacity: 0; }
}

/* Les fantômes d'iris. C'est LE détail qui fait « objectif de cinéma » et pas
   « barre blanche » : dans une vraie optique, la source se réfléchit entre les
   lentilles et sème une chaîne de disques colorés — et cette chaîne se déplace
   dans le SENS INVERSE de la source, parce qu'elle est son image par le centre
   du groupe optique. Trois disques suffisent ; au-delà on ne lit plus une
   chaîne, on lit un collier. */
.gdrop-flare s {
  width: 150px; height: 30px; translate: -50% -50%; text-decoration: none;
  background:
    radial-gradient(circle 8px  at 10% 30%, color-mix(in srgb, var(--gift-a) 95%, #fff) 0%, color-mix(in srgb, var(--gift-a) 60%, transparent) 55%, transparent 72%),
    radial-gradient(circle 15px at 48% 70%, color-mix(in srgb, var(--gift-d) 85%, #fff) 0%, color-mix(in srgb, var(--gift-d) 45%, transparent) 60%, transparent 74%),
    radial-gradient(circle 6px  at 86% 24%, color-mix(in srgb, var(--gift-e) 95%, #fff) 0%, transparent 72%);
  filter: blur(.6px) saturate(1.3);
  animation: gdrop-ghost 7.3s ease-in-out infinite;
}
@keyframes gdrop-ghost {
  0%   { translate: calc(-50% + 120px) -50%; opacity: 0; }
  28%  { opacity: .85; }
  72%  { opacity: .85; }
  100% { translate: calc(-50% - 120px) -50%; opacity: 0; }
}
.gdrop.opened .gdrop-flare s { animation: gdrop-ghostblast .9s ease-out forwards; }
@keyframes gdrop-ghostblast {
  0%   { translate: -50% -50%; opacity: .9; scale: .4; }
  100% { translate: -50% -50%; opacity: 0; scale: 2.6; }
}
/* Sur l'acide, on garde `screen` — et c'est contre-intuitif. Réflexe le 24/08 :
   « fond clair, donc une lumière blanche ne se verra pas, passons en multiply ».
   Regardé à l'écran, ça donnait une TIGE NOIRE qui traverse le sceau, tout sauf
   un reflet. La raison : le flare ne passe pas sur la capsule blanche, il passe
   sur la FEUILLE, et sur l'affiche acide la feuille est faite d'encres SOMBRES
   (`--gift-a…e` y sont redéfinies). Le support est sombre dans les deux thèmes,
   donc la lumière s'y ajoute pareil. Seule la traînée se resserre, parce qu'un
   halo large déborderait sur le blanc où il n'existe pas. */
[data-theme="light"] .gdrop-flare b { filter: blur(.8px); }
[data-theme="light"] .gdrop-flare u { filter: blur(1.4px); }
/* … SAUF à l'ouverture : la feuille vient de se déchirer, le flash traverse
   maintenant la CAPSULE, qui elle est blanche. Là, `screen` ne rend plus rien
   (vu à l'écran : un coup de flash invisible). Le flash y devient donc un trait
   d'ENCRE — c'est la même bascule que le bouton, qui passe au texte blanc sur
   l'acide : ce qui change de thème n'est pas la couleur du cadeau, c'est ce
   qu'on pose dessus. */
[data-theme="light"] .gdrop.opened .gdrop-flare { mix-blend-mode: multiply; }
[data-theme="light"] .gdrop.opened .gdrop-flare b {
  background: linear-gradient(90deg, transparent, var(--gift-a) 26%, var(--gift-c) 50%, var(--gift-e) 74%, transparent);
  filter: blur(1.2px);
}
[data-theme="light"] .gdrop.opened .gdrop-flare u {
  background: radial-gradient(circle, var(--gift-c) 0%, color-mix(in srgb, var(--gift-c) 35%, transparent) 45%, transparent 70%);
}
[data-theme="light"] .gdrop-wave { border-width: 3px; border-color: var(--gift-c); }

/* Le coup de flash de l'ouverture — le trait s'étire en hauteur puis s'aplatit,
   comme un tube qui décharge. Il remplace le passage tranquille (`forwards`
   pour qu'il ne reboucle pas : ça n'arrive qu'une fois). */
.gdrop.opened .gdrop-flare b { animation: gdrop-blast .9s cubic-bezier(.12,.8,.2,1) forwards; }
.gdrop.opened .gdrop-flare u { animation: gdrop-blastdot .9s cubic-bezier(.12,.8,.2,1) forwards; }
@keyframes gdrop-blast {
  0%   { opacity: 0; scale: .2 6; }
  10%  { opacity: 1; scale: 1.1 4; }
  40%  { opacity: .9; scale: 1.35 1; }
  100% { opacity: 0; scale: 1.6 .25; }
}
@keyframes gdrop-blastdot {
  0%   { translate: -50% -50%; opacity: 0; scale: .3; }
  10%  { translate: -50% -50%; opacity: 1; scale: 1.8; }
  100% { translate: -50% -50%; opacity: 0; scale: 4.5; }
}

/* L'onde de choc : un seul anneau, fin, qui part du sceau. Elle dit « ça a
   claqué » là où les éclats disent « ça a jailli » — deux mots différents pour
   le même instant, c'est ce qui empêche la révélation de se lire comme un seul
   effet appliqué deux fois. */
.gdrop-wave {
  position: absolute; left: 50%; top: 50%; width: 74px; height: 74px;
  translate: -50% -50%; border-radius: 50%; opacity: 0; pointer-events: none;
  border: 2px solid color-mix(in srgb, var(--gift-c) 75%, #fff);
}
.gdrop.opened .gdrop-wave { animation: gdrop-wave .85s cubic-bezier(.15,.7,.25,1) forwards; }
@keyframes gdrop-wave { from { opacity: .85; scale: .25; } to { opacity: 0; scale: 3.6; } }

/* Le voile encaisse le flash — 90 ms d'éclaircissement, pas plus : au-delà on
   ne lit plus un flash, on lit un changement de thème. */
.gdrop.opened .gdrop-veil { animation: gdrop-flash .55s ease-out; }
@keyframes gdrop-flash { 0% { filter: brightness(1); } 10% { filter: brightness(1.75); } 100% { filter: brightness(1); } }

.gdrop-burst { position: absolute; inset: 0; pointer-events: none; }
.gdrop-burst i {
  position: absolute; left: 50%; top: 50%;
  width: var(--w, 4px); height: var(--h, 12px); border-radius: 2px;
  background: var(--c, var(--gift-a));
  /* La lueur, pas un `filter` : un `drop-shadow` par éclat, ça fait 26 filtres
     à composer sur la même image. Une ombre portée colorée coûte le prix d'un
     rectangle flou et rend la même chose à cette taille. */
  box-shadow: 0 0 9px var(--c, var(--gift-a));
  animation: gdrop-shard .95s cubic-bezier(.15,.7,.3,1) forwards;
}
@keyframes gdrop-shard {
  from { transform: translate(-50%,-50%) rotate(var(--a)) translateY(0) rotate(0deg); opacity: 1; }
  to   { transform: translate(-50%,-50%) rotate(var(--a)) translateY(calc(var(--d) * -1)) rotate(var(--r)); opacity: 0; }
}

.gdrop-go {
  margin: 16px 0 0; width: 100%; padding: 13px 18px;
  font: 600 15px/1 var(--sans, inherit); cursor: pointer;
  color: #06070a; border: 0; border-radius: 12px;
  background: linear-gradient(100deg, var(--gift-a), var(--gift-c), var(--gift-e));
  background-size: 200% 100%;
  box-shadow: 0 10px 30px -10px color-mix(in srgb, var(--gift-c) 75%, transparent);
  animation: gdrop-slide 5s ease-in-out infinite;
  transition: transform .16s ease, filter .16s ease;
}
.gdrop-go:hover { filter: brightness(1.08); transform: translateY(-1px); }
.gdrop-go:active { transform: translateY(0) scale(.99); }
.gdrop-go[disabled] { opacity: .6; cursor: default; animation: none; }
.gdrop-go.done { animation: none; background-size: 100% 100%; }
@keyframes gdrop-slide { 0%,100% { background-position: 0 0; } 50% { background-position: 100% 0; } }

/* La règle. Elle est SOUS le bouton et en petit à dessein : c'est la promesse
   du prochain retour, pas l'information du moment. */
.gdrop-rule {
  margin: 14px 2px 0; font-size: 12px; line-height: 1.5;
  color: var(--fg-3, #7b8391);
}

/* ── Le même dessin sur l'affiche acide ──────────────────────────────────
   Le thème clair ne se contente pas d'inverser : il redéfinit `--gift-a … e`
   en ENCRES sombres (#007a6b, #6b1fbf, #a35a00) — c'est ce qui fait qu'une
   teinte existe sur un fond acide. Conséquence directe, vue à l'œil le 24/08 :
   le texte noir du bouton se retrouvait sur un dégradé sombre, illisible. Ce
   qui change de thème n'est donc pas la couleur du cadeau, c'est ce qu'on
   POSE dessus. Et le relief passe à l'ombre dure du poster, comme la pastille
   du cadeau du jour — pas une lueur diluée, qui n'existe pas sur l'acide. */
[data-theme="light"] .gdrop-go { color: #fff; }
[data-theme="light"] .gdrop-foil { color: rgba(255,255,255,.82); }
[data-theme="light"] .gdrop-box {
  border-color: #000;
  box-shadow: 7px 7px 0 #000;
}
[data-theme="light"] .gdrop-halo { filter: saturate(1.1); }
[data-theme="light"] .gdrop-burst i { box-shadow: none; }

@media (prefers-reduced-motion: reduce) {
  .gdrop-aur i, .gdrop-halo, .gdrop-foil, .gdrop-go, .gdrop-burst i,
  .gdrop-q, .gdrop-flare b, .gdrop-flare u, .gdrop-flare s, .gdrop-wave, .gdrop-veil { animation: none; }
  .gdrop-aur { translate: none; }
  .gdrop-box { transition: opacity .2s ease; transform: none; }
  .gdrop-foil { transition: opacity .2s ease; }
}
/* ── Companion — la carte d'angle (js/companion-promo.js) ────────────────────
   Elle vit dans theme.css et PAS dans landing.css (14/09) : depuis que
   src/server.js l'injecte dans toute page publique — sauf le studio, la
   console et l'app sœur, où l'on y est déjà — sa feuille doit être celle que
   TOUTES les pages chargent. Rangée ailleurs, une page ajoutée demain
   recevrait la carte sans son habillage.
   Elle se pose au-dessus du composer ancré (--dock-h mesuré en JS) et arrive
   par la DROITE : /companion est un endroit où aller, le mouvement montre la
   direction. Le rose est l'accent de l'app sœur (son app.css), pas une
   nouvelle teinte de CE thème — il signe « produit frère », comme le badge
   d'un autre atelier cousu sur la même veste. Tout le reste est tokens :
   la carte tient dans les deux thèmes sans une ligne de plus. */
/* Phil (14/09) : « too invisible » sur var(--bg-1) — une pub qui porte la
   surface de la page N'EST PAS une pub. La carte porte donc SA couleur :
   le dégradé rose de companion, autonome (aucun token de surface), donc
   identique et visible sur les DEUX thèmes. */
.cai-promo {
  position: fixed; z-index: 60;
  right: 18px; bottom: calc(var(--dock-h, 72px) + 18px);
  width: 296px; padding: 14px 16px 16px;
  /* Le fond VIT (Phil 14/09 : « colorful that move ») : les deux accents de
     l'app sœur (rose #c94f7c, bleu #4f9cc9) dérivent lentement sur un dégradé
     surdimensionné. background-position seul — pas de repaint de contenu, et
     UN canal d'animation pour l'objet (le bord reste immobile). */
  background: linear-gradient(130deg, #33101f, #a63a63 22%, #c94f7c 40%, #4f9cc9 62%, #832e50 82%, #33101f);
  background-size: 320% 320%;
  animation: cai-drift 11s ease-in-out infinite;
  border: 1px solid #d9698f;
  border-radius: var(--radius-lg);
  box-shadow: 0 12px 40px rgba(201, 79, 124, .38), 0 2px 10px rgba(0, 0, 0, .35);
  opacity: 0; transform: translateX(28px);
  transition: opacity .4s var(--ease), transform .4s var(--ease);
  pointer-events: none;
}
@keyframes cai-drift {
  0%   { background-position: 0% 30%; }
  50%  { background-position: 100% 70%; }
  100% { background-position: 0% 30%; }
}
.cai-promo.on { opacity: 1; transform: none; pointer-events: auto; }

.cai-x {
  position: absolute; top: 6px; right: 8px;
  background: none; border: 0; color: rgba(255, 255, 255, .55);
  font-size: 18px; line-height: 1; cursor: pointer; padding: 4px;
}
.cai-x:hover { color: #fff; }

.cai-head { float: left; margin: 2px 12px 4px 0; }
.cai-face { width: 56px; height: 56px; display: block; }
/* transform-box: fill-box — sans lui, « center » est le centre du VIEWPORT
   SVG (32,32), pas celui de la paupière : la fermeture s'écrase 4 px au-dessus
   de l'œil et le clignement devient un artefact qui flashe. */
.cai-face .cai-lid { transform: scaleY(0); transform-box: fill-box; transform-origin: center; transition: transform 80ms ease-out; }
.cai-face .cai-pupil { transition: transform .3s var(--ease); }

.cai-kicker {
  margin: 0 0 2px; font-family: var(--display); font-size: 11px;
  letter-spacing: .08em; text-transform: uppercase; color: #ffb3cd;
}
.cai-title { margin: 0 0 6px; font-family: var(--display); font-size: 19px; letter-spacing: .02em; color: #fff; }
.cai-title span { color: #ffb3cd; }
.cai-line { margin: 0 0 12px; font-size: 13px; line-height: 1.45; color: rgba(255, 255, 255, .82); }
.cai-line b { color: #fff; font-weight: 600; }

.cai-go {
  display: block; clear: both; text-align: center;
  padding: 9px 12px; border-radius: var(--radius);
  background: #fff; color: #a63a63; font-family: var(--display); font-weight: 600;
  font-size: 13px; letter-spacing: .03em; text-decoration: none;
  transition: filter var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.cai-go:hover { filter: brightness(1.12); transform: translateY(-1px); }

/* Le téléphone est le poste de contrôle : à 390 px la carte devient une
   bande pleine largeur au-dessus du composer, jamais un timbre-poste. */
@media (max-width: 560px) {
  .cai-promo { right: 10px; left: 10px; width: auto; }
}
@media (prefers-reduced-motion: reduce) {
  .cai-promo { transition: opacity .01s linear; transform: none; animation: none; }
  .cai-go:hover { transform: none; }
}

/* Phil (14/09) : « shaking like make intention every 5 sec » + « a heart that
   splash random size 3-4 heart, make it lovly ». Deux gestes, deux canaux —
   la règle de la maison : deux animations sur un objet ne partagent pas une
   propriété. Le fond garde `background-position` (cai-drift, en boucle CSS) ;
   l'appel d'intention prend `transform`, mais PAS en CSS : la carte utilise
   déjà transform pour son entrée par la droite. Il est joué en WAAPI avec
   `composite: 'add'` (js/companion-promo.js) — le geste s'AJOUTE à la
   transformation en place au lieu de l'écraser, donc il peut survenir pendant
   l'entrée sans la faire sauter. Rien à écrire ici pour lui.
   Restent les cœurs : une couche qui déborde la carte (les cœurs montent PLUS
   HAUT qu'elle), inerte au clic, et qui ne capture jamais le pointeur. */
.cai-hearts {
  /* Les deux teintes d'un cœur vivent ICI et pas dans le script : sur le thème
     JOUR, le cœur blanc du thème NUIT se pose sur une page blanche et cesse
     d'exister. Le script tire au sort entre --heart-a et --heart-b, le thème
     décide ce que ces deux noms valent — un rendu validé sur un seul thème
     n'est pas validé. */
  --heart-a: #ff8fb8;
  --heart-b: #ffffff;
  position: absolute; inset: 0;
  overflow: visible;          /* le cœur sort du cadre : c'est tout l'effet */
  pointer-events: none;
  z-index: 1;                 /* sous le texte : la carte reste lisible */
}
.cai-heart {
  position: absolute;
  display: block;
  will-change: transform, opacity;
  /* Deux fonds successifs sous le même objet : le dégradé rose de la carte,
     puis le noir de la page dès qu'il en sort. Une seule ombre ne peut pas
     tenir les deux — la sombre le détache du rose, le halo rose le détache
     du noir. Les deux, sinon il disparaît sur l'un ou sur l'autre. */
  filter: drop-shadow(0 2px 5px rgba(0, 0, 0, .45)) drop-shadow(0 0 7px rgba(255, 127, 168, .75));
}
[data-theme="light"] .cai-hearts {
  --heart-a: #d93a72;
  --heart-b: #ff6f9f;
}
[data-theme="light"] .cai-heart {
  /* Le halo rose servait à détacher le cœur du NOIR ; sur blanc il le noie.
     Le jour, c'est l'ombre portée seule qui le découpe. */
  filter: drop-shadow(0 2px 6px rgba(120, 20, 55, .3));
}
@media (prefers-reduced-motion: reduce) {
  /* Pas d'atténuation : l'effet est RETIRÉ (rien ne s'atténue tout seul). */
  .cai-hearts { display: none; }
}

/* ── STUDIO | COMPANION — un bouton, deux moitiés, toujours là ─────────────
   Phil (15/09) : « moitié gauche = studio (jaune), moitié droite = companion
   (rose), et il ne doit jamais disparaître ». Une seule pièce soudée plutôt
   que deux boutons voisins : soudés, ils disent « deux moitiés du MÊME
   produit » — ce qui est exactement le rapport entre le studio et l'app sœur
   (même compte, même porte-monnaie). Deux boutons séparés diraient l'inverse. */
.gp-duo {
  position: relative; isolation: isolate;
  display: inline-flex; align-items: stretch; flex: 0 0 auto;
  border-radius: 999px; overflow: hidden; margin-right: 10px;
  /* Un anneau sombre, pas seulement une ombre portée : en thème CLAIR
     l'en-tête GenPix est exactement ce jaune (#fcee0a), et la moitié Studio
     s'y fondait — invisible. L'anneau trace le contour de la pastille sur
     n'importe quel fond. */
  box-shadow: 0 0 0 1.5px rgba(0, 0, 0, .34), 0 2px 12px rgba(0, 0, 0, .28);
  /* Écrit en propriétés séparées, PAS en raccourci `font:`. Le raccourci
     portait `var(--ui, inherit)` comme famille : `--ui` n'existe pas sur ce
     site, `inherit` n'est pas une valeur valide DANS un raccourci, et une
     substitution invalide annule TOUTE la déclaration — le bouton retombait en
     15px/400 hérités de l'en-tête. Un raccourci qui contient un var() est un
     tout-ou-rien : ici on ne prend pas ce risque. */
  font-family: var(--ui, inherit);
  font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: .01em;
}
.gp-duo-h {
  display: grid; place-items: center; padding: 9px 16px;
  text-decoration: none; white-space: nowrap; position: relative;
  transition: filter var(--dur, .18s) var(--ease, ease), transform var(--dur, .18s) var(--ease, ease);
}
/* Le jaune est écrit en dur, pas `var(--accent)` : en thème clair l'accent du
   site est un bleu-vert (#006f80), et ce bouton-ci n'est pas un accent — c'est
   la MARQUE. Phil : « avec le thème jaune comme genpix l'est, pour que
   l'utilisateur sache que ça vient de genpix ». Il doit être le même dans les
   deux thèmes, sinon il ne signe plus rien.
   Les classes sont préfixées : `.studio` nu percutait la vue principale du
   studio (padding 18px 20px 40px), et la moitié gauche partait à 418 px. */
.gp-duo-h.gp-duo-s { background: #fcee0a; color: #000; padding-right: 34px; z-index: 0; }
.gp-duo-h.gp-duo-c { background: #c94f7c; color: #fff; z-index: 1; }

/* ── La jointure liquide (15/09) ───────────────────────────────────────────
   Phil : « l'arête entre leurs deux couleurs, je veux voir plus de créativité,
   une transition en forme d'eau ». Donc pas de trait : un MÉNISQUE. La moitié
   rose remonte de 16 px SOUS la jaune et se découpe par un `clip-path` dont le
   bord gauche ondule ; trois états du même polygone (mêmes 10 points, donc le
   navigateur interpole vraiment) font respirer la courbe. C'est de la découpe
   géométrique animée, pas un dégradé : le bord reste net comme une surface
   d'eau, et les deux couleurs restent franches — le jaune ne devient jamais
   sale. Une gouttelette rose détachée dans le jaune finit de dire « liquide » :
   elle ne fait que 6 px et ne se répète pas à l'identique (deux cycles de
   durées premières entre elles), pour rester vivante sans se faire apprendre
   par l'œil. */
.gp-duo-h.gp-duo-c {
  margin-left: -16px; padding-left: 30px;
  /* Deux calques de masque, réunis : un rectangle qui part de 15 px du bord
     gauche, et une colonne de disques qui se répète verticalement et déborde
     dans le jaune. Un polygone donnait des dents de scie — l'eau n'a pas de
     segments droits ; un disque, si. Le masque défile d'exactement une tuile,
     en linéaire : la frontière COULE, sans retour en arrière ni répétition
     visible. */
  /* Les disques se CHEVAUCHENT verticalement (tuile 14 px pour un diamètre de
     16) : leur réunion n'est plus une file de bosses détachées — qui se lisait
     comme une dent de puzzle — mais une seule courbe continue qui ondule de
     4 px. Une surface d'eau bouge peu ; c'est son absence de droite qui la
     trahit, pas son amplitude. */
  --lobe: radial-gradient(circle 8px at 8px 50%, #000 96%, rgba(0, 0, 0, 0) 100%);
  -webkit-mask-image: var(--lobe), linear-gradient(#000 0 0);
          mask-image: var(--lobe), linear-gradient(#000 0 0);
  -webkit-mask-size: 16px 14px, calc(100% - 8px) 100%;
          mask-size: 16px 14px, calc(100% - 8px) 100%;
  -webkit-mask-repeat: repeat-y, no-repeat;
          mask-repeat: repeat-y, no-repeat;
  -webkit-mask-position: 0 0, 100% 0;
          mask-position: 0 0, 100% 0;
  animation: gp-duo-flow 4.6s linear infinite;
}
@keyframes gp-duo-flow {
  to { -webkit-mask-position: 0 14px, 100% 0; mask-position: 0 14px, 100% 0; }
}
.gp-duo-h.gp-duo-s::after {
  content: ''; position: absolute; right: 23px; top: 50%;
  width: 6px; height: 6px; background: #c94f7c; opacity: .9;
  border-radius: 60% 40% 55% 45% / 50% 60% 40% 50%;
  transform: translateY(-50%); pointer-events: none;
  animation: gp-duo-drop 5.3s ease-in-out infinite;
}
@keyframes gp-duo-drop {
  0%, 100% { transform: translate(0, -50%) scale(1);
             border-radius: 60% 40% 55% 45% / 50% 60% 40% 50%; }
  30%      { transform: translate(-3px, -78%) scale(.78);
             border-radius: 45% 55% 40% 60% / 60% 45% 55% 40%; }
  62%      { transform: translate(-1px, -24%) scale(1.15);
             border-radius: 55% 45% 60% 40% / 40% 55% 45% 60%; }
}
.gp-duo:hover .gp-duo-h.gp-duo-c { animation-duration: 1.9s; }
.gp-duo:hover .gp-duo-h.gp-duo-s::after { animation-duration: 2.1s; }
@media (prefers-reduced-motion: reduce) {
  .gp-duo-h.gp-duo-c, .gp-duo-h.gp-duo-s::after { animation: none; }
}

.gp-duo-h:hover { filter: brightness(1.1); }
/* La moitié où l'on se trouve : éteinte, et elle ne clique pas. Un bouton qui
   mène à la page qu'on regarde est un bouton cassé — mieux vaut qu'il se taise
   que qu'il mente. Le `here` de la moitié jaune n'est plus figé sur app.html :
   duo.js le synchronise avec la vue active (js/duo.js, adopt()). */
/* Éteinte, oui — désaturée, non. À saturate(.25) le jaune GenPix devenait un
   beige boueux, et c'est la page où l'on passe le plus de temps : la marque y
   était sale en permanence. On baisse la lumière, on garde la couleur. */
.gp-duo-h.here { filter: saturate(.8) brightness(.84); pointer-events: none; cursor: default; }
.gp-duo-h.here:hover { filter: saturate(.8) brightness(.84); }
/* L'onglet de vue « Studio » d'origine : il reste dans le document (il définit
   la vue par défaut et app.js le clique), il ne se montre plus. */
.gp-duo-dup { display: none !important; }
/* Sans en-tête (page d'atterrissage nue), il flotte — mais il existe. */
.gp-duo.floating { position: fixed; top: 14px; right: 14px; z-index: 60; }
@media (max-width: 560px) {
  .gp-duo { font-size: 12px; margin-right: 6px; }
  .gp-duo-h { padding: 8px 11px; }
  .gp-duo-h.gp-duo-s { padding-right: 30px; }
  .gp-duo-h.gp-duo-c { padding-left: 25px; }
}

/* ── Les missions — une GRILLE colorée (15/09, 3e passe) ───────────────────
   Phil : « i dont want to scroll left and right… change it color to be more
   attractive ». Le rail horizontal est mort : `auto-fit` enroule les cartes,
   donc les cinq missions sont visibles d'un coup à toutes les largeurs et il
   n'y a plus UN pixel de défilement latéral. La ligne repliée fait toujours
   38 px — la mosaïque reste le produit, les missions restent l'à-côté.

   La couleur n'est pas une décoration : chaque carte porte `--msn-accent`
   (envoyé par le serveur, éditable dans la console). Tout en jaune, les cinq
   pastilles se lisaient comme un bloc ; une teinte par geste donne un repère
   avant même la lecture. */
.missions {
  margin: 0 0 14px; border-radius: 13px;
  /* Un fond qui bouge : deux halos très doux, jaune (studio) et rose
     (companion), pour que la barre ne ressemble plus à une rangée de boutons
     désactivés. Amplitude minuscule, jamais clignotant. */
  background:
    radial-gradient(120% 140% at 0% 0%, rgba(252, 238, 10, .09), transparent 55%),
    radial-gradient(120% 140% at 100% 0%, rgba(255, 95, 162, .10), transparent 55%),
    color-mix(in srgb, var(--bg-1, #14121a) 88%, transparent);
  border: 1px solid color-mix(in srgb, var(--line, #2c2836) 80%, rgba(255, 95, 162, .35));
  overflow: hidden;
}
.msn-bar {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 9px 12px; background: none; border: 0; color: inherit;
  font: inherit; text-align: left; cursor: pointer;
}
.msn-bar:hover { background: color-mix(in srgb, var(--ink, #fff) 4%, transparent); }
.msn-spark { font-size: 13px; filter: saturate(1.3); }
.msn-title { font-size: 13px; letter-spacing: .02em; white-space: nowrap; font-weight: 700; }
.msn-cap {
  position: relative; flex: 1 1 auto; max-width: 220px; min-width: 60px; height: 9px;
  border-radius: 8px; background: var(--line, #2c2836); overflow: hidden;
}
.msn-cap-fill {
  position: absolute; inset: 0 auto 0 0; border-radius: 8px; min-width: 4px;
  /* Jaune → rose : les deux couleurs de la maison, dans le sens de la lecture. */
  background: linear-gradient(90deg, #fcee0a, #ffb02e 45%, #ff5fa2);
  transition: width .5s cubic-bezier(.2, .9, .3, 1);
}
/* Le montant a quitté la jauge (15/09, vu à l'écran deux fois de suite).
   Dedans, en `mix-blend-mode: difference` puis en blanc ombré, la VIRGULE
   était invisible dans les deux cas : le total du jour se lisait sans sa virgule. Un
   trait sous la ligne de base, à 10,5 px, sur une piste sombre, avec une ombre
   portée juste dessous — aucun réglage ne sauve ça. La jauge est une jauge, le
   chiffre est un chiffre, et il se lit sur le fond de la barre. */
.msn-sum { font-size: 12px; white-space: nowrap; opacity: .75; }
.msn-sum b { color: var(--accent, #fcee0a); font-weight: 700; opacity: 1; }
.msn-left { margin-left: auto; opacity: .6; white-space: nowrap; }
/* Le chevron est dessiné en bordures : deux triangles CSS ne demandent ni
   police ni fichier, et tournent avec le panneau. */
.msn-chev {
  width: 7px; height: 7px; flex: none; opacity: .6;
  border-right: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor;
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform .22s cubic-bezier(.2, .9, .3, 1);
}
.missions[data-open="1"] .msn-chev { transform: rotate(-135deg) translate(-2px, -2px); }

/* LA grille. `auto-fit` + `minmax` : trois ou quatre cartes sur un écran large,
   deux sur une tablette, une seule sur un téléphone — et jamais de barre de
   défilement horizontale, qui était tout le problème. */
.msn-grid {
  display: grid; gap: 8px;
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
  padding: 0 12px 4px;
}
.missions[data-open="0"] .msn-grid,
.missions[data-open="0"] .msn-note { display: none; }

.msn {
  display: grid; gap: 6px; text-align: left; cursor: pointer;
  padding: 9px 11px; border-radius: 11px; color: inherit; font: inherit;
  border: 1px solid color-mix(in srgb, var(--msn-accent, #fcee0a) 34%, var(--line, #2c2836));
  background:
    linear-gradient(135deg, color-mix(in srgb, var(--msn-accent, #fcee0a) 14%, transparent), transparent 62%),
    color-mix(in srgb, var(--bg-0, #0d0b12) 62%, transparent);
  transition: transform .18s cubic-bezier(.2, .9, .3, 1), box-shadow .18s, border-color .18s;
  /* Vu à l'écran, pas déduit du CSS : la phrase « où le geste se fait » débordait
     SUR la carte voisine (263 px de texte dans une carte de 224). Cause : dans
     une grille, un enfant vaut `min-width: auto` — il ne rétrécit pas sous la
     largeur de son contenu, et `-webkit-line-clamp` ne rattrape rien parce que
     le débordement est HORIZONTAL. Les deux lignes ci-dessous sont la vraie
     correction ; la troncature n'était qu'un pansement. */
  overflow: hidden;
}
.msn > *, .msn-head > * { min-width: 0; }
.msn:hover, .msn:focus-visible {
  transform: translateY(-2px);
  border-color: var(--msn-accent, #fcee0a);
  box-shadow: 0 6px 18px -8px color-mix(in srgb, var(--msn-accent, #fcee0a) 70%, transparent);
}
.msn:active { transform: translateY(0); }
.msn-head { display: flex; align-items: center; gap: 7px; }
.msn-ico { flex: none; color: var(--msn-accent, #fcee0a); }
.msn-name {
  /* Le titre S'ENROULE sur deux lignes au lieu de se faire couper : « Say how
     one tur… » ne dit pas ce que la mission demande, et la grille égalise de
     toute façon la hauteur des cartes — les deux lignes ne coûtent rien. */
  font-weight: 650; font-size: 12.5px; line-height: 1.2;
  display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2;
  -webkit-box-orient: vertical; overflow: hidden;
}
.msn-cents {
  margin-left: auto; flex: none; font-weight: 800; font-size: 11.5px;
  color: var(--msn-accent, #fcee0a);
  padding: 2px 6px; border-radius: 999px;
  background: color-mix(in srgb, var(--msn-accent, #fcee0a) 16%, transparent);
}
/* La phrase dit OÙ le geste se fait — c'est elle qui répond à « the mission is
   not adapted to what we have ». Deux lignes maximum : au-delà, la carte
   devient un paragraphe et la grille se déforme. */
.msn-where {
  opacity: .62; line-height: 1.32; min-height: 2.1em; max-height: 2.7em;
  display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2;
  -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere;
}
.msn-foot { display: flex; align-items: center; gap: 8px; }
/* Les pièces : des disques dessinés, pas le caractère ●. Un glyphe change de
   taille d'une police à l'autre et la rangée se met à danser. */
.msn-coins { display: flex; gap: 3px; font-style: normal; }
.msn-coins u {
  width: 7px; height: 7px; border-radius: 50%; text-decoration: none;
  /* Une pièce éteinte doit se VOIR éteinte : en `background: var(--line)` sur
     un fond presque identique, elle disparaissait — donc « 2 sur 5 » se lisait
     « 2 », sans le 5, c'est-à-dire sans la progression. Un contour la pose. */
  background: transparent; border: 1.5px solid color-mix(in srgb, var(--ink, #fff) 28%, transparent);
}
.msn-coins u.on {
  background: var(--msn-accent, #fcee0a); border-color: var(--msn-accent, #fcee0a);
  box-shadow: 0 0 5px color-mix(in srgb, var(--msn-accent, #fcee0a) 60%, transparent);
}
.msn-go-txt {
  margin-left: auto; font-style: normal; font-size: 11px; font-weight: 700;
  color: var(--msn-accent, #fcee0a); opacity: .85; white-space: nowrap;
}
.msn.full { opacity: .5; }
.msn.full .msn-go-txt { color: inherit; opacity: .6; }
.msn-note { margin: 2px 12px 10px; }

/* La tuile désignée par une mission (« go → »). Un halo court dans la teinte
   de la maison : défiler sans rien désigner laisse l'œil devant trente images
   identiques. */
.tile.msn-point { animation: msn-point 2.2s ease-out 1; border-radius: 12px; }
@keyframes msn-point {
  0%, 100% { box-shadow: 0 0 0 0 rgba(252, 238, 10, 0); }
  20%      { box-shadow: 0 0 0 3px rgba(252, 238, 10, .75); }
  60%      { box-shadow: 0 0 0 3px rgba(255, 95, 162, .5); }
}

@media (max-width: 560px) {
  .msn-title { display: none; }
  .msn-sum { font-size: 11px; }
  .msn-left { display: none; }
  /* Deux colonnes étroites plutôt qu'une large : cinq cartes pleine largeur
     feraient 400 px de haut sur un téléphone, soit exactement le défaut de la
     1re version qu'on vient de corriger. */
  .msn-grid { grid-template-columns: repeat(auto-fit, minmax(138px, 1fr)); gap: 6px; }
  .msn { padding: 8px 9px; }
  .msn-where { display: none; }
  /* Trois lignes sur téléphone : « Put one of yours in the window » ne tient
     pas en deux dans 138 px, et « Put one o… » ne dit pas quoi faire. */
  .msn-name { font-size: 12px; -webkit-line-clamp: 3; line-clamp: 3; }
}

/* La pièce qui saute au vote payé. En fixe dans <body> : la tuile la rognerait. */
.coin-burst {
  position: fixed; z-index: 950; translate: -50% 0; pointer-events: none;
  font: 800 15px/1 inherit; color: #fcee0a;
  text-shadow: 0 1px 4px rgba(0, 0, 0, .6), 0 0 12px rgba(252, 238, 10, .4);
  animation: coin-up 1.1s cubic-bezier(.2, .9, .3, 1) both;
}
@keyframes coin-up {
  from { opacity: 0; transform: translateY(4px) scale(.7); }
  18%  { opacity: 1; transform: translateY(-6px) scale(1.12); }
  to   { opacity: 0; transform: translateY(-42px) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .coin-burst { animation: none; opacity: 1; }
  .msn-cap-fill { transition: none; }
}
