/* ══════════════════════════════════════════════════════════════════════════
   telemedicineafrica.org — one stylesheet for every page.

   Colour is the "warm clay" scheme documented in ../palette.md, sampled from
   Dr Malila's colour references and mapped onto Material Design 3 roles. Token
   names and values are copied from flyer/make_flyer_v3.py so the site, the
   flyer, the programme and the speaker cards stay one system.

   Orange is UJ, who own the event, so it takes the primary role. Blue is UCT,
   who host us, so it takes secondary. UJ orange #F47B20 manages only 2.6:1 on
   white — it is a fill, never a text colour. Text-weight primary steps the same
   hue down to Terracotta #A63115 (6.4:1).

   The clinic application at virtualclinic.masterfilmon.com stays on UCT blue.
   This file does not touch it.
   ══════════════════════════════════════════════════════════════════════════ */

:root {
  color-scheme: light dark;

  /* ── colour · light ─────────────────────────────────────────────── */
  --m3-primary:#A63115;                --m3-on-primary:#FFFFFF;
  --m3-primary-container:#FBD0A7;      --m3-on-primary-container:#6F190D;
  --m3-secondary:#173C90;              --m3-on-secondary:#FFFFFF;
  --m3-secondary-container:#C7D5E3;    --m3-on-secondary-container:#0E2B6F;
  --m3-tertiary:#D13E0E;               --m3-on-tertiary:#FFFFFF;
  --m3-tertiary-container:#FCB988;     --m3-on-tertiary-container:#6F190D;
  --m3e-warm-container:#ECD6C0;
  --m3-surface:#FDF7F1;                --m3-on-surface:#24150E;
  --m3-surface-container-lowest:#FFFFFF;
  --m3-surface-container-low:#F4E5D4;
  --m3-on-surface-variant:#4A2916;
  --m3-outline:#B36B37;                --m3-outline-variant:#DCB58F;
  --m3-error:#8C1D18;                  --m3-on-error:#FFFFFF;

  /* brand seeds, off-system — fills only */
  --m3e-uj:#F47B20; --m3e-marigold:#F38218; --m3e-tangerine:#FD6C1D;
  --m3e-cobalt:#173C90;

  /* ── shape ──────────────────────────────────────────────────────── */
  --radius-xs:4px; --radius-sm:8px; --radius-md:12px;
  --radius-lg:16px; --radius-xl:28px; --radius-full:9999px;

  /* ── elevation ──────────────────────────────────────────────────── */
  --elev-1:0 1px 2px rgba(53,25,10,.07), 0 1px 3px rgba(53,25,10,.09);
  --elev-2:0 2px 6px rgba(53,25,10,.09), 0 4px 12px rgba(53,25,10,.11);
  --elev-3:0 6px 18px rgba(53,25,10,.12), 0 2px 6px rgba(53,25,10,.08);

  /* ── type ───────────────────────────────────────────────────────── */
  --text-display-lg:clamp(2.5rem,6vw,4rem);
  --text-display-sm:clamp(1.9rem,4vw,2.25rem);
  --text-headline-lg:clamp(1.6rem,3vw,2rem);
  --text-headline-md:1.75rem; --text-headline-sm:1.375rem;
  --text-title-lg:1.1875rem; --text-title-md:1rem; --text-title-sm:.875rem;
  --text-body-lg:1.0625rem; --text-body-md:.9375rem; --text-body-sm:.8125rem;
  --text-label-lg:.875rem; --text-label-md:.75rem; --text-label-sm:.6875rem;

  --font-sans:'Source Sans 3',system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;
  --font-mono:ui-monospace,'Cascadia Mono','Segoe UI Mono',Menlo,monospace;

  /* ── motion ─────────────────────────────────────────────────────────
     M3 duration and easing sets, values copied from the clinic app's
     globals.css so the two stay one system. The old --ease-emph here was
     cubic-bezier(.34,1.4,.64,1) — a springy overshoot, which is M3's
     *spring* family, not its emphasized curve. Both are below under their
     real names. */
  --duration-short-1:50ms;   --duration-short-2:100ms;
  --duration-short-3:150ms;  --duration-short-4:200ms;
  --duration-medium-1:250ms; --duration-medium-2:300ms;
  --duration-medium-3:350ms; --duration-medium-4:400ms;
  --duration-long-1:450ms;   --duration-long-2:500ms;

  --ease-standard:cubic-bezier(0.2, 0, 0, 1);
  --ease-emph:cubic-bezier(0.2, 0, 0, 1);
  --ease-emph-decel:cubic-bezier(0.05, 0.7, 0.1, 1);
  --ease-emph-accel:cubic-bezier(0.3, 0, 0.8, 0.15);
  --ease-spring-emph:cubic-bezier(0.18, 0.89, 0.32, 1.28);

  /* Kept so the rest of the sheet does not have to change in one commit. */
  --dur-2:var(--duration-short-2); --dur-3:var(--duration-short-3);
  --dur-4:var(--duration-medium-2);

  /* Brand-mark plate. Transparent in light mode - the page is already light -
     and warm paper in dark mode, where an un-themeable navy mark needs one.
     `-always` is the variant for marks that carry a plate in both schemes. */
  --logo-plate:transparent;
  --logo-plate-always:#FDF7F1;

  --wrap:1140px;
  --gut:clamp(1.25rem,4vw,2.5rem);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --logo-plate:#FDF7F1;
    --m3-primary:#FCB988;              --m3-on-primary:#5A1408;
    --m3-primary-container:#81200E;    --m3-on-primary-container:#FBD0A7;
    --m3-secondary:#A4C2DD;            --m3-on-secondary:#0E2B6F;
    --m3-secondary-container:#0E2B6F;  --m3-on-secondary-container:#C7D5E3;
    --m3-tertiary:#FC9355;             --m3-on-tertiary:#5A1408;
    --m3-tertiary-container:#763515;   --m3-on-tertiary-container:#FBD0A7;
    --m3e-warm-container:#4A2916;
    --m3-surface:#24150E;              --m3-on-surface:#F4E5D4;
    --m3-surface-container-lowest:#1A0F09;
    --m3-surface-container-low:#331B10;
    --m3-on-surface-variant:#DCB58F;
    --m3-outline:#B36B37;              --m3-outline-variant:#4A2916;
    --m3-error:#F2B8B5;                --m3-on-error:#601410;
    --elev-1:0 1px 2px rgba(0,0,0,.4);
    --elev-2:0 2px 8px rgba(0,0,0,.5);
    --elev-3:0 8px 24px rgba(0,0,0,.55);
  }
}

:root[data-theme="dark"] {
    --logo-plate:#FDF7F1;
  --m3-primary:#FCB988;              --m3-on-primary:#5A1408;
  --m3-primary-container:#81200E;    --m3-on-primary-container:#FBD0A7;
  --m3-secondary:#A4C2DD;            --m3-on-secondary:#0E2B6F;
  --m3-secondary-container:#0E2B6F;  --m3-on-secondary-container:#C7D5E3;
  --m3-tertiary:#FC9355;             --m3-on-tertiary:#5A1408;
  --m3-tertiary-container:#763515;   --m3-on-tertiary-container:#FBD0A7;
  --m3e-warm-container:#4A2916;
  --m3-surface:#24150E;              --m3-on-surface:#F4E5D4;
  --m3-surface-container-lowest:#1A0F09;
  --m3-surface-container-low:#331B10;
  --m3-on-surface-variant:#DCB58F;
  --m3-outline:#B36B37;              --m3-outline-variant:#4A2916;
  --m3-error:#F2B8B5;                --m3-on-error:#601410;
  --elev-1:0 1px 2px rgba(0,0,0,.4);
  --elev-2:0 2px 8px rgba(0,0,0,.5);
  --elev-3:0 8px 24px rgba(0,0,0,.55);
}

/* Late-bound aliases, so a scheme switch above flows through once. */
:root {
  --primary:var(--m3-primary);            --on-primary:var(--m3-on-primary);
  --primary-cont:var(--m3-primary-container);
  --on-primary-cont:var(--m3-on-primary-container);
  --secondary:var(--m3-secondary);        --on-secondary:var(--m3-on-secondary);
  --secondary-cont:var(--m3-secondary-container);
  --on-secondary-cont:var(--m3-on-secondary-container);
  --tertiary:var(--m3-tertiary);          --on-tertiary:var(--m3-on-tertiary);
  --tertiary-cont:var(--m3-tertiary-container);
  --on-tertiary-cont:var(--m3-on-tertiary-container);
  --warm:var(--m3e-warm-container);
  --surface:var(--m3-surface);
  --surface-2:var(--m3-surface-container-lowest);
  --surface-3:var(--m3-surface-container-low);
  --on-surface:var(--m3-on-surface);
  --on-surface-var:var(--m3-on-surface-variant);
  /* Rail width, sized to its own longest line rather than a round 288px.
     Measured in the browser at 14px SSSans 600:
       widest nav label   "Switch theme"     96.3px + 63.6 chrome = 159.9
       brand wordmark     "Telemedicine Africa" 158.2px + 32 mark + gap = 190.2
     plus the rail's own 24px padding -> 214.2px needed. 224 leaves a little
     air. The brand is the constraint, not the nav, which is why trimming the
     nav labels alone would not have helped.
     Both the rail AND body's padding-left read this, so they can never
     disagree - which they would if either were hardcoded separately. If a
     longer label is ever added, re-measure; `min-content` below stops the rail
     clipping in the meantime. */
  --rail-w:224px;
  --rail-w-collapsed:76px;
  --outline:var(--m3-outline-variant);
  /* The landing page names this one `--outline-var` and the rail, the cards
     and six other borders were copied across still saying so. Undefined here,
     `1px solid var(--outline-var)` computes to no border at all - so the rail
     had a hairline on the home page and none on every generated one. Aliased
     rather than renamed: both names now resolve to the same colour, whichever
     file a rule came from. */
  --outline-var:var(--m3-outline-variant);
  --outline-strong:var(--m3-outline);
  --error:var(--m3-error);
}

/* ── base ─────────────────────────────────────────────────────────────── */

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

html { scroll-behavior:smooth; -webkit-text-size-adjust:100% }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior:auto }
  /* Gentler, not zero. Killing every transition also kills the ones that carry
     meaning - a state layer lighting, a colour confirming a press. So movement
     comes off (transform is dropped from every transition) while opacity and
     colour stay, at a short duration. */
  *,*::before,*::after {
    animation-duration:.01ms !important;
    animation-iteration-count:1 !important;
    transition-property:opacity, color, background-color, border-color, box-shadow, fill !important;
    transition-duration:120ms !important;
  }
}

/* `[hidden]` has to beat every component's own `display`.

   The UA stylesheet sets `[hidden]{display:none}` at attribute
   specificity with no class, so ANY component rule - `.rail-item
   {display:flex}`, `.tab{display:flex}` - silently outranks it and the
   element stays on screen while still reading as hidden in the DOM.

   That is not hypothetical: it is how the Evaluation nav item, which
   build.py marks `hidden data-reveal-at="2026-09-10T15:15"` precisely so
   it is out of sight AND out of the tab order until the session ends,
   was visible in the rail from the day it was added. Nothing looked
   wrong in the markup; the attribute was there and being ignored.

   `!important` is the point rather than a smell - `hidden` is the
   platform saying "this is not here", and no component should be able
   to argue with it. */
[hidden] { display:none !important }

body {
  margin:0;
  font-family:var(--font-sans);
  font-size:var(--text-body-lg);
  line-height:1.62;
  color:var(--on-surface);
  background:var(--surface);
  -webkit-font-smoothing:antialiased;
}

h1,h2,h3,h4 { margin:0; line-height:1.18; letter-spacing:-.018em; font-weight:800 }
p { margin:0 0 1em }
p:last-child { margin-bottom:0 }
ul,ol { margin:0 0 1em; padding-left:1.25em }
img { max-width:100%; height:auto; display:block }
hr { border:0; border-top:1px solid var(--outline); margin:2.5rem 0 }

a { color:var(--primary); text-decoration-thickness:1px; text-underline-offset:.18em }
a:hover { text-decoration-thickness:2px }

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

.skip {
  position:absolute; left:-9999px; top:0; z-index:100;
  background:var(--secondary); color:var(--on-secondary);
  padding:.75rem 1.25rem; border-radius:0 0 var(--radius-md) 0; font-weight:700;
}
.skip:focus { left:0 }

.wrap { max-width:var(--wrap); margin:0 auto; padding-inline:var(--gut) }
.wrap-narrow { max-width:820px; margin:0 auto; padding-inline:var(--gut) }

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

/* ── header ───────────────────────────────────────────────────────────── */

/* ══ navigation rail ═══════════════════════════════════════════════════════
   An M3 navigation drawer, ported from the clinic app's
   `frontend/components/clinic/NavDrawer.tsx`: 288px panel, rounded on its
   inner edge, elevation 3, section labels in caps, and items that are a
   24px icon plus a label with the M3 state layer at 8% hover / 10% active.
   Its timings are that file's too - duration-medium-2 on the transform, and
   duration-short-3 on the item state layers.

   Two modes, as there. From 1100px it is PERMANENT: always open, no scrim,
   and the page is inset by its width. Below that it is MODAL: off-canvas,
   opened by the top bar's menu button, with a scrim and Escape to close.

   The rail is set in SSSans (--font-display). Navigation is chrome, not
   prose, and the display face at label sizes reads as a UI voice distinct
   from Source Sans 3 carrying the content. */
.rail {
  position:fixed; inset-block:0; left:0; z-index:50;
  /* Plain var(), not `max(var(--rail-w), min-content)` - that was invalid CSS.
     max() takes lengths and min-content is a keyword, so the whole declaration
     was thrown away and the rail was falling back to shrink-to-fit. It happened
     to land near the right number, which is exactly why it went unnoticed. */
  width:var(--rail-w);
  display:flex; flex-direction:column; gap:.15rem;
  padding:1.35rem .75rem 1rem;
  /* Beige (#ECD6C0), not the near-white surface. It is already in the warm
     clay ramp and already aliased as --warm, so the rail reads as a warmer
     plane than the page rather than a white slab beside it. Dark mode
     resolves the same token to Coffee #4A2916. */
  background:var(--warm); border-right:1px solid var(--outline-var);
  box-shadow:var(--elev-3);
  font-family:var(--font-display);
  transform:translateX(-100%);
  /* The drawer curve, not --ease-emph. --ease-emph is cubic-bezier(0.2,0,0,1),
     M3's standard easing, which is flat at the start - and this is an ENTERING
     element, so that flat start delays the exact moment being watched. This is
     the one place the responsiveness cost outweighs system cohesion. */
  transition:transform var(--dur-4) cubic-bezier(0.32, 0.72, 0, 1),
             width var(--dur-4) var(--ease-standard);
  /* overflow:visible, so a collapsed-rail tooltip can sit BESIDE the rail
     instead of being clipped by it. Any non-visible overflow value clips on
     both axes - `overflow-x:visible; overflow-y:hidden` is not a thing the
     spec allows, it computes the x axis to auto - so scrolling the rail and
     showing a tooltip outside it are mutually exclusive. The nav list takes
     the scroll instead, and only when the window is genuinely too short for
     it (see .rail-nav). */
  overflow:visible;
}
:root[data-nav="open"] .rail { transform:translateX(0) }

.rail-scrim {
  position:fixed; inset:0; z-index:45; background:rgba(36,21,14,.42);
  opacity:0; pointer-events:none;
  transition:opacity var(--dur-4) var(--ease-standard);
}
:root[data-nav="open"] .rail-scrim { opacity:1; pointer-events:auto }

.rail-brand {
  display:flex; align-items:center; gap:.65rem; text-decoration:none;
  color:inherit; padding:.35rem .5rem 1rem; flex:none;
}
.rail-brand:hover { text-decoration:none }
.rail .rail-close { position:absolute; top:1.1rem; right:.75rem; z-index:2 }

.rail-label {
  padding:.35rem .85rem .3rem; font-size:var(--text-label-sm); font-weight:700;
  letter-spacing:.14em; text-transform:uppercase; color:var(--on-surface-var);
}
.rail-nav { display:flex; flex-direction:column; gap:.15rem; flex:1 1 auto; min-height:0 }
.rail-foot { flex:none }
/* Only a genuinely short window makes the nav scroll. Above this the rail fits
   comfortably and overflow stays visible so tooltips can escape it; below it,
   keeping Switch theme, Full screen and Collapse reachable matters more than a
   tooltip, so the list scrolls and clipping is accepted.
   Measured: brand 54 + label 28 + six items 286 + foot 158 + padding 38 = 564,
   so 700px leaves real headroom. */
@media (max-height:700px) {
  .rail-nav { overflow-y:auto; overscroll-behavior:contain; scrollbar-width:thin }
}

/* The M3 state-layer item: an ::before tinted with the element's own on-*
   colour, 8% hover / 10% focus-visible / 10% active. Same numbers as
   Button.tsx, and the same numbers the rest of this stylesheet uses. */
.rail-item {
  position:relative; isolation:isolate;
  display:flex; align-items:center; gap:.9rem;
  padding:.8rem .85rem; border-radius:var(--radius-lg);
  font-size:var(--text-label-lg); font-weight:600; line-height:1.2;
  color:var(--on-surface); text-decoration:none; text-align:left;
  background:none; border:0; width:100%; cursor:pointer;
  font-family:inherit;
  transition:color var(--dur-3) var(--ease-standard);
}
.rail-item::before {
  content:''; position:absolute; inset:0; z-index:-1; border-radius:inherit;
  background:currentColor; opacity:0;
  transition:opacity var(--dur-3) var(--ease-standard);
}
.rail-item:hover { text-decoration:none }
/* Gated: a tap on a touch screen fires a hover that then sticks, leaving the
   state layer lit on an item the finger has already left. */
@media (hover:hover) and (pointer:fine) {
  .rail-item:hover::before { opacity:.08 }
}
.rail-item:focus-visible::before, .rail-item:active::before { opacity:.10 }
.rail-item:focus-visible { outline:2px solid var(--primary); outline-offset:2px }
.rail-item .msym { font-size:22px; color:var(--on-surface-var); flex:none }

/* Active destination: M3 fills the item with secondary-container rather than
   underlining it. */
.rail-item[aria-current="page"] {
  background:var(--secondary-cont); color:var(--on-secondary-cont); font-weight:700;
}
.rail-item[aria-current="page"] .msym { color:var(--on-secondary-cont) }

.rail-foot {
  flex:none; margin-top:.5rem; padding-top:.6rem;
  border-top:1px solid var(--outline-var);
  display:flex; flex-direction:column; gap:.15rem;
}

/* ── collapsed rail ──────────────────────────────────────────────────────
   Icons only. Labels are hidden with clip-path rather than display:none, so
   each item keeps its accessible name - a nav that goes silent to a screen
   reader when it narrows is worse than one that never collapses.

   There is NO transition on body's padding-left, and that is deliberate. The
   rail used to appear to "expand and then collapse" on every single page load:
   site.js is deferred, so the page painted at full width, the script then read
   tha-rail from storage, set the attribute, and a 300ms padding transition
   animated the correction in front of the reader. It is fixed at both ends now
   - an inline <head> script sets the attribute before first paint, so there is
   nothing left to correct, and no transition to draw it out if there were. */
@media (min-width:1100px) {
  :root[data-rail="collapsed"] .rail {
    width:var(--rail-w-collapsed); padding-inline:.6rem;
  }
  :root[data-rail="collapsed"] body { padding-left:var(--rail-w-collapsed) }
  :root[data-rail="collapsed"] .rail-item { justify-content:center; padding-inline:.5rem }
  :root[data-rail="collapsed"] .rail-item > span:not(.msym) {
    position:absolute; width:1px; height:1px; overflow:hidden;
    clip-path:inset(50%); white-space:nowrap;
  }
  :root[data-rail="collapsed"] .rail-label,
  :root[data-rail="collapsed"] .brand-txt { display:none }
  :root[data-rail="collapsed"] .rail-brand { justify-content:center; padding-inline:0 }
  /* RIGHT, not up. The glyph is expand_more (U+E5CF), which points DOWN
     natively, so rotate(180deg) pointed it up - at a control whose job is to
     push the rail rightwards. -90deg from a down-pointing base is right. The
     arrow always points the way the click will move the rail: left to collapse,
     right to expand. */
  :root[data-rail="collapsed"] .rail-collapse .msym { transform:rotate(-90deg) }
  :root[data-rail="collapsed"] .rail-ext { display:none }
}
.rail-collapse .msym { transform:rotate(90deg); transition:transform var(--dur-3) var(--ease-standard) }

/* ── rail tooltips ─────────────────────────────────────────────────────────
   A native title="" tooltip cannot be styled: no browser exposes its font,
   colour or timing to CSS, and it is drawn by the OS outside the page. So SSSans
   means a real element. This is a ::after on the item, fed by data-tip.

   Two rules make it safe:
     - The tooltip text DUPLICATES the visible label, so it adds no information
       a screen reader would miss. It is decorative, and it is not announced:
       there is no title, no aria-describedby, and ::after content is not
       exposed by any screen reader that matters.
     - It is gated behind (hover:hover) and (pointer:fine). On a touch screen a
       tap fires a hover that then sticks, leaving a tooltip lit over a page the
       reader has already navigated away from.

   ONLY when the rail is collapsed. Expanded, the label is already sitting
   next to the icon and a tooltip repeating it is pure noise; collapsed, the
   glyph is all a sighted mouse user has, which is the case tooltips exist for.

   Placed BESIDE the collapsed rail, which is why .rail carries
   `overflow:visible`. Under the icon it would have been clipped by the rail's
   own bottom edge and would also have added to the rail's scroll height - the
   chip is laid out even at opacity 0. Beside it, at 76px wide, there is simply
   nowhere else for it to go. */
@media (hover:hover) and (pointer:fine) {
  :root[data-rail="collapsed"] .rail-item[data-tip]::after {
    content:attr(data-tip);
    position:absolute; left:calc(100% + 10px); top:50%; translate:0 -50%;
    z-index:2; pointer-events:none;
    font-family:var(--font-display); font-size:var(--text-label-sm);
    font-weight:700; letter-spacing:.02em; line-height:1; white-space:nowrap;
    padding:.35rem .55rem; border-radius:var(--radius-sm);
    background:var(--m3-inverse-surface, #24150E); color:var(--surface);
    box-shadow:var(--elev-2);
    opacity:0; scale:.94;
    transition:opacity var(--dur-2) var(--ease-standard),
               scale var(--dur-2) var(--ease-standard);
    transition-delay:0s;
  }
  :root[data-rail="collapsed"] .rail-item[data-tip]:hover::after,
  :root[data-rail="collapsed"] .rail-item[data-tip]:focus-visible::after {
    opacity:1; scale:1;
    /* A short delay so moving the pointer down the rail does not strobe six
       tooltips on the way past. */
    transition-delay:.35s;
  }
}
@media (prefers-reduced-motion:reduce) {
  .rail-item[data-tip]::after { transition:none; scale:1 }
}
/* The external-link chevron on Register. Muted, and it never takes the
   current-page colour because an outbound link is never the current page. */
.rail-item .rail-ext {
  margin-left:auto; font-size:16px; color:var(--on-surface-var); opacity:.75; flex:none;
}
@media (hover:hover) and (pointer:fine) {
  .rail-item[data-tip]:hover .rail-ext { opacity:0 }
}

/* ── permanent from 1100px ─────────────────────────────────────────────── */
@media (min-width:1100px) {
  .rail { transform:none; box-shadow:none }
  .rail-scrim { display:none }
  .rail .rail-close { display:none }
  .topbar { display:none }
  body { padding-left:var(--rail-w) }
  /* The rail eats 288px, so the content column re-centres in what is left. */
  .wrap { max-width:calc(var(--wrap) - 80px) }
}
@media (max-width:1099px) {
  /* visibility, not just transform. translateX(-100%) moves the drawer off
     screen but leaves all ten of its controls focusable, so a keyboard user
     tabbed through an invisible menu with no focus ring anywhere on screen.
     visibility:hidden takes it out of the tab order and the accessibility
     tree; the 0s delay holds it visible for the length of the close animation
     so the drawer is still seen sliding out. */
  .rail {
    padding-top:1.1rem;
    visibility:hidden;
    transition:transform var(--dur-4) cubic-bezier(0.32, 0.72, 0, 1),
               visibility 0s linear var(--dur-4);
  }
  :root[data-nav="open"] .rail {
    visibility:visible;
    transition:transform var(--dur-4) cubic-bezier(0.32, 0.72, 0, 1),
               visibility 0s linear 0s;
  }
}

/* The narrow-width bar. From 1100px the rail is permanent and this is hidden,
   so it only ever shows below that - which is why it carries three links and
   not the whole nav: the rail is one tap away and holds all six plus the theme,
   full-screen and collapse controls.

   Verbatim from the landing page, which had drifted its own copy: different
   padding, a 34px mark against 32, token font sizes against fixed ones, and a
   theme-toggle button here instead of the links. `.nav`, the pre-rail nav this
   replaces, had no markup left anywhere and is deleted rather than carried. */
.topbar {
  position:sticky; top:0; z-index:40;
  background:color-mix(in srgb, var(--surface) 88%, transparent);
  backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px);
  border-bottom:1px solid var(--outline-var);
}
.topbar-in { display:flex; align-items:center; gap:20px; padding:10px 24px }
.brand { display:flex; align-items:center; gap:10px; text-decoration:none; color:inherit; flex:none }
.brand:hover { text-decoration:none }
.brand-mark { display:block; width:32px; height:32px; flex:none }
.brand-txt b { display:block; font-size:15px; font-weight:700; line-height:1.15 }
.brand-txt span { display:block; font-size:12px; color:var(--on-surface-var); line-height:1.2 }

.topnav { margin-left:auto; display:flex; align-items:center; gap:2px }
.topnav a {
  padding:8px 14px; border-radius:var(--radius-full);
  font-size:15px; font-weight:600; color:var(--on-surface); text-decoration:none;
  white-space:nowrap;
  /* Pinned, not inherited: this page's body runs 1.62 and the landing page's
     1.55, which is a page-level typographic choice on each - but inheriting it
     made the same bar 1px shorter there than here. */
  line-height:1.5;
}
.topnav a:hover { text-decoration:none }
.topnav a:focus-visible { outline:2px solid var(--primary); outline-offset:2px }
/* The state layer is the shared `:is(.btn, .topnav a, …)` block near the end of
   this file, not a local copy: same 8%/10% at the same 150ms, plus the
   pointer-events and reduced-motion handling every other control here gets. */

/* At narrow widths the wordmark goes and the mark carries the brand, so the
   three destinations keep their full labels rather than being cut to icons. */

/* Below ~420px the three pills, the menu button and the mark stop fitting one
   line: the nav ran 10px past a 320px viewport, and "Media kit" broke across
   two lines inside its own pill at 375. It takes its own row instead. Nothing
   is dropped - and the menu button beside it still opens the rail with all six
   items either way. */
@media (max-width:420px) {
  .topbar-in { flex-wrap:wrap; row-gap:4px }
  .topnav { flex:1 0 100%; margin-left:0; justify-content:space-between }
}
@media (max-width:560px) {
  .topbar-in { gap:10px; padding:8px 16px }
  .brand-txt { display:none }
  .topnav { gap:0 }
  .topnav a { padding:8px 10px; font-size:14px }
}

.icon-btn {
  flex:none; width:40px; height:40px; display:grid; place-items:center;
  border:1px solid var(--outline); border-radius:var(--radius-full);
  background:transparent; color:var(--on-surface-var); cursor:pointer;
  transition:background var(--dur-2) var(--ease-standard);
}
.icon-btn:hover { background:var(--surface-3) }
.icon-btn svg { width:20px; height:20px }
/* The menu button carries `.icon-btn`'s own display, as on the landing page,
   and the rail's `@media (min-width:1100px)` hides the whole bar with it. It
   used to be hidden below and shown again only at 900px, left over from the
   drop-down nav this replaced - which meant that between 901px and 1099px the
   bar was on screen with no button on it and the drawer could not be opened at
   all. The drop-down's own rules went with it; nothing has rendered `.nav`
   since the rail landed. */

/* ── hero ─────────────────────────────────────────────────────────────── */

.hero { position:relative; overflow:hidden; background:var(--surface-3) }
.hero::before {
  content:''; position:absolute; inset:0; pointer-events:none;
  background:
    radial-gradient(60% 90% at 88% -10%, color-mix(in srgb, var(--m3e-uj) 26%, transparent), transparent 70%),
    radial-gradient(50% 80% at 4% 108%, color-mix(in srgb, var(--m3e-cobalt) 16%, transparent), transparent 70%);
}
.hero-in { position:relative; padding-block:clamp(2.5rem,6vw,4.5rem) clamp(2rem,5vw,3.5rem) }

/* ── congress strip ────────────────────────────────────────────────────────
   The bar the flyer carries across its top, reproduced here. It replaced the
   text-only eyebrow, which named the congress in words while this shows its
   mark - the two together said the same thing twice inside 40px.
   `.tile` (see the partners block) rounds the TCC AI mark's corners: its
   circuit field is opaque by design, so it is presented as a tile rather than
   keyed out. */
/* On a page with a hero this sits inside it and inherits the hero's own top
   padding, which is where it started. `congress-lead` is the wrapper for the
   pages that have no hero to sit in - it supplies that spacing itself so the
   strip looks identical either way, rather than reading as a separate band
   bolted above the page. */
.congress-lead { padding-block:clamp(2rem,5vw,3.25rem) 0 }
.congress {
  display:flex; align-items:center; gap:1rem; flex-wrap:wrap;
  margin-bottom:1.6rem;
}
.congress .cg-wcph { height:60px; width:auto; flex:none }
.congress .cg-strap {
  font-size:var(--text-label-sm); font-weight:800; letter-spacing:.08em;
  text-transform:uppercase; color:var(--secondary); line-height:1.4;
  border-left:2px solid var(--outline); padding-left:.95rem;
}
.congress .cg-strap em {
  display:block; font-style:normal; font-weight:500; letter-spacing:.01em;
  text-transform:none; font-size:var(--text-body-sm);
  color:var(--on-surface-var); margin-top:2px;
}
/* 120px. The flyer renders TCC at 46px, but that is logo-tcc-big.png - the
   globe alone. This is the full lockup: globe, eight orbiting icon discs and
   a two-line wordmark. Its background has been keyed out (see the unmatte
   entry in assets/normalise.py), so it needs no container and can carry the
   extra size without a box growing with it. */
.congress .cg-tcc { height:104px; width:auto; flex:none }
/* The congress mark's wordmark is dark navy and recedes on a dark surface -
   the same problem the partner band solves with one cream plate behind the
   row. Recolouring the congress's own mark is not ours to do, so it gets the
   plate. The TCC tile already carries its own opaque field and needs none. */
:root[data-theme="dark"] .congress .cg-wcph,
:root[data-theme="dark"] .congress .cg-tcc {
  background:var(--logo-plate-always); border-radius:var(--radius-md);
  padding:.5rem .7rem; box-sizing:content-box;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .congress .cg-wcph,
  :root:not([data-theme="light"]) .congress .cg-tcc {
    background:var(--logo-plate-always); border-radius:var(--radius-md);
    padding:.5rem .7rem; box-sizing:content-box;
  }
}

/* UJ's anniversary mark, added 31 Aug 2026. Sized off its own wordmark rather
   than matched to the TCC lockup's 120px: this is a horizontal lockup with a
   small sun and a long word, and matching heights would have made the words
   twice the size of everything else in the strip. It carries no plate in dark
   - its sun is black and its type is UJ orange, and the orange holds. */
.congress .cg-uj21 { height:34px; width:auto; flex:none }
/* The strip was exactly full at four items - 897px of content in a 897px box -
   so a fifth wrapped it to two rows and made the heading 196px tall on every
   page. The two congress marks came down 8px and 16px to make room rather than
   the strip growing a row; at 1280 the five now measure 816px of 897. */
:root[data-theme="dark"] .congress .cg-uj21,
:root:not([data-theme="light"]) .congress .cg-uj21 { filter:none }
/* The black sun disappears on a coffee-brown page, so in dark the mark is
   lifted to cream and only the sun changes - the orange word is left alone. */
:root[data-theme="dark"] .congress .cg-uj21 { filter:invert(1) hue-rotate(180deg) saturate(1.15) }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .congress .cg-uj21 {
    filter:invert(1) hue-rotate(180deg) saturate(1.15);
  }
}

.congress .cg-badge {
  flex:none; background:var(--m3e-uj); color:#3A1500;
  font-size:var(--text-label-sm); font-weight:800; letter-spacing:.12em;
  text-transform:uppercase; padding:.42rem .85rem; border-radius:var(--radius-full);
  /* The auto margin lives here now, not on the mark: badge and TCC travel to
     the right edge as a pair, with the badge reading as the join between the
     congress's theme and the lab that runs the day. */
  margin-left:auto;
}
/* Below ~720px the marks and the badge stack rather than squeezing the strap
   into two words per line. margin-left:auto stops meaning anything once the
   row wraps, so it is cleared. */
@media (max-width:720px) {
  .congress { gap:.75rem }
  .congress .cg-wcph { height:52px }
  .congress .cg-tcc { height:84px }
  .congress .cg-badge { margin-left:0 }
  .congress .cg-strap { border-left:0; padding-left:0; flex-basis:100%; order:3 }
  .congress .cg-badge { order:2 }
}

.eyebrow {
  display:inline-block; font-size:var(--text-label-md); font-weight:800;
  letter-spacing:.14em; text-transform:uppercase; color:var(--on-primary-cont);
  background:var(--primary-cont); padding:.4rem .8rem; border-radius:var(--radius-full);
  margin-bottom:1.1rem;
}
.hero h1 { font-size:var(--text-display-lg); font-weight:900; max-width:20ch }
.hero .tag {
  margin-top:.7rem; font-size:clamp(1.05rem,2.2vw,1.35rem); font-weight:600;
  color:var(--primary); max-width:34ch;
}
.hero .lede { margin-top:1.25rem; max-width:62ch; color:var(--on-surface-var) }

/* Every auto-fit track below wraps its minimum in min(...,100%): a bare
   minmax(300px,1fr) cannot shrink under its own minimum, so at a 320px
   viewport - whose content box is ~288px - the track overflowed the page.
   With min() the track collapses to the container when there is less room
   than the ideal, and behaves identically whenever there is more. */
.facts { display:grid; gap:.8rem; margin-top:2rem; grid-template-columns:repeat(auto-fit,minmax(min(215px,100%),1fr)) }
.fact {
  background:var(--surface-2); border:1px solid var(--outline);
  border-radius:var(--radius-lg); padding:.95rem 1.05rem; box-shadow:var(--elev-1);
}
.fact .k {
  font-size:var(--text-label-sm); font-weight:800; letter-spacing:.12em;
  text-transform:uppercase; color:var(--on-surface-var);
}
.fact .v { margin-top:.3rem; font-weight:750; font-size:var(--text-title-md); line-height:1.32 }
.fact .v small { display:block; font-weight:500; font-size:var(--text-body-sm); color:var(--on-surface-var); margin-top:.15rem }
.fact a { color:var(--secondary) }

/* ── countdown ────────────────────────────────────────────────────────── */

.countdown { margin-top:2rem }
.countdown-label {
  font-size:var(--text-label-sm); font-weight:800; letter-spacing:.14em;
  text-transform:uppercase; color:var(--on-surface-var); margin-bottom:.55rem;
}
.clock { display:flex; gap:.55rem; flex-wrap:wrap }
/* M3 cards, not cobalt slabs. Four saturated navy blocks with elev-2 made the
   countdown the heaviest thing in the hero - heavier than the CTA below it -
   which inverts the palette: palette.md gives orange the primary role and blue
   the secondary. As outlined surface cards they match the fact cards directly
   above and let the CTA lead. The live state still flips to UJ orange. */
.unit {
  min-width:80px; flex:1 1 80px; max-width:120px; text-align:center;
  background:var(--surface-2); color:var(--on-surface);
  border:1px solid var(--outline); border-radius:var(--radius-lg);
  padding:.7rem .5rem .6rem; box-shadow:var(--elev-1);
}
.unit b {
  /* The seconds digit is scaled once a second by the countdown (see beat() in
     site.js). Origin centred so it pulses in place, and the figures are
     tabular so its width never changes underneath the animation. */
  transform-origin:center;
  display:block; font-size:clamp(1.6rem,4.5vw,2.15rem); font-weight:800;
  letter-spacing:-.03em; font-variant-numeric:tabular-nums; line-height:1.05;
}
.unit span {
  display:block; font-size:var(--text-label-sm); font-weight:700;
  letter-spacing:.1em; text-transform:uppercase; color:var(--on-surface-var); margin-top:.15rem;
}
.clock[data-state="live"] .unit { background:var(--m3e-uj); color:#24150E; border-color:transparent }

/* Four units at 80px wrap to two rows inside a 375px viewport, which reads as
   two unrelated pairs. Tighten just enough to keep one line. */
@media (max-width:430px) {
  .clock { gap:.4rem }
  .unit { min-width:0; flex:1 1 0; padding:.6rem .25rem .55rem }
  .unit b { font-size:1.5rem; letter-spacing:-.04em }
  .unit span { font-size:.625rem; letter-spacing:.04em }
}
.countdown-note { margin-top:.6rem; font-size:var(--text-body-sm); color:var(--on-surface-var) }

/* ── buttons ──────────────────────────────────────────────────────────── */

.btn-row { display:flex; flex-wrap:wrap; gap:.7rem; margin-top:1.75rem }

/* ══ M3 state layer ════════════════════════════════════════════════════════
   The library's signature, and the thing this stylesheet was missing entirely.
   An overlay tinted with the element's own on-* colour, fading in at 8% hover /
   10% focus-visible / 10% active, 0% disabled — the same numbers as
   frontend/components/m3/Button.tsx.

   `background:currentColor` is what makes one rule serve every variant: an M3
   variant's text colour *is* its on-* role, so the layer is correct for filled
   (on-primary), tonal (on-secondary-container) and outlined/text (primary)
   without a lookup table.

   z-index:-1 with isolation:isolate puts the overlay above the element's own
   background and below its content, which is where a state layer belongs — no
   need to wrap every label in a positioned span the way the React version does.
   ────────────────────────────────────────────────────────────────────────── */
.m3-state { position:relative; isolation:isolate }
.m3-state::before {
  content:''; position:absolute; inset:0; z-index:-1;
  border-radius:inherit; pointer-events:none;
  background:currentColor; opacity:0;
  transition:opacity var(--duration-short-3) var(--ease-standard);
}
.m3-state:hover::before          { opacity:.08 }
.m3-state:focus-visible::before  { opacity:.10 }
.m3-state:active::before         { opacity:.10 }
.m3-state:disabled::before,
.m3-state[aria-disabled="true"]::before { opacity:0 }
@media (prefers-reduced-motion:reduce) {
  .m3-state::before { transition:none }
}

/* ══ buttons ══════════════════════════════════════════════════════════════
   M3 geometry: 40dp small, 56dp default, fully rounded, label-large. Elevation
   is the hover response for filled/tonal/elevated; the state layer is the
   response for all of them. No transform — an M3 button does not lift, and the
   translateY that used to be here was the clearest tell that this was not M3. */
.btn {
  display:inline-flex; align-items:center; justify-content:center; gap:.5rem;
  min-height:56px; padding:0 1.5rem; border-radius:var(--radius-full);
  font-size:var(--text-title-md); font-weight:600; text-decoration:none;
  border:1px solid transparent; cursor:pointer; text-align:center;
  transition:box-shadow var(--duration-short-3) var(--ease-standard),
             background-color var(--duration-short-3) var(--ease-standard);
}
.btn:hover { text-decoration:none }
.btn svg { width:18px; height:18px; flex:none }
.btn-sm { min-height:40px; padding:0 1rem; font-size:var(--text-label-lg) }
.btn-xs { min-height:32px; padding:0 .75rem; font-size:var(--text-label-md) }

/* filled — the CTA. Off-spec in one way, deliberately: M3 says bg-primary, and
   this uses the UJ seed, because palette.md gives UJ the primary role and then
   steps text-weight primary down to Terracotta for contrast. The seed is the
   brand colour at fill weight; #24150E is its on- pair. */
.btn-filled  { background:var(--m3e-uj); color:#24150E; font-weight:700 }
.btn-filled:hover  { box-shadow:var(--elev-1) }
.btn-filled:active { box-shadow:none }

.btn-tonal   { background:var(--secondary-cont); color:var(--on-secondary-cont) }
.btn-tonal:hover   { box-shadow:var(--elev-1) }
.btn-tonal:active  { box-shadow:none }

.btn-elevated { background:var(--surface-3); color:var(--primary); box-shadow:var(--elev-1) }
.btn-elevated:hover { box-shadow:var(--elev-2) }

.btn-outline { border-color:var(--outline-strong); color:var(--primary); background:transparent }
.btn-text    { background:transparent; color:var(--primary); padding:0 .75rem }

.btn:disabled, .btn[aria-disabled="true"] {
  background:color-mix(in srgb, var(--on-surface) 12%, transparent);
  color:color-mix(in srgb, var(--on-surface) 38%, transparent);
  border-color:transparent; box-shadow:none; cursor:not-allowed;
}
.btn-outline:hover { background:var(--surface-3) }
.btn-text { color:var(--primary); background:transparent; padding-inline:.6rem }
.btn-text:hover { background:var(--surface-3) }
.btn[disabled] { opacity:.55; pointer-events:none }
.btn-sm { padding:.45rem .9rem; font-size:var(--text-label-md) }

/* ── sections ─────────────────────────────────────────────────────────── */

.section { padding-block:clamp(2.75rem,6vw,4.25rem) }
/* No rule between sections. There was a 1px hairline, then UJ's pixel band -
   both removed on 1 Sep 2026: the band was too loud for a divider and the
   hairline was not wanted either. The alternating `.section-alt` background
   and the section padding do the separating, which is quieter and needs no
   ink. */
/* Section 02's prose. Full section width, matching every other block - see the
   note in build.py. Only the paragraph rhythm is set here. */
.abstract p { margin:0 0 1em }
.abstract p:last-child { margin-bottom:0 }

.section-alt { background:var(--surface-3) }

.sec-head { display:flex; align-items:flex-start; gap:1rem; margin-bottom:1.6rem }
.sec-num {
  flex:none; width:44px; height:44px; display:grid; place-items:center;
  border-radius:var(--radius-md); background:var(--m3e-uj); color:#24150E;
  font-weight:900; font-size:var(--text-title-lg); letter-spacing:-.02em;
}
.sec-head h2 { font-size:var(--text-headline-lg) }
.sec-head .sub { margin-top:.4rem; color:var(--on-surface-var); max-width:64ch }

.grid { display:grid; gap:1rem }
.grid-2 { grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr)) }
.grid-3 { grid-template-columns:repeat(auto-fit,minmax(min(265px,100%),1fr)) }
.grid-4 { grid-template-columns:repeat(auto-fit,minmax(min(210px,100%),1fr)) }

.card {
  background:var(--surface-2); border:1px solid var(--outline);
  border-radius:var(--radius-lg); padding:1.15rem 1.25rem; box-shadow:var(--elev-1);
}
.card h3 { font-size:var(--text-title-lg); margin-bottom:.45rem }
.card p { font-size:var(--text-body-md); color:var(--on-surface-var) }
.card .who {
  margin-top:.8rem; padding-top:.7rem; border-top:1px solid var(--outline);
  font-size:var(--text-body-sm); font-weight:650; color:var(--primary);
}
.card-ico {
  width:38px; height:38px; border-radius:var(--radius-md); margin-bottom:.7rem;
  display:grid; place-items:center; background:var(--primary-cont); color:var(--on-primary-cont);
}
.card-ico svg { width:21px; height:21px }

.obj { display:flex; gap:.9rem; align-items:flex-start }
.obj .n {
  flex:none; width:30px; height:30px; border-radius:var(--radius-full);
  background:var(--m3e-cobalt); color:#fff; display:grid; place-items:center;
  font-weight:800; font-size:var(--text-label-lg);
}
.obj h3 { font-size:var(--text-title-md); margin-bottom:.2rem }
.obj p { font-size:var(--text-body-md); color:var(--on-surface-var); margin:0 }

.chips { display:flex; flex-wrap:wrap; gap:.45rem }
.chip {
  /* inline-FLEX, not inline-block: the chips carry a leading icon now, and an
     inline-block box aligns the glyph on the text baseline, which sits it two
     pixels low against a label of this size. */
  display:inline-flex; align-items:center; gap:.4rem;
  padding:.4rem .8rem .4rem .65rem; border-radius:var(--radius-full);
  border:1px solid var(--outline); background:var(--surface-2);
  font-size:var(--text-label-lg); font-weight:600; color:var(--on-surface-var);
}
.chip .msym { font-size:18px; flex:none; color:var(--primary) }
.chip-solid .msym { color:var(--on-primary-cont) }
.chip-solid { background:var(--warm); border-color:transparent; color:var(--on-surface) }

.callout {
  border-left:4px solid var(--m3e-uj); background:var(--surface-2);
  border-radius:0 var(--radius-md) var(--radius-md) 0;
  padding:.9rem 1.1rem; font-size:var(--text-body-md); color:var(--on-surface-var);
}
.callout strong { color:var(--on-surface) }
.callout-warn { border-left-color:var(--error) }

/* ── programme ────────────────────────────────────────────────────────── */

.prog { border:1px solid var(--outline); border-radius:var(--radius-lg); overflow:hidden; background:var(--surface-2) }
.prog-row { border-bottom:1px solid var(--outline) }
.prog-row:last-child { border-bottom:0 }

.prog-head {
  display:grid; grid-template-columns:130px 1fr auto; gap:.9rem; align-items:center;
  padding:.85rem 1.1rem; width:100%; text-align:left; background:transparent;
  border:0; font:inherit; color:inherit; cursor:pointer;
}
/* No hover on the row. The header is not a control - only .prog-cue is - so
   lighting the whole strip up promised an interaction the row does not have. */
.prog-head { cursor:default }

/* The lead's face, previewed where you hover.

   A floating disc anchored to the NAME rather than an inline chip that pushes
   the row around: at 26px inline it was too small to recognise anyone, and
   expanding it in flow reflowed the line under the pointer. 84px, the same
   circular crop and object-position as .sp-face on the speaker cards, sitting
   just above the name it belongs to.

   Anchored to the name, not tracking the cursor: it needs no JS, it lands in
   the same place every time, and it works on keyboard focus too - a pointer
   chase does none of those.

   Fine pointers only. On a touch screen a tap fires a hover that then sticks,
   leaving a portrait floating over a row the reader has already left. */
.prog-face {
  position:absolute; left:0; bottom:calc(100% + 8px); z-index:5;
  width:84px; height:84px; pointer-events:none;
  border-radius:var(--radius-full); object-fit:cover; object-position:center 18%;
  background:var(--surface-3); border:3px solid var(--surface-2);
  box-shadow:var(--elev-3);
  opacity:0; scale:.82; transform-origin:bottom left;
  transition:opacity var(--dur-3) var(--ease-standard),
             scale var(--dur-3) var(--ease-standard);
}
@media (hover:hover) and (pointer:fine) {
  .prog-what .w:hover .prog-face,
  .prog-what .w:focus-visible .prog-face { opacity:1; scale:1 }
}
@media (prefers-reduced-motion:reduce) { .prog-face { transition:none; scale:1 } }
/* The anchor for the disc above. `position:relative` on the name only, so the
   preview is placed against the name and not against the whole row. */
.prog-what .w { position:relative; display:inline-flex; align-items:center }

.prog-time {
  font-size:var(--text-label-lg); font-weight:800; font-variant-numeric:tabular-nums;
  color:var(--secondary); letter-spacing:-.01em;
}
.prog-what { min-width:0; display:flex; flex-direction:column; align-items:flex-start }
/* .t and .w must be block-level. As inline spans they ran together with no
   separator ("Opening and welcome remarksDr Bessie Malila") on every row, and
   the margin-top below silently did nothing. */
.prog-what .t { font-weight:700; font-size:var(--text-title-md); line-height:1.3 }
.prog-what .w { font-size:var(--text-body-sm); color:var(--on-surface-var); margin-top:.18rem }
.prog-what .w a { color:var(--primary) }

.kind {
  display:inline-block; margin-right:.45rem; padding:.15rem .5rem; border-radius:var(--radius-xs);
  font-size:var(--text-label-sm); font-weight:800; letter-spacing:.08em; text-transform:uppercase;
  vertical-align:.1em;
}
.kind-talk { background:var(--primary-cont); color:var(--on-primary-cont) }
.kind-activity { background:var(--secondary-cont); color:var(--on-secondary-cont) }
.kind-keynote { background:var(--m3e-uj); color:#24150E }
.kind-demo { background:var(--tertiary-cont); color:var(--on-tertiary-cont) }

.prog-cue {
  flex:none; display:inline-flex; align-items:center; gap:.35rem;
  font-size:var(--text-label-md); font-weight:750; color:var(--primary);
  border:1px solid var(--outline-strong); border-radius:var(--radius-full);
  padding:.3rem .7rem; white-space:nowrap;
}
.prog-cue svg { width:14px; height:14px; transition:transform var(--dur-3) var(--ease-standard) }
.prog-row[data-open="true"] .prog-cue svg { transform:rotate(180deg) }

.prog-body { padding:0 1.1rem 1.1rem 1.1rem; display:none }
.prog-row[data-open="true"] .prog-body { display:block }
.prog-body .desc { font-size:var(--text-body-md); color:var(--on-surface-var); max-width:70ch }
.prog-body .pending {
  font-size:var(--text-body-md); color:var(--on-surface-var); font-style:italic;
}

.prog-row.is-break { background:var(--warm) }
.prog-row.is-break .prog-time,
.prog-row.is-break .prog-what .t { color:var(--on-surface); font-weight:700 }
.prog-row.is-head { background:var(--secondary-cont) }
.prog-row.is-head .prog-what .t,
.prog-row.is-head .prog-time { color:var(--on-secondary-cont) }
.prog-row.is-sub .prog-head { padding-left:2.2rem }

@media (max-width:720px) {
  .prog-head { grid-template-columns:1fr auto; row-gap:.35rem }
  .prog-time { grid-column:1 / -1 }
  .prog-row.is-sub .prog-head { padding-left:1.5rem }
}

/* ── speaker cards ──────────────────────────────────────────────────────
   Grammar from the nine app-UI references in source/: a banner strip, the
   portrait overlapping its edge as a disc, name, small role, a fact row, one
   action. See person_card() in build.py for what was deliberately not copied.

   The portrait is 96px against sources that run 173-278px wide, so it is
   still downscaling. Every reference put the avatar half on the banner and
   half on the body; the negative margin does that, and the ring is the card
   surface so it reads as cut into the banner rather than floating on it. */
/* align-items:start, because the cards expand. Stretched to a common height,
   opening one biography made every neighbour in that row as tall as the open
   card and floated their facts and disclosure to the bottom of the empty
   space. Each card now sizes to its own content. */
.people {
  display:grid; gap:1.25rem; align-items:start;
  grid-template-columns:repeat(auto-fit,minmax(min(260px,100%),1fr));
}

.sp {
  display:flex; flex-direction:column; position:relative;
  text-decoration:none; color:inherit;
  background:var(--surface-2); border:1px solid var(--outline-var);
  border-radius:var(--radius-xl); overflow:hidden; box-shadow:var(--elev-1);
  transition:box-shadow var(--dur-3) var(--ease-standard);
}
/* The card is a container now, not a control: no hover lift, and no focus
   ring on a thing that cannot be focused. Its two real controls - the name
   link and the disclosure - carry their own. */
@media (hover:hover) and (pointer:fine) {
  .sp:hover { box-shadow:var(--elev-2) }
  .sp .sp-cta:hover { gap:.55rem }
}

/* Warm clay, not the reference's abstract art: a generated gradient would be
   decoration with no narrative role, and art we do not own is worse. */
/* UJ's pixel band rather than a three-stop gradient. Rendered at its NATURAL
   55px height and centred on UJ orange, not stretched to fill 76px: the tile
   is pixel art, and scaling it 1.38x softens every edge it has. The orange
   above and below is the same hue the gradient started from, so the band reads
   as inlaid rather than pasted on. */
/* UJ's pixel band, filling the banner edge to edge.

   `auto 100%` scales the 117x55 tile to the full 76px height and repeats it
   horizontally. That is a 1.38x upscale of pixel art, which would normally
   soften it - but the tile is repeated on the X axis only and lands near
   native device pixels on a 2x screen, and filling the strip is the point.
   An earlier version sat it at its natural 55px with orange above and below;
   this fills. */
.sp-banner {
  height:76px; flex:none;
  background:var(--m3e-uj) url('/assets/decor-pixelband.png') repeat-x center / auto 100%;
}

/* The speaker's institution, in the free corner of the banner.

   The card's banner is a 76px strip with the portrait overlapping its lower
   LEFT; the upper right is empty, which is where this goes. Circular to rhyme
   with the portrait below it and the partner discs on the home page, on an
   always-light plate because several of these marks are dark navy and would
   disappear into a themed surface in dark mode.

   `alt` carries the institution name rather than being empty: this is the only
   place on the card that says which of two universities a speaker with a joint
   affiliation is being shown under. */
/* Beside the name, not floating in the banner corner - the banner is UJ's
   pixel band and a logo dropped on it read as a sticker rather than as part of
   the card. Next to the name it sits where the eye already is, and the pairing
   says plainly which institution the person is being shown under. */
.sp-name { display:flex; align-items:center; gap:.55rem }
.sp-name > span { min-width:0 }
.sp-inst {
  flex:none;
  width:38px; height:38px; border-radius:var(--radius-full);
  background:var(--logo-plate-always); object-fit:contain;
  /* 0.146d again - the inset that keeps a mark of any aspect ratio clear of
     the circular clip. At 38px that is 5.6px. */
  padding:6px; box-sizing:border-box;
  box-shadow:var(--elev-1);
}
.sp { position:relative }

/* The country flag beside "Based in". These are country OUTLINES filled with
   the flag, not rectangles, so the silhouette is what identifies them - which
   is also why they survive at this size where a tricolour would not.

   The pair is its own nowrap group: Makubalo is "South Africa &middot; Geneva,
   Switzerland" and gets two, and with the flags as direct children of a
   wrapping flex row the second one dropped to its own line as soon as the
   text beside it grew. Only the text wraps now. */
.sp-where { display:flex; align-items:center; gap:.45rem; flex-wrap:wrap }
.sp-where .sp-flags { display:flex; align-items:center; gap:.3rem; flex:none; flex-wrap:nowrap }
.sp-flag { width:30px; height:30px; object-fit:contain; flex:none; display:block }

/* Credential tags, each with its keyword-chosen icon (CREDENTIAL_ICONS in
   content.py). Aligned to the first line rather than centred: several tags run
   to two lines and a centred icon then floats in the middle of the block. */
.sp-creds li { display:flex; align-items:flex-start; gap:.4rem }
.sp-creds .msym { font-size:16px; flex:none; color:var(--primary); margin-top:.12em }

.sp-face {
  width:96px; height:96px; border-radius:var(--radius-full);
  object-fit:cover; object-position:center 18%;
  border:4px solid var(--surface-2); background:var(--surface-3);
  margin:-52px 0 0 1.1rem; position:relative; z-index:1; flex:none;
}
.sp-face-none {
  display:grid; place-items:center; color:var(--on-surface-var);
  font-size:2rem; box-sizing:border-box;
}
.sp-body { padding:.85rem 1.1rem 1.15rem; display:flex; flex-direction:column; flex:1 }
.sp-role {
  font-size:var(--text-label-sm); font-weight:800; letter-spacing:.1em;
  text-transform:uppercase; color:var(--primary);
}
.sp-body h3 { font-size:var(--text-title-lg); line-height:1.25; margin-top:.2rem }
.sp-org { font-size:var(--text-body-sm); color:var(--on-surface-var); margin-top:.25rem }
.sp-talk {
  font-size:var(--text-body-md); font-weight:700; margin-top:.7rem;
  line-height:1.35;
}

/* Two facts, not the references' three. They had ratings, patient counts and
   years; the only short quantitative things we hold are the slot and the
   country, and inventing a third would be exactly the fabricated-proof
   failure the references model. */
.sp-facts {
  display:grid; grid-template-columns:1fr 1fr; gap:.6rem; margin:.9rem 0 0;
  padding-top:.75rem; border-top:1px solid var(--outline-var);
}
.sp-facts div { min-width:0 }
.sp-facts dt {
  font-size:var(--text-label-sm); font-weight:700; letter-spacing:.1em;
  text-transform:uppercase; color:var(--on-surface-var);
}
.sp-facts dd {
  margin:.2rem 0 0; font-size:var(--text-body-md); font-weight:700;
  font-variant-numeric:tabular-nums; line-height:1.3;
}
.sp-cta {
  display:inline-flex; align-items:center; gap:.35rem; margin-top:1rem;
  font-size:var(--text-label-lg); font-weight:700; color:var(--primary);
  transition:gap var(--dur-3) var(--ease-standard);
}
.sp-cta svg { width:15px; height:15px }

/* ── credential tags ────────────────────────────────────────────────────
   The references carry these as chips - "MBBS", "Fellowship in USA". Ours
   condense a qualification, a position or an achievement already in
   content.py; see CREDENTIALS there. They replaced an inline biography
   disclosure, because five tags say what a hundred words said without asking
   the reader to open anything.

   A <ul>, not a row of spans: it is a list of credentials and reads as one to
   a screen reader. list-style is removed visually, so `role="list"` is kept
   for Safari, which drops the list semantics when it is. */
.sp-creds {
  list-style:none; margin:.85rem 0 0; padding:0;
  display:flex; flex-wrap:wrap; gap:.3rem;
}
.sp-creds li {
  font-size:var(--text-label-md); font-weight:600; line-height:1.35;
  color:var(--on-surface-var);
  background:var(--surface-3); border:1px solid var(--outline-var);
  border-radius:var(--radius-sm); padding:.22rem .5rem;
}
/* The first tag is the qualification in every case, so it leads. */
.sp-creds li:first-child {
  background:var(--primary-cont); color:var(--on-primary-cont);
  border-color:transparent; font-weight:700;
}

/* The card links its NAME and its button, not its whole surface, so both
   need to look like the controls they are - and neither should carry a
   default underline at rest. */
.sp-body h3 a { color:inherit; text-decoration:none }
.sp-cta { text-decoration:none }
@media (hover:hover) and (pointer:fine) {
  .sp-body h3 a:hover { color:var(--primary); text-decoration:underline; text-underline-offset:3px }
  .sp-cta:hover { text-decoration:underline; text-underline-offset:3px }
}
.sp-body h3 a:focus-visible,
.sp-cta:focus-visible { outline:2px solid var(--primary); outline-offset:2px; border-radius:var(--radius-xs) }

.sp-pending { border-style:dashed; box-shadow:none; background:transparent }
.sp-pending .sp-banner { background:var(--surface-3) }
.sp-pending .sp-why {
  font-size:var(--text-body-sm); color:var(--on-surface-var);
  font-style:italic; margin-top:.6rem;
}

/* ── profile page ─────────────────────────────────────────────────────── */

.profile-hero { background:var(--surface-3); border-bottom:1px solid var(--outline) }
.profile-hero-in {
  display:grid; grid-template-columns:220px 1fr; gap:clamp(1.25rem,4vw,2.5rem);
  align-items:start; padding-block:clamp(2rem,5vw,3rem);
}
/* Circular, matching .sp-face on the speaker cards and the portraits in the
   home page's proof row. It was a rounded square, which made the profile page
   the only place on the site where a person was not a circle. */
.profile-photo {
  width:220px; aspect-ratio:1/1; border-radius:var(--radius-full); overflow:hidden;
  border:5px solid var(--surface-2); box-shadow:var(--elev-2); background:var(--surface-2);
}
.profile-photo img { width:100%; height:100%; object-fit:cover; object-position:center 18% }
.profile-head h1 { font-size:var(--text-display-sm); font-weight:900 }
.profile-head .pos { margin-top:.6rem; font-size:var(--text-title-lg); font-weight:650; color:var(--on-surface-var) }
/* Institution and country each get their mark beside the words. Flex rows
   rather than an inline image so the mark stays vertically centred against a
   line that wraps - several institution names run to two lines at narrow
   widths, and a baseline-aligned image then sits against the first of them. */
.profile-head .inst {
  margin-top:.25rem; font-weight:700; color:var(--secondary);
  display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;
}
/* One institution and its own mark, kept together when the line wraps - a
   dual affiliation that broke between a logo and its university would be
   worse than no logo at all. */
.profile-head .inst-one { display:flex; align-items:center; gap:.45rem; min-width:0 }
.profile-head .inst-sep { color:var(--on-surface-var); font-weight:400 }
.profile-head .country {
  margin-top:.35rem; font-size:var(--text-body-md); color:var(--on-surface-var);
  display:flex; align-items:center; gap:.45rem; flex-wrap:wrap;
}
/* Both lines can carry two flags (Makubalo is "South Africa - Geneva,
   Switzerland"), so the marks must not be pushed out of shape by the text. */
.profile-head .inst > span, .profile-head .country > span { min-width:0 }

.talk-band {
  margin-top:1.4rem; background:var(--surface-2); border:1px solid var(--outline);
  border-left:5px solid var(--m3e-uj); border-radius:var(--radius-md);
  padding:.9rem 1.1rem;
}
.talk-band .k {
  font-size:var(--text-label-sm); font-weight:800; letter-spacing:.13em;
  text-transform:uppercase; color:var(--on-surface-var);
}
.talk-band .v { font-size:var(--text-title-lg); font-weight:800; margin-top:.2rem }
.talk-band .when { font-size:var(--text-body-md); color:var(--secondary); font-weight:650; margin-top:.2rem }

@media (max-width:760px) {
  .profile-hero-in { grid-template-columns:1fr }
  .profile-photo { width:min(220px,55%) }
}

.profile-grid { display:grid; grid-template-columns:1fr 320px; gap:clamp(1.5rem,4vw,3rem); align-items:start }
@media (max-width:900px) { .profile-grid { grid-template-columns:1fr } }

.block + .block { margin-top:2rem }
.block h2 {
  font-size:var(--text-headline-sm); padding-bottom:.5rem; margin-bottom:.9rem;
  border-bottom:2px solid var(--m3e-uj);
}
.block .bio-100 {
  background:var(--surface-3); border-radius:var(--radius-md); padding:1rem 1.15rem;
  font-size:var(--text-body-lg);
}
.block .bio-100 .wc {
  display:block; margin-top:.6rem; font-size:var(--text-label-md);
  color:var(--on-surface-var); font-variant-numeric:tabular-nums;
}
.list-tight { list-style:none; padding:0; margin:0; display:grid; gap:.55rem }
/* The bullet is absolutely positioned rather than a flex sibling. As a flex
   row the item never wrapped, and every bare text run between the inline
   <code> chips became its own unwrappable flex item - so on /mediakit two of
   these had a min-content width of 981px, sized the grid track to match and
   pushed the page 640px past a 375px viewport with no way to scroll to it.
   As a block the text simply wraps and the bullet sits where it did. */
.list-tight li { position:relative; padding-left:1.05rem; font-size:var(--text-body-md) }
.list-tight li::before {
  content:''; position:absolute; left:0; top:.55em;
  width:7px; height:7px; border-radius:2px; background:var(--m3e-uj);
}
.list-tight.blue li::before { background:var(--secondary) }

/* A framed self-contained document (/flyer, /programme-document).

   The document keeps its own stylesheet and its own @page rules inside the
   frame, which is the whole reason for a frame rather than inlining its body:
   an A4 print document and a responsive site page want incompatible things
   from the same selectors.

   Aspect ratio rather than a fixed height, so an A4 sheet stays A4-shaped at
   every width. The frame scrolls internally for the pages below the first. */
/* The diamond and sun watermarks were removed on 31 Aug 2026 - one asset
   family too many for what they added. The `main, .foot { position:relative;
   z-index:1 }` rule they needed goes with them: nothing sits behind the page
   body now, and leaving a stacking context behind would quietly change how
   .scarf-field's ::after and any future overlay resolve. */

/* ══ THE WATERFRONT ═══════════════════════════════════════════════════
   The V&A Waterfront - the masterclass is at Breakwater Lodge, which is on it -
   behind the whole opening block: the congress marks AND the headline, as one
   picture rather than a band above a page.

   It sits on `.topshot`, which page_home() wraps around the congress strip and
   the hero band precisely so one background can cover both. Backgrounds cannot
   span siblings, and two earlier attempts failed on exactly that: on #main the
   gradient sized itself against a 4,900px page, so a stop at 62 per cent landed
   3,000px down and never touched the photo; on a fixed-height pseudo-element it
   was guessing where the hero ended, and guessed short, so the picture read as
   a band behind the logos only. This box IS the opening block, so 100 per cent
   means what it should and the fade always lands on the fold.

   The scrim is measured off the photograph rather than eyeballed. Sampled
   against #24150E text, worst-case pixel in each band:
     sky, where the congress marks sit   rgb(114,135,154) -> 8.4:1 at a 42 veil
     water, behind the headline          rgb(3,0,0)       -> 2.97:1 at 42,
                                                             5.87:1 at 62
   That near-black pixel is a boat hull in shadow and it is the constraint: 42
   fails even the 3:1 large-text bar, 62 clears the 4.5:1 body-text bar the lede
   needs. One flat veil cannot serve both ends - strong enough for the hull
   washes the sky out entirely - so the veil ramps. */
#main.main-photo .topshot{ position:relative; isolation:isolate }
/* `cover`, centred. The brief is ONE cohesive photograph behind the whole
   landing block - the congress marks, the headline AND the ledger card - so it
   has to fill the box rather than sit in the top third of it. Fitted to the page
   width the photo only reached about 669px of a ~1067px block, which is exactly
   why it kept reading as a band at the top rather than a background.

   Centred, not top-aligned: `cover` on a 900x500 source in this box renders it
   about 1920 wide, and top-alignment then showed nothing but sky. Centre puts
   the hotel and the waterline behind the copy.

   The cost is real and worth stating: a 900x500 source upscaled past 1900 is
   soft. Behind a 44-60 per cent veil that reads as atmosphere rather than as a
   blurry photo, but a larger original would be better if one exists.

   The veil holds roughly flat across the block instead of ramping to nothing -
   it has to keep text legible all the way down now - and only releases in the
   last 12 per cent so the section joins the page below without a hard edge. */
#main.main-photo .topshot::before{
  content:''; position:absolute; inset:0; z-index:-1; pointer-events:none;
  background:
    linear-gradient(to bottom,
      color-mix(in srgb, var(--surface) 44%, transparent) 0%,
      color-mix(in srgb, var(--surface) 54%, transparent) 22%,
      color-mix(in srgb, var(--surface) 60%, transparent) 46%,
      color-mix(in srgb, var(--surface) 74%, transparent) 88%,
      var(--surface) 100%),
    url('/assets/va-waterfront.jpg') center center / cover no-repeat;
}
:root[data-theme="dark"] #main.main-photo .topshot::before{
  background:
    linear-gradient(to bottom,
      color-mix(in srgb, var(--surface) 64%, transparent) 0%,
      color-mix(in srgb, var(--surface) 72%, transparent) 22%,
      color-mix(in srgb, var(--surface) 76%, transparent) 46%,
      color-mix(in srgb, var(--surface) 88%, transparent) 88%,
      var(--surface) 100%),
    url('/assets/va-waterfront.jpg') center center / cover no-repeat;
}
@media (prefers-color-scheme: dark){
  :root:not([data-theme="light"]) #main.main-photo .topshot::before{
    background:
      linear-gradient(to bottom,
      color-mix(in srgb, var(--surface) 64%, transparent) 0%,
      color-mix(in srgb, var(--surface) 72%, transparent) 22%,
      color-mix(in srgb, var(--surface) 76%, transparent) 46%,
      color-mix(in srgb, var(--surface) 88%, transparent) 88%,
      var(--surface) 100%),
      url('/assets/va-waterfront.jpg') center center / cover no-repeat;
  }
}
/* The scarf texture is redundant under a photograph and only muddies it. */
#main.main-photo::before{ opacity:0 }
/* Everything between the photo and the reader has to be see-through.

   `.hero-band` was made transparent first and the picture STILL stopped dead
   under the congress marks. The thing actually painting was `section.hero`,
   which site.css gives `background:var(--surface-3)` - an opaque cream covering
   y=182 to y=1068, i.e. the headline, the lede and the ledger card. The wrapper
   was transparent; its child was not, and I had been reading the wrapper.

   Both radial washes go too. They are warm orange gradients drawn to give a
   flat cream hero some depth, and over a photograph they only tint the sky
   peach - which is why the picture kept reading as "cream" even where it was
   painting correctly underneath. */
#main.main-photo .hero-band{ background:transparent }
#main.main-photo .hero-band::before{ opacity:0 }
#main.main-photo .hero{ background:transparent }
#main.main-photo .hero::before{ opacity:0 }
@media print{ #main.main-photo .topshot::before{ display:none } }

/* ══ THE SCARF MOTIF ══════════════════════════════════════════════════════
   UJ's orange scarf carries a shweshwe-style lattice: a concentric ring -
   maroon outer, cream mid, orange field, dark centre - repeating with small
   cream diamonds in the gaps.

   Redrawn as vector, not cropped from the photograph. The photo is of a DRAPED
   scarf: every repeat sits at a different angle under different light, so no
   crop of it tiles - lay two side by side and the seam is a fold.
   assets/decor-scarf-swatch.png is the honest 220px crop and belongs in the
   media kit, where a photograph is what is wanted. This is the version that
   repeats, and being vector it stays crisp at any size and weighs ~1 KB.

   Held in a custom property because TWO rules need the same tile at different
   strengths, and a 900-byte data URI written twice is a 900-byte bug waiting to
   be edited in only one of them. */
:root {
  --scarf-tile:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'><g fill='%23A63115'><circle cx='0' cy='0' r='26'/><circle cx='120' cy='0' r='26'/><circle cx='0' cy='120' r='26'/><circle cx='120' cy='120' r='26'/><circle cx='60' cy='60' r='26'/></g><g fill='%23FDF7F1'><circle cx='0' cy='0' r='18'/><circle cx='120' cy='0' r='18'/><circle cx='0' cy='120' r='18'/><circle cx='120' cy='120' r='18'/><circle cx='60' cy='60' r='18'/></g><g fill='%23F47B20'><circle cx='0' cy='0' r='11'/><circle cx='120' cy='0' r='11'/><circle cx='0' cy='120' r='11'/><circle cx='120' cy='120' r='11'/><circle cx='60' cy='60' r='11'/></g><g fill='%23A63115'><circle cx='0' cy='0' r='4'/><circle cx='120' cy='0' r='4'/><circle cx='0' cy='120' r='4'/><circle cx='120' cy='120' r='4'/><circle cx='60' cy='60' r='4'/></g><g fill='%23FDF7F1' opacity='.85'><path d='M60 3 69 12 60 21 51 12z'/><path d='M60 99 69 108 60 117 51 108z'/><path d='M3 60 12 51 21 60 12 69z'/><path d='M99 60 108 51 117 60 108 69z'/></g></svg>");
}

/* Behind the whole page body. `main` is the one container shell() gives every
   page, so one rule reaches all eighteen. `position:fixed` on the
   pseudo-element rather than absolute: a 4,900px page would otherwise tile
   forty rows of it, and a fixed layer stays a single composited surface while
   the content scrolls over it.
   Much fainter than the hero - this is paper texture under a page of reading,
   and at hero strength it would sit under body copy. */
#main { position:relative; isolation:isolate }
#main::before {
  content:''; position:fixed; inset:0; z-index:-1; pointer-events:none;
  background:var(--scarf-tile) repeat 0 0 / 120px 120px;
  opacity:.045;
}
:root[data-theme="dark"] #main::before { opacity:.07 }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) #main::before { opacity:.07 }
}
@media print { #main::before { display:none } }

/* The hero gets the same motif at feature strength, fading out downward so it
   is texture at the top of the band and gone before the copy underneath. */
/* `.scarf-field` is gone. It was a SECOND copy of the motif laid over the hero
   at 8.5% on top of the page-wide layer below at 4.5%. Two problems, not one:
   the opacities stacked, and - because the page-wide layer is position:fixed
   (viewport origin) while this one was absolute (element origin) - the two
   tilings sat out of phase, so the same 120px tile printed twice at an offset
   and the pattern read as doubled and blurred.

   One layer now, `#main::before`, fixed and uniform. Being fixed it already
   covers the congress strip, which is what the overlay was added to do. */

/* UJ's digital pixel band. Already a repeating strip in the brand pack, cut to
   exactly one 117px repeat by assets/decor.py - see that file for why the
   period had to be measured rather than guessed. Used as a rule, never as a
   field: it is fully saturated UJ orange and black, and a page of it would
   fight everything on top of it. */
.pixelband {
  height:14px; background:url('/assets/decor-pixelband.png') repeat-x center/auto 100%;
  /* The strip is a fixed 117x55 raster. Rendering it at 14px tall on a 2x
     screen samples it at roughly its own resolution; larger and it softens. */
}
.foot .pixelband { margin-bottom:2rem }

/* The evaluation gate. Shown in place of the form until the masterclass ends. */
.eval-gate {
  margin-top:1.5rem; padding:1.75rem 1.5rem;
  background:var(--surface-2); border:1px solid var(--outline);
  border-radius:var(--radius-lg); text-align:center;
}
.eval-gate-label {
  font-family:var(--font-display); font-size:var(--text-title-md); font-weight:700;
  margin:0 0 1rem;
}
.eval-gate .clock { justify-content:center }
.eval-gate-note {
  margin:1.1rem 0 0; font-size:var(--text-body-sm); color:var(--on-surface-var);
  display:flex; align-items:center; justify-content:center; gap:.4rem; flex-wrap:wrap;
}

/* Institution marks: an overlapping stack of discs, with the logo sitting ON
   the disc rather than inside it.

   This went round the houses, so the reasoning is worth keeping. The marks
   started as `.faces` (52px, forced by a specificity tie), then became
   aspect-ratio pills (uncropped but unstacked), then circular <img>s with
   `border-radius` (stacked, and cropped three different ways).

   The crop had two causes and only one was the overlap:

     1. -22px on a 104px disc covered a fifth of each neighbour, so
        "UNIVERSITY OF JOHANNESBURG" read as "...HANNESBU". Now 12px on 120px.
     2. `border-radius` ON THE IMAGE clips it to the circle. The usual rule -
        inset by 0.146d and any aspect ratio survives - assumes the INK is
        centred in the box. In these marks it is not: UJ, MSU and Rochester all
        put a full-width wordmark at the top or bottom of the image, exactly
        where the circle has narrowed to nothing. No inset short of making the
        logo illegible fixes that.

   So the circle moved OFF the image and onto a wrapper. `.orgs-disc` is the
   plate - background, radius, shadow - and the <img> sits on top of it with no
   radius and therefore no clip. The logo is sized to 78% of the disc, which
   keeps it inside the plate for every mark here; if one ever exceeded it, it
   would overhang the edge rather than lose letters, which is the right way for
   this to fail. */
.orgs-row{
  display:flex; align-items:center; flex-wrap:wrap; row-gap:10px;
}
.orgs-disc{
  width:120px; height:120px; flex:none;
  margin-left:-12px; border-radius:var(--radius-full);
  background:var(--logo-plate-always);
  display:grid; place-items:center;
  /* Separation without a bezel: the disc in front casts onto the one behind,
     which is what overlapping objects do. An edge drawn around a circle is a
     bezel; a shadow under one is depth. */
  box-shadow:-7px 0 14px -7px rgba(53,25,10,.38), var(--elev-1);
  transition:transform var(--dur-hover) var(--ease);
}
.orgs-disc:first-child{ margin-left:0 }
.orgs-disc img{
  /* 66%, down from 78%. The TCC lockup and the UJ FEBE tile both carry a lot of
     internal detail and were reaching the plate edge; dropping the fill gives
     every mark the same breathing room rather than sizing them one by one,
     which would make six marks six decisions. */
  width:66%; height:66%; object-fit:contain; display:block;
  /* No border-radius. That is the whole point - see above. */
}
/* The one exception. UJ FEBE's mark has an opaque orange field baked into the
   file, so unclipped it renders as a square tile in a row of circles. It is
   also the one mark whose wordmark sits in the vertical CENTRE, where the
   circle is at full width - so it is the one that can be clipped to a circle
   without losing letters. It fills the disc rather than sitting inside it. */
.orgs-disc img.tiled{
  /* The tiled mark fills its disc because its own orange field IS the disc -
     but inset a little so it does not sit flush against the plate edge. */
  width:88%; height:88%; object-fit:cover; border-radius:var(--radius-full);
}
/* Earlier marks sit on top of later ones, so the stack reads left to right. */
.orgs-disc:nth-child(1){ z-index:6 }
.orgs-disc:nth-child(2){ z-index:5 }
.orgs-disc:nth-child(3){ z-index:4 }
.orgs-disc:nth-child(4){ z-index:3 }
.orgs-disc:nth-child(5){ z-index:2 }
.orgs-disc:nth-child(6){ z-index:1 }
@media (max-width:700px){
  .orgs-disc{ width:88px; height:88px; margin-left:-9px }
}
@media (hover:hover) and (pointer:fine){
  .orgs:hover .orgs-disc{transform:translateY(-2px)}
}

/* Section 09 uses the same stack under a section heading rather than in a hero,
   so it needs its own spacing and the names underneath it. */
/* Nine discs on one row, sized to actually fit the column.

   Eleven now, since the African Union and Carnegie Corporation joined. The
   arithmetic is d + (n-1)*(d-overlap) against the 1041px content column:

     11 @ 112/-14  ->  1092  overflows
     11 @ 104/-16  ->   984  fits

   At the home page's 120/-12 they would run to 1200. Resize this rule whenever
   a mark is added; the home page keeps 120px because it carries six, not
   eleven. */
/* No grid `gap`: the spacing is asymmetric on purpose. A caption belongs to
   the row ABOVE it, so it sits tight under its own marks and well clear of the
   next row's. A uniform gap would put the caption equidistant from both and
   make it ambiguous which row it names. */
.orgs-strip-sec { margin-top:1.25rem }
/* Eleven marks over TWO rows, 6 then 5, rather than one row of eleven.
   On one line they had to shrink to 104px, and at that size the wordmark
   inside the disc - the only thing that identifies the institution - stopped
   being readable. Two rows buys 176px, a 69% increase.

   The width is arithmetic, not taste: the section column is 980px, and the
   longer row - the top one since the 6/5 split - is
   6 x 176 - 5 x 20 of overlap = 956px. Adding a twelfth mark
   makes it 1112 and it wraps, so re-split the rows in build.py rather than
   shrinking the discs back down. */
.orgs-strip-sec .orgs-disc { width:176px; height:176px; margin-left:-20px }
.orgs-strip-sec .orgs-disc:first-child { margin-left:0 }
/* Both rows start at the same left edge. The rows are deliberately ragged -
   5 above 6 - because centring them would break the left-to-right reading the
   caption underneath depends on. */
.orgs-strip-sec .orgs-row { flex-wrap:wrap; justify-content:flex-start }
@media (max-width:1060px) {
  .orgs-strip-sec .orgs-disc { width:148px; height:148px; margin-left:-17px }
}
@media (max-width:860px) {
  .orgs-strip-sec .orgs-disc { width:112px; height:112px; margin-left:-13px }
}
@media (max-width:700px) {
  /* Below this the 5/6 split stops being a composition and the rows just
     wrap; the marks stay as large as the column allows. */
  .orgs-strip-sec .orgs-disc { width:96px; height:96px; margin-left:-11px }
}
/* One caption line per row of marks, each naming its row left to right.
   Full column width, not a 70ch reading measure: set narrow, eleven
   institution names wrapped into a block a third the width of the marks above
   them and read as a stray paragraph rather than as that row's caption. */
.orgs-names {
  margin:1rem 0 0; font-size:var(--text-body-sm); color:var(--on-surface-var);
}
.orgs-strip-sec .orgs-names { margin:.7rem 0 1.6rem }
.orgs-strip-sec .orgs-names:last-child { margin-bottom:0 }

/* ══ BOTTOM SHEET ═════════════════════════════════════════════════════════
   The venue map. Geometry, timings and easings are the clinic app's
   `components/m3/Sheet.tsx` verbatim - the same component the consultation room
   uses - so the two products behave identically:

     panel     inset-x-0 bottom-0, centred, max 640px, max-height 80vh,
               28px radius on the top corners only
     enter     translateY(100%) -> 0   300ms  ease-emph-decel
     exit      0 -> translateY(100%)   200ms  ease-emph-accel
     scrim     40% black,  250ms in  /  150ms out
     grip      4px x 32px, on-surface-variant at 40%

   Two classes rather than one, mirroring Sheet.tsx's mounted/visible pair:
   `.is-mounted` puts it in the layout, `.is-visible` (added a frame later)
   drives the transition. A transition cannot animate from a style the element
   has never held, which is why one class alone would make it appear instantly.
   `visibility` is on the same transition so the closed sheet leaves the
   accessibility tree and the tab order rather than sitting invisible over the
   page - the same bug the navigation drawer had. */
.sheet { position:fixed; inset:0; z-index:120; display:flex; align-items:flex-end;
         justify-content:center; visibility:hidden; }
.sheet.is-mounted { visibility:visible }

.sheet-scrim {
  position:absolute; inset:0; background:rgba(0,0,0,.4); opacity:0; cursor:pointer;
  transition:opacity var(--duration-short-3) var(--ease-emph-accel);
}
.sheet.is-visible .sheet-scrim {
  opacity:1; transition:opacity var(--duration-medium-1) var(--ease-emph-decel);
}

.sheet-panel {
  position:relative; z-index:1; display:flex; flex-direction:column;
  /* Wider and taller than Sheet.tsx's 640/80vh default. That size is tuned for
     a phone-width panel of text; this one holds a map, and a map needs area to
     be worth opening at all. */
  width:100%; max-width:880px; max-height:90vh;
  background:var(--surface-2); color:var(--on-surface);
  border-radius:var(--radius-xl) var(--radius-xl) 0 0;
  box-shadow:var(--elev-3); outline:none;
  transform:translateY(100%);
  transition:transform var(--duration-short-4) var(--ease-emph-accel);
  /* The sheet reaches the bottom edge of the screen; on a phone that is where
     the home indicator lives. */
  padding-bottom:env(safe-area-inset-bottom, 0);
}
.sheet.is-visible .sheet-panel {
  transform:translateY(0);
  transition:transform var(--duration-medium-2) var(--ease-emph-decel);
}

.sheet-grip {
  flex:none; width:32px; height:4px; margin:1rem auto .5rem;
  border-radius:var(--radius-full); background:var(--on-surface-var); opacity:.4;
}
.sheet-head {
  flex:none; display:flex; align-items:flex-start; gap:1rem;
  padding:.25rem 1.25rem .75rem;
}
.sheet-head > div { flex:1 1 auto; min-width:0 }
.sheet-title { font-family:var(--font-display); font-size:var(--text-title-md);
               font-weight:700; margin:0 }
.sheet-sub { font-size:var(--text-body-sm); color:var(--on-surface-var); margin:.15rem 0 0 }

.sheet-map {
  flex:1 1 auto; min-height:0; position:relative;
  background:var(--surface-3); overflow:hidden;
}
.sheet-map iframe { display:block; width:100%; height:100%; min-height:460px; border:0 }
.sheet-map-wait {
  position:absolute; inset:0; display:grid; place-items:center;
  font-size:var(--text-body-sm); color:var(--on-surface-var);
}
.sheet-act { flex:none; padding:1rem 1.25rem 1.25rem; display:flex; justify-content:flex-end }

/* Reduced motion: the sheet still fades, it just does not travel. Sliding a
   panel the height of the viewport is exactly the movement this setting is
   asking us not to make. */
@media (prefers-reduced-motion:reduce) {
  .sheet-panel { transform:none; opacity:0;
                 transition:opacity var(--duration-short-3) var(--ease-standard) }
  .sheet.is-visible .sheet-panel { opacity:1 }
}
@media print { .sheet { display:none } }

.docframe {
  margin-top:1.5rem;
  border:1px solid var(--outline-var); border-radius:var(--radius-lg);
  background:var(--surface-2); overflow:hidden;
  box-shadow:var(--elev-1);
}
.docframe iframe {
  display:block; width:100%; border:0;
  aspect-ratio:1 / 1.35;          /* a little taller than A4, so the next page peeks */
  min-height:520px;
  background:#fff;                /* the documents are white-paper artefacts in both themes */
}
@media (max-width:700px) { .docframe iframe { aspect-ratio:1 / 1.7 } }
/* A frame is a scroll container the reader cannot see the edges of on paper,
   and 400 KB of data-URI images is not something to send to a printer twice. */
@media print { .docframe { display:none } }

.side { position:sticky; top:75px; display:grid; gap:1rem }
@media (max-width:900px) { .side { position:static } }
.side-card {
  background:var(--surface-2); border:1px solid var(--outline);
  border-radius:var(--radius-lg); padding:1rem 1.1rem;
}
.side-card h3 {
  font-size:var(--text-label-md); font-weight:800; letter-spacing:.13em;
  text-transform:uppercase; color:var(--on-surface-var); margin-bottom:.7rem;
}
.social-list { display:grid; gap:.45rem }
.social-list a, .social-list span {
  display:flex; align-items:center; gap:.55rem; font-size:var(--text-body-md);
  padding:.45rem .6rem; border-radius:var(--radius-sm); text-decoration:none;
}
.social-list a { background:var(--surface-3); color:var(--secondary); font-weight:650 }
.social-list a:hover { background:var(--secondary-cont); color:var(--on-secondary-cont); text-decoration:none }
.social-list span { color:var(--on-surface-var); font-style:italic }
.social-list svg { width:17px; height:17px; flex:none }

.awaiting {
  border:1px dashed var(--outline-strong); border-radius:var(--radius-md);
  padding:.8rem .95rem; font-size:var(--text-body-md); color:var(--on-surface-var);
  background:transparent;
}
.awaiting b { color:var(--on-surface); font-weight:700 }

/* ── forms ────────────────────────────────────────────────────────────── */

.form-section {
  background:var(--surface-2); border:1px solid var(--outline);
  border-radius:var(--radius-lg); padding:1.25rem; margin-bottom:1.25rem;
}
.form-section > h2 { font-size:var(--text-headline-sm); margin-bottom:.35rem }
.form-section > .blurb { font-size:var(--text-body-md); color:var(--on-surface-var); margin-bottom:1rem }

fieldset { border:0; margin:0; padding:0; min-width:0 }
legend { padding:0 }
label { font-size:var(--text-body-md) }

.field { display:grid; gap:.3rem; margin-bottom:.9rem }
.field > label { font-weight:650 }
.input, textarea, select {
  width:100%; font:inherit; font-size:var(--text-body-md); color:var(--on-surface);
  background:var(--surface); border:1px solid var(--outline-strong);
  border-radius:var(--radius-sm); padding:.6rem .75rem;
}
textarea { min-height:104px; resize:vertical; line-height:1.55 }
.input:focus, textarea:focus { border-color:var(--secondary); outline:none; box-shadow:0 0 0 3px color-mix(in srgb, var(--secondary) 26%, transparent) }

/* ── registration form ───────────────────────────────────────────────────
   Two columns where there is room, one where there is not. The consent
   checkbox and the action row always span, because a decision and a
   destination should not sit in a column beside something else. */
.form-grid {
  display:grid; gap:0 1.25rem;
  grid-template-columns:repeat(auto-fit,minmax(min(280px,100%),1fr));
}
.field-wide { grid-column:1 / -1 }
.req { color:var(--error); font-weight:700 }
.err {
  font-size:var(--text-body-sm); font-weight:650; color:var(--error);
  display:flex; align-items:center; gap:.3rem;
}
/* aria-invalid is what the field carries for assistive technology, so it is
   also what colours the border - one source of truth for the state. */
.input[aria-invalid="true"], textarea[aria-invalid="true"] {
  border-color:var(--error); box-shadow:0 0 0 3px color-mix(in srgb, var(--error) 20%, transparent);
}

.field-consent { margin:1.4rem 0 .4rem }
/* The label wraps the control, so the whole sentence is the hit target and
   there is no dead zone between the box and its text. */
.check {
  display:flex; align-items:flex-start; gap:.7rem; cursor:pointer;
  font-size:var(--text-body-md); line-height:1.55; max-width:70ch;
}
.check input {
  width:20px; height:20px; margin:.18rem 0 0; flex:none;
  accent-color:var(--m3e-uj); cursor:pointer;
}
.check span { min-width:0 }

.form-actions {
  display:flex; align-items:center; gap:1rem; flex-wrap:wrap;
  margin-top:1.35rem; padding-top:1.2rem; border-top:1px solid var(--outline-var);
}
.form-actions .small { font-size:var(--text-body-sm); color:var(--on-surface-var); margin:0 }

/* Likert — a real table on wide screens, stacked radio groups on narrow.
   Each row is one radio group with a shared `name`, so arrow keys work. */
.likert { width:100%; border-collapse:collapse; font-size:var(--text-body-md) }
.likert th, .likert td { padding:.55rem .4rem; text-align:center; vertical-align:middle }
.likert thead th {
  font-size:var(--text-label-md); font-weight:800; letter-spacing:.04em;
  color:var(--on-surface-var); border-bottom:2px solid var(--outline-strong);
  line-height:1.25;
}
.likert thead th:first-child { text-align:left }
.likert tbody th {
  text-align:left; font-weight:600; font-size:var(--text-body-md);
  padding-right:.9rem; line-height:1.42;
}
.likert tbody tr { border-bottom:1px solid var(--outline) }
.likert tbody tr:last-child { border-bottom:0 }
.likert tbody tr:hover { background:var(--surface-3) }
.likert td label { display:grid; place-items:center; padding:.3rem; cursor:pointer; border-radius:var(--radius-sm) }
.likert col.scale { width:74px }
.likert col.na { width:64px }

input[type=radio], input[type=checkbox] { accent-color:var(--m3e-cobalt); width:20px; height:20px; margin:0; cursor:pointer }

/* The scale word inside each cell. Hidden on wide screens, where the column
   header carries it; visible on narrow, where the table reflows to blocks and
   the header is gone. One set of inputs either way — rendering the table twice
   and hiding one copy would put two radios in the same group and leave the
   visible one looking unanswered. */
.cl {
  position:absolute; width:1px; height:1px; padding:0; margin:-1px;
  overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap;
}
.likert td label.cell:has(input:checked) { background:var(--secondary-cont) }

@media (max-width:820px) {
  .likert, .likert tbody, .likert tr, .likert th, .likert td { display:block; width:auto }
  .likert thead, .likert colgroup { display:none }
  .likert tbody tr { padding:.9rem 0 }
  .likert tbody tr:hover { background:transparent }
  .likert tbody th { padding:0 0 .6rem; font-weight:650 }
  .likert tbody td { display:inline-block; padding:0 .35rem .4rem 0 }
  .likert td label.cell {
    display:inline-flex; align-items:center; gap:.45rem; padding:.42rem .75rem;
    border:1px solid var(--outline-strong); border-radius:var(--radius-full);
    font-size:var(--text-label-lg); place-items:unset;
  }
  .likert td label.cell:has(input:checked) {
    background:var(--secondary-cont); border-color:var(--secondary);
    color:var(--on-secondary-cont); font-weight:700;
  }
  .cl { position:static; width:auto; height:auto; margin:0; clip:auto; overflow:visible }
}

.radio-row { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:.5rem }
.radio-row label {
  display:inline-flex; align-items:center; gap:.5rem; padding:.5rem .95rem;
  border:1px solid var(--outline-strong); border-radius:var(--radius-full);
  font-size:var(--text-body-md); cursor:pointer;
}
.radio-row label:has(input:checked) {
  background:var(--secondary-cont); border-color:var(--secondary);
  color:var(--on-secondary-cont); font-weight:700;
}

.nps { margin-top:.7rem }
.nps-scale { display:flex; gap:.3rem; flex-wrap:wrap }
.nps-scale label {
  flex:1 1 44px; min-width:44px; text-align:center; padding:.5rem .2rem;
  border:1px solid var(--outline-strong); border-radius:var(--radius-sm);
  font-weight:750; font-variant-numeric:tabular-nums; cursor:pointer;
  font-size:var(--text-body-md);
}
.nps-scale label:has(input:checked) { background:var(--m3e-cobalt); color:#fff; border-color:var(--m3e-cobalt) }
.nps-scale input { position:absolute; opacity:0; pointer-events:none }
.nps-ends { display:flex; justify-content:space-between; font-size:var(--text-label-md); color:var(--on-surface-var); margin-top:.35rem }

.progress-bar {
  height:6px; border-radius:var(--radius-full); background:var(--outline);
  overflow:hidden; margin-top:.5rem;
}
/* scaleX, not width: width goes through layout -> paint -> composite on every
   frame, scaleX is composited alone. JS sets --p (0..1) on this element. */
.progress-bar > i {
  display:block; height:100%; width:100%; background:var(--m3e-uj);
  transform:scaleX(var(--p, 0)); transform-origin:left center;
  transition:transform var(--dur-4) var(--ease-standard);
}

.form-sticky {
  position:sticky; bottom:0; z-index:20; margin-top:1.5rem;
  background:color-mix(in srgb, var(--surface) 92%, transparent);
  backdrop-filter:blur(12px); border-top:1px solid var(--outline);
  padding:.85rem 0; display:flex; flex-wrap:wrap; gap:.8rem; align-items:center;
}
.form-sticky .meta { font-size:var(--text-body-sm); color:var(--on-surface-var); flex:1 1 200px }

.banner {
  border-radius:var(--radius-md); padding:.85rem 1rem; margin-bottom:1.25rem;
  font-size:var(--text-body-md); border:1px solid transparent;
}
.banner-info { background:var(--secondary-cont); color:var(--on-secondary-cont) }
.banner-warn { background:var(--tertiary-cont); color:var(--on-tertiary-cont) }
.banner-ok { background:var(--primary-cont); color:var(--on-primary-cont) }
.banner code { font-family:var(--font-mono); font-size:.92em }

.thanks { text-align:center; padding-block:3rem }
.thanks .tick {
  width:72px; height:72px; margin:0 auto 1.25rem; border-radius:var(--radius-full);
  background:var(--primary-cont); color:var(--on-primary-cont); display:grid; place-items:center;
}
.thanks .tick svg { width:36px; height:36px }
.thanks h2 { font-size:var(--text-headline-lg); margin-bottom:.6rem }
.token {
  display:inline-block; font-family:var(--font-mono); font-size:var(--text-body-lg);
  background:var(--surface-3); border:1px dashed var(--outline-strong);
  border-radius:var(--radius-sm); padding:.5rem .9rem; margin-top:.4rem; user-select:all;
}

/* ── media kit ────────────────────────────────────────────────────────── */

.assets { display:grid; gap:1rem; grid-template-columns:repeat(auto-fit,minmax(min(215px,100%),1fr)) }
.asset {
  display:flex; flex-direction:column; background:var(--surface-2);
  border:1px solid var(--outline); border-radius:var(--radius-lg); overflow:hidden;
}
.asset-preview {
  aspect-ratio:4/3; background:var(--surface-3); display:grid; place-items:center;
  padding:.7rem; position:relative; overflow:hidden;
}
.asset-preview img { max-width:100%; max-height:100%; width:auto; height:auto; object-fit:contain }
.asset-preview.is-photo { padding:0 }
.asset-preview.is-photo img { width:100%; height:100%; object-fit:cover; object-position:center 18% }
.asset-preview .ext {
  font-family:var(--font-mono); font-weight:800; font-size:var(--text-title-lg);
  color:var(--on-surface-var); letter-spacing:.04em;
}
.asset-body { padding:.8rem .9rem 1rem; display:flex; flex-direction:column; gap:.4rem; flex:1 }
.asset-body h3 { font-size:var(--text-title-md); line-height:1.3 }
.asset-body .det { font-size:var(--text-body-sm); color:var(--on-surface-var); flex:1 }
.asset-body .lic {
  font-size:var(--text-label-md); font-weight:700; padding:.25rem .55rem;
  border-radius:var(--radius-xs); align-self:flex-start;
}
.lic-event { background:var(--primary-cont); color:var(--on-primary-cont) }
.lic-speaker { background:var(--secondary-cont); color:var(--on-secondary-cont) }
.lic-third-party { background:var(--warm); color:var(--on-surface) }
.lic-congress { background:var(--tertiary-cont); color:var(--on-tertiary-cont) }
.lic-unconfirmed { background:var(--error); color:var(--m3-on-error) }
.asset-body .dl {
  font-size:var(--text-label-lg); font-weight:750; color:var(--primary);
  text-decoration:none; display:inline-flex; align-items:center; gap:.35rem;
}
.asset-body .dl svg { width:15px; height:15px }

.pending-table { width:100%; border-collapse:collapse; font-size:var(--text-body-md) }
.pending-table th, .pending-table td { text-align:left; padding:.7rem .6rem; border-bottom:1px solid var(--outline); vertical-align:top }
.pending-table thead th {
  font-size:var(--text-label-md); text-transform:uppercase; letter-spacing:.1em;
  color:var(--on-surface-var); border-bottom:2px solid var(--outline-strong);
}
.pending-table td:first-child { font-weight:700 }
.pending-table td:last-child { white-space:nowrap; color:var(--secondary); font-weight:650 }
@media (max-width:720px) {
  .pending-table thead { display:none }
  .pending-table tr { display:block; border-bottom:1px solid var(--outline); padding:.7rem 0 }
  .pending-table td { display:block; border:0; padding:.15rem 0 }
  .pending-table td:last-child { white-space:normal; margin-top:.35rem }
}

/* ── social kit ───────────────────────────────────────────────────────── */

.post {
  background:var(--surface-2); border:1px solid var(--outline);
  border-radius:var(--radius-lg); overflow:hidden; margin-bottom:1.1rem;
}
.post-head {
  display:flex; align-items:center; gap:.7rem; padding:.8rem 1rem;
  border-bottom:1px solid var(--outline); background:var(--surface-3); flex-wrap:wrap;
}
.post-head .plat {
  display:inline-flex; align-items:center; gap:.4rem; font-weight:800;
  font-size:var(--text-label-lg);
}
.post-head .plat svg { width:18px; height:18px }
.post-head .lbl { font-size:var(--text-body-sm); color:var(--on-surface-var) }
.post-head .count {
  margin-left:auto; font-family:var(--font-mono); font-size:var(--text-label-md);
  color:var(--on-surface-var); font-variant-numeric:tabular-nums;
}
.post-head .count.over { color:var(--error); font-weight:800 }
.post-body {
  margin:0; padding:1rem; font-family:var(--font-sans); font-size:var(--text-body-md);
  line-height:1.6; white-space:pre-wrap; overflow-wrap:break-word;
}
.post-foot {
  display:flex; align-items:center; gap:.7rem; padding:.7rem 1rem;
  border-top:1px solid var(--outline); flex-wrap:wrap;
}
.post-foot .hint { font-size:var(--text-body-sm); color:var(--on-surface-var); flex:1 1 180px }

.copy-btn { position:relative }
.copy-btn[data-copied="true"] { background:var(--primary-cont); color:var(--on-primary-cont) }

.acct { display:grid; gap:1rem; grid-template-columns:repeat(auto-fit,minmax(min(290px,100%),1fr)) }
.acct-card {
  background:var(--surface-2); border:1px solid var(--outline);
  border-radius:var(--radius-lg); padding:1.05rem 1.15rem; display:flex;
  flex-direction:column; gap:.5rem;
}
.acct-card .top { display:flex; align-items:center; gap:.6rem }
.acct-card .top b { font-size:var(--text-title-md) }
.acct-card .top .kind {
  margin:0; background:var(--warm); color:var(--on-surface);
}
.acct-card .h { font-family:var(--font-mono); font-size:var(--text-body-md); color:var(--secondary) }
.acct-card .needs, .acct-card .notes { font-size:var(--text-body-sm); color:var(--on-surface-var) }
.acct-card .notes { border-left:3px solid var(--m3e-uj); padding-left:.7rem }
.acct-card .actions { margin-top:auto; display:flex; gap:.5rem; flex-wrap:wrap }

.sched { width:100%; border-collapse:collapse; font-size:var(--text-body-md) }
.sched th, .sched td { text-align:left; padding:.65rem .6rem; border-bottom:1px solid var(--outline); vertical-align:top }
.sched thead th {
  font-size:var(--text-label-md); text-transform:uppercase; letter-spacing:.1em;
  color:var(--on-surface-var); border-bottom:2px solid var(--outline-strong);
}
.sched td:first-child { font-weight:750; white-space:nowrap; color:var(--secondary) }
@media (max-width:720px) {
  .sched thead { display:none }
  .sched tr { display:block; padding:.65rem 0; border-bottom:1px solid var(--outline) }
  .sched td { display:block; border:0; padding:.12rem 0 }
  .sched td:first-child { white-space:normal }
}

/* ── FAQ ──────────────────────────────────────────────────────────────── */

.faq { border:1px solid var(--outline); border-radius:var(--radius-lg); overflow:hidden; background:var(--surface-2) }
.faq details { border-bottom:1px solid var(--outline) }
.faq details:last-child { border-bottom:0 }
.faq summary {
  padding:.95rem 1.1rem; cursor:pointer; font-weight:700; font-size:var(--text-title-md);
  display:flex; align-items:center; gap:.7rem; list-style:none;
}
.faq summary::-webkit-details-marker { display:none }
.faq summary::after {
  content:''; margin-left:auto; flex:none; width:10px; height:10px;
  border-right:2px solid var(--primary); border-bottom:2px solid var(--primary);
  transform:rotate(45deg); transition:transform var(--dur-3) var(--ease-standard);
}
.faq details[open] summary::after { transform:rotate(-135deg) }
.faq summary:hover { background:var(--surface-3) }
.faq .a { padding:0 1.1rem 1.1rem; font-size:var(--text-body-md); color:var(--on-surface-var); max-width:70ch }

/* ── partners ─────────────────────────────────────────────────────────── */

.logos { display:flex; flex-wrap:wrap; gap:1.5rem 2.25rem; align-items:center }
.logos figure { margin:0; display:flex; flex-direction:column; gap:.35rem; align-items:center; text-align:center }
.logos img { max-height:56px; width:auto; filter:none }
.logos figcaption { font-size:var(--text-label-md); color:var(--on-surface-var); max-width:15ch }

/* Marks whose background IS the mark — UJ FEBE's orange field, the EU flag's
   blue. There is nothing to key out, so they are presented as tiles: the full
   square, corners rounded to the shape scale. Never cropped to a disc, which
   is what ate "UNIVERSITY OF JOHANNESBURG" and the FEBE checkerboard. */
.logos img.tile, img.tile { border-radius:var(--radius-md) }

/* Retained for the asset previews in the media kit, which still want discs. */
.logos img.round {
  /* max-width:none matters: the base `img{max-width:100%}` otherwise lets the
     flex item's content-derived width squash the disc into an ellipse. */
  height:64px; width:64px; max-height:64px; max-width:none; flex:none;
  object-fit:contain;
  border-radius:50%; padding:.4rem; box-sizing:border-box;
  background:var(--logo-plate-always); box-shadow:var(--elev-1);
}

/* ── brand marks in dark mode ──────────────────────────────────────────────
   The assets are genuinely transparent now, so nothing carries its own white
   box. But a third-party mark is not themeable — UCT's seal, the AAS wordmark
   and Rochester's crest are all dark navy, and on a dark surface they simply
   disappear. Recolouring someone's logo is not ours to do.

   So dark mode puts them on a plate. One deliberate, generous, rounded surface
   per mark, in warm paper rather than pure white so it belongs to this palette
   — not the tight white rectangle each PNG used to drag along with it. */
/* One band behind the whole row, not a plate per mark: nine separate cream
   rectangles read as stickers stuck to the page, one surface reads as a logo
   band. The discs sit on it without their own shadow. */
:root[data-theme="dark"] .logos img.round,
:root:not([data-theme="light"]) .logos img.round { box-shadow:none }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .logos {
    background:var(--logo-plate); border-radius:var(--radius-lg); padding:1.1rem 1.35rem;
  }
  :root:not([data-theme="light"]) .logos figcaption { color:#4A2916 }
  :root:not([data-theme="light"]) .asset-preview img { background:var(--logo-plate) }
}
:root[data-theme="dark"] .logos {
  background:var(--logo-plate); border-radius:var(--radius-lg); padding:1.1rem 1.35rem;
}
/* The plate flips the surface to light, so the ink on it has to flip too.
   Without this the captions keep the dark scheme's #DCB58F and land on cream
   at 1.79:1 against a 4.5:1 requirement. */
:root[data-theme="dark"] .logos figcaption { color:#4A2916 }
:root[data-theme="dark"] .asset-preview img { background:var(--logo-plate) }

/* ── footer ───────────────────────────────────────────────────────────── */

.foot { background:var(--surface-3); border-top:1px solid var(--outline); padding-block:2.5rem 2rem }
.foot-grid { display:grid; gap:1.75rem; grid-template-columns:repeat(auto-fit,minmax(min(215px,100%),1fr)) }
.foot h3 {
  font-size:var(--text-label-md); font-weight:800; letter-spacing:.13em;
  text-transform:uppercase; color:var(--on-surface-var); margin-bottom:.7rem;
}
.foot ul { list-style:none; padding:0; margin:0; display:grid; gap:.4rem }
.foot a { color:var(--on-surface); text-decoration:none; font-size:var(--text-body-md) }
.foot a:hover { color:var(--primary); text-decoration:underline }
.foot .fine {
  margin-top:2rem; padding-top:1.25rem; border-top:1px solid var(--outline);
  font-size:var(--text-body-sm); color:var(--on-surface-var);
}

/* ── print ────────────────────────────────────────────────────────────── */
/* Screen breakpoints above are unscoped on purpose: this is a website, not a
   print deliverable. What follows is the minimum needed for someone printing a
   speaker profile or the programme. */

@media print {
  .topbar, .topnav, .icon-btn, .btn-row, .form-sticky, .skip, .countdown,
  .tabbar { display:none !important }
  /* The bar is gone, so the space reserved for it must go too. */
  body { padding-bottom:0 !important }
  body { background:#fff; color:#000; font-size:11pt }
  .section { padding-block:1rem; break-inside:avoid }
  .hero::before { display:none }
  .prog-body { display:block !important }
  .faq details { break-inside:avoid }
  .faq details::details-content { content-visibility:visible; block-size:auto }
  a { color:#000; text-decoration:underline }
  .side { position:static }
}

/* ══ /infoconflict — the information-conflict register ═══════════════════════
   A working page for the organising team, so it optimises for scanning: state
   first, then whose claim, then the consequence. Undecided sorts to the top and
   is the only thing wearing UJ orange, which per palette.md is a fill colour
   only (2.6:1 on white) and never text. */

.cf-stats { display:flex; flex-wrap:wrap; gap:.9rem }
.cf-stat {
  flex:1 1 240px; padding:1rem 1.15rem; border-radius:var(--radius-md);
  background:var(--surface-3); border:1px solid var(--outline);
}
.cf-stat-wide { flex-basis:100%; margin-top:.9rem }
.cf-stat b {
  display:block; font-size:var(--text-headline-md); line-height:1;
  letter-spacing:-.03em; color:var(--primary);
}
.cf-stat span {
  display:block; margin-top:.35rem; font-weight:800;
  font-size:var(--text-label-lg); color:var(--on-surface);
}
.cf-stat em {
  display:block; margin-top:.4rem; font-style:normal;
  font-size:var(--text-body-sm); line-height:1.5; color:var(--on-surface-var);
}
.cf-stat[data-state="open"] b { color:var(--tertiary) }

/* ── one conflict ─────────────────────────────────────────────────────────── */
.cf {
  margin-top:1.5rem; padding:1.25rem 1.4rem 1.35rem;
  background:var(--surface-2); border:1px solid var(--outline);
  border-radius:var(--radius-lg); box-shadow:var(--elev-1);
  border-left:5px solid var(--outline-strong);
}
.cf[data-state="open"]      { border-left-color:var(--m3e-uj) }
.cf[data-state="corrected"] { border-left-color:var(--secondary) }
.cf-decided                 { border-left-color:var(--outline); box-shadow:none }

.cf > header { display:flex; flex-wrap:wrap; align-items:center; gap:.55rem }
.cf-flag {
  display:inline-block; padding:.2rem .55rem; border-radius:var(--radius-xs);
  font-size:var(--text-label-sm); font-weight:800; letter-spacing:.06em;
  text-transform:uppercase; background:var(--outline-strong); color:#fff;
}
.cf[data-state="open"] .cf-flag      { background:var(--m3e-uj); color:#24150E }
.cf[data-state="corrected"] .cf-flag { background:var(--secondary); color:#fff }
.cf-decided .cf-flag {
  background:transparent; color:var(--on-surface-var);
  border:1px solid var(--outline);
}
.cf-area {
  font-size:var(--text-label-md); font-weight:700; letter-spacing:.03em;
  color:var(--on-surface-var);
}
.cf h3 { margin:.55rem 0 0; font-size:var(--text-title-lg); line-height:1.3 }
.cf h3 a { color:inherit; text-decoration:none }
.cf h3 a:hover { text-decoration:underline; text-underline-offset:3px }
.cf > p { margin:.6rem 0 0; font-size:var(--text-body-md); line-height:1.62 }

/* ── the competing claims ─────────────────────────────────────────────────── */
.cf-sources { list-style:none; margin:.9rem 0 0; padding:0; display:grid; gap:.55rem }
.cf-sources li {
  display:grid; grid-template-columns:minmax(0,15rem) 1fr; gap:.2rem .9rem;
  padding:.6rem .75rem; border-radius:var(--radius-sm);
  /* --surface-3, not white: .cf already sits on --surface-2, and a literal
     white here would be white-on-dark in the dark scheme. */
  background:var(--surface-3);
  border:1px solid var(--outline);
}
.cf-who {
  font-family:var(--font-mono); font-size:var(--text-label-md);
  font-weight:600; line-height:1.45; color:var(--secondary);
  overflow-wrap:anywhere;
}
.cf-says { font-size:var(--text-body-sm); line-height:1.6; color:var(--on-surface) }

/* ── consequence, resolution, owner ───────────────────────────────────────── */
.cf-grid {
  margin-top:1rem; display:grid; gap:.9rem;
  grid-template-columns:repeat(auto-fit, minmax(min(190px,100%),1fr));
}
.cf-k {
  display:block; font-size:var(--text-label-sm); font-weight:800;
  letter-spacing:.1em; text-transform:uppercase; color:var(--on-surface-var);
}
.cf-grid p, .cf-edits li {
  margin:.3rem 0 0; font-size:var(--text-body-sm); line-height:1.58;
  color:var(--on-surface);
}
.cf-edits { margin-top:.9rem }
.cf-edits ul { margin:0; padding-left:1.1rem }
.cf code {
  font-family:var(--font-mono); font-size:.9em;
  background:var(--primary-cont); color:var(--on-primary-cont);
  padding:.05em .35em; border-radius:var(--radius-xs);
}

@media screen and (max-width:640px) {
  .cf { padding:1rem 1.05rem 1.15rem }
  .cf-sources li { grid-template-columns:1fr }
  .cf-who { color:var(--secondary) }
}


/* ══ M3 conformance for the rest of the interactive surfaces ══════════════
   Anything a pointer can act on carries a state layer. Listed explicitly
   rather than blanket-applied, so adding a control is a deliberate decision
   about whether it is interactive. */
:is(.btn, .topnav a, .icon-btn, .prog-cue, .lnk, .brand, .foot-grid a, .cf h3 a) {
  position:relative; isolation:isolate;
}
:is(.btn, .topnav a, .icon-btn, .prog-cue, .lnk, .brand, .foot-grid a, .cf h3 a)::before {
  content:''; position:absolute; inset:0; z-index:-1;
  border-radius:inherit; pointer-events:none;
  background:currentColor; opacity:0;
  transition:opacity var(--duration-short-3) var(--ease-standard);
}
:is(.btn, .topnav a, .icon-btn, .prog-cue, .lnk, .brand, .foot-grid a, .cf h3 a):hover::before { opacity:.08 }
:is(.btn, .topnav a, .icon-btn, .prog-cue, .lnk, .brand, .foot-grid a, .cf h3 a):focus-visible::before { opacity:.10 }
:is(.btn, .topnav a, .icon-btn, .prog-cue, .lnk, .brand, .foot-grid a, .cf h3 a):active::before { opacity:.10 }
:is(.btn, .icon-btn, .prog-cue):disabled::before { opacity:0 }
@media (prefers-reduced-motion:reduce) {
  :is(.btn, .topnav a, .icon-btn, .prog-cue, .lnk, .brand, .foot-grid a, .cf h3 a)::before {
    transition:none;
  }
}

/* The nav pill used to paint a surface colour on hover, which is a background
   swap standing in for a state layer. The layer above does that job now. */
.topnav a:hover { background:transparent; color:var(--on-surface) }

/* M3 icon button: 40dp target, fully round, state layer, no background of its
   own until it is toggled. */
.icon-btn {
  width:40px; height:40px; display:inline-flex; align-items:center;
  justify-content:center; border-radius:var(--radius-full);
  border:0; background:transparent; color:var(--on-surface-var); cursor:pointer;
}
.icon-btn svg { width:22px; height:22px }

/* Programme rows are list items, so the layer follows the row's own shape. */
.prog-cue { border-radius:var(--radius-full) }


/* ══ Material Symbols Rounded ══════════════════════════════════════════════
   Self-hosted and subset to 25 glyphs by assets/icons.py — 5.3 MB down to
   32 KB. Self-hosted rather than linked from fonts.googleapis.com because the
   CSP in firebase.json allows font-src 'self' only, and because a conference
   site should not hand every visitor's IP to a third party to draw a clock.

   The variable axes survive the subset, so these are the real M3 controls:
   FILL 0..1, wght 100..700, GRAD -50..200, opsz 20..48. */
/* Mirrors assets/msym.py font_face(). That module emits the same rule inline
   for the three self-contained documents; this is the served-file version. */
@font-face {
  font-family:'Material Symbols Rounded';
  font-style:normal;
  font-weight:100 700;
  font-display:block;          /* icons: no flash of fallback letterforms */
  src:url('/static/material-symbols-subset.woff2?v=b9d4e705') format('woff2');
}

.msym {
  font-family:'Material Symbols Rounded';
  font-weight:normal; font-style:normal;
  font-size:1.25em; line-height:1;
  letter-spacing:normal; text-transform:none; white-space:nowrap;
  direction:ltr; display:inline-block; flex:none;
  -webkit-font-feature-settings:'liga'; -webkit-font-smoothing:antialiased;
  font-variation-settings:'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
/* Filled variant for the moments that want weight behind them. */
.msym-fill { font-variation-settings:'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24 }

/* The fact card's eyebrow is an icon plus a label. The icon sits at the label's
   own weight and colour so it reads as part of the line, not as decoration
   bolted on the front. */
.fact .k { display:flex; align-items:center; gap:.4rem }
.fact .k .msym { font-size:1.05rem; opacity:.9 }

/* ══ display face ═════════════════════════════════════════════════════════
   Marcellus, self-hosted from assets/fonts/Marcellus (SIL Open Font License —
   the only one of the three supplied faces that carries a licence permitting
   web use). 18 KB as woff2.

   Display only: it is a classical Roman capital face, superb at headline size
   and tiring below it, so body text stays on the system sans. `font-display:
   swap` because a heading is content — better a moment in the fallback than a
   moment of nothing.

   To revert, drop --font-display back to var(--font-sans); nothing else refers
   to the face by name. */
@font-face {
  font-family:'Marcellus';
  font-style:normal; font-weight:400;
  font-display:swap;
  src:url('/static/marcellus.woff2?v=a0538bc6') format('woff2');
}
/* Titles are SSSans. Marcellus is retained below and unreferenced - its
   @font-face costs nothing until a token names it, so reverting the display
   face to the serif is a one-line change here. */
:root { --font-display:'SSSans', system-ui, -apple-system, 'Segoe UI', sans-serif }

/* ── SSSans (SkySports) ────────────────────────────────────────────────────
   The body face. Built by `assets/sssans.py` from the supplied TTFs; four
   upright weights, 68 KB total, Latin-1 plus the punctuation this site sets.

   LICENCE UNRESOLVED. The files carry no licence — nameID 13 and 14 are absent
   from all ten — and the family is "Sky Sports", a broadcaster's corporate
   typeface. The woff2 are gitignored, so a clean clone has no font files and
   every `src` below 404s. That is deliberate and it degrades correctly: the
   stack falls through to the system UI face and the layout is unchanged,
   because the metrics are close enough not to reflow. Resolve the licence
   before this is served publicly. */
/* Source Sans 3 - the body face. One variable file, wght 200-900, 21 KB.
   SIL OFL 1.1, declared in the font's own nameID 13, so self-hosting is
   exactly what the licence permits. It stands in for Aptos, which was asked
   for and cannot be used: Aptos is proprietary to Microsoft and licensed for
   typing, printing and PDF export, explicitly NOT for self-hosting as a
   webfont. Source Sans 3 is the nearest legitimate match - humanist, similar
   x-height, built for text sizes. */
@font-face { font-family:'Source Sans 3'; font-style:normal; font-weight:200 900;
  font-display:swap; src:url('/static/sourcesans3.woff2?v=3e31e683') format('woff2') }

@font-face { font-family:'SSSans'; font-style:normal; font-weight:400; font-display:swap;
  src:url('/static/sssans-400.woff2?v=5a391433') format('woff2') }
@font-face { font-family:'SSSans'; font-style:normal; font-weight:500; font-display:swap;
  src:url('/static/sssans-500.woff2?v=582799f8') format('woff2') }
@font-face { font-family:'SSSans'; font-style:normal; font-weight:700; font-display:swap;
  src:url('/static/sssans-700.woff2?v=4afbf3ca') format('woff2') }
@font-face { font-family:'SSSans'; font-style:normal; font-weight:900; font-display:swap;
  src:url('/static/sssans-900.woff2?v=4621e303') format('woff2') }

.hero h1, .sec-head h2, .cf h3, h1 { font-family:var(--font-display) }
/* The face has no bold, so weight has to come from size and colour, not from
   a synthesised bold that would smear its thin strokes. */
.hero h1, .sec-head h2 { font-weight:700; letter-spacing:-.022em }


/* ══ view transitions ══════════════════════════════════════════════════════
   The speakers list morphing into a speaker's profile, ported from the clinic
   app's patients list. There it is React's <ViewTransition name={...}>; here
   it is the native CSS View Transitions API, which is the same mechanism for
   a multi-page site and needs no framework.

   The PORTRAIT carries `view-transition-name: sp-<slug>` on both sides -
   `.sp-face` on the card and `.profile-photo` on the profile - so the face
   travels and everything else crossfades with the root.

   It used to be on the whole card paired with the whole profile hero. Measured,
   those are 307x593 (aspect 0.52) and 1041x480 (aspect 2.17): a 4.2x aspect
   change. A group stretches both snapshots to its animating box, so the card
   smeared sideways as it flattened. Shrinking back the eye forgave it; blowing
   up forward it did not, which is exactly why the exit read well and the entry
   did not. The two portraits are both aspect 1.00, so the morph is now a pure
   scale.

   Timings are the clinic's, from frontend/app/globals.css:
     root crossfade   600ms on its effects curve - opacity must not bounce
     the morph        700ms on a clean decel, z-index 30

   The clinic learned that the Expressive spring OVERSHOOTS on the way up -
   the card grows past final, settles back, and the eye reads "rise, pause,
   re-rise" instead of one motion. So the forward trip uses a decel curve with
   no overshoot and no mid-morph blur. Same choice here.

   Unsupported browsers simply navigate: no transition, nothing broken. */
@view-transition { navigation: auto }

/* The travelling portrait is a circle at both ends, so the group that carries
   it has to be one for the whole trip - otherwise the snapshot is a square with
   transparent corners and the disc visibly squares off mid-flight. */
::view-transition-group(.sp-face),
::view-transition-image-pair(*) { border-radius:var(--radius-full) }

::view-transition-group(*) {
  /* 400ms, not the clinic's 700. That was tuned for a hero morph inside a
     clinical app; here it fires on every speaker a visitor opens, and the
     modal/drawer budget is 200-500ms. */
  animation-duration:400ms;
  animation-timing-function:var(--ease-emph-decel);
  z-index:30;
}
::view-transition-old(root),
::view-transition-new(root) {
  /* 300ms: this rides on top of EVERY navigation, so it sets the felt speed
     of the whole site. */
  animation-duration:300ms;
  animation-timing-function:cubic-bezier(0.34, 0.80, 0.34, 1);
  z-index:auto;
}

/* Motion here is decorative - the navigation completes identically without
   it - so it comes off entirely under reduced motion rather than being
   shortened. */
@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none }
  ::view-transition-group(*),
  ::view-transition-old(root),
  ::view-transition-new(root) { animation:none }
}

/* ── Sign-in gate ─────────────────────────────────────────────────────────
   The media kit and the social kit sit behind this. It replaces the page
   body rather than overlaying it, because an overlay implies the content is
   there underneath and merely obscured — and on a static site that is exactly
   the wrong thing to imply. The markup IS still there; see static/gate.js.

   Sized as a card in the flow rather than a full-height hero: this is a
   checkpoint on the way somewhere, not a destination, and a viewport-filling
   sign-in screen reads as an error state. */
.gate-wrap { padding-top:2.5rem; padding-bottom:3.5rem }
.gate {
  max-width:26rem; margin-inline:auto; padding:2rem 1.75rem 1.75rem;
  background:var(--surface-2); border:1px solid var(--outline);
  border-radius:var(--radius-lg);
}
.gate-lock {
  display:grid; place-items:center; width:44px; height:44px; margin-bottom:1rem;
  border-radius:50%; background:var(--secondary-cont); color:var(--on-secondary-cont);
}
.gate-lock svg { width:22px; height:22px }
.gate-title {
  font-family:var(--font-display); font-size:var(--text-headline-sm);
  font-weight:700; margin:0 0 .4rem;
}
.gate-lede { margin:0 0 1.4rem; color:var(--on-surface-var); font-size:var(--text-body-md) }
.gate-busy { margin:0; color:var(--on-surface-var); font-size:var(--text-body-sm) }
.gate-form .field:last-of-type { margin-bottom:1.2rem }
.gate-submit { width:100% }
.gate-help {
  margin:1.2rem 0 0; padding-top:1rem; border-top:1px solid var(--outline);
  font-size:var(--text-body-sm); color:var(--on-surface-var);
}
/* Not colour alone: the panel is bordered and prefixed, so the failure is
   legible without hue. role="alert" carries it to a screen reader. */
.gate-err {
  margin:0 0 1rem; padding:.7rem .85rem;
  background:color-mix(in srgb, var(--error) 10%, transparent);
  border:1px solid var(--error); border-radius:var(--radius-sm);
  color:var(--on-surface); font-size:var(--text-body-sm);
}
.gate-err::before { content:'⚠ '; font-weight:700 }

/* The signed-in strip, above the unlocked content. */
.gate-bar {
  display:flex; align-items:center; justify-content:flex-end; gap:.75rem;
  flex-wrap:wrap; padding-top:1rem;
  font-size:var(--text-body-sm); color:var(--on-surface-var);
}
.gate-who { margin:0 }

/* ── Mobile tab bar ───────────────────────────────────────────────────────
   The site's navigation below 700px, modelled on FlyPOS26's BottomNav: fixed
   to the bottom, safe-area inset, an active pill above a filled icon chip,
   horizontal scroll when the items outrun the screen.

   Why 700px and not the rail's 1100px: between the two the top bar and its
   drawer are a perfectly good tablet pattern, and a bottom tab bar on a
   1000px-wide screen reads as a phone app running in the wrong place. 700px
   is also the breakpoint the rest of this stylesheet already treats as
   "phone", so there is one line in the sand rather than two.

   See build.py's tabbar() for why Home is not a tab and why labels stay. */
.tabbar { display:none }

@media (max-width:700px) {
  .tabbar {
    display:block; position:fixed; left:0; right:0; bottom:0;
    /* Above the sticky top bar (40), below the drawer scrim (45) - so opening
       the menu covers the tab bar rather than fighting it - and well below the
       bottom sheet (120), which must cover everything. */
    z-index:42;
    background:var(--surface-2); border-top:1px solid var(--outline);
    /* The iPhone home indicator sits in this space; without the inset the last
       row of labels is under it. */
    padding-bottom:env(safe-area-inset-bottom, 0px);
  }
  .tabbar-in {
    display:flex; overflow-x:auto;
    scrollbar-width:none; -ms-overflow-style:none; -webkit-overflow-scrolling:touch;
  }
  .tabbar-in::-webkit-scrollbar { display:none }

  /* A scrolling row that gives no sign it scrolls is a row half the visitors
     never see the end of. These fades are the sign: the side with more behind
     it dims. site.js sets data-scroll to none|start|middle|end, so a row that
     fits shows neither fade and a row scrolled to its end stops hinting right.

     Pointer-events off - they sit over the tabs and must not eat taps. */
  .tabbar::before, .tabbar::after {
    content:''; position:absolute; top:0; bottom:0; width:1.75rem;
    pointer-events:none; opacity:0;
    transition:opacity var(--duration-short-3) var(--ease-standard);
  }
  .tabbar::before {
    left:0; background:linear-gradient(to right, var(--surface-2), transparent);
  }
  .tabbar::after {
    right:0; background:linear-gradient(to left, var(--surface-2), transparent);
  }
  .tabbar[data-scroll="start"]::after,
  .tabbar[data-scroll="middle"]::after,
  .tabbar[data-scroll="middle"]::before,
  .tabbar[data-scroll="end"]::before { opacity:1 }

  /* `flex:1 0 auto` + a min width: five items share the width evenly, and when
     Evaluation appears on the day the sixth pushes the row into a scroll
     rather than crushing every label. */
  .tab {
    position:relative; flex:1 0 auto; min-width:4.25rem;
    display:flex; flex-direction:column; align-items:center; gap:2px;
    padding:.5rem .3rem .55rem; text-decoration:none;
    color:var(--on-surface-var);
    font-family:var(--font-display); font-size:var(--text-label-sm);
    line-height:1.15; text-align:center; -webkit-tap-highlight-color:transparent;
  }
  .tab:hover { text-decoration:none }
  /* The theme tab is a <button> among links: strip the UA chrome so it is
     indistinguishable from its neighbours.

     NOT `font:inherit`. The shorthand inherits from the PARENT, which
     overrides .tab's own font-size on this element - it rendered a size
     larger than the links beside it. The UA button font is a UA-origin
     declaration, so .tab's author-origin rules already win and there is
     nothing to reset but the weight. */
  .tab-theme {
    appearance:none; background:none; border:0; margin:0;
    font-weight:inherit; cursor:pointer; color:var(--on-surface-var);
  }
  .tab:focus-visible {
    outline:2px solid var(--primary); outline-offset:-3px;
    border-radius:var(--radius-sm);
  }

  /* Active state carries THREE cues - colour, the pill, and the filled chip -
     because colour alone is not a state anyone can rely on. */
  .tab[aria-current="page"] { color:var(--primary) }
  .tab[aria-current="page"]::before {
    content:''; position:absolute; top:0; left:50%; transform:translateX(-50%);
    width:2.25rem; height:3px; border-radius:0 0 var(--radius-full) var(--radius-full);
    background:var(--primary);
  }
  .tab-i {
    display:grid; place-items:center; width:2.5rem; height:1.9rem;
    border-radius:var(--radius-full);
    transition:background-color var(--duration-short-3) var(--ease-standard);
  }
  .tab[aria-current="page"] .tab-i {
    background:var(--secondary-cont); color:var(--on-secondary-cont);
  }
  .tab svg { width:22px; height:22px }
  .tab-l { white-space:nowrap }

  /* Content has to clear a fixed bar, or the footer's last line sits under it.
     4.25rem is the bar's own height; the inset is added on top for the phones
     that have one. */
  body { padding-bottom:calc(4.25rem + env(safe-area-inset-bottom, 0px)) }
  /* The form action bar is sticky at bottom:0 and would land underneath the
     tab bar. Lift it by exactly the bar's height so both are usable. */
  .form-sticky { bottom:calc(4.25rem + env(safe-area-inset-bottom, 0px)) }
}

@media (max-width:700px) and (prefers-reduced-motion:reduce) {
  .tab-i { transition:none }
}

/* (see the rule below) */
/* Below 700px the whole top bar goes: the tab bar carries navigation, and a
   sticky header above it was a second copy two thumb-lengths away plus ~56px
   of every screen spent on chrome. Nothing is stranded by that any more - the
   tab bar's last item is the theme toggle, and every destination is a tab. */
@media (max-width:700px) {
  .topbar { display:none }
}
