/* Self-hosted, so the CSP stays at 'self' and no request leaves for a third
   party. One 47KB latin subset carries the whole 100-900 weight axis. */
@font-face {
  font-family: "Inter Variable";
  src: url("/static/fonts/inter-latin-variable.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
  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;
}

/* Two brand colourways, used as a pair. The page takes one, the form panel and
   the mobile menu take the other, so controls read as solid surfaces rather
   than as translucent boxes floating on the ground colour.

   Everything downstream is written against --bg / --fg. The panel simply
   swaps those two, which is why every control inside it recolours for free. */
:root {
  --navy:  #0E1B2C;
  --cream: #F2EDE0;

  --bg: var(--navy);
  --fg: var(--cream);

  /* The panel's colourway: always the opposite of the page. */
  --panel-bg: var(--cream);
  --panel-fg: var(--navy);

  --error-on-navy:  #F0A9A4;
  --error-on-cream: #9B2C24;
  --error: var(--error-on-navy);

  --radius: 12px;
  --radius-card: 20px;
  --radius-pill: 999px;
  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  --ease-soft: cubic-bezier(0.45, 0, 0.55, 1);
  --speed: 180ms;
}

/* The light colourway, reached two ways: the OS preference for a visitor who
   has expressed no choice, and the choice itself once they have made one.
   site.js writes data-theme on <html> in <head>, before first paint, so the
   attribute governs wherever JavaScript runs and the media query is what
   serves everyone else. The declarations appear twice because a media query
   cannot be folded into a selector list -- keep the two in step. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    --bg: var(--cream);
    --fg: var(--navy);
    --panel-bg: var(--navy);
    --panel-fg: var(--cream);
    --error: var(--error-on-cream);
  }
}

:root[data-theme="light"] {
  --bg: var(--cream);
  --fg: var(--navy);
  --panel-bg: var(--navy);
  --panel-fg: var(--cream);
  --error: var(--error-on-cream);
}

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

/* Flat ground, deliberately. A low-opacity radial gradient over a dark
   background bands into visible rings on 8-bit displays, which reads as
   cheap. Depth comes from the card's shadow instead. */
body {
  margin: 0;
  min-height: 100dvh;
  background: var(--bg);
  color: var(--fg);
  font-family: "Inter Variable", "Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont,
               "Segoe UI Variable Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  font-optical-sizing: auto;
  text-rendering: optimizeLegibility;
  font-size: 16px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  accent-color: var(--fg);
}

/* Fine dithered grain -- what the radial gradient was reaching for, except
   grain cannot band, because it is already noise. Fixed so it does not swim
   when the page scrolls, non-interactive, and it sits below the nav dropdown
   (z-index 10) because it never claims a stacking index of its own. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  opacity: 0.032;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: 200px 200px;
}

/* Topographic contours behind the landing page. The mark is drawn from
   Auyan-tepui, so contours are the honest motif rather than decoration.

   Applied as a mask over a --fg fill rather than as a background image, so the
   lines follow the theme from a single asset: cream on navy, navy on cream.

   z-index: -1 is load-bearing. A fixed-position pseudo-element with auto
   z-index paints above in-flow content, which would veil the mark. Negative
   puts it above the body background and below everything else.

   On every page. Only the fade-in below is landing-only -- the texture itself
   is the ground the site stands on, and having it appear and vanish as you
   navigate would read as a glitch. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background-color: var(--fg);
  -webkit-mask-image: url("/static/topo.svg");
  mask-image: url("/static/topo.svg");
  -webkit-mask-size: cover;
  mask-size: cover;
  mask-position: center;
  mask-repeat: no-repeat;
  opacity: 0.085;
}

/* The centring layout lives here rather than on <body>: Turnstile injects a
   hidden element as a direct child of <body>, and as a grid item it would
   claim a row and push the page off centre. Rows are assigned explicitly
   for the same reason. */
.shell {
  min-height: 100dvh;
  display: grid;
  grid-template-rows: auto 1fr auto;
  justify-items: center;
  align-items: center;
  padding: 1.5rem 1.25rem 1.5rem;
}
.shell > .nav    { grid-row: 1; align-self: start; }
.shell > main    { grid-row: 2; }
.shell > .legal  { grid-row: 3; align-self: end; }

/* Balances the navbar at the top, which also re-centres main optically. */
.legal {
  margin: 0;
  padding-top: 2rem;
  font-size: 0.66rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  opacity: 0.32;
}

/* --- navbar ------------------------------------------------------------- */

.nav {
  width: 100%;
  max-width: 60rem;
  display: flex;
  justify-content: flex-end;
  align-items: center;
  /* Separates the switch from the links it now follows, and from the hamburger
     once the links collapse. .nav-links is absolutely positioned at that
     width, so the gap never applies to the dropdown itself. */
  gap: 0.9rem;
  position: relative;
}

.nav-links { display: flex; gap: 1.75rem; }

.nav-links a {
  color: inherit;
  text-decoration: none;
  opacity: 0.5;
  font-size: 0.72rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  padding: 0.5rem 0.15rem;
  border-radius: 4px;
  transition: opacity var(--speed) var(--ease);
}
.nav-links a:hover,
.nav-links a[aria-current="page"] { opacity: 1; }
.nav-links a:focus-visible {
  opacity: 1;
  outline: 2px solid color-mix(in srgb, var(--fg) 55%, transparent);
  outline-offset: 3px;
}

/* Resets the global button styling; this one is an icon, not an action. */
.nav-toggle {
  display: none;
  flex-direction: column;
  justify-content: center;
  gap: 5px;
  width: 42px;
  height: 42px;
  padding: 0;
  margin: 0;
  border: 0;
  border-radius: 10px;
  background: none;
  color: inherit;
  cursor: pointer;
}
.nav-toggle span {
  display: block;
  width: 20px;
  height: 1.5px;
  margin-inline: auto;
  background: currentColor;
  transition: transform var(--speed) var(--ease), opacity var(--speed) var(--ease);
}
.nav-toggle[aria-expanded="true"] span:nth-child(1) { transform: translateY(6.5px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.nav-toggle[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); }
.nav-toggle:focus-visible {
  outline: 2px solid color-mix(in srgb, var(--fg) 55%, transparent);
  outline-offset: 2px;
}

/* A switch: a track with a knob that slides between the two themes. Resets the
   global button styling for the same reason .nav-toggle does -- this is a
   control, not an action. Hidden without JavaScript, like the collapsed menu:
   there is nothing for a click to do. */
.theme-switch { display: none; }
.js .theme-switch {
  display: inline-flex;
  align-items: center;
  position: relative;
  width: 46px;
  height: 26px;
  flex: none;
  padding: 0;
  margin: 0;
  border: 1px solid color-mix(in srgb, var(--fg) 30%, transparent);
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--fg) 7%, transparent);
  color: inherit;
  align-self: center;
  cursor: pointer;
  transition: background var(--speed) var(--ease), border-color var(--speed) var(--ease);
}
.js .theme-switch:hover { border-color: color-mix(in srgb, var(--fg) 55%, transparent); }
.theme-switch:focus-visible {
  outline: 2px solid color-mix(in srgb, var(--fg) 55%, transparent);
  outline-offset: 3px;
}

/* Positioned from the left and moved with translate, so only the transform
   animates and the track never reflows under it. */
.theme-switch-knob {
  position: absolute;
  top: 50%;
  left: 3px;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--fg);
  color: var(--bg);
  transform: translateY(-50%);
  transition: transform var(--speed) var(--ease);
}
.theme-switch-knob svg { display: block; width: 11px; height: 11px; }

/* Knob left is dark, knob right is light -- the icon inside it is the theme
   you are in, not the one a click leads to. data-theme is always present when
   this control is visible: site.js writes it in <head>, and the switch itself
   only appears under .js. */
:root[data-theme="dark"]  .theme-switch .icon-sun  { display: none; }
:root[data-theme="light"] .theme-switch .icon-moon { display: none; }
:root[data-theme="light"] .theme-switch-knob { transform: translate(20px, -50%); }

/* --- layout ------------------------------------------------------------- */

main {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  max-width: 26rem;
}

/* The mark takes the left column and the form the right, so the form never
   sits below the fold. */
main.two-col {
  display: grid;
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
  align-items: center;
  gap: clamp(2rem, 5vw, 4rem);
  max-width: 54rem;
}

/* Clear space around the mark is at least half its width, per the logo kit. */
.mark { display: block; }
.mark svg {
  width: min(56vw, 250px);
  height: auto;
  display: block;
  margin-inline: auto;
}
a.mark { border-radius: var(--radius); transition: opacity var(--speed) var(--ease); }
a.mark:hover { opacity: 0.8; }
a.mark:focus-visible {
  outline: 2px solid color-mix(in srgb, var(--fg) 55%, transparent);
  outline-offset: 8px;
}

main.two-col .mark svg { width: min(30vw, 210px); }

.tagline {
  margin: 2rem 0 0;
  opacity: 0.6;
  font-size: 0.95rem;
  letter-spacing: 0.06em;
  text-align: center;
}

/* --- the panel: a card in the opposite colourway ------------------------ */

.panel {
  width: 100%;
  min-width: 0;

  /* Swapping these two is the whole trick. Every control below is written
     against --bg / --fg, so all of them invert together. */
  --bg: var(--panel-bg);
  --fg: var(--panel-fg);
  --error: var(--error-on-cream);

  background: var(--bg);
  color: var(--fg);
  padding: clamp(1.5rem, 3vw, 2.25rem);
  border-radius: var(--radius-card);
  box-shadow: 0 18px 50px -12px rgb(0 0 0 / 0.45);
  text-align: left;
}

/* Same pairing as the root palette above: OS preference, then explicit choice. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .panel {
    --error: var(--error-on-navy);
    box-shadow: 0 18px 50px -18px rgb(0 0 0 / 0.35);
  }
}

:root[data-theme="light"] .panel {
  --error: var(--error-on-navy);
  box-shadow: 0 18px 50px -18px rgb(0 0 0 / 0.35);
}

/* --- forms -------------------------------------------------------------- */

form {
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
  width: 100%;
  text-align: left;
}

label { display: block; }

/* These mix against var(--fg) in the declaration that uses it rather than
   going through an intermediate custom property. A custom property inherits
   its already-substituted value, so a --line declared at :root stays cream
   even inside the navy-on-cream panel. Written here, it resolves against the
   element's own --fg. */
input, textarea {
  width: 100%;
  font: inherit;
  color: var(--fg);
  background: color-mix(in srgb, var(--fg) 5%, transparent);
  border: 1px solid color-mix(in srgb, var(--fg) 30%, transparent);
  border-radius: var(--radius);
  padding: 0.75rem 0.95rem;
  transition: border-color var(--speed) var(--ease),
              background-color var(--speed) var(--ease),
              box-shadow var(--speed) var(--ease);
}
input::placeholder, textarea::placeholder { color: var(--fg); opacity: 0.5; }
input:hover, textarea:hover { border-color: color-mix(in srgb, var(--fg) 55%, transparent); }
input:focus, textarea:focus {
  outline: none;
  border-color: var(--fg);
  background: color-mix(in srgb, var(--fg) 8%, transparent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--fg) 14%, transparent);
}
textarea { min-height: 6rem; resize: vertical; }

button {
  font: inherit;
  font-weight: 500;
  cursor: pointer;
  align-self: flex-start;
  padding: 0.7rem 1.7rem;
  margin-top: 0.3rem;
  border: 1px solid transparent;
  border-radius: var(--radius-pill);
  background: var(--fg);
  color: var(--bg);
  letter-spacing: 0.04em;
  transition: transform var(--speed) var(--ease),
              opacity var(--speed) var(--ease),
              box-shadow var(--speed) var(--ease);
}
button:hover { opacity: 0.88; transform: translateY(-1px); }
button:active { transform: translateY(0); }
button:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px var(--bg),
              0 0 0 4px color-mix(in srgb, var(--fg) 60%, transparent);
}

.error, .notice { margin: 0; font-size: 0.85rem; }
.error { color: var(--error); }
.notice { opacity: 0.75; }

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

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* --- narrow screens ----------------------------------------------------- */

@media (max-width: 720px) {
  .nav-toggle { display: flex; }

  /* The links collapse into an absolutely positioned dropdown, leaving the
     switch and the hamburger as the only two items in flow. The switch sits
     to the left of the hamburger here, so it is pulled back ahead of it --
     the markup keeps the wide layout's order, where tab order matters most. */
  .js .theme-switch { order: -1; }

  /* Collapsed only when JS is available to reopen it. Without JS the links
     stay inline and the site keeps working. */
  .js .nav-links {
    display: none;
    position: absolute;
    top: calc(100% + 0.5rem);
    right: 0;
    z-index: 10;
    flex-direction: column;
    gap: 0.25rem;
    min-width: 11rem;
    padding: 0.6rem 0.9rem;
    background: var(--panel-bg);
    color: var(--panel-fg);
    border-radius: 14px;
    box-shadow: 0 14px 40px -10px rgb(0 0 0 / 0.5);
    animation: drop 160ms var(--ease) both;
  }
  .js .nav-links.open { display: flex; }
  .js .nav-links a { opacity: 0.75; padding: 0.55rem 0.15rem; }
  .js .nav-links a:hover,
  .js .nav-links a[aria-current="page"] { opacity: 1; }

  main.two-col {
    grid-template-columns: 1fr;
    gap: 1.5rem;
    max-width: 26rem;
  }
  main.two-col .mark svg { width: min(44vw, 150px); }
  .panel { text-align: center; }
  button { align-self: stretch; }
}

@keyframes drop {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: none; }
}

/* Short windows: the card can overshoot a ~650px viewport, and the whole
   point of the two-column layout is that nothing needs scrolling. */
@media (max-height: 780px) {
  .shell { padding: 1rem 1.25rem 1.25rem; }
  .panel { padding: 1.4rem 1.6rem; }
  textarea { min-height: 4.5rem; }
  form { gap: 0.6rem; }
  .tagline { margin-top: 1.25rem; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

/* --- landing entrance --------------------------------------------------- */

/* The mark is drawn from Auyan-tepui and Salto Angel, so the sequence follows
   the water: the hexagon draws itself, the eight bars fall from the top down,
   then the wordmark and the tagline settle.

   Selectors lean on the SVG's own attributes rather than added classes, so
   re-copying the asset from the kit cannot silently break them: the single
   stroked path is the hexagon, the one filled group holds the eight bars, and
   the lone direct-child path is the wordmark. test_landing.py guards that shape.

   Every rule uses animation-fill-mode `both` rather than setting opacity: 0 on
   the elements. The backwards fill hides them during the delay, and if
   animations never run the mark simply renders as normal instead of staying
   invisible.

   Landing page only. Re-running it on every visit to /contact and /login would
   turn a flourish into a tax. */
@media (prefers-reduced-motion: no-preference) {
  /* `backwards`, not `both`, and the dasharray lives in the keyframes rather
     than on the element. A dasharray left applied afterwards renders the closed
     path's start and end as two butt-capped dash ends instead of one
     stroke-linejoin, leaving a visible notch at the top vertex. Dropping it at
     the end of the animation restores the proper join. */
  main:not(.two-col) .mark svg path[stroke] {
    /* A symmetric curve, not the shared ease-out. A pen accelerates and then
       decelerates; an aggressive ease-out spends a third of the duration on
       the last few percent of the outline, which reads as crawling to a halt
       rather than as finishing. */
    animation: draw-hex 850ms var(--ease-soft) backwards;
  }

  main:not(.two-col) .mark svg g[fill] > path {
    animation: fall 480ms var(--ease) both;
  }
  main:not(.two-col) .mark svg g[fill] > path:nth-child(1) { animation-delay: 280ms; }
  main:not(.two-col) .mark svg g[fill] > path:nth-child(2) { animation-delay: 340ms; }
  main:not(.two-col) .mark svg g[fill] > path:nth-child(3) { animation-delay: 340ms; }
  main:not(.two-col) .mark svg g[fill] > path:nth-child(4) { animation-delay: 400ms; }
  main:not(.two-col) .mark svg g[fill] > path:nth-child(5) { animation-delay: 400ms; }
  main:not(.two-col) .mark svg g[fill] > path:nth-child(6) { animation-delay: 460ms; }
  main:not(.two-col) .mark svg g[fill] > path:nth-child(7) { animation-delay: 460ms; }
  main:not(.two-col) .mark svg g[fill] > path:nth-child(8) { animation-delay: 520ms; }

  /* Opacity only. These carry their own transform attributes, and a CSS
     transform would replace them and shunt the type out of position.
     :nth-of-type counts the direct-child paths: 1 is the wordmark, 2 is the
     motto on the landing page's namemotto lockup. */
  main:not(.two-col) .mark svg > path:nth-of-type(1) {
    animation: fade-in 600ms var(--ease) 760ms both;
  }
  main:not(.two-col) .mark svg > path:nth-of-type(2) {
    animation: fade-in 600ms var(--ease) 940ms both;
  }

  /* Brought up under the mark rather than simply being there on load. */
  body.landing::before {
    animation: topo-in 1600ms var(--ease) 120ms both;
  }

  main:not(.two-col) .tagline {
    animation: settle 600ms var(--ease) 1020ms both;
  }

  /* Ambient drift once the entrance has settled, so the page reads as alive
     rather than frozen. Nine seconds, six pixels. `.mark` is a plain div here,
     so this transform cannot collide with an SVG transform attribute the way
     one on the wordmark would. */
  main:not(.two-col) .mark {
    animation: breathe 9s var(--ease-soft) 1900ms infinite;
  }
}

@keyframes breathe {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-6px); }
}

@keyframes draw-hex {
  /* Exactly the hexagon's perimeter, and it has to be exact. The drawn length
     is min(dasharray - offset, perimeter), so any dasharray above the true
     perimeter finishes the stroke before the offset reaches zero -- and the
     remainder of the animation then draws nothing. At 700 against a real
     perimeter of 668.56 the stroke completed at 95.51% of the animated value,
     which under this ease curve is 67% of the duration: 278ms of dead time,
     with the linejoin popping in at the end of it.
     tests/test_landing.py recomputes this from the path's own `d`. */
  from { stroke-dasharray: 668.56; stroke-dashoffset: 668.56; }
  to   { stroke-dasharray: 668.56; stroke-dashoffset: 0; }
}

@keyframes fall {
  from { opacity: 0; transform: translateY(-12px); }
  to   { opacity: 1; transform: none; }
}

@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* Ends at the texture's resting opacity, not at 1. */
@keyframes topo-in {
  from { opacity: 0; }
  to   { opacity: 0.085; }
}

/* Ends at the tagline's resting opacity, not at 1. */
@keyframes settle {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 0.6; transform: none; }
}
