/* ==========================================
   SOTIS AI - Design System
   Dark only. Rewritten 2026.
   ========================================== */

/* ---- SELF-HOSTED TYPEFACES ----
   Geist and Geist Mono, SIL Open Font License 1.1 (see fonts/OFL.txt).
   Self-hosted rather than pulled from the Google Fonts CDN for two reasons:
   the CDN discloses every visitor's IP to a third country (LG Munchen
   3 O 17493/20), which is a real question for a French freelance serving EU
   clients; and it removes a render-blocking third-party round trip.

   Only the latin and latin-ext subsets are shipped: 4 files, ~83 KB total.
   Both families are VARIABLE, so one file covers the whole weight range.

   The url() paths are relative to THIS file. That is deliberate:
   CompressedManifestStaticFilesStorage rewrites them to hashed names at
   collectstatic time, and a path it cannot resolve makes collectstatic exit
   non-zero, which in turn stops the container from booting (entrypoint.sh runs
   collectstatic before gunicorn). Never make these absolute or templated. */

@font-face {
  font-family: 'Geist';
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url("../fonts/geist-latin-ext.49215a3bccae.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: 'Geist';
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url("../fonts/geist-latin.f4634c3bc1fa.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: 'Geist Mono';
  font-style: normal;
  font-weight: 400 500;
  font-display: swap;
  src: url("../fonts/geist-mono-latin-ext.a27466d06912.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: 'Geist Mono';
  font-style: normal;
  font-weight: 400 500;
  font-display: swap;
  src: url("../fonts/geist-mono-latin.cd5b25781181.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;
}

/* CSS Custom Properties (Design Tokens) */
:root {
  /* ---- COLOR ----
     SINGLE SOURCE OF TRUTH: no file outside this one may hardcode a brand
     colour, as a hex or as rgb() channels. DesignTokenTest enforces it, and it
     scans COMMENTS too, in .css .js .html .py .yaml .yml .json .svg .scss .ts
     .tsx. Markdown is not scanned, so docs may quote hexes freely.

     REBRAND CHECKLIST. The brand hues below are the input; the rest of this
     list is hand-derived from them and does NOT follow on its own.

       1. The brand hexes: --violet-primary / -light / -dark / -fill,
          --yellow-primary / -light / -dark / -light-fixed.
       2. The RGB mirrors: --violet-primary-rgb, --violet-dark-rgb,
          --violet-fill-rgb, --yellow-primary-rgb. rgba() cannot read a hex
          custom property, so every translucent brand colour goes through one.
          A stale mirror is the one thing here the test catches by itself
          (test_rgb_mirrors_match_their_token).
       3. --violet-wash: the translucent violet that replaced ~30 pastel
          backgrounds across the apps. It is an rgba(), so it must NEVER get an
          -rgb mirror of its own: the mirror test requires a hex and would fail.
       4. The DATA-VIZ PALETTE: --viz-ramp-1..5 (plus --viz-ramp-4-rgb),
          --viz-series-1..4, --viz-heat-*, --viz-highlight*. These are literal
          hexes calibrated against the --bg-primary canvas, not aliases of the
          brand hues. Re-derive them, do not merely re-point them.
       5. --viz-highlight and --viz-highlight-stroke: orange BECAUSE they must
          read against both violet and yellow. Re-pick against the new hues.

     Out of reach of any token, to redo by hand: the logo PNGs in
     ../images/, and the KaTeX \textcolor{} plus matplotlib colours in the app6
     and app8 course content (both exempted from the guard, see tests.py).

     WHY --viz-ramp-* MUST STAY LITERAL. The guard derives its protected set
     from this file and asserts at least 15 DISTINCT hexes. Pointing the ramp
     back at var(--violet-*) collapses it onto colours already counted and
     drops the total to 13, failing test_protected_token_set_is_not_empty. That
     assertion is doing its job: a ramp made of three violets and a yellow is a
     categorical palette wearing a sequential costume, and it does not read
     low-to-high on a dark canvas. */

  /* Brand */
  --violet-primary: #C09CF0;   /* accent + ink. The identity colour. */
  --violet-light: #E5D7F5;     /* ink only, on violet fills. NOT a background:
                                  as a surface it is a near-white pastel that
                                  reads as light-theme chrome. Use
                                  --violet-wash for that. */
  --violet-dark: #9B7AC4;      /* muted violet, borders and secondary ink */
  --violet-fill: #7C5BD9;      /* the only violet deep enough to carry white
                                  text: filled buttons, active states */

  --yellow-primary: #F7E64D;   /* the signature spark. Budget: one per screen. */
  --yellow-dark: #E5D43E;
  --yellow-light: #4A4520;     /* dark olive: the yellow-tinted SURFACE on a
                                  near-black canvas, not a pale yellow */
  --yellow-light-fixed: #FFF9D9; /* the genuinely pale yellow, for the few
                                  surfaces that are not themselves dark (the
                                  analytics heat legend over a light basemap) */

  /* RGB channel mirrors, for rgba() consumers (scrims, washes, glows).
     Keep each in sync with its hex above: the build fails if one goes stale. */
  --violet-primary-rgb: 192, 156, 240;
  --violet-dark-rgb: 155, 122, 196;
  --violet-fill-rgb: 124, 91, 217;
  --yellow-primary-rgb: 247, 230, 77;

  /* The translucent violet that replaced the pastel backgrounds. One token,
     one job: a brand-tinted surface that stays legible on near-black. */
  --violet-wash: rgba(var(--violet-primary-rgb), 0.10);
  --violet-wash-strong: rgba(var(--violet-primary-rgb), 0.16);

  /* ---- SURFACES ----
     A near-black canvas with a faint violet cast rather than a neutral grey.
     The steps are close together on purpose: depth comes from the hairline
     borders below, not from big luminance jumps. */
  --bg-primary: #0A0A0C;       /* the canvas */
  --bg-secondary: #101013;     /* bands, sections */
  --card-bg: #101013;
  --surface-raised: #16161A;   /* cards ON a band, popovers, inputs */
  --input-bg: #16161A;
  --footer-bg: #08080A;

  /* Hairlines. Translucent white rather than a fixed grey, so a border keeps
     the same apparent weight whatever surface it sits on. */
  --border-color: rgba(255, 255, 255, 0.07);
  --border-strong: rgba(255, 255, 255, 0.13);

  /* Ink. Contrast against --bg-primary: 16.9:1, 7.1:1, 3.6:1. The tertiary
     step is below AA for body copy by design; it is for decorative metadata
     only (timestamps, counters), never for anything load-bearing. */
  --text-primary: #EDEDF0;
  --text-secondary: #9A9AA6;
  --text-tertiary: #71717D;

  /* Legacy neutrals. Kept because ~90 rules across the apps still name them.
     --white is genuine white and is still correct as INK on a filled button;
     it is never a background any more. */
  --white: #FFFFFF;
  --dark-primary: #3A3A3A;
  --dark-text: #1A1A1A;
  --gray-light: #16161A;
  --gray-medium: #9A9AA6;
  --footer-text: #9A9AA6;

  /* ---- TYPOGRAPHY ---- */
  --font-sans: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
    'Helvetica Neue', Arial, sans-serif;
  --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco,
    Consolas, 'Liberation Mono', monospace;
  /* Alias kept for the handful of rules that still say var(--font-base). */
  --font-base: var(--font-sans);

  --font-weight-normal: 400;
  --font-weight-medium: 500;
  --font-weight-bold: 600;   /* 700 reads heavy in Geist; 600 is the "bold" */

  --text-xs: 0.75rem;      /* 12px, mono labels */
  --text-sm: 0.8125rem;    /* 13px, metadata */
  --text-base: 0.9375rem;  /* 15px, UI text */
  --text-md: 1rem;         /* 16px, body copy */
  --text-lg: 1.125rem;     /* 18px, lead paragraphs */
  --text-xl: 1.375rem;     /* 22px */
  --text-2xl: 1.75rem;     /* 28px, section titles */
  --text-3xl: 2.25rem;     /* 36px */
  --text-4xl: 3rem;        /* 48px */
  --text-5xl: 3.75rem;     /* 60px, hero */

  --leading-tight: 1.1;
  --leading-snug: 1.3;
  --leading-normal: 1.55;
  --leading-relaxed: 1.7;

  /* Negative tracking on display sizes is most of what separates a "tech
     premium" headline from a default one. Positive tracking is for the small
     uppercase mono eyebrows, which need the opposite treatment. */
  --tracking-tighter: -0.035em;
  --tracking-tight: -0.02em;
  --tracking-normal: 0;
  --tracking-wide: 0.08em;

  /* ---- SPACING ---- */
  --space-xs: 0.5rem;
  --space-sm: 1rem;
  --space-md: 2rem;
  --space-lg: 3rem;
  --space-xl: 4rem;

  /* ---- RADII ----
     Tightened from 8/16/24. Large radii are the single most dating detail on a
     dark technical interface. */
  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 14px;
  --radius-full: 9999px;

  /* ---- ELEVATION ----
     Black, not violet-tinted: a coloured shadow is invisible on near-black and
     only muddies the edge. Depth is carried by --ring-inset, a 1px top
     highlight that reads as a lit edge. */
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
  --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.6);
  --shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.55);
  --ring-inset: inset 0 1px 0 rgba(255, 255, 255, 0.06);

  /* Scrim for modal overlays */
  --overlay-dark: rgba(0, 0, 0, 0.72);

  /* ---- MOTION ---- */
  --transition-fast: 0.12s ease;
  --transition-base: 0.2s ease;
  --transition-slow: 0.4s ease;

  /* ---- STATUS ----
     Semantic, NOT brand. A rebrand must leave these alone: red means error
     whatever the palette. Backgrounds are translucent so they sit correctly on
     any surface. */
  --success: #3FBF6F;
  --success-bg: rgba(63, 191, 111, 0.12);
  --success-text: #7FDCA3;
  --warning: #E8B93B;
  --warning-bg: rgba(232, 185, 59, 0.12);
  --warning-text: #F0CE72;
  --danger: #E5484D;
  --danger-bg: rgba(229, 72, 77, 0.12);
  --danger-text: #FF9592;
  --info-bg: rgba(122, 162, 247, 0.12);
  --info-text: #9EC1FF;
  --success-rgb: 63, 191, 111;
  --warning-rgb: 232, 185, 59;
  --danger-rgb: 229, 72, 77;

  /* ---- CODE SURFACES ----
     Editor-like. Now only slightly lifted off the canvas rather than a
     free-standing dark island, since the page itself is dark. */
  --code-bg: #0E0E11;
  --code-text: #D4D4D8;
  --code-chrome-bg: #16161A;

  /* ---- DATA-VIZ PALETTE ----
     Literal hexes calibrated against --bg-primary. See the note above on why
     these must not be aliases of the brand hues.

     Charts and maps read these through SotisTheme.cssVar() (js/theme.js)
     rather than hardcoding a colour. */

  /* Sequential ramp, low -> high. Perceptually ordered: it climbs in lightness
     the whole way, from a near-canvas indigo to the yellow top end, so a
     density legend reads without consulting it. */
  --viz-ramp-1: #2B2340;
  --viz-ramp-2: #453575;
  --viz-ramp-3: #6B4FAD;
  --viz-ramp-4: #9E7BE0;
  --viz-ramp-5: #F0D64A;
  /* Mirror for the one ramp step consumers need translucent. */
  --viz-ramp-4-rgb: 158, 123, 224;

  /* Categorical series. Four hues that stay distinguishable at 2px stroke on
     near-black, which three shades of violet do not. The teal and rose exist
     ONLY here: they are chart affordances, not brand colours, and must not
     leak into the chrome. */
  --viz-series-1: #B79CF5;
  --viz-series-2: #5FD1C4;
  --viz-series-3: #F7E64D;
  --viz-series-4: #E8829B;

  /* Directional deltas (for a buyer, a rising price reads as negative) */
  --viz-positive: #3FBF6F;
  --viz-negative: #E5484D;

  /* Highlighted path through a diagram: the branch actually taken in a
     walkthrough. Orange because it must read as "this one, not the others"
     against both the violet and the yellow. The only viz role that also owns
     a stroke. */
  --viz-highlight: #F7A54D;
  --viz-highlight-stroke: #E5851D;

  /* Heat ramp, pale -> intense. Deliberately NOT derived from the surfaces
     above: it sits on an always-light CartoDB basemap in the analytics
     choropleth, so it keeps a light-background reading. */
  --viz-heat-1: var(--yellow-light-fixed);
  --viz-heat-2: var(--yellow-primary);
  --viz-heat-3: var(--yellow-dark);
  --viz-heat-4: #C9A000;

  /* Plot chrome: axes, gridlines, tick labels. Named tokens so Plotly and
     Chart.js stop carrying their own light/dark ternaries. */
  --viz-axis: #3A3A42;
  --viz-grid: #232329;
  --viz-tick: #8B8B96;

  /* Tells the browser to render scrollbars, form controls and the canvas
     background in their dark variants. One line, and it removes every
     white flash on a native widget. */
  color-scheme: dark;
}

/* ==========================================
   RESET & BASE STYLES
   ========================================== */

*,
*::before,
*::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* `clip`, NOT `hidden`, and the distinction is the whole reason the sticky
   header works.
   `overflow-x: hidden` forces the other axis to compute to `auto` (CSS Overflow
   3), which turns html AND body into scroll containers. A `position: sticky`
   header then sticks to the top of its nearest scrolling ancestor, which is the
   body box, not the viewport: it scrolls away with the page and looks like
   sticky is simply broken. `clip` clips the same overflow WITHOUT creating a
   scroll container, so the header resolves against the viewport again.
   Do not "simplify" this back to hidden. */
html {
  overflow-x: clip;
  max-width: 100vw;
  /* Anchored jumps land below the sticky header instead of under it. */
  scroll-padding-top: 5rem;
}

body {
  font-family: var(--font-sans);
  font-size: var(--text-md);
  line-height: var(--leading-normal);
  color: var(--text-primary);
  background-color: var(--bg-primary);
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  /* Meme raison que sur html : `hidden` ferait de body un conteneur de
     defilement et decrocherait le header colle. Voir le commentaire ci-dessus. */
  overflow-x: clip;
  /* Geist is drawn for screen. Without these it renders noticeably heavier on
     macOS, which on a dark background reads as blur rather than weight. */
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  font-feature-settings: 'kern' 1;
}

/* Display sizes need negative tracking; body sizes do not. Setting it here
   rather than per-component is what keeps headings consistent across the eight
   apps, none of which declare tracking of their own. */
h1, h2, h3, h4, h5, h6 {
  font-weight: var(--font-weight-bold);
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-tight);
  text-wrap: balance;
}

h1 { letter-spacing: var(--tracking-tighter); }

p {
  text-wrap: pretty;
}

a {
  color: inherit;
  text-decoration: none;
}

img {
  max-width: 100%;
  height: auto;
  display: block;
}

::selection {
  background: rgba(var(--violet-primary-rgb), 0.3);
  color: var(--text-primary);
}

/* Small uppercase monospace label. The recurring "technical signage" element:
   section eyebrows, metadata, counters. Deliberately global, so apps get it
   without redefining a mono stack (there used to be five different ones). */
/* --text-secondary, not --text-tertiary: the eyebrow is the label that says
   what a section is, so it has to be readable. At 12px, tertiary measures
   4.11:1 against --bg-primary and AA asks for 4.5:1 below 18px. Tertiary is
   still the right token for decoration that carries no information. */
.eyebrow {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-medium);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-secondary);
}

/* ==========================================
   HEADER & NAVIGATION
   ========================================== */

/* The header is a translucent hairline rail over the canvas, not a coloured
   band. It used to be a violet gradient, which on a near-black page read as a
   separate light-themed application sitting on top of the site.

   The selector MUST stay scoped to `body > header`. A bare `header {}` here is
   a global element selector: it also matched every sectioning <header> in the
   page body (home's nine `.section-head`, `.module-header`, `.portfolio-header`,
   `.project-detail-header`, `.reco-modal-header`), which then inherited
   `position: sticky; z-index: 1100` and stuck to the top of the viewport ON TOP
   of the real navbar while scrolling. Same z-index, later in DOM order, so the
   section header won. Do not un-scope it. */
body > header {
  position: sticky;
  top: 0;
  background: rgba(10, 10, 12, 0.72);
  -webkit-backdrop-filter: saturate(180%) blur(14px);
  backdrop-filter: saturate(180%) blur(14px);
  border-bottom: 1px solid var(--border-color);
  /* Above the map UI ceiling: Leaflet control corners default to z-index 1000,
     which otherwise ties this header and wins on DOM order (the navbar dropdown
     then renders behind the immo map controls). Do NOT lower this, and do not
     introduce a positioned ancestor around <header>: that would create a new
     stacking context and put the whole rail back under the map. */
  z-index: 1100;
}

/* No backdrop-filter support: fall back to an opaque rail rather than letting
   content scroll through a 72% transparent bar. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  body > header { background: var(--bg-primary); }
}

.nav-container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 var(--space-md);
  height: 56px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.logo {
  color: var(--text-primary);
  text-decoration: none;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  transition: opacity var(--transition-fast);
}

.logo:hover {
  opacity: 0.8;
}

/* The brand mark, in the header and in the footer's brand block.

   26px rather than the 22px the placeholder SVG used: the mark is cut out of a
   tile that carried its own internal padding, so at equal box height it reads
   noticeably smaller than a shape drawn edge to edge. The nav row is 56px, so
   this still leaves 15px of air above and below.

   flex-shrink: 0 because .logo is a flex row and the wordmark next to it would
   otherwise squeeze the image on narrow screens. */
.logo-img {
  height: 26px;
  width: auto;
  display: block;
  flex-shrink: 0;
}

.logo-text {
  font-size: var(--text-base);
  font-weight: var(--font-weight-bold);
  letter-spacing: var(--tracking-tight);
  color: var(--text-primary);
}

.nav-links {
  list-style: none;
  display: flex;
  gap: 0.25rem;
  align-items: center;
}

.nav-links a,
.dropdown-toggle {
  color: var(--text-secondary);
  text-decoration: none;
  padding: 0.4rem 0.7rem;
  border-radius: var(--radius-sm);
  font-size: var(--text-base);
  font-weight: var(--font-weight-normal);
  font-family: inherit;
  background: none;
  border: none;
  cursor: pointer;
  transition: color var(--transition-fast), background-color var(--transition-fast);
}

.nav-links a:hover,
.dropdown-toggle:hover,
.dropdown:hover .dropdown-toggle,
.dropdown.is-open .dropdown-toggle {
  color: var(--text-primary);
  background-color: rgba(255, 255, 255, 0.05);
}

/* Current page. Set by chrome.js from the URL, so it survives any nav reorder. */
.nav-links a[aria-current="page"] {
  color: var(--text-primary);
}

/* ---- Nav CTA ----
   The one conversion link that is present on all 19 pages. It is an OUTLINE
   button, deliberately not `.btn-accent`: yellow is rationed to one occurrence
   per screen and the home page already spends it on the closing "Réserver un
   créneau". A yellow pill in the sticky header would fire on every page and
   collide with that one, which is exactly how the accent stops signalling.
   A near-white fill was the other candidate; it competes with `.btn-primary`
   in the hero, three centimetres below it. */
.nav-links a.nav-cta {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  margin-left: 0.4rem;
  padding: 0.4rem 0.85rem;
  color: var(--text-primary);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-full);
  white-space: nowrap;
}

.nav-links a.nav-cta:hover {
  background-color: var(--violet-wash);
  border-color: var(--violet-dark);
}

/* Logout button styled as nav link */
.logout-button {
  background: none;
  border: none;
  outline: none;
  color: var(--text-secondary);
  cursor: pointer;
  font-size: var(--text-base);
  font-family: inherit;
  padding: 0.4rem 0.7rem;
  margin: 0;
  text-decoration: none;
  border-radius: var(--radius-sm);
  transition: color var(--transition-fast), background-color var(--transition-fast);
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
}

.logout-button:hover {
  color: var(--text-primary);
  background-color: rgba(255, 255, 255, 0.05);
}

/* ---- Dropdown ---- */
.dropdown {
  position: relative;
}

.dropdown-caret {
  width: 14px !important;
  height: 14px !important;
  margin-right: 0 !important;
  margin-left: 0.25rem;
  opacity: 0.6;
}

.dropdown-menu {
  display: none;
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  background: var(--surface-raised);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  min-width: max-content;
  box-shadow: var(--shadow-lg), var(--ring-inset);
  list-style: none;
  padding: 0.3rem;
  margin: 0;
  z-index: 1001;
}

/* Hover for pointers, .is-open for keyboard and touch (chrome.js). The old
   markup was a hover-only <a href="#">, which no keyboard user could reach. */
.dropdown:hover .dropdown-menu,
.dropdown.is-open .dropdown-menu {
  display: block;
}

/* Bridges the 6px gap between toggle and menu so the pointer can travel down
   without the menu closing under it. */
.dropdown:hover::after {
  content: '';
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  height: 8px;
}

.dropdown-menu li {
  margin: 0;
}

.dropdown-menu a,
.dropdown-logout-button {
  display: flex;
  align-items: center;
  width: 100%;
  padding: 0.45rem 0.7rem;
  border-radius: var(--radius-sm);
  color: var(--text-secondary);
  text-decoration: none;
  background: none;
  border: none;
  text-align: left;
  cursor: pointer;
  font-family: inherit;
  font-size: var(--text-base);
  white-space: nowrap;
  transition: color var(--transition-fast), background-color var(--transition-fast);
}

.dropdown-menu a:hover,
.dropdown-menu a:focus-visible,
.dropdown-logout-button:hover,
.dropdown-logout-button:focus-visible {
  background-color: rgba(255, 255, 255, 0.06);
  color: var(--text-primary);
  outline: none;
}

/* Navigation Icons (Lucide) */
.nav-links a svg,
.dropdown-toggle svg,
.dropdown-menu a svg,
.dropdown-logout-button svg {
  width: 16px;
  height: 16px;
  vertical-align: middle;
  margin-right: 0.5rem;
  flex-shrink: 0;
  opacity: 0.75;
}

.nav-links > li > a,
.dropdown-toggle {
  display: inline-flex;
  align-items: center;
}

/* Mobile Menu Toggle Button */
.mobile-menu-toggle {
  display: none;
  background: none;
  border: 1px solid var(--border-strong);
  color: var(--text-primary);
  cursor: pointer;
  padding: 0.35rem;
  border-radius: var(--radius-sm);
  transition: background-color var(--transition-fast);
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
}

.mobile-menu-toggle svg {
  width: 20px;
  height: 20px;
  display: block;
}

.mobile-menu-toggle .close-icon {
  display: none;
}

.mobile-menu-toggle.active .menu-icon {
  display: none;
}

.mobile-menu-toggle.active .close-icon {
  display: block;
}

.mobile-menu-toggle:hover {
  background-color: rgba(255, 255, 255, 0.06);
}

/* ==========================================
   MAIN CONTENT
   ========================================== */

main {
  max-width: 1200px;
  width: 100%;
  margin: var(--space-md) auto;
  padding: 0 var(--space-md);
  min-height: calc(100vh - 200px);
  flex: 1;
  overflow-x: hidden;
}

@media (max-width: 480px) {
  main {
    padding: 0 var(--space-xs);
    margin: var(--space-sm) auto;
  }
}

/* ==========================================
   MESSAGES & ALERTS
   ========================================== */

.messages {
  position: fixed;
  top: 72px;
  right: var(--space-md);
  z-index: 1000;
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  max-width: 400px;
}

.message {
  padding: 0.7rem var(--space-sm);
  border-radius: var(--radius-md);
  border: 1px solid var(--border-strong);
  border-left: 3px solid;
  font-size: var(--text-base);
  background-color: var(--surface-raised);
  box-shadow: var(--shadow-lg);
  animation: slideInRight 0.3s ease-out;
  transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}

.message.fade-out {
  opacity: 0;
  transform: translateX(20px);
}

@keyframes slideInRight {
  from {
    opacity: 0;
    transform: translateX(20px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* One rule per level. The light/dark pairs these replaced disagreed on more
   than luminance: the light "success" was violet while the dark one was a
   stale indigo that matched no token.

   The status tokens are TRANSLUCENT, which is right for a badge sitting on a
   known surface and wrong for a toast: setting them as `background-color`
   replaced the opaque `--surface-raised` of `.message` instead of tinting it,
   so whatever the toast floated over showed straight through the text. It went
   unnoticed while the only toasts were server-rendered ones on quiet pages;
   `SotisDialog.notify` now raises them over the immo map and the analytics
   charts. The tint therefore goes in as a background LAYER, over the opaque
   colour rather than in place of it, which keeps the tokens as they are. */
.message.success {
  background-image: linear-gradient(var(--success-bg), var(--success-bg));
  color: var(--success-text);
  border-color: var(--success);
}

.message.error {
  background-image: linear-gradient(var(--danger-bg), var(--danger-bg));
  color: var(--danger-text);
  border-color: var(--danger);
}

.message.info {
  background-image: linear-gradient(var(--info-bg), var(--info-bg));
  color: var(--info-text);
  border-color: var(--violet-primary);
}

.message.warning {
  background-image: linear-gradient(var(--warning-bg), var(--warning-bg));
  color: var(--warning-text);
  border-color: var(--warning);
}

/* ==========================================
   CONFIRMATION DIALOG (SotisDialog.confirm)
   ==========================================
   Above .messages (1000) and above the header (1100, itself set above the
   1000 ceiling of the Leaflet controls): a confirmation that a map control
   can overlap is a confirmation the user can misread. */

.confirm-backdrop {
  position: fixed;
  inset: 0;
  z-index: 1200;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-md);
  background: var(--overlay-dark);
  backdrop-filter: blur(2px);
}

.confirm-dialog {
  width: 100%;
  max-width: 30rem;
  background: var(--surface-raised);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  padding: var(--space-lg);
  animation: slideIn 0.15s ease-out;
}

.confirm-message {
  margin: 0 0 var(--space-md);
  color: var(--text-primary);
  font-size: var(--text-md);
  line-height: var(--leading-normal);
}

.confirm-actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--space-xs);
  flex-wrap: wrap;
}

/* "Annuler" stays the plain .btn-secondary outline and "Confirmer" the filled
   .btn-primary, even though the dialog opens focused on "Annuler": the ring
   comes from :focus-visible, which a programmatic focus does not always show,
   so the quiet button is not guaranteed to be marked. Left as is on purpose,
   the destructive answer being the one that should have to be aimed at. */

@media (max-width: 480px) {
  .confirm-actions button {
    flex: 1 1 auto;
  }
}

/* ==========================================
   BUTTONS
   ========================================== */

/* Hierarchy, in descending order of weight:
     .btn-primary    near-white fill      the one action on the page
     .btn-accent     yellow fill          the single brand spark, at most once
     .btn-secondary  hairline outline     everything else
     .btn-ghost      bare text            tertiary, inline

   The old scheme filled .btn-primary with yellow and .btn-secondary with
   violet, which put two saturated fills side by side in every hero and left
   nothing to escalate to. Yellow now signals precisely because it is rare. */

.btn-primary,
.btn-secondary,
.btn-accent,
.btn-ghost {
  padding: 0.6rem 1.1rem;
  border-radius: var(--radius-sm);
  font-family: inherit;
  font-size: var(--text-base);
  font-weight: var(--font-weight-medium);
  letter-spacing: var(--tracking-tight);
  line-height: 1.2;
  cursor: pointer;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.45rem;
  text-align: center;
  border: 1px solid transparent;
  transition: background-color var(--transition-fast),
              border-color var(--transition-fast),
              color var(--transition-fast),
              opacity var(--transition-fast);
}

.btn-primary {
  background-color: var(--text-primary);
  color: var(--bg-primary);
}

.btn-primary:hover {
  background-color: var(--white);
}

.btn-accent {
  background-color: var(--yellow-primary);
  color: var(--dark-text);
}

.btn-accent:hover {
  background-color: var(--yellow-dark);
}

.btn-secondary {
  background-color: transparent;
  border-color: var(--border-strong);
  color: var(--text-primary);
}

.btn-secondary:hover {
  background-color: rgba(255, 255, 255, 0.06);
  border-color: rgba(255, 255, 255, 0.22);
}

.btn-ghost {
  background: transparent;
  color: var(--text-secondary);
  padding-left: 0.6rem;
  padding-right: 0.6rem;
}

.btn-ghost:hover {
  color: var(--text-primary);
  background-color: rgba(255, 255, 255, 0.05);
}

/* Violet fill, for the rare case where an action must read as branded rather
   than neutral. --violet-fill is the only violet dark enough to carry white. */
.btn-violet {
  background-color: var(--violet-fill);
  color: var(--white);
}

.btn-violet:hover {
  background-color: var(--violet-dark);
}

.btn-primary:active,
.btn-secondary:active,
.btn-accent:active,
.btn-ghost:active {
  opacity: 0.85;
}

.btn-primary:focus-visible,
.btn-secondary:focus-visible,
.btn-accent:focus-visible,
.btn-ghost:focus-visible,
.btn-violet:focus-visible {
  outline: 2px solid var(--violet-primary);
  outline-offset: 2px;
}

.btn-primary svg,
.btn-secondary svg,
.btn-accent svg,
.btn-ghost svg,
.btn-violet svg {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
}

/* ==========================================
   FORMS & INPUTS
   ========================================== */

.auth-container {
  max-width: 400px;
  margin: var(--space-xl) auto;
  background: var(--card-bg);
  padding: var(--space-md);
  border: 1px solid var(--border-color);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg), var(--ring-inset);
}

.auth-container h1 {
  margin-bottom: var(--space-md);
  color: var(--text-primary);
  font-size: var(--text-xl);
}

.auth-form {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}

.form-group {
  display: flex;
  flex-direction: column;
}

.form-group label {
  margin-bottom: var(--space-xs);
  font-weight: var(--font-weight-medium);
  color: var(--text-primary);
}

/* Inputs used to declare a border but no background or colour, so they fell
   back to the browser's own control rendering: a grey box that matched nothing
   on the page. Both are now explicit. */
.form-group input,
.form-group textarea,
.form-group select {
  padding: 0.6rem 0.7rem;
  background: var(--input-bg);
  color: var(--text-primary);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  font-size: var(--text-base);
  font-family: inherit;
  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}

.form-group input::placeholder,
.form-group textarea::placeholder {
  color: var(--text-tertiary);
}

.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
  outline: none;
  border-color: var(--violet-primary);
  box-shadow: 0 0 0 3px rgba(var(--violet-primary-rgb), 0.18);
}

.form-group input:hover,
.form-group textarea:hover,
.form-group select:hover {
  border-color: rgba(255, 255, 255, 0.22);
}

.help-text {
  font-size: 0.875rem;
  color: var(--text-secondary);
  margin-top: 0.25rem;
}

.field-error {
  color: var(--danger-text);
  font-size: var(--text-sm);
  margin-top: 0.25rem;
  font-weight: var(--font-weight-medium);
}

.form-errors {
  background-color: var(--danger-bg);
  color: var(--danger-text);
  padding: var(--space-xs) var(--space-sm);
  border: 1px solid var(--border-strong);
  border-left: 3px solid var(--danger);
  border-radius: var(--radius-sm);
  margin-bottom: var(--space-sm);
  font-size: var(--text-base);
}

.auth-link {
  text-align: center;
  margin-top: var(--space-sm);
  color: var(--text-secondary);
}

.auth-link a {
  color: var(--violet-primary);
  text-decoration: none;
  font-weight: var(--font-weight-medium);
  transition: color var(--transition-base);
}

.auth-link a:hover {
  color: var(--text-primary);
  text-decoration: underline;
}

/* ==========================================
   PAGE HEADER (Unified style for all apps)
   ========================================== */

/* Left-aligned, with a hairline rule under it. Centred page titles are the
   single most template-looking thing on a content page; every app uses this
   class, so changing it here re-aligns all eight at once. */
.page-header {
  margin-bottom: var(--space-md);
  padding-bottom: var(--space-sm);
  border-bottom: 1px solid var(--border-color);
  max-width: 100%;
  overflow-wrap: break-word;
  word-wrap: break-word;
}

.page-header h1 {
  color: var(--text-primary);
  margin-bottom: 0.4rem;
  font-size: var(--text-3xl);
  letter-spacing: var(--tracking-tighter);
  overflow-wrap: break-word;
  word-wrap: break-word;
  hyphens: auto;
}

.page-header .subtitle,
.page-header p {
  color: var(--text-secondary);
  font-size: var(--text-md);
  max-width: 62ch;
}

/* Optional monospace kicker above the title. */
.page-header .eyebrow {
  display: block;
  margin-bottom: 0.6rem;
}

@media (max-width: 768px) {
  .page-header h1 {
    font-size: var(--text-2xl);
  }
  .page-header .subtitle,
  .page-header p {
    font-size: var(--text-base);
  }
}

@media (max-width: 480px) {
  .page-header h1 {
    font-size: var(--text-xl);
  }
}

/* ==========================================
   FOOTER
   ========================================== */

/* NOTE: `footer` is a bare element selector, so it reaches every <footer> in
   every app, including ones nested inside components. Keep the rules here
   about the page footer only, and scope anything richer to .site-footer. */
footer {
  background-color: var(--footer-bg);
  color: var(--text-secondary);
  border-top: 1px solid var(--border-color);
  padding: var(--space-lg) var(--space-md) var(--space-md);
  margin-top: var(--space-xl);
  font-size: var(--text-base);
}

footer a {
  color: var(--text-secondary);
  transition: color var(--transition-fast);
}

footer a:hover {
  color: var(--text-primary);
}

.site-footer-inner {
  max-width: 1200px;
  margin: 0 auto;
  display: grid;
  grid-template-columns: 1.6fr 1fr 1fr;
  gap: var(--space-md);
  align-items: start;
}

.site-footer-lockup {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

/* Slightly larger than in the header: the footer has room, and the four bars
   under the cloud only separate cleanly above roughly 26px. */
.site-footer-lockup .logo-img {
  height: 28px;
}

.site-footer-brand .logo-text {
  font-size: var(--text-base);
}

.site-footer-brand p {
  margin-top: 0.6rem;
  color: var(--text-tertiary);
  font-size: var(--text-sm);
  max-width: 34ch;
}

.site-footer-col h2 {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-medium);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-tertiary);
  margin-bottom: 0.7rem;
}

.site-footer-col ul {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

.site-footer-bottom {
  max-width: 1200px;
  margin: var(--space-md) auto 0;
  padding-top: var(--space-sm);
  border-top: 1px solid var(--border-color);
  display: flex;
  justify-content: space-between;
  gap: var(--space-sm);
  flex-wrap: wrap;
  color: var(--text-tertiary);
  font-size: var(--text-sm);
}

/* --text-secondary, not the --text-tertiary of the line it sits on: the two
   copyright spans beside it are metadata, this one is a link the law expects a
   visitor to be able to find and follow. */
.site-footer-legal a {
  color: var(--text-secondary);
  text-decoration: none;
}

.site-footer-legal a:hover {
  color: var(--text-primary);
  text-decoration: underline;
}

@media (max-width: 768px) {
  .site-footer-inner {
    grid-template-columns: 1fr;
    gap: var(--space-sm);
  }
}

/* ==========================================
   CUSTOM SELECT COMPONENT (Reusable)
   ========================================== */

.custom-select {
  position: relative;
  width: 100%;
}

.custom-select-trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  width: 100%;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--violet-primary);
  border-radius: var(--radius-sm);
  background: var(--input-bg);
  color: var(--text-primary);
  font-size: 0.9rem;
  cursor: pointer;
  transition: var(--transition-fast);
  text-align: left;
}

.custom-select-trigger:hover {
  border-color: var(--violet-dark);
}

.custom-select.open .custom-select-trigger {
  border-color: var(--violet-dark);
  box-shadow: 0 0 0 2px rgba(var(--violet-primary-rgb), 0.3);
}

.custom-select-trigger .selected-text {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.custom-select-trigger .dropdown-arrow {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  transition: transform var(--transition-fast);
}

.custom-select.open .dropdown-arrow {
  transform: rotate(180deg);
}

.custom-select-dropdown {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  min-width: 100%;
  width: max-content;
  max-width: min(450px, calc(100vw - 2rem));
  background: var(--card-bg);
  border: 1px solid var(--violet-dark);
  border-top: none;
  border-radius: 0 0 var(--radius-sm) var(--radius-sm);
  box-shadow: var(--shadow-lg);
  z-index: 1001;
  max-height: 350px;
  overflow: hidden;
  flex-direction: column;
}

.custom-select.open .custom-select-dropdown {
  display: flex;
}

/* Simple dropdowns (without search) */
.custom-dropdown-simple {
  max-height: 250px;
  border-radius: var(--radius-sm);
  border-top: 1px solid var(--violet-dark);
  margin-top: 2px;
}

.custom-select-simple.open .custom-select-trigger {
  border-bottom-left-radius: var(--radius-sm);
  border-bottom-right-radius: var(--radius-sm);
}

.custom-dropdown-simple .custom-dropdown-options {
  padding: 0.25rem 0;
}

/* Dropdown with search */
.custom-select.open:not(.custom-select-simple) .custom-select-trigger {
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}

.custom-dropdown-search {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.75rem;
  border-bottom: 1px solid var(--border-color);
  background: var(--bg-secondary);
}

.custom-dropdown-search svg {
  width: 16px;
  height: 16px;
  color: var(--text-secondary);
  flex-shrink: 0;
}

.custom-dropdown-search input {
  flex: 1;
  border: none;
  background: transparent;
  color: var(--text-primary);
  font-size: 0.9rem;
  outline: none;
}

.custom-dropdown-search input::placeholder {
  color: var(--text-secondary);
}

.custom-dropdown-options {
  flex: 1;
  overflow-y: auto;
  padding: 0 0 0.25rem 0;
}

.custom-dropdown-option {
  padding: 0.5rem 0.75rem;
  cursor: pointer;
  font-size: 0.85rem;
  color: var(--text-primary);
  transition: background var(--transition-fast);
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.custom-dropdown-option:hover {
  background: rgba(var(--violet-primary-rgb), 0.15);
}

.custom-dropdown-option.selected {
  background: rgba(var(--violet-primary-rgb), 0.25);
  font-weight: 500;
  color: var(--violet-dark);
}

.custom-dropdown-option-special {
  border-bottom: 1px solid var(--border-color);
  font-weight: 500;
  color: var(--violet-dark);
  padding: 0.6rem 0.75rem;
}

.custom-dropdown-option-special svg {
  width: 16px;
  height: 16px;
}

/* Grouped options */
.custom-dropdown-group {
  padding: 0;
}

.custom-dropdown-group-header {
  padding: 0.5rem 0.75rem;
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--violet-dark);
  background: var(--bg-secondary);
  position: sticky;
  top: 0;
  z-index: 1;
  border-bottom: 1px solid var(--border-color);
}

.custom-dropdown-group .custom-dropdown-option {
  padding-left: 1.25rem;
}

/* Search results (flat list when searching) */
.custom-dropdown-results {
  padding: 0.25rem 0;
}

.custom-dropdown-results .custom-dropdown-option {
  justify-content: space-between;
}

.custom-dropdown-results .result-label {
  flex: 1;
}

.custom-dropdown-results .result-tag {
  font-size: 0.75rem;
  color: var(--text-secondary);
  background: rgba(var(--violet-primary-rgb), 0.15);
  padding: 0.15rem 0.5rem;
  border-radius: var(--radius-full);
  margin-left: 0.5rem;
  white-space: nowrap;
}

.custom-dropdown-no-results {
  padding: 1rem;
  text-align: center;
  color: var(--text-secondary);
  font-size: 0.9rem;
}

/* ==========================================
   UTILITY CLASSES
   ========================================== */

.text-center {
  text-align: center;
}

.mb-1 {
  margin-bottom: var(--space-sm);
}

.mb-2 {
  margin-bottom: var(--space-md);
}

.mt-1 {
  margin-top: var(--space-sm);
}

.mt-2 {
  margin-top: var(--space-md);
}

/* ==========================================
   RESPONSIVE DESIGN
   ========================================== */

@media (max-width: 768px) {
  :root {
    --space-md: 1.5rem;
    --space-lg: 2rem;
    --space-xl: 2.5rem;
  }

  /* ---- Mobile navigation ----
     The nav collapses into a sheet that drops out of the sticky rail. The
     breakpoint here must stay in sync with MOBILE_BREAKPOINT in chrome.js,
     which closes the sheet when the viewport grows past it. */
  .nav-container {
    flex-direction: row;
    flex-wrap: wrap;
    gap: 0;
    position: relative;
    height: auto;
    min-height: 56px;
    padding-top: 0.5rem;
    padding-bottom: 0.5rem;
  }

  .mobile-menu-toggle {
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .nav-links {
    display: none;
    flex-direction: column;
    width: 100%;
    padding: var(--space-xs) 0 0;
    gap: 0;
    order: 3;
  }

  .nav-links.mobile-open {
    display: flex;
  }

  .nav-links > li {
    width: 100%;
    border-top: 1px solid var(--border-color);
  }

  .nav-links > li > a,
  .nav-links > li > .dropdown-toggle {
    display: flex;
    padding: 0.7rem 0;
    width: 100%;
    border-radius: 0;
  }

  /* In the mobile sheet every entry is already a full-width row with its own
     hairline above it, so the CTA's pill outline would draw a second border
     inside the first. It keeps the violet ink instead, which is what makes it
     read as the action among the destinations. */
  .nav-links > li > a.nav-cta {
    margin-left: 0;
    border: none;
    color: var(--violet-primary);
  }

  .nav-links > li > a.nav-cta:hover {
    background-color: transparent;
  }

  /* Submenus open in flow rather than floating: a popover anchored to a
     full-width row would overflow the viewport. */
  .dropdown-menu {
    position: static;
    background: transparent;
    border: none;
    border-radius: 0;
    box-shadow: none;
    padding: 0 0 0.4rem;
    min-width: 100%;
  }

  /* Hover must not open submenus on touch: the tap that "hovers" is the same
     tap that would follow the first link underneath. */
  .dropdown:hover .dropdown-menu {
    display: none;
  }

  .dropdown.is-open .dropdown-menu {
    display: block;
  }

  .dropdown-menu a,
  .dropdown-menu li form {
    padding-left: var(--space-sm);
  }

  main {
    padding: 0 var(--space-sm);
  }
}

@media (max-width: 480px) {
  .auth-container {
    padding: var(--space-sm);
  }
}

/* ==========================================
   ANIMATIONS & LOADING STATES
   ========================================== */

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

.spinner {
  display: inline-block;
  width: 20px;
  height: 20px;
  border: 3px solid var(--violet-light);
  border-top-color: var(--violet-primary);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

/* Smooth scrolling */
html {
  scroll-behavior: smooth;
}

/* Focus visible for accessibility */
:focus-visible {
  outline: 2px solid var(--yellow-primary);
  outline-offset: 2px;
}
