/* =========================================================
   Beyond the Algorithm — site styles

   Type follows criticalinfralab.net: Source Sans 3 for
   everything, Source Code Pro for labels, buttons and inputs.
   Headings sit at normal weight — hierarchy comes from size
   and the sans/mono contrast, not from bold.

   Colour is still the physical game's palette.
   ========================================================= */

/* ---------------------------------------------------------
   1. Tokens
   --------------------------------------------------------- */

:root {
  /* Forms / surfaces */
  --paper: #f5efec;
  --paper-accent: #f9b27b;
  --paper-header: #ecc2a3;

  /* Data cards */
  --data-dark: #6b718b;
  --data-light: #abb3c7;

  /* Risk Mitigation zone */
  --risk-bg: #ecc2a3;
  --risk-text: #d69477;

  /* Justification zone */
  --just-bg: #cbdbbc;
  --just-text: #698b7d;

  /* Domain Team */
  --domain-bg: #ffe8c7;
  --domain-accent: #f3cc92;
  --domain-text: #edb35c;
  --domain-header: #ffdba6;

  /* Tech Team */
  --tech-bg: #d0ebeb;
  --tech-accent: #8fc7cc;
  --tech-text: #6aa9af;
  --tech-header: #abdcd8;

  /* Type */
  --ink: #081912;
  --ink-soft: #081912b3;
  --ink-faint: #08191259;

  --font-body: "Source Sans 3", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --font-mono: "Source Code Pro", ui-monospace, SFMono-Regular, Menlo, monospace;

  /* Rhythm */
  --measure: 64ch;
  --page: 1080px;
  --gap: 1.5rem;
  --radius: 8px;
  --radius-lg: 16px;
  --radius-pill: 999px;

  --shadow-card: 0 1px 2px #0819120d, 0 8px 24px -14px #08191233;
  --shadow-lift: 0 2px 4px #08191212, 0 18px 40px -20px #08191238;

  --rule: #08191214;

  /* A small hairline scale, one step per weight actually in use on a
     border. --rule is the divider between sections; these are the
     tighter, boxed-in weights that show up on cards, controls, and
     dashed edges. Named rather than left as repeated raw hex so the
     four steps read as a deliberate scale, not four accidents. */
  --line-soft: #08191212;
  --line: #08191226;
  --line-strong: #08191233;
  --line-loud: #08191238;

  /* Fractal noise at very low opacity. Inline so there is no extra
     request and nothing to 404 on GitHub Pages. */
  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
}

/* ---------------------------------------------------------
   2. Reset and base
   --------------------------------------------------------- */

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

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

/* A faint paper grain over the flat fill. It is doing almost nothing at
   any single pixel, which is the point — it stops large areas reading as
   screen-flat and nudges them towards printed card stock. */
body {
  margin: 0;
  background-color: var(--paper);
  background-image: var(--grain);
  color: var(--ink);
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 1.0625rem;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  font-variant: common-ligatures tabular-nums;
}

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

a {
  color: inherit;
  text-decoration-color: var(--ink-faint);
  text-underline-offset: 0.18em;
  transition: text-decoration-color 0.15s ease;
}

a:hover {
  text-decoration-color: currentColor;
}

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

/* ---------------------------------------------------------
   3. Type scale — normal weight throughout, no bold headings
   --------------------------------------------------------- */

h1,
h2,
h3,
h4 {
  font-family: var(--font-body);
  font-weight: 400;
  line-height: 1.15;
  letter-spacing: -0.01em;
  margin: 0 0 0.6em;
  text-wrap: balance;
}

h1 {
  font-size: clamp(2.1rem, 5.5vw, 3.3rem);
}
h2 {
  font-size: clamp(1.45rem, 3vw, 1.95rem);
}
h3 {
  font-size: 1.15rem;
  letter-spacing: 0;
}
h4 {
  font-size: 1rem;
  letter-spacing: 0;
}

p {
  margin: 0 0 1.1em;
  max-width: var(--measure);
}

strong,
b {
  font-weight: 600;
}

em,
i {
  font-style: italic;
}

/* Mono micro-labels: eyebrows, card tabs, nav, buttons, inputs */
.eyebrow {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  letter-spacing: 0.02em;
  color: var(--ink-soft);
  margin: 0 0 1rem;
}

.num {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

/* ---------------------------------------------------------
   4. Layout
   --------------------------------------------------------- */

.wrap {
  width: min(100% - 2.5rem, var(--page));
  margin-inline: auto;
}

.wrap--narrow {
  width: min(100% - 2.5rem, 720px);
  margin-inline: auto;
}

section {
  padding-block: clamp(2.75rem, 7vw, 5rem);
}

.section-head {
  margin-bottom: clamp(1.5rem, 4vw, 2.5rem);
}

.section-head p {
  color: var(--ink-soft);
}

.grid {
  display: grid;
  gap: var(--gap);
}

@media (min-width: 720px) {
  .grid--2 {
    grid-template-columns: repeat(2, 1fr);
  }
  .grid--3 {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media (min-width: 980px) {
  .grid--4 {
    grid-template-columns: repeat(4, 1fr);
  }
}

@media (min-width: 720px) and (max-width: 979px) {
  .grid--4 {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* ---------------------------------------------------------
   5. Header / navigation
   --------------------------------------------------------- */

/* Fixed rather than sticky. Sticky reserves a strip of layout at the top
   of the page, which is why the hero used to need a negative top margin to
   climb back under it; fixed takes the bar out of flow entirely, so the
   opening screen simply starts at the top of the window. The pages with no
   hero get that strip back as padding below. */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 50;
  background: var(--paper-header);
  border-bottom: 1px solid #0819121a;
}

body:not(:has(.hero)) #main {
  padding-top: var(--header-h, 3.5rem);
}

.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding-block: 0.7rem;
}

.brand {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  text-decoration: none;
  flex-shrink: 0;
}

.brand__mark {
  width: 30px;
  height: 30px;
  flex: none;
}

.nav-toggle {
  display: none;
  background: transparent;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  padding: 0;
  color: var(--ink);
  cursor: pointer;
}

/* A burger rather than the word "Menu". The label lives on aria-label,
   so the button still announces itself to a screen reader.

   Stroke, not fill: the two icons are open paths, and the global
   `img, svg { max-width: 100% }` would otherwise leave a viewBox-only
   svg at the 300x150 replaced-element default - the same trap the
   LinkedIn mark on the contact page had to dodge. */
.nav-toggle svg {
  width: 1.3rem;
  height: 1.3rem;
  display: block;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}

/* Open swaps to a close cross, driven off the same aria-expanded the
   script already maintains — no second state to keep in sync.

   Written as a descendant rather than the bare `.nav-toggle__close`,
   because `.nav-toggle svg { display: block }` above is a class plus a
   type and would otherwise beat a lone class and leave both icons
   showing at once. Two classes clears it. */
.nav-toggle .nav-toggle__close {
  display: none;
}

.nav-toggle[aria-expanded="true"] .nav-toggle__open {
  display: none;
}

.nav-toggle[aria-expanded="true"] .nav-toggle__close {
  display: block;
}

.nav {
  display: flex;
  align-items: center;
  gap: 0.1rem;
  flex-wrap: wrap;
}

.nav a {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  padding: 0.45rem 0.7rem;
  border-radius: var(--radius);
  text-decoration: none;
  white-space: nowrap;
}

.nav a:hover {
  background: #ffffff80;
}

.nav a[aria-current="page"] {
  background: var(--paper);
}

@media (max-width: 800px) {
  /* grid + a 44px floor rather than display:block with padding: the
     icon is 20.8px, so padding alone would leave a ~38px button, under
     the size a thumb reliably hits. */
  .nav-toggle {
    display: grid;
    place-items: center;
    min-width: 44px;
    min-height: 44px;
  }

  .nav {
    display: none;
    position: absolute;
    left: 0;
    right: 0;
    top: 100%;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    background: var(--paper-header);
    border-bottom: 1px solid #0819121a;
    padding: 0.3rem min(1.25rem, 4vw) 0.8rem;
  }

  .nav.is-open {
    display: flex;
  }

  .nav a {
    padding: 0.65rem 0.5rem;
    font-size: 0.9rem;
  }
}

/* ---------------------------------------------------------
   6. Buttons
   --------------------------------------------------------- */

.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.82rem;
  padding: 0.75rem 1.2rem;
  border-radius: var(--radius);
  border: 1px solid transparent;
  text-decoration: none;
  cursor: pointer;
  transition: background 0.14s ease, box-shadow 0.14s ease;
}

.btn--primary {
  background: var(--paper-accent);
  color: var(--ink);
}

.btn--primary:hover {
  box-shadow: var(--shadow-lift);
}

.btn--ghost {
  background: transparent;
  border-color: var(--line-strong);
  color: var(--ink);
}

.btn--ghost:hover {
  background: #ffffff80;
}

.btn-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.7rem;
  margin-top: 1.6rem;
}

/* ---------------------------------------------------------
   7. Cards
   --------------------------------------------------------- */

.card {
  background: #fffdfb;
  border-radius: var(--radius-lg);
  padding: 1.5rem;
  box-shadow: var(--shadow-card);
  border: 1px solid var(--line-soft);
}

.card > *:last-child {
  margin-bottom: 0;
}

/* Cards lift a little on hover — enough to read as objects on a table
   rather than boxes on a page. */
.card,
.person {
  transition: transform 0.18s ease, box-shadow 0.18s ease;
}

.card:hover,
.person:hover {
  transform: translateY(-3px);
  box-shadow: var(--shadow-lift);
}

.card p {
  color: var(--ink-soft);
  font-size: 1rem;
}

.card__tab {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 0.875rem;
  padding: 0.25rem 0.6rem;
  border-radius: var(--radius-pill);
  margin-bottom: 0.85rem;
}

.card--domain {
  background: var(--domain-bg);
  border-color: var(--domain-accent);
}
.card--domain .card__tab {
  background: var(--domain-header);
  color: #7a5620;
}
.card--domain h3 {
  color: #b0762a;
}
.card--domain p {
  color: #7a5620;
}

.card--tech {
  background: var(--tech-bg);
  border-color: var(--tech-accent);
}
.card--tech .card__tab {
  background: var(--tech-header);
  color: #305559;
}
.card--tech h3 {
  color: #4a8086;
}
.card--tech p {
  color: #3d6b70;
}

/* The emphasised card, currently the invitation on the contact page.
   Warm fill rather than the dark --data slate that used to occupy this
   slot: every other surface on that page is warm paper, and slate is a
   game-artefact colour rather than a site surface.

   Same specificity as .card (0,1,0), so it has to stay below it.
   .card:hover is (0,2,0) and still wins, so the lift survives. */
.card--invite {
  background: var(--wedge-warm);
  border-color: var(--wedge-warm-accent);
}

/* ---------------------------------------------------------
   8. Hero
   --------------------------------------------------------- */

/* The hero's own padding lives on .hero__stage; see the V3b block. */

/* Set as large as the column allows and broken deliberately across lines.
   "Algorithm" drops into the mono face — the sans/mono contrast is already
   the site's one typographic idea, so the title may as well say it. */
.hero h1 {
  font-size: clamp(2.6rem, 7.5vw, 5.1rem);
  line-height: 0.98;
  letter-spacing: -0.035em;
  margin-bottom: 0.35em;
  text-wrap: initial;
}

.hero h1 span {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.86em;
  letter-spacing: -0.05em;
}

/* ---------------------------------------------------------
   9. Sprint timeline
   --------------------------------------------------------- */

.sprints {
  display: flex;
  gap: 0.35rem;
  flex-wrap: wrap;
  margin: 1.5rem 0 0;
  padding: 0;
  list-style: none;
}

.sprints li {
  flex: 1 1 4rem;
  min-width: 3.25rem;
  background: #fffdfb;
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  padding: 0.7rem 0.5rem;
  text-align: center;
  font-family: var(--font-mono);
  font-size: 1rem;
  box-shadow: var(--shadow-card);
}

/* ---------------------------------------------------------
   10. Steps
   --------------------------------------------------------- */

.step {
  display: grid;
  gap: 1rem;
  padding-block: 1.75rem;
  border-top: 1px solid var(--rule);
  align-items: start;
}

@media (min-width: 720px) {
  .step {
    grid-template-columns: 7rem 1fr;
    gap: 2.5rem;
  }
}

.step__index {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  color: var(--ink-faint);
  padding-top: 0.3rem;
}

.step__body > *:last-child {
  margin-bottom: 0;
}

/* ---------------------------------------------------------
   11. Team
   --------------------------------------------------------- */

.person {
  background: #fffdfb;
  border-radius: var(--radius-lg);
  border: 1px solid var(--line-soft);
  box-shadow: var(--shadow-card);
  overflow: hidden;
}

/* The frame lives on the figure, NOT on the photograph.

   filter applies to an element's own border and outline as well as to
   its content, so a coloured border drawn on .person__photo comes out
   greyscaled along with the face — tried it, the five colours rendered
   as five near-identical greys. The wrapper sits outside the filter,
   which is the whole reason .person__figure still exists.

   width: fit-content shrinks the frame to the photograph rather than
   the card, which is what makes it read as a frame and not a panel. */
.person__figure {
  width: fit-content;
  margin: 1.5rem 1.35rem 0;
  padding: 6px;
  border: 2px solid var(--frame);
  border-radius: 14px;
}

/* Inset and square, rather than spanning the card. The photographs were
   the first and loudest thing on the page; at 168px the card drops from
   778px to roughly 537px and the names lead instead.

   object-position favours the upper frame: a square crop is tighter at
   the top than the old 4/5 was, and where a crop has to fall it should
   fall below the chin, not above the hair. */
.person__photo {
  width: 168px;
  aspect-ratio: 1 / 1;
  display: block;
  object-fit: cover;
  object-position: center 25%;
  border-radius: 8px;
  filter: grayscale(1) contrast(1.05);
}

/* Frame colours, in card order. Greyscale portraits are what make this
   work — the colour has somewhere to live now that the faces have none.

   Tying these to :nth-child rather than a modifier class keeps the
   markup clean. The cost is that reordering the cards reshuffles the
   colours, which is harmless: none of them means anything, they are
   five palette tokens chosen to sit well together. */
.person:nth-child(1) { --frame: var(--deep); }
.person:nth-child(2) { --frame: var(--domain-accent); }
.person:nth-child(3) { --frame: var(--tech-accent); }
.person:nth-child(4) { --frame: var(--just-text); }
.person:nth-child(5) { --frame: var(--risk-text); }

/* Hover drops the greyscale and shows the photograph in colour. A
   nicety only: there is no hover on touch, and the reduced-motion gate
   means anyone who asked for less movement keeps the still version.

   This used to swap mix-blend-mode, which is not an animatable
   property, so the transition beside it had nothing to act on and the
   change snapped. filter animates, so it now actually fades. */
@media (prefers-reduced-motion: no-preference) {
  .person__photo {
    transition: filter 0.35s ease;
  }

  .person:hover .person__photo {
    filter: none;
  }
}

.person__body {
  padding: 1.2rem 1.35rem 1.4rem;
}

.person__body h3 {
  margin-bottom: 0.1rem;
}

/* The 0.1rem above is tuned to sit tight against the mono role line.
   The three supervisors have no role line — their project role is a
   sentence in the bio instead — so on those cards the bio would collide
   with the name. This restores the breathing room only where the role
   line is absent. */
.person__body h3 + p:not(.person__role) {
  margin-top: 0.6rem;
}

.person__role {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  color: var(--risk-text);
  margin: 0 0 0.7rem;
}

.person__body p {
  font-size: 1rem;
  color: var(--ink-soft);
  margin-bottom: 0;
}

/* ---------------------------------------------------------
   13. Bands
   --------------------------------------------------------- */

.band--muted {
  background: #ffffff66;
}
.band--dark {
  background: var(--data-dark);
  color: var(--paper);
}
.band--dark p {
  color: #f5efecc4;
}
.band--dark .eyebrow {
  color: #f5efec9c;
}

.page-banner {
  background: var(--paper-header);
  padding-block: clamp(2.25rem, 5vw, 3.5rem);
  border-bottom: 1px solid #0819121a;
}

.page-banner h1 {
  margin-bottom: 0.25em;
}

.page-banner p {
  color: var(--ink-soft);
  max-width: 52ch;
  margin-bottom: 0;
}

/* Title only — no eyebrow, no standfirst. The page says its name once
   and then gets on with it. */
.page-banner--bare {
  padding-block: clamp(1.75rem, 4vw, 2.75rem);
}

/* h2 on the landing page, where "About the game" is a chapter divider
   inside a page that already has its h1, not a page title of its own. */
.page-banner--bare h1,
.page-banner--bare h2 {
  margin-bottom: 0;
  font-size: clamp(1.9rem, 4.5vw, 2.75rem);
}

/* Cleared past the sticky header when jumped to from the nav. */
.page-banner[id] {
  scroll-margin-top: 4.5rem;
}

/* ---------------------------------------------------------
   15. Footer — one short line
   --------------------------------------------------------- */

/* Light, because the two institutional logos are black artwork with one
   accent each and both need a pale ground to read on. Peach rather than
   plain paper so the footer still closes the page — it is the same tone
   the nav bar takes once the hero is behind you. */
.site-footer {
  background: var(--paper-header);
  color: var(--ink);
  padding-block: 1.75rem;
  margin-top: 2rem;
  font-family: var(--font-mono);
  font-size: 0.8rem;
}

.site-footer a {
  color: var(--ink);
}

/* Centred, because the footer now holds one thing. This was
   space-between when the Beyond the Algorithm mark sat opposite the two
   institutional logos; with the mark gone, space-between has nothing to
   space and silently collapses to a left-aligned row, which reads as a
   layout that lost something rather than one that never had it. */
.site-footer__inner {
  display: flex;
  flex-wrap: wrap;
  gap: 1.25rem 1.75rem;
  justify-content: center;
  align-items: center;
}

.site-footer p {
  margin: 0;
  color: var(--ink-soft);
  max-width: none;
}

.site-footer__logos {
  display: flex;
  align-items: center;
  gap: 1.5rem;
}

/* gamelab.png is 204px wide at this height, and the pair does not fit
   beside each other on a narrow phone: 12px of horizontal scrollbar at
   360px, which is one of the commonest Android widths, and 52px at
   320px. Let them stack instead of shrinking them — these are
   institutional marks and the house styles do not allow them to be
   scaled down to fit.

   The pull-in margin goes with it. It exists to close the dead canvas
   between this logo and the TU Delft mark to its left, and there is no
   mark to its left once they are on separate lines. */
@media (max-width: 420px) {
  .site-footer__logos {
    flex-wrap: wrap;
    justify-content: center;
  }

  .site-footer__logos .logo--raster {
    margin-inline: 0;
  }
}

/* Its own full-width row under the logos. .site-footer__inner is a
   centred wrapping flex, so a basis of 100% is what forces the break —
   without it the line sits beside the logos and the footer reads as one
   long row of unrelated things. */
.site-footer__legal {
  flex: 1 0 100%;
  text-align: center;
  color: var(--ink-faint);
}

/* Full strength: these are institutional marks and neither TU Delft's
   house style nor the Gamelab's allows theirs to be faded.

   The two are not measured the same way, so their heights cannot match
   on paper and look right on screen. tu-delft.svg is cropped tight to
   its artwork, so its height is all mark; gamelab.png is a screen
   capture whose mark sits inside a lot of empty canvas, so most of its
   height is padding the multiply blend makes invisible. Matching the
   printed marks means the numbers below have to disagree. */
.site-footer__logos img {
  height: 77px;
  width: auto;
}

/* INTERIM. gamelab.png is a screen capture: opaque white background,
   page artefacts along the top and bottom edges, and the mark itself
   sitting in a lot of empty padding.

   The white cannot be edited out of the file from here, but it can be
   made to disappear optically — multiply leaves any colour darker than
   the backdrop alone and lets anything white through to it, so the
   background and the padding both drop out against the peach. It only
   works because the footer is light; on a dark ground this would need
   the real asset.

   The clip crops the two artefact lines, which are grey and pink and so
   survive the blend. The negative margin pulls the surviving whitespace
   back in so the two logos sit at a sensible distance.

   That margin is start-only, deliberately. A negative margin shrinks an
   element's layout slot but not the box it actually paints, so a
   -1.75rem on the END edge put this logo's right edge 28px past the
   wrap — 8px beyond the viewport — and gave every page in the site a
   horizontal scrollbar. It stayed hidden while the footer wordmark
   forced the logos onto their own wrapped line; removing the wordmark
   exposed it. Pull it leftward toward the TU Delft mark all you like;
   never pull it rightward past the container.

   Delete this whole rule the moment a transparent PNG or an SVG lands. */
.site-footer__logos .logo--raster {
  height: 86px;
  mix-blend-mode: multiply;
  clip-path: inset(4% 0 6% 0);
  margin-inline: -2.1rem 0;
}

/* ---------------------------------------------------------
   16. Spread — mono label left, content right

   The workhorse layout. A single centred column of prose reads as a
   document; pushing a small mono label into its own column on the left
   makes the same text read as a spec sheet, and gives the right-hand
   side of the page something to do.
   --------------------------------------------------------- */

.spread {
  display: grid;
  gap: 0.75rem clamp(1.5rem, 3vw, 3rem);
  padding-block: clamp(2rem, 5vw, 3.25rem);
  border-top: 1px solid var(--rule);
  align-items: start;
}

.spread:first-of-type {
  border-top: 0;
  padding-top: 0;
}

/* The label gutter stays narrow until there is width to spare, so mid-size
   screens spend their pixels on the content rather than on the margin. */
@media (min-width: 820px) {
  .spread {
    grid-template-columns: minmax(6rem, 7rem) 1fr;
  }

  .spread__label {
    position: sticky;
    top: 5rem;
  }
}

@media (min-width: 1100px) {
  .spread {
    grid-template-columns: 9rem 1fr;
  }
}

/* Font-size and color are set once, on the pill-styled rule further
   down — this rule only carries the properties that one doesn't. */
.spread__label {
  font-family: var(--font-mono);
  margin: 0;
  padding-top: 0.35rem;
}

.spread__body > *:last-child {
  margin-bottom: 0;
}

.spread__body h2 {
  margin-top: 0;
}

/* Two prose columns, so long sections do not run as one thin ribbon down
   the left. */
@media (min-width: 820px) {
  .spread__body--split {
    columns: 2;
    column-gap: clamp(1.75rem, 3vw, 2.5rem);
  }

  .spread__body--split p {
    max-width: none;
    break-inside: avoid;
  }

  .spread__body--split > *:first-child {
    margin-top: 0;
  }
}

/* A row of cards carries its own padding and border, so its text starts
   further right than the plain prose in every other spread. Pulling the
   row out by exactly that inset puts the two back on one line.

   1130px, and the number is arithmetic rather than taste. .wrap is
   `min(100% - 2.5rem, var(--page))`, so until the viewport reaches
   --page + 2.5rem = 1120px the page has exactly 20px of gutter on each
   side — and this rule pulls 25px. A negative margin shrinks an
   element's layout slot but not the box it actually paints, so those
   extra 5px landed outside the viewport and put a horizontal scrollbar
   on every page carrying a card row, at every width from 820px to
   1130px. Exactly the failure documented on .logo--raster in the
   footer, in the other direction.

   At 1130px the gutter is (1130 - 1080) / 2 = 25px, which is the first
   width where the bleed fits. Below it the cards simply do not bleed:
   their text sits 25px right of the prose, which is a misalignment
   nobody will notice and not a scrollbar, which everybody does. */
@media (min-width: 1130px) {
  .spread__body--bleed {
    margin-inline: calc(-1 * (1.5rem + 1px));
  }
}

/* Used to split into a prose column and a narrower photograph column at
   980px. The gallery is the point of the section, so it now stays full
   width at every size instead of being squeezed to ~54% of the row.
   Plain block flow — paragraph, then gallery under it — at all
   viewports. */

/* The paragraph above the gallery is a single short sentence, not
   several — .spread__body--split's two-column flow would just leave it
   stuck in the first (half-width) column, so the fix here is simpler:
   drop the site-wide 64ch prose cap so it runs the same full width as
   the gallery beneath it, instead of leaving the rest of the row empty. */
.spread__body--aside p {
  max-width: none;
}

/* Keeps the dots and arrows against the right edge on the narrow widths
   where a long caption still wraps the bar onto two lines. */
.spread__body--aside .gallery__nav {
  margin-left: auto;
}

/* ---------------------------------------------------------
   17. Storyline — the opening statement on About
   --------------------------------------------------------- */

.storyline {
  letter-spacing: -0.015em;
  margin: 0 0 1.75rem;
}

/* Colour carries the emphasis on its own. The slant was doing the
   same job twice and tipped the line into sounding insistent. */
.storyline em {
  color: var(--risk-text);
  font-style: normal;
}

/* ---------------------------------------------------------
   18. Run — the shape of a session as a track

   Four stops on a line. Deliberately terse: the page should show the
   arc of a session at a glance, not narrate it.
   --------------------------------------------------------- */

.run {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 2rem;
  position: relative;
}

@media (min-width: 780px) {
  .run {
    grid-template-columns: repeat(4, 1fr);
    gap: 1.75rem;
  }

  /* Every stop draws the segment to its right, including the last one,
     which now runs to the end of its own column instead of stopping at
     the final dot. The track used to end in mid-air above Debrief's
     text; it now finishes where that text finishes, so the line reads
     as spanning the session rather than trailing off.

     left: 0 rather than 18px puts the start of the line on the same
     vertical as the text below it. The dot sits on the line instead of
     the line starting after the dot. */
  .run__stop::after {
    content: "";
    position: absolute;
    top: 6px;
    left: 0;
    right: -1.75rem;
    height: 1px;
    background: repeating-linear-gradient(
      to right,
      var(--ink-faint) 0 6px,
      transparent 6px 12px
    );
  }

  .run__stop:last-child::after {
    right: 0;
  }

  /* Above the line, so the dashes do not run through it. */
  .run__stop::before {
    z-index: 1;
  }
}

.run__stop {
  position: relative;
  padding-left: 1.5rem;
}

@media (min-width: 780px) {
  .run__stop {
    padding-left: 0;
    padding-top: 1.75rem;
  }
}

/* All four dots are filled and identical. Sprints used to be the only
   solid one, which made it read as the single marker on the track
   rather than one stop of four. */
.run__stop::before {
  content: "";
  position: absolute;
  left: 0;
  top: 6px;
  width: 13px;
  height: 13px;
  border-radius: 50%;
  background: var(--ink-faint);
  border: 1px solid var(--ink-faint);
}

@media (min-width: 780px) {
  .run__stop::before {
    top: 0;
  }
}

/* The stop you actually spend the session inside gets filled in. */
.run__stop h3 {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  margin: 0 0 0.5rem;
}

.run__stop p {
  font-size: 0.95rem;
  line-height: 1.45;
  color: var(--ink-soft);
  margin: 0;
  max-width: 32ch;
}

.run__count {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  background: var(--paper-accent);
  border-radius: var(--radius-pill);
  padding: 0.1rem 0.45rem;
  margin-left: 0.35rem;
}

/* ---------------------------------------------------------
   19. Shot — photograph slot

   Every one of these is empty until the real photographs exist. They are
   styled as obvious gaps rather than invisible ones so nobody ships the
   site with them still in.
   --------------------------------------------------------- */

.shot {
  background: #ffffff5c;
  border: 1px dashed var(--line-loud);
  border-radius: var(--radius-lg);
  aspect-ratio: 4 / 3;
  display: grid;
  place-items: center;
  padding: 1rem;
  text-align: center;
  font-family: var(--font-mono);
  font-size: 0.74rem;
  line-height: 1.4;
  color: var(--ink-faint);
  margin: 0;
}

.shot--wide {
  aspect-ratio: 16 / 9;
}

.shot img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: var(--radius-lg);
}

figure.shot-fig {
  margin: 0;
}

figure.shot-fig figcaption {
  font-family: var(--font-mono);
  font-size: 0.74rem;
  color: var(--ink-faint);
  margin-top: 0.6rem;
}

/* ---------------------------------------------------------
   20. Facts — a compact requirements strip
   --------------------------------------------------------- */

.facts {
  display: grid;
  gap: 0;
  margin: 0 0 2rem;
  border-top: 1px solid var(--rule);
}

@media (min-width: 700px) {
  .facts {
    grid-template-columns: repeat(3, 1fr);
    border-top: 0;
  }
}

.facts__item {
  padding: 1rem 0;
  border-bottom: 1px solid var(--rule);
}

/* The heavy top rules that used to sit here read as a timeline — three
   segments on a line — which is not what this strip is. The box's own
   symbols carry each item instead. */
@media (min-width: 700px) {
  .facts__item {
    border-bottom: 0;
    padding: 0 1.5rem 0 0;
  }
}

.facts__icon {
  display: block;
  width: 30px;
  height: 30px;
  margin-bottom: 0.7rem;
  color: var(--deep);
}

.facts__icon svg {
  width: 100%;
  height: 100%;
  display: block;
}

.facts__value {
  font-size: clamp(1.4rem, 3vw, 1.9rem);
  line-height: 1.1;
  margin: 0 0 0.3rem;
  letter-spacing: -0.02em;
}

.facts__note {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  color: var(--ink-soft);
  margin: 0;
  max-width: 30ch;
}

/* ---------------------------------------------------------
   22. Contact
   --------------------------------------------------------- */

/* The address is the page, so it is set as display type. Mono, because
   an email address is a string you copy, not a sentence you read. */
.storyline a {
  font-family: var(--font-mono);
  font-size: 0.86em;
  letter-spacing: -0.03em;
  text-decoration-color: var(--paper-accent);
  text-decoration-thickness: 2px;
  word-break: break-word;
}

.social {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin: 0.45rem 0 0;
  padding: 0;
  list-style: none;
}

.social a {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 0.74rem;
  padding: 0.3rem 0.65rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  text-decoration: none;
}

.social a:hover {
  background: #ffffff99;
}

/* ---------------------------------------------------------
   23. Acknowledgements
   --------------------------------------------------------- */

.ack {
  border-top: 1px solid var(--rule);
  padding-top: 1.75rem;
  margin-top: clamp(2.5rem, 6vw, 4rem);
}

.ack h2 {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  color: var(--ink-faint);
  letter-spacing: 0;
  margin-bottom: 0.9rem;
}

/* Full width rather than the prose measure — it is a closing note under
   the whole grid, so it should sit across the grid, not beside it. */
.ack p {
  color: var(--ink-soft);
  font-size: 1rem;
  max-width: none;
}

/* ---------------------------------------------------------
   24. Utilities
   --------------------------------------------------------- */

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--ink);
  color: var(--paper);
  padding: 0.7rem 1.1rem;
  z-index: 100;
}

.skip-link:focus {
  left: 0;
}

.mb-0 {
  margin-bottom: 0;
}

/* =========================================================
   V2 MAKEOVER
   =========================================================
   Everything below is the visual pass taken from the printed
   box and materials. It is appended rather than woven in, so
   deleting this block returns the site to the v1 look.

   What the physical game does that the site was not doing:
     - a deep slate blue carries the wordmark and the headings
     - the two teams are coded by icon, not only by colour
       (Domain = sparkle, Tech = gear)
     - section labels are uppercase on a coloured strip
     - large, confident shapes: the disc and the two wedges
   ========================================================= */

:root {
  /* From the box cover. The site's existing palette is the
     materials palette — pale card stock — and was missing the
     deep blue that the cover and the wordmark are built on. */
  --deep: #3f6184;
  --deep-mid: #5c7fa3;
  --deep-light: #7fa8c9;

  --wedge-warm: #f7e6cf;
  --wedge-warm-accent: #e3bd91;
  --wedge-cool: #b3ddd9;
}

/* ---- Headings carry the cover blue ---------------------- */

h1,
h2 {
  color: var(--deep);
}

.hero h1 {
  color: var(--deep);
}

/* "Algorithm" is the lighter blue on the box. Same move here. */
.hero h1 span {
  color: var(--deep-light);
}

/* ---- Section labels as printed strips -------------------
   On the boards every zone is titled with uppercase text on a
   colour strip. The spread labels become the same object. */

.spread__label {
  display: inline-block;
  font-size: 0.75rem;
  font-weight: 400;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--deep);
  background: var(--wedge-warm);
  padding: 0.3rem 0.55rem;
  border-radius: var(--radius-pill);
}

@media (min-width: 820px) {
  .spread__label {
    padding-top: 0.3rem;
  }
}

/* ---- Team icons ----------------------------------------
   The sparkle and the gear are how the printed game tells the
   two sides apart at a glance, before you have read a word. */

.card__head {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  margin-bottom: 0.85rem;
}

/* The tab is an <h3> on the team cards, so the card keeps a heading in
   the document outline now that the composition line has moved into the
   prose. Zero the heading margins the element inherits as a heading —
   the flex head supplies the spacing. */
.card__head .card__tab {
  margin: 0;
}

.card__icon {
  width: 34px;
  height: 34px;
  flex: none;
  border-radius: 50%;
  display: grid;
  place-items: center;
}

.card__icon svg {
  width: 20px;
  height: 20px;
  display: block;
}

.card--domain .card__icon {
  background: #fdf1e0;
  color: #e79a54;
}

.card--tech .card__icon {
  background: #eef7f5;
  color: #6aa9af;
}

/* ---- The run track, in the cover blue -------------------- */

.run__stop::before {
  border-color: var(--deep-mid);
  background: var(--deep-mid);
}

.run__stop h3 {
  color: var(--deep);
}

/* ---- Photograph slots ------------------------------------
   Still deliberately visible, but warm rather than grey, so an
   unfilled page reads as unfinished-by-design and not broken. */

.shot {
  background: var(--wedge-warm);
  border-color: #d8b58a;
  color: #a8763c;
}

/* ---- Page banner: the disc behind the title --------------
   One large shape, bled off the left edge, echoing the circle
   on the cover without reprinting it. */

.page-banner {
  position: relative;
  overflow: hidden;
}

.page-banner > .wrap {
  position: relative;
  z-index: 1;
}

.page-banner::before {
  content: "";
  position: absolute;
  top: 50%;
  right: -60px;
  width: 220px;
  height: 220px;
  border-radius: 50%;
  background: #ffffff2e;
  transform: translateY(-50%);
  pointer-events: none;
}

/* ---- Facts strip ---------------------------------------- */

.facts__value {
  color: var(--deep);
}

.facts {
  border-color: var(--deep);
}

/* ---- Buttons -------------------------------------------- */

.btn--primary {
  background: var(--deep);
  color: #f7f2ee;
  border-color: var(--deep);
}

.btn--primary:hover {
  background: var(--deep-mid);
  border-color: var(--deep-mid);
}

/* ---- Footer ---------------------------------------------
   The V2 pass put this on the cover blue. It is light again,
   because the two institutional logos are black artwork and
   have to sit on a pale ground. See the base rule. */

/* ---- Real photographs -----------------------------------
   A .shot holding an <img> is no longer a placeholder, so it
   drops the dashed frame and the padding. This means dropping
   a photo in needs no class change — add the <img> and the
   slot stops looking empty. */

.shot:has(img) {
  background: none;
  border: 0;
  padding: 0;
  box-shadow: var(--shadow-card);
}

/* The photographs are warm and low-contrast; a hairline keeps
   them from bleeding into the paper background. */
.shot img {
  box-shadow: inset 0 0 0 1px var(--rule);
}

/* ---- A wide photograph between sections ------------------ */

.shot--band {
  aspect-ratio: 21 / 9;
}

/* The label is a chip, so it should be as wide as its text. Without this
   the grid stretches it to the full column on narrow screens. */
.spread__label {
  justify-self: start;
}

/* =========================================================
   V2 — HOME PAGE REFINEMENT
   ========================================================= */

/* ---- Photo frames: the stretch fix -----------------------
   .shot is display:grid with place-items:center so the
   placeholder text can sit in the middle of an empty box.
   That centring also stops a real photo from filling the
   frame, so images kept their own proportions and the rows
   came out ragged. A frame holding an image becomes a plain
   block and the image fills it. */

.shot:has(img) {
  display: block;
}

.shot img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* ---- Section rhythm --------------------------------------
   Five identical label-and-column sections down one flat
   background read as a single corridor. Tinting alternate
   sections gives the scroll a pulse, and lets a section own
   the full width instead of always sitting in the same box. */

.band {
  padding-block: clamp(2.5rem, 6vw, 4.5rem);
}

.band--tint {
  background: #ecc2a31f;
  border-block: 1px solid var(--rule);
}

.band > .wrap > .spread:first-of-type {
  padding-top: 0;
}

/* The .runner tablist that used to live here is gone — the session
   track is a plain timeline again, which is what §18's .run rules
   were written for. Note that `.runner .run__stop { padding: 0 }`
   had been quietly beating the 780px rule that gives each stop its
   1.75rem of clearance under the dot, so removing it puts the
   dot-on-a-line back the way it was designed. */

/* ---- Gallery ---------------------------------------------
   A scroll-snap strip. Without JS it is a horizontally
   scrollable row of photographs, which is already usable and
   is what a touch device gets anyway; the script only adds
   the arrows and the dots. */

.gallery__viewport {
  border-radius: var(--radius-lg);
  overflow: hidden;
}

.gallery__track {
  display: flex;
  gap: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;
}

.gallery__track::-webkit-scrollbar {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .gallery__track {
    scroll-behavior: auto;
  }
}

.gallery__slide {
  flex: 0 0 100%;
  scroll-snap-align: start;
  margin: 0;
}

/* Every slide is the same 3:2 window regardless of what shape the
   photograph is: it fills the window and is cropped from the centre,
   so a portrait shot and a landscape one still step through at an
   identical size. object-position is the default, but it is stated
   here because it is the half of this that is easy to lose. */
.gallery__slide img {
  display: block;
  width: 100%;
  height: 100%;
  aspect-ratio: 3 / 2;
  object-fit: cover;
  object-position: center;
}

/* Two of the festival photographs do not survive a centred crop, so they
   name their own focal point. Both selectors below are (0,1,1), exactly
   like the rule above, so it is position in the file and nothing else
   that lets them win. Keep them here.

   Cropping these in the file instead would be tidier, but sips on macOS
   only does a centred crop — which is what object-fit: cover already
   does — so there is nothing to be gained by baking it in.

   --floor: close to half of that frame is suspended ceiling.
   --tall: the one portrait in the set. A 3:4 frame keeps only its middle
   half in a 3:2 window, and centred that half cuts the lettering off the
   banner the caption is naming. */
.gallery__slide--floor img {
  object-position: center 85%;
}

.gallery__slide--tall img {
  object-position: center 90%;
}

.gallery__bar {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin-top: 1rem;
  flex-wrap: wrap;
}

.gallery__caption {
  flex: 1 1 14rem;
  font-family: var(--font-mono);
  font-size: 0.74rem;
  line-height: 1.5;
  color: var(--ink-soft);
  margin: 0;
}

.gallery__nav {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.gallery__btn {
  width: 38px;
  height: 38px;
  border-radius: 50%;
  border: 1px solid var(--ink-faint);
  background: none;
  color: var(--deep);
  display: grid;
  place-items: center;
  cursor: pointer;
  transition: background 0.2s ease, border-color 0.2s ease;
}

.gallery__btn:hover:not(:disabled) {
  background: var(--wedge-warm);
  border-color: var(--deep);
}

.gallery__btn:disabled {
  opacity: 0.3;
  cursor: default;
}

.gallery__btn svg {
  width: 16px;
  height: 16px;
}

.gallery__dots {
  display: flex;
  gap: 0.35rem;
  align-items: center;
}

.gallery__dot {
  position: relative;
  width: 8px;
  height: 8px;
  padding: 0;
  border: 0;
  border-radius: var(--radius-pill);
  background: var(--ink-faint);
  cursor: pointer;
  transition: width 0.25s ease, background 0.25s ease;
}

/* The dot stays 8px — it matches the motif on the box — but the tap
   target does not: an invisible square centred on it, sized to the
   44px floor, so the visible mark and the clickable one can differ. */
.gallery__dot::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}

.gallery__dot[aria-current="true"] {
  width: 22px;
  background: var(--deep);
}

@keyframes art-turn {
  to { transform: rotate(360deg); }
}

/* =========================================================
   V3 — BOLD PASS
   =========================================================
   Three decisions drive everything below:

     1. A heavy display serif carries the site. The original
        "no bold headings" rule came from criticalinfralab and
        was the single biggest reason the page read as quiet.
        It is dropped here, deliberately.
     2. The opening screen is the box lid at full size, with
        the title set solid over it.
     3. One saturated field. The deep box blue owns the hero
        and the footer; the content between them stays paper,
        so the page opens loud, reads calm, closes loud.

   Appended last so it wins on order, not on specificity.
   ========================================================= */

:root {
  --font-display: "Fraunces", Georgia, "Times New Roman", serif;
  --cream: #fbf5ee;
}

/* ---- Type: display serif, quiet -------------------------
   This was 800 at -0.025em with the optical size pinned to the
   top of its axis, which is Fraunces at its most shouting. For
   an academic research game that is the wrong register. Weight
   600, tracking close to normal, a mid optical size so it stops
   using the tightest display cut, and a high SOFT to round the
   terminals off. Same face, much calmer voice. */

h1,
h2,
h3 {
  font-family: var(--font-display);
  font-weight: 600;
  letter-spacing: -0.008em;
  font-variation-settings: "opsz" 48, "SOFT" 60;
}

.storyline {
  font-family: var(--font-display);
  font-weight: 600;
  letter-spacing: -0.008em;
  /* Half the size it was, so a lower optical size again — Fraunces at
     "opsz" 48 is cut for display and looks coarse at 1.5rem. */
  font-variation-settings: "opsz" 24, "SOFT" 60;
}

h3 {
  font-weight: 600;
  font-size: 1.25rem;
  font-variation-settings: "opsz" 24, "SOFT" 60;
}

h2 {
  font-size: clamp(1.8rem, 4vw, 2.9rem);
  line-height: 1.02;
}

.page-banner--bare h1,
.page-banner--bare h2 {
  font-size: clamp(2.4rem, 6vw, 4.2rem);
  line-height: 0.95;
}

/* The opening statement spans the full width of the spread body rather
   than sitting as a narrow block above the left-hand column — it is a
   headline over the two columns, so it should measure like one.

   This is the rule that wins; the earlier .storyline block is dead on
   both of these properties.

   One size up from the 1.5rem cap it sat at. That was set when the line
   was shorter and was half of an earlier 3rem, which had read as a
   banner; between the two, 1.85rem is a headline over two columns
   without shouting.

   text-wrap: balance is switched off here. Balancing evens the line
   lengths, which on a two-line headline pulls both lines in from the
   right and makes a full-width element look like a narrow centred
   block — the thing this rule exists to prevent. */
.storyline {
  font-size: clamp(1.35rem, 2.6vw, 1.85rem);
  line-height: 1.4;
  max-width: none;
  text-wrap: pretty;
}

/* The storyline lost a sentence and a whole paragraph, and what was left
   read thin under a headline that size — three short columns of body
   copy with a lot of air beneath them.

   Scoped to the split that follows the display line, not to
   .spread__body--split generally: research.html uses the same layout
   twice with no display line above it at all, and those sections carry
   enough prose that they do not want the extra size. */
.storyline + .spread__body--split p {
  font-size: 1.05rem;
  line-height: 1.6;

  /* Overriding the shared break-inside: avoid. Three paragraphs of very
     uneven length — short, long, short — and an unbreakable middle one
     cannot be balanced: the browser fills the first column to the half
     way mark, finds the long paragraph will not fit whole, and pushes it
     to the second column. That left one short paragraph on the left and
     two thirds of the text banked up on the right.

     Letting it break is what a two-column layout is for. Only safe here
     because these are long paragraphs of continuous prose; the same
     change on research.html's shorter blocks would strand single lines. */
  break-inside: auto;
}

/* Out of the display face altogether. These are figures, not a
   headline — the sans reads them as data, and it sets up a
   proper contrast with the mono note underneath. */
.facts__value {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: clamp(1.45rem, 2.8vw, 1.95rem);
  letter-spacing: -0.01em;
  font-variation-settings: normal;
}

/* ---- The opening screen ---------------------------------- */

.hero {
  position: relative;
  height: 200svh;                    /* 100svh pinned + 100svh of scroll room */
  /* The generic `section` rule and the original .hero rule both set
     padding-block. All of the hero's real padding lives on .hero__stage
     now, and because box-sizing is border-box any padding here is taken
     out of the height above — that is, out of the scroll room. */
  padding: 0;
  background: var(--deep);
  color: var(--cream);
  /* The grain sits on paper, not on this. */
  background-image: none;
}

.hero__stage {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: hidden;                  /* was on .hero — killed the sticky above */
  display: grid;
  grid-template-rows: 1fr auto 1fr;
}

.hero__lid {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.hero__lid svg {
  width: 100%;
  height: 100%;
  display: block;
}

/* These started at 90s and a 6% pulse, which turned out to be below the
   threshold of being noticed at all rather than pleasantly subtle. The
   gear now turns at 15°/sec — plainly moving if you look at it, still
   slow enough to ignore while reading. */
@media (prefers-reduced-motion: no-preference) {
  .lid__gear {
    animation: art-turn 24s linear infinite;
    transform-box: fill-box;
    transform-origin: center;
  }

  /* Each sparkle blinks on its own clock. The durations are deliberately
     not multiples of one another, so the three never sync up into a
     single pulsing blob. */
  .lid__spark {
    animation: lid-twinkle 4s ease-in-out infinite;
    transform-box: fill-box;
    transform-origin: center;
  }
  .lid__spark:nth-child(2) { animation-duration: 5.7s; animation-delay: -1.9s; }
  .lid__spark:nth-child(3) { animation-duration: 3.1s; animation-delay: -2.4s; }
}

/* A blink, not a pulse: the sparkle shrinks back and dims most of the way
   out before coming up again. Holding the bright end briefly (45–60%) is
   what makes it read as twinkling rather than breathing. */
@keyframes lid-twinkle {
  0%, 100%  { transform: scale(0.82); opacity: 0.35; }
  45%, 60%  { transform: scale(1.1);  opacity: 1; }
}

/* One centred column. The two-column hero was the most
   conventional shape on the page and the one thing all of the
   references avoid. */
.hero__inner {
  position: relative;
  z-index: 1;
  display: block;
  text-align: center;
}

/* As large as the viewport will take. Two lines, hard-broken,
   the second in the lighter blue exactly as the box lid sets
   the wordmark. */
.hero h1 {
  font-size: clamp(3.1rem, 13vw, 9.5rem);
  line-height: 0.84;
  letter-spacing: -0.045em;
  color: var(--cream);
  margin: 0 0 clamp(1.5rem, 4vh, 2.25rem);
  text-wrap: initial;
}

.hero h1 span {
  display: block;
  font-family: var(--font-display);
  font-size: 1em;
  letter-spacing: -0.05em;
  color: var(--deep-light);
}

/* The header sits over the blue hero, so it cannot carry its
   own paper fill until the page has scrolled past it. */
.site-header {
  background: #ecc2a3;
}

/* ---- Everything after the hero --------------------------- */

/* ---- The header rides on the hero ------------------------
   The hero is pulled up under the sticky header so the opening
   screen is a true 100svh from the very top of the window,
   rather than 100svh starting below a bar. Without this the
   scroll cue lands just under the fold, which is the one place
   it is useless.

   --header-h is measured and set by main.js; the fallback only
   has to be close enough for the first paint. */

/* The bar is fixed, so it reserves no layout and the hero already starts
   at the top of the window — no negative margin needed. The stage still
   has to keep its own content clear of the bar, though. */
.hero__stage {
  padding-top: calc(var(--header-h, 3.5rem) + clamp(2.5rem, 7vh, 4.5rem));
}

/* One state, everywhere. The bar used to swap to a peach fill once the
   hero had scrolled past; it now keeps the frosted glass it opens with,
   all the way down. That removes the .is-stuck / :not(.is-stuck) pair
   entirely — it was carrying a second set of link, pill, toggle and
   mobile-menu colours for a state that no longer looks different. */
.site-header {
  background: #f8f4efc4;
  -webkit-backdrop-filter: blur(12px) saturate(1.15);
  backdrop-filter: blur(12px) saturate(1.15);
  border-bottom: 1px solid #22405a1f;
}

/* =========================================================
   V3b — THE HERO IS THE BOX LID
   =========================================================
   Replaces the deep-blue field. The opening screen is now the
   printed lid at scale, in the lid's own colours, so the first
   thing on the screen is the object itself rather than an
   interpretation of it.

   That means the hero is light again, so everything set up to
   sit on dark — header links, buttons, spec, cue — is put back
   to ink here.
   ========================================================= */

:root {
  /* Sampled off the box. These are the lid's values, not the
     card-stock palette the rest of the site uses. */
  --lid-paper: #f8f4ef;
  --lid-disc: #4e7192;
  --lid-plate: #f0c9ad;
  --lid-ink: #22405a;
  --lid-light: #9cc0da;

  --font-wordmark: "Montserrat", "Source Sans 3", sans-serif;
}

.hero {
  background: var(--lid-paper);
  color: var(--lid-ink);
  background-image: none;
}

.hero__lid svg {
  width: 100%;
  height: 100%;
  display: block;
}

/* ---- The wordmark lockup --------------------------------
   The box sets this in a heavy geometric sans, not in the
   site's display serif, so the lockup keeps its own face. The
   serif still carries every heading below the fold — this is a
   logo, and a logo is allowed to be its own thing. */

.hero__plate {
  background: #f0c9adeb;
  border: 1.5px solid #2c4a634d;
  padding: clamp(0.9rem, 2.4vw, 1.9rem) clamp(1.2rem, 4vw, 3.2rem);
  margin: 0 auto clamp(0.9rem, 2.2vh, 1.4rem);
  max-width: min(94vw, 880px);
}

.hero h1 {
  font-family: var(--font-wordmark);
  font-weight: 800;
  text-transform: uppercase;
  font-size: clamp(1.9rem, 7vw, 5rem);
  line-height: 0.96;
  letter-spacing: -0.005em;
  color: var(--lid-ink);
  margin: 0;
  text-wrap: initial;
  font-variation-settings: normal;
}

/* "Algorithm" is set larger than "Beyond the" on the lid, in
   the pale blue with a darker outline around it. */
.hero h1 span {
  display: block;
  font-family: var(--font-wordmark);
  font-size: 1.26em;
  letter-spacing: 0.004em;
  color: var(--lid-light);
  -webkit-text-stroke: 2px var(--lid-ink);
  paint-order: stroke fill;
}

.hero__tagline {
  font-family: var(--font-body);
  font-size: clamp(1rem, 1.8vw, 1.25rem);
  color: var(--lid-paper);           /* was var(--lid-ink) — 2.5:1 on the disc */
  margin: 0 auto clamp(1.75rem, 4vh, 2.5rem);
  max-width: 40ch;
}

/* ---- Ink on a light field, in every state ---------------- */

.site-header .nav a,
.site-header .nav-toggle {
  color: var(--ink);
}

.site-header .nav a[aria-current="page"] {
  background: #ffffff8c;
}

.site-header .nav-toggle {
  border-color: var(--ink-faint);
}

@media (max-width: 800px) {
  .site-header .nav.is-open {
    background: var(--lid-paper);
    box-shadow: var(--shadow-lift);
  }
}

/* ---- Hero rows -------------------------------------------
   Three rows: empty, the lockup, empty. With 1fr on the outer
   two the middle row lands on the vertical centre of the
   screen, which is where the disc is, which is where the
   wordmark is printed. Row 3 stays in grid-template-rows as a
   symmetric spacer even with nothing in it. */

.hero__inner {
  grid-row: 2;
  align-self: center;
}

/* ---- The lid on narrow screens ---------------------------
   Filling a tall, narrow window with a 3:2 artwork crops away
   about 70% of its width — which on this lid is both coloured
   fields, leaving only the disc and none of what makes it
   recognisable. Below 760px the artwork is shown whole instead
   of cropped: it becomes a band across the middle of the paper,
   with the wordmark plate sitting over it exactly as printed. */

@media (max-width: 760px) {
  .hero__lid {
    display: grid;
    align-content: center;
  }

  /* Over-width so the band is tall enough that the plate sits between
     the two badges rather than covering the gear, which is how the lid
     is actually printed. At 165% the disc comes out around 356px on a
     390px phone and the badges land on its edge. The overflow costs the
     outer part of each field and is worth it.

     margin-left is always half the overhang, so the artwork stays
     centred. Change one of these two numbers and you must change the
     other.

     max-width: none is load-bearing. The global
     `img, svg { max-width: 100% }` reset is a bare type selector, so
     `.hero__lid svg` outranks it on specificity for width — but it
     never declared max-width, and an unbeaten max-width: 100% clamps
     any over-width straight back to 100%. This rule was inert for that
     reason: the svg stayed at 100% while the negative margin still
     applied, which shoved the artwork half a phone-width left and left
     the disc badly off-centre with the tagline hanging off it. Set the
     width and you must unset the cap, or you get the offset without the
     width that justifies it. */
  .hero__lid svg {
    width: 165%;
    max-width: none;
    margin-left: -32.5%;
    height: auto;

    /* 165% is sized for the finished composition, where the two fields
       close in and frame the disc. At rest they are pulled off to the
       sides and the disc is alone on the paper, where that same width
       makes it 91% of a phone screen - a blue ball spanning edge to
       edge rather than a circle sitting on a page.

       So the lid grows with the fields instead of waiting at full size
       for them: 0.8 at rest, where the disc is 73% of the screen, up to
       1 as they arrive. The fallback of 1 matters - if the script never
       runs, or under reduced motion where the reveal is skipped
       entirely, this resolves to the finished size rather than leaving
       the lid stuck small. */
    transform-origin: center;
    transform: scale(calc(0.8 + var(--field-in, 1) * 0.2));
  }

  /* The tagline is HTML, so it does not scale with the lid — which
     means shrinking the disc at rest shrinks the circle out from under
     fixed-size text. Below about 380px that is exactly what happened:
     three lines are taller than a disc at 0.8, and the text broke out
     of the top and bottom of it, where paper-coloured type on paper is
     not type at all.

     Narrowing the tagline does not help; the binding dimension is
     vertical, and a narrower cap only adds a fourth line and makes it
     worse. Measured: 20ch, 18ch and 16ch all land on the same overflow,
     14ch is worse. So the disc shrinks less here instead. */
  @media (max-width: 380px) {
    .hero__lid svg {
      transform: scale(calc(0.9 + var(--field-in, 1) * 0.1));
    }
  }

  /* Only the two coloured fields — never the disc, never the badges.
     The rectangle on a phone was those two shapes ending at a hard
     horizontal line; the ground behind them is already --lid-paper, so
     fading them lets the page show through and the edge stops existing.

     Set from CSS rather than a mask="" attribute in the markup exactly
     so it can sit behind this media query. Above 760px the lid covers
     the viewport and the fields are supposed to run to the top and
     bottom of the screen, where a fade would be wrong.

     Sizing and fading are one change, not two: the fade is what frees
     the width above to be chosen for the composition rather than kept
     small to keep the seam inconspicuous. */
  .lid__field {
    -webkit-mask: url(#lid-fade-mask);
    mask: url(#lid-fade-mask);
  }

  /* The tagline is paper-coloured so it reads on the dark disc, which
     makes anything that slips off the disc onto the pale fields beside
     it effectively invisible. At 40ch it did exactly that on a phone.
     20ch wraps it to three lines and keeps every corner inside 90% of
     the disc's radius, measured rather than guessed. */
  .hero__tagline {
    max-width: 20ch;
  }

}

/* ---- Header: mark left, nav right, Contact set apart -----
   With the wordmark gone the mark was stranded on one side of
   a very wide bar. The nav group is pushed hard right and
   Contact is pulled out of it as a pill, so the bar reads as
   two objects at the two ends rather than one lonely icon. */

.nav {
  margin-left: auto;
  gap: 0.15rem;
}

.nav__cta {
  margin-left: 0.85rem;
  border: 1px solid var(--ink-faint);
  border-radius: var(--radius-pill);
  padding-inline: 1rem;
  transition: background 0.2s ease, border-color 0.2s ease;
}

.nav__cta:hover {
  background: var(--ink);
  border-color: var(--ink);
  color: var(--paper);
}

.site-header .nav__cta {
  border-color: #22405a4d;
}

.site-header .nav__cta:hover {
  background: var(--lid-ink);
  border-color: var(--lid-ink);
  color: var(--lid-paper);
}

/* On the mobile menu the pill would look stranded in a stacked
   list, so it goes back to being a plain item. */
@media (max-width: 799px) {
  .nav__cta {
    margin-left: 0;
    border-radius: var(--radius);
  }
}

.hero__stage {
  padding-bottom: clamp(2rem, 5vh, 3rem);
}

/* ---- No "About the game" banner --------------------------
   The chapter divider between the hero and the content is
   gone: the storyline arriving straight after the opening
   screen already announces what the page has turned to, and a
   labelled bar in between only slowed the scroll down.

   #about now hangs on the first band instead of on the banner,
   so the nav jump still lands in the right place — and needs
   its own clearance past the sticky header, which the banner
   used to provide. The first band also opens wider, since it
   is now the seam rather than the second thing after one. */

#about {
  scroll-margin-top: 4.5rem;
  padding-top: clamp(3.5rem, 8vw, 6rem);
}

/* =========================================================
   V3c — THE HERO REVEAL
   =========================================================
   main.js publishes --hero-p and --reveal-badges
   on .hero as the pinned stage is scrolled through. Every custom
   property below falls back to the finished state, so a script
   failure (or a browser with JS off) renders the hero complete
   and static rather than stuck half-hidden.
   ========================================================= */

.lid__badge__in {
  transform-box: fill-box;
  transform-origin: center;
  opacity: var(--reveal-badges, 1);
  transform:
    translateY(calc((1 - var(--reveal-badges, 1)) * 26px))
    scale(calc(0.86 + var(--reveal-badges, 1) * 0.14));
}

/* The two teams arriving at the table.
   The disc is the table. At rest it sits alone on the paper and the two
   coloured fields are pulled back off either side; scrolling brings them
   in until they meet it, which is the composition printed on the lid.

   They travel outward to their start position rather than inward from
   it, and that is what stops a gap opening. The warm field spans x
   0→524, so at −300 its left edge is off-canvas at −300 and the canvas
   edge stays covered; the cool field spans 676→1200, so at +300 its
   right edge is at 1500. Neither can ever expose bare paper at the
   edge it owns.

   Fallback 1 = arrived, so with no JS the lid renders exactly as
   printed rather than half-built. */
.lid__field--warm {
  translate: calc((1 - var(--field-in, 1)) * -300px) 0;
}

.lid__field--cool {
  translate: calc((1 - var(--field-in, 1)) * 300px) 0;
}

/* Held well back now that the fields carry the movement — at the old
   0.06 the zoom competed with them. */
.lid__ground {
  transform-box: view-box;
  transform-origin: center;
  transform: scale(calc(1 + var(--hero-p, 0) * 0.03));
}

.lid__disc {                          /* composes on top of the ground scale */
  transform-box: fill-box;
  transform-origin: center;
  transform: translate(
    calc(var(--mx, 0) * 7px),
    calc(var(--hero-p, 0) * -20px + var(--my, 0) * 5px)
  );
}

/* The badge groups carry their position as an SVG translate() attribute,
   which a CSS transform here would replace rather than compose with —
   so the parallax rides on .lid__badge__in's own translate property
   instead, which composes with the transform above it.

   Both the scroll drift and the pointer drift ride on that one property.
   The gear travels furthest and the disc least, which is the whole of
   what reads as depth — if they all moved the same amount the lid would
   just look like it was sliding about. */
.lid__badge--gear .lid__badge__in {
  translate:
    calc(var(--mx, 0) * 22px)
    calc(var(--hero-p, 0) * -34px + var(--my, 0) * 16px);
}
.lid__badge--spark .lid__badge__in {
  translate:
    calc(var(--mx, 0) * 14px)
    calc(var(--hero-p, 0) * 26px + var(--my, 0) * 10px);
}

/* The title drifts and dims slightly, so the release into the
   storyline below is not abrupt. */
.hero__inner {
  transform: translateY(calc(var(--hero-p, 0) * -26px));
  opacity: calc(1 - var(--hero-p, 0) * 0.18);
}

@media (prefers-reduced-motion: reduce) {
  .hero { height: auto; min-height: 100svh; }
  .hero__stage { position: static; height: auto; min-height: 100svh; }
  .lid__ground, .lid__disc, .lid__badge__in, .lid__field,
  .hero__inner {
    transform: none; translate: none; opacity: 1;
  }
}

/* ---- Section reveal on scroll ------------------------------
   Home-page only in practice: main.js only ever adds .reveal-pending
   on a page with a .hero, so Research/Team/Contact never get this
   class at all and are untouched. Each section fades and lifts in the
   first time it crosses into view, one at a time as you scroll rather
   than all at once.

   .reveal-pending is added by main.js right before it starts
   observing, never by default: if JS fails, is disabled, or
   IntersectionObserver is unsupported, every section simply renders
   at its final, visible state instead of staying hidden forever. */
.reveal-pending {
  opacity: 0;
  transform: translateY(28px);
  transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
    transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}

.reveal-pending.is-revealed {
  opacity: 1;
  transform: none;
}

/* Frosted glass needs a fallback surface for the handful of browsers
   without backdrop-filter — otherwise the nav sits directly on the
   artwork again, which is the bug this phase fixes. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .site-header { background: var(--lid-paper); }
}

/* A long pinned stage costs more on a phone, where a screen of scrolling
   is a lot of thumb. Shorter here, same sequence. */
@media (max-width: 760px) {
  .hero { height: 155svh; }
}

/* ---------------------------------------------------------
   25. Contact

   Used to be the one page with its own layout: artwork bleeding
   off the left edge, a form on the right. Both are gone now —
   the artwork sat oddly once the form it was paired with came
   out, and the page underneath it is just two short sections.
   It uses the site's standard page-banner + .spread pattern
   (see sections 16 and the page banner rules) like Research and
   Team do, rather than carrying its own grid for that. Only what
   still has no equivalent elsewhere stays here: the email link,
   the social pill and the two-card row.
   --------------------------------------------------------- */

/* ---- No form, and no buttons either ------------------------
   The form asked for an inbox this site doesn't have yet, and
   silently risked losing a message the moment someone actually
   used it. Gone. In its place: the plain address people can act
   on right now, and the invitation that used to sit in the
   home page's footer, given a proper home instead of getting
   dropped when the merge came apart.

   The two were stacked .spread blocks with a rule between them,
   which read as two unrelated sections when they are two ways of
   doing the same thing — both end at the same address. They are
   now one spread holding a .grid.grid--2 row of two cards, the
   same row "Who's at the table" uses on the home page.

   Neither card has a button. Each one ended in a mailto: button
   sitting directly beneath the address it linked to, which gave
   a visitor two identical things to click and said nothing the
   sentence had not already said. The address is simply inline in
   the prose. */

/* Set on the link itself rather than a wrapping paragraph — the address
   now sits inline in a sentence instead of on its own line, so the
   emphasis has to be the anchor's, not a parent's. */
.contact__email {
  color: var(--ink);
  font-weight: 600;
  text-decoration-color: var(--paper-accent);
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
  word-break: break-word;
}

.contact__social {
  margin: 1.25rem 0 0;
}

/* inline-flex, not inline-block: the pill now carries the LinkedIn mark
   beside its label, and the two have to sit on a shared centre line. */
.contact__social a {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  font-family: var(--font-mono);
  font-size: 0.74rem;
  padding: 0.35rem 0.8rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  text-decoration: none;
  color: var(--ink);
}

/* Load-bearing, not polish. The global `img, svg { max-width: 100%;
   height: auto }` leaves an inline svg that carries a viewBox and no
   dimensions at the replaced-element default of 300x150. */
.contact__social svg {
  width: 1em;
  height: 1em;
  flex: none;
}

/* The pill sits on white in one card and on the warm fill in the other,
   and the old #ffffff99 was invisible against both. */
.contact__social a:hover {
  background: #0819120f;
}

/* ---- The two contact cards -------------------------------
   The pill used to be pushed to the bottom of the card with an auto top
   margin, which read well while it was the last thing in there. It is
   not any more — the Gamelab address follows it — and an auto margin
   would have opened a gap above the pill and dropped that address below
   it, splitting the address from the sentence introducing it. Ordinary
   flow instead, so the pill stays attached to the address it belongs
   to. .card > *:last-child already zeroes the trailing margin. */
.contact-cards .contact__social {
  margin-bottom: 1.25rem;
}

/* A named smaller heading scale for a heading inside a card, not across
   a page. The global h2 is clamp(1.8rem, 4vw, 2.9rem) — sized for a
   section opener, and far too large inside a box with 1.5rem of
   padding. A class, not a bare `.contact-cards h2`, so it reads as an
   intentional second heading size rather than a page-specific override
   easy to miss when scanning the type scale. */
.heading--compact {
  font-size: 1.35rem;
  line-height: 1.15;
  margin: 0 0 0.75rem;
}

/* ---------------------------------------------------------
   26. Photograph slots that actually hold a photograph

   .shot is a placeholder: a dashed outline on a warm ground
   with the words "photo — ..." inside it, so an unfilled page
   reads as unfinished-by-design rather than broken. Once a
   real image lands, all of that has to come off, or the
   photograph sits inside a dashed box announcing that it is
   missing.

   At the end of the file deliberately. The placeholder look is
   re-declared in a later stratum than the base .shot rule, and
   this is a single class like both of them, so order is the
   only thing that settles it.
   --------------------------------------------------------- */

.shot--photo {
  background: none;
  border: 0;
  padding: 0;
  overflow: hidden;
}

.shot--photo img {
  display: block;
}

/* This photograph is a 4:3 phone frame whose top third is suspended
   ceiling. Shown whole it is mostly ceiling; in the 16/9 slot the crop
   takes that off, and pulling the focal point down the frame keeps the
   table, the boards and the people rather than centring on empty air. */
.shot--wide.shot--photo img {
  object-position: center 72%;
}
