/* ============================================================================
   base.css — reset, page floor, typography, focus, scrollbars, motion policy.
   Depends on tokens.css. Load order: tokens.css → base.css → app.css
   ========================================================================= */

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

/* The UA rule for [hidden] is display:none at the lowest specificity, so any
   author `display:` on the same element silently defeats it — which is what
   `.field-error { display: block }` was doing to every inline error on the
   site. Restated here as the reset's job, with !important because beating
   author display rules is exactly the point. */
[hidden] { display: none !important; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
}

body, h1, h2, h3, h4, p, figure, blockquote, dl, dd, ul, ol {
  margin: 0;
}
ul[class], ol[class] { list-style: none; padding: 0; }

img, svg, video, canvas { display: block; max-width: 100%; }

button, input, select, textarea {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
}
button { background: none; border: none; padding: 0; cursor: pointer; }

table { border-collapse: collapse; width: 100%; }

/* Tap highlight is a grey box that fights every :active state we author.
   `touch-action: manipulation` is the other half of the same idea, and it is
   an ergonomics fix rather than a cosmetic one: with the default the browser
   holds a finger for up to ~300ms deciding whether it is a tap, a scroll or a
   double-tap-to-zoom, and paints NOTHING until it has decided. The motion
   trial measured exactly that — a chip returned no first-painted-frame inside
   40 frames while a square, which already carries `touch-action: none`,
   answered in 26ms. Dropping double-tap zoom costs nothing here (every target
   is ≥44px and the page is already responsive) and buys back the press. */
a, button, label, summary, [role="button"] {
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}

/* ------------------------------------------------------------ page floor -- */
body {
  min-height: 100dvh;
  margin: 0;
  background: var(--ink);
  color: var(--text-primary);
  font-family: var(--font-sans);
  font-size: var(--size-body);
  font-weight: var(--weight-body);
  line-height: var(--leading-body);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* Inter is a variable font with an optical size axis; letting it run means
     the 44px page title and the 13px timestamp are not the same drawing
     scaled. Stated rather than inherited. */
  font-optical-sizing: auto;
  /* Room for the mobile tab bar so the last row is never trapped under it. */
  padding-bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom, 0px));
}

/* ---- The floor -----------------------------------------------------------
   The galaxy map's stylesheet has zero @keyframes and zero animation: in it,
   and that is not restraint, it is a COUNTERWEIGHT — a live WebGL galaxy is
   turning behind it. This site inherited the still layer and never replaced
   the moving one, so the page floor was a flat #030405 with two faint radials
   on it and nothing else. What follows is the replacement, and it is static:
   it modulates VALUE across the page so panels have something to sit on,
   rather than adding movement so something is happening.

   Rules that keep it honest:
     · fixed + pointer-events:none, so it never repaints scrolling content
     · one compositor layer, no repaint during scroll, no repaint at all
       except the 150s transform-only drift on desktop pointers
     · low enough contrast that no text ever sits on a gradient it notices
     · everything here survives greyscale and reduced motion unchanged     */
body::before {
  content: '';
  position: fixed;
  inset: -25%;
  z-index: var(--z-ambient);
  pointer-events: none;
  /* The key light, agreeing with the elevation ladder's stated source: above
     and to the left. This is the layer that makes the floor a lit surface
     instead of a colour, and it renders on EVERY device — the drift below is
     the only part that was ever desktop-only. */
  background: var(--floor-lights);
  will-change: transform;
}

/* Vignette. The map's `inset 0 0 250px` is calibrated for a full-bleed canvas
   and, over a 1120px centred column, darkened margins that were already black.
   This one is proportional, so it bends the floor the same way at 320 and at
   1440, and its bright point is up and left to agree with the light. */
body::after {
  content: '';
  position: fixed;
  inset: 0;
  z-index: var(--z-ambient);
  pointer-events: none;
  background: var(--vignette);
}

@media (pointer: fine) and (prefers-reduced-motion: no-preference) {
  body::before { animation: drift var(--dur-drift) var(--ease) infinite alternate; }
}
@keyframes drift {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to   { transform: translate3d(3%, -2%, 0) scale(1.12); }
}

/* ------------------------------------------------------------ typography --
   ONE thing on this site is set in display caps: the page title. Everything
   below it is set in mixed case and separated by SIZE and WEIGHT, which is
   what a type system is. Uppercase survives only where it is a label — a
   control, a column head, a nav item: things you aim at rather than read.

   Before this, h1, h2, .label, .field-label, .btn, .nav-brand, .nav-link,
   .tab, .pill, .table th, .chip, .acell, .skip-link and .prose h3 were all
   uppercase + black + wide-tracked, so nine consecutive shouted elements
   could stand between the top of the Schedule page and one sentence of
   prose. When everything shouts, the page reads as a monotone, and that
   monotone is most of what "dry" meant here: not an absence of noise, an
   absence of dynamic range. */
h1, h2, h3, h4 {
  color: var(--text-strong);
  line-height: var(--leading-tight);
  text-wrap: balance;
  /* Punctuation hangs into the margin rather than eating from the measure,
     so a heading that opens on a quote still starts on the same edge as the
     paragraph under it. Ignored where unsupported; costs nothing. */
  hanging-punctuation: first last;
}

h1 {
  font-size: var(--size-h1);
  font-weight: var(--weight-black);
  /* Tighter than --track-header, which is set for 13-14px controls. The same
     GESTURE at 44px needs less air or it stops being signage and becomes a
     gap. This is the one place on the site display caps are spent. */
  letter-spacing: var(--track-display);
  text-transform: uppercase;
}
h2 {
  /* Mixed case, and the weight and the tracking carry it instead. This is
     the single largest legibility change in this pass: h2 is the heading
     that appears most often on every page, and caps cost a reader real time
     on every one of them. */
  font-size: var(--size-h2);
  font-weight: var(--weight-black);
  letter-spacing: var(--track-tight);
}
h3 {
  font-size: var(--size-h3);
  font-weight: var(--weight-bold);
  letter-spacing: var(--track-tight);
}
h4 {
  font-size: var(--size-body);
  font-weight: var(--weight-medium);
  letter-spacing: var(--track-data);
}

@media (min-width: 720px) {
  h1 { font-size: var(--size-display); }
}

/* A phone held sideways is 844px wide and 390px tall: wide enough to trip the
   720px breakpoint, and nowhere near tall enough to afford what that
   breakpoint hands out. The display heading and the desktop top padding cost
   most of a quarter of that screen between them, above the one thing the page
   exists for. Height is the honest question here, so height is what is asked.
   Must sit after the 720px block to win. */
@media (max-height: 520px) {
  h1 { font-size: var(--size-h1); }
}

p {
  max-width: var(--measure);
  /* balance is for headings (≤6 lines); pretty is for paragraphs. It costs
     nothing and it is the only thing that stops a paragraph ending on a
     one-word line, which happens somewhere on this site at every one of the
     six capture widths. */
  text-wrap: pretty;
  hanging-punctuation: first last;
}
p + p { margin-top: var(--space-4); }

/* Space above a heading is always larger than the space below it — the
   heading belongs to what follows, not to what it interrupts. */
* + h2 { margin-top: var(--space-10); }
* + h3 { margin-top: var(--space-8); }
h2 + *, h3 + *, h4 + * { margin-top: var(--space-3); }

/* A page's intro paragraph and the first panel under it are the biggest
   structural break on the page, and it was the SMALLEST gap: `* + h2` gave
   headings 40px and nothing gave a panel anything, so the paragraph's last
   line sat ~15px off the panel's accent edge — tighter than the 16px between
   panels and tighter than the panel's own 24px of padding. Grouping is
   proximity, and this was the one place proximity was lying. */
p + .panel, p + section, .page > p + *,
header + .panel, header + section, .page > header + * { margin-top: var(--space-8); }

/* One panel ending and the next beginning is the largest structural break on a
   page, and it had NO rule at all — every section after the first measured
   margin-top: 0, so the only thing between "Your availability" and "Best dates",
   or between "Sessions" and "Propose a session", was the two panels' own
   padding. Two 24px paddings read as one 48px room, not as two rooms; the eye
   needs the gap BETWEEN to beat the gap WITHIN or the grouping is lying, which
   is the same argument the rule above makes for an intro paragraph.
   40px, one rung above the 32px an intro gets, because a section boundary
   outranks it. Applies on every page and to both sides of the availability
   grid, so the break exists whether or not anything has been marked yet. */
.page > section + section,
.page > section + .panel,
.page > .panel + section,
.page > .panel + .panel { margin-top: var(--space-10); }

/* A page's intro paragraph is PROSE, not metadata. It shipped as `.meta` —
   13px, the size reserved for timestamps and helper text — on three of the
   four pages, which is a large part of why the site read as a control panel
   with no voice: the first sentence a reader meets was set at the smallest
   size the system has. It is the one paragraph on the page that is genuinely
   read, so it is set to be read. */
header > p, header > p.meta {
  font-size: var(--size-body);
  color: var(--text-secondary);
  max-width: var(--measure-lead);
  margin-top: var(--space-4);
}

/* An intro is read once and fast, so it takes a shorter line than an article
   does. Measure is per context, not one global ceiling. */
.lead, .answer, .empty p { max-width: var(--measure-lead); }

a {
  color: var(--text-accent);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  /* Pointer acknowledgement: the house curve at the pointer duration. */
  transition: color var(--dur-hover) var(--ease),
              text-decoration-color var(--dur-hover) var(--ease);
}
a:hover { color: var(--text-strong); }
/* Every link answers the finger, not just the cursor. An inline link is the
   one control on the site with no box to press, so the acknowledgement is the
   only thing it has — and on a phone, where there is no hover at all, it was
   the ONLY state a link had between rest and a new page. */
a:active { color: var(--accent-press); transition-duration: var(--dur-press); }

strong, b { font-weight: var(--weight-medium); color: var(--text-strong); }
em, i { font-style: italic; }

small, .meta {
  font-size: var(--size-meta);
  color: var(--text-secondary);
}

code, kbd, samp, .mono, .num {
  font-family: var(--font-mono);
  /* Relative, so mono sits right next to the prose around it — but floored at
     --size-meta: 0.9375em of an already-13px line is 12.2px, and a wikilink in
     a caption is information, not decoration. */
  font-size: max(var(--size-meta), 0.9375em);
  /* slashed-zero is a decision, not an inheritance: this is a near-black
     terminal-descended surface where 0 and O appear in dates, counts and
     session numbers, and telling them apart is the whole reason the mono
     stack is here at all. Mono is used where characters must ALIGN and
     nowhere as a costume. */
  font-variant-numeric: tabular-nums slashed-zero;
  letter-spacing: var(--track-data);
}

/* Anything whose value CHANGES IN PLACE must be tabular or it reflows its
   neighbours every time it ticks — a count going 9 → 10, a time, a date. */
time, output, .count, .tally, [data-count] {
  font-variant-numeric: tabular-nums;
}

hr {
  border: none;
  border-top: var(--rule-print) double var(--chrome-rule);
  margin: var(--space-8) 0;
}

/* ---- Optical correction ---------------------------------------------------
   THE SIGN. CSS adds a letter-space AFTER the last glyph as well as between
   glyphs, so the trailing space is inside the box on the RIGHT. A centred
   tracked label's ink therefore sits half a tracking-unit LEFT of its box
   centre, and this file used to "correct" that with a further negative
   text-indent, which moved it further left still — measured on the shipped
   build, "NOT YOU?" sat 2.15px left of centre with pads of 11.6 against 15.9
   where the uncompensated error would have been 0.84px. The fix made it three
   times worse, on every tracked control on the site.

   Two corrections, and which one applies is decided by the box, not by taste:

     · the box HUGS the text (any padded button, chip or pill) — take one
       whole tracking unit off the RIGHT padding. The phantom space leaves the
       box, so left, centre and right alignment all come out true at once.
     · the box is STRETCHED and the text is centred in it (a grid cell, a
       column head) — `text-indent: <one tracking unit>`, positive. A centred
       line moves by half its indent, so a full unit cancels the half-unit
       error exactly.
     · the text is LEFT-aligned in a stretched box — nothing. There is no
       leading space; the left edge was already true and the old negative
       indent was pulling it off the column it shares with the prose below.

   `.optical-caps` is deleted rather than fixed: it rendered to zero elements
   on all four pages while every component that needed the correction wrote it
   out by hand. The correction now lives on the components, once each, next to
   the letter-spacing it is cancelling. Measure it, do not eyeball it —
   verify/runs/20-css/probe.mjs reads ink bounds, not box bounds. */

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

::placeholder {
  color: var(--text-muted);   /* 6.2:1 — placeholders are information too */
  opacity: 1;
}

/* ----------------------------------------------------------------- focus -- */
/* Cyan alone is invisible against a cyan-edged panel, so the ring is a solid
   stroke plus a soft halo plus a dark offset — legible on ink, on glass, and
   on a filled accent button alike. */
:focus { outline: none; }
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  box-shadow: var(--ring-focus);
  border-radius: var(--radius-sm);
}
/* On an already-cyan surface the ring inverts to white so it still reads. */
.btn-primary:focus-visible,
[data-status="free"]:focus-visible {
  outline-color: var(--text-strong);
}

.skip-link {
  position: absolute;
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
  left: var(--space-4);
  top: var(--space-4);
  z-index: var(--z-gate);
  /* Hugs its text, so the trailing letter-space comes off the right padding. */
  padding: var(--space-3) calc(var(--space-4) - var(--track-header)) var(--space-3) var(--space-4);
  background: var(--accent);
  color: var(--text-on-accent);
  font-size: var(--size-label);
  font-weight: var(--weight-black);
  letter-spacing: var(--track-header);
  text-transform: uppercase;
  text-decoration: none;
  border-radius: var(--radius-sm);
  box-shadow: var(--elev-overlay);
  transform: translateY(calc(-100% - var(--space-8)));
  /* Arriving, so it uses the arrival curve — it is the first thing a keyboard
     user sees and it should land, not glide. */
  transition: transform var(--dur-tick) var(--ease-out);
}
/* :focus, not :focus-visible. A skip link is only ever reached by keyboard, and
   under touch emulation Chrome does not always call that focus "visible" — which
   leaves a focused link still translated off-screen. */
.skip-link:focus { transform: translateY(0); }

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

/* ------------------------------------------------------------ scrollbars -- */
* { scrollbar-width: thin; scrollbar-color: var(--chrome-accent) transparent; }
::-webkit-scrollbar { width: 3px; height: 3px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--chrome-accent); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent); }

/* ------------------------------------------------------ layout primitives -- */
.page {
  width: 100%;
  max-width: var(--measure-page);
  margin: 0 auto;
  padding: var(--space-6) var(--gutter) var(--space-16);
}
@media (min-width: 720px) {
  .page { padding: var(--space-10) var(--gutter-wide) var(--space-20); }
}
/* See the note by h1: on a short screen the top gutter is 40px of nothing. */
@media (max-height: 520px) {
  .page { padding-top: var(--space-4); }
}

.stack > * + * { margin-top: var(--space-4); }
.stack-lg > * + * { margin-top: var(--space-8); }

.row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
}
.row-end { margin-left: auto; }

/* ------------------------------------------------------------- entrance --
   First paint only, and event-triggered re-entrance for the two moments that
   deserve one. These are CSS animations with no iteration count, so they run
   once and never again — no scroll observers, no IntersectionObserver,
   nothing re-triggering on scroll. Six panels at 45ms apart finish in under a
   third of a second, and NOTHING is hidden until animated: every element is
   readable at frame zero, which is why `both` fills from an opacity the
   content survives.

   Arrival uses --ease-out, not the house curve. An entrance and a hover are
   different events and were previously indistinguishable in a frame capture.
   A thing that MOVED rather than arrived is the FLIP in ui.js, which animates
   the real distance on --ease-settle — no class, no fixed nudge. */
.enter { animation: enter var(--dur-enter) var(--ease-out) both; }
.stagger > * { animation: enter var(--dur-enter) var(--ease-out) both; }
.stagger > *:nth-child(1)  { animation-delay:   0ms; }
.stagger > *:nth-child(2)  { animation-delay:  45ms; }
.stagger > *:nth-child(3)  { animation-delay:  90ms; }
.stagger > *:nth-child(4)  { animation-delay: 135ms; }
.stagger > *:nth-child(5)  { animation-delay: 180ms; }
.stagger > *:nth-child(6)  { animation-delay: 225ms; }
.stagger > *:nth-child(n+7){ animation-delay: 260ms; }

@keyframes enter {
  from { opacity: 0; transform: translate3d(0, 10px, 0); }
  to   { opacity: 1; transform: none; }
}

/* --------------------------------------------------- reduced transparency -- */
/* Some people get motion sick or simply cannot read through blur. The
   overlapping surfaces go opaque; nothing about the layout moves. Content
   panels are not listed — they carry no blur to begin with. */
@media (prefers-reduced-transparency: reduce) {
  .sheet, .toast, .nav, .tabbar, .gate-card {
    background: var(--panel-solid) !important;
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
  }
  body::before { display: none; }
}

/* ------------------------------------------------------- reduced motion --
   This is a DESIGNED variant with its own timings, not a blanket
   `animation: none`. Motion is REMOVED — nothing translates, scales, drifts,
   staggers or overshoots, and no duration is merely shortened into a
   flicker. What replaces it is a colour and border choreography at a single
   80ms linear step, which still communicates all six control states and
   still lets a tapped square visibly answer the finger.

   The one thing that must survive is causality: with motion off, the site
   still has to be able to say SOMETHING JUST HAPPENED. The confirmation
   below is that sentence, told in colour instead of light — it holds for
   --dur-tick with no fade, so it is a state change and not a movement. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }

  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    animation-delay: 0ms !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
    scroll-behavior: auto !important;
  }

  body::before { animation: none; transform: none; }

  /* Re-permit the few transitions that are feedback rather than decoration,
     kept short enough to read as a state change, not as movement. */
  .btn, .acell, .chip, .nav-link, .tab, .field, .row-link, .wiki-item, .saved {
    transition: background-color 80ms linear,
                color 80ms linear,
                border-color 80ms linear !important;
  }
  /* Motion is REMOVED, not shortened — including the motion that lives in a
     hover rather than a press. `.row-link` indents 4px under a pointer and
     `.chip` drops 1px under a finger; with the durations clamped to 0.01ms
     both were still *moving*, just instantly, which is the failure mode this
     block exists to prevent. What is left is colour, border and elevation. */
  .btn:active, .acell:active, .chip:active,
  .row-link:hover, .row-link:active { transform: none !important; }

  /* The save confirmation, without the light. Same token, same duration, no
     glow and no movement: the confirmed squares hold a solid mint edge for
     220ms and then return. */
  .acell[data-confirm] {
    box-shadow: none !important;
    border-color: var(--status-free-edge) !important;
  }
}
