/* ==========================================================================
   RMIT-Sri Lanka Research and Development Centre - site stylesheet
   Kept separate from markup on purpose so colours/type/spacing can be
   tuned later without touching the HTML.
   ========================================================================== */

/* R3-26: Inter, taken by KM site-wide. Latin subset, four weights, because
   the stylesheet uses exactly four: 400 twice, 500 once, 600 twenty times and
   700 thirteen times. No italics, because the site has none; if one is ever
   introduced the browser will synthesise a slant, which is not the same thing
   as an italic, and that is the moment to add the real files.

   font-display: swap, NOT the block the mock used. Block was right for a
   comparison, so no candidate was judged on a flash of Helvetica, and it is
   wrong live because it hides text for up to three seconds. The cost of swap
   is a reflow on first paint, and Inter runs about 10% wider than Helvetica so
   that reflow is visible. The alternative, optional, avoids the shift by
   sometimes not using Inter at all on a first visit. swap is a stated choice.

   No unicode-range, deliberately. It exists so a browser can skip subsets it
   does not need, and we ship one subset, so there is nothing to select
   between.

   Paths are relative to THIS FILE, so fonts/ resolves to css/fonts/. That
   directory is already published by the recursive css entry in amplify.yml and
   the allowlist needs no change. Worth knowing that css/ and js/ are flat
   today, so that recursive entry has never matched anything and css/fonts/ is
   the first thing to test it; the pattern form itself is proven by the
   equivalent images entry matching the live images/events/ files. Check one
   woff2 by URL after deploy.

   THE GLOB PATTERNS ARE DESCRIBED IN WORDS ON PURPOSE. Written literally they
   contain a star followed by a slash, which ENDS A CSS COMMENT. The first
   draft of this block did that and silently deleted the @font-face rule
   underneath it, so body text rendered in Inter 500 instead of 400 with no
   error anywhere. Do not paste an amplify.yml pattern into this file.

   Licence: SIL Open Font License 1.1, and publishing this repo is
   distribution. css/fonts/OFL.txt ships beside the binaries. The copyright
   line reads "Copyright 2016 The Inter Project Authors" with NO reserved font
   name clause, read from the file rather than assumed, so there is no naming
   constraint and nothing is required in the page itself. */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('fonts/inter-latin-400-normal.woff2') format('woff2');
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('fonts/inter-latin-500-normal.woff2') format('woff2');
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('fonts/inter-latin-600-normal.woff2') format('woff2');
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('fonts/inter-latin-700-normal.woff2') format('woff2');
}

:root {
  /* Brand */
  --red: #E2231A;
  --red-dark: #B91B14;
  --black: #101010;
  --ink: #1C1C1C;
  --body: #4A4A4A;
  --muted: #5F5F5F;
  --white: #FFFFFF;

  /* Neutrals */
  --bg-page: #FFFFFF;
  --bg-soft: #F7F6F5;
  --bg-panel: #FAF9F8;
  --border: #E7E4E1;

  /* Navy is the logo colour. 13.64:1 on white, 12.64:1 on --bg-soft.
     Approved for focus indication, the skip link, and the footer column
     headings and top rule. Not for body copy, section headings, links or
     buttons. */
  --navy: #002A6A;
  --focus-ring: var(--navy);

  /* Header height, shared by the header box and the mobile panel offset. */
  --header-h: 76px;

  /* Type. Eight steps. The site was using 19 distinct rendered sizes,
     including pairs half a pixel apart, and H3 alone rendered at 16.5,
     17, 20 and 34px in different components. Each heading level now maps
     to exactly one step, and the steps are redefined once at 960px rather
     than overridden per component. Seven steps: the 46px display step was
     retired when heading levels were made to map one-to-one onto sizes, so
     the home hero h1 now sits at the h1 step like every other h1. */
  /* The existing stack stays BEHIND Inter. If the font files ever fail to
     load, every page renders exactly as it did before R3-26 rather than
     falling to a browser default. */
  --font: 'Inter', "Helvetica Neue", Helvetica, Arial, sans-serif;
  --fs-h1:      34px;
  --fs-h2:      28px;
  --fs-h3:      20px;
  --fs-lead:    16px;
  --fs-body:    14px;
  --fs-small:   13px;
  --fs-micro:   12px;

  /* Spacing / shape */
  --radius: 10px;
  --radius-sm: 6px;
  --shadow-card: 0 1px 3px rgba(16,16,16,0.06), 0 8px 24px rgba(16,16,16,0.05);
  --container: 1180px;
}

* { box-sizing: border-box; }

html { scroll-behavior: smooth; }

body {
  margin: 0;
  font-family: var(--font);
  color: var(--body);
  background: var(--bg-page);
  -webkit-font-smoothing: antialiased;
  font-variant-numeric: tabular-nums;
}

/* Intrinsic width and height are now on every img tag the phase could
   reach, so the browser can reserve the box before the file arrives.
   height:auto keeps the attributes from fixing the rendered height when the
   width is constrained. Components that crop with object-fit set their own
   height and override this. */
img { max-width: 100%; height: auto; display: block; }

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

.wrap {
  max-width: var(--container);
  margin: 0 auto;
  padding: 0 32px;
}

h1, h2, h3, h4 {
  color: var(--black);
  font-weight: 700;
  line-height: 1.15;
  margin: 0 0 16px;
}

h2 { font-size: var(--fs-h2); letter-spacing: -0.01em; }
h3 { font-size: var(--fs-h3); }

/* R3-09: card headings take the card-heading size, not their document level's
   size. KM's decision, "a lower size for all these kind of cards".

   A card heading is a COMPONENT. The scale directly above maps heading LEVELS
   to sizes for document structure, and that mapping is untouched: an h2 is
   still 28px wherever it is a section heading. Inside a card the size comes
   from the component instead, because a card heading's job is to label a card
   rather than to set the size of a page section. If you are auditing the scale
   and have found a card heading that is not at its level's size, this is why.

   var(--fs-h3), NOT a fixed 20px, and the token is the change rather than the
   number it happens to resolve to. The same component is marked up at
   different levels on different pages, correctly: h2 on research-themes, where
   the page runs h1 straight to the cards, and h3 on news-events and
   research-joint-phd, where they sit under an h2. So before this rule one
   component rendered at four sizes, 28/24 and 20/18.

   A FIXED 20px WOULD HAVE RAISED HALF OF THEM. Below 960 the scale steps h3 to
   18px, so a flat 20px pushes every h3 card heading UP, and KM asked for
   lower. var(--fs-h3) steps 20/18 at that same boundary, so it lowers the h2
   instances at every width and leaves the h3 instances exactly where they are.

   EACH CLASS CARRIES ITS OWN :is() CLAUSE ON PURPOSE. Written as
   ".a, .b, .c :is(h2)" a selector list binds loosely and parses as .a OR .b OR
   (.c :is(h2)), so only the last class would get its headings and the other
   four would be bare class selectors matching the cards themselves. That
   mistake silently changed one page of six when this was measured. */
.theme-card :is(h1, h2, h3, h4),
.facility-card :is(h1, h2, h3, h4),
.programme-card :is(h1, h2, h3, h4),
.path-card :is(h1, h2, h3, h4),
.login-card :is(h1, h2, h3, h4) {
  font-size: var(--fs-h3);
}

p { line-height: 1.7; margin: 0 0 16px; }

/* Prose measure. Body copy was running to 101 characters a line at 1440px
   in the wider content columns. Cards, tables, nav and the footer keep
   their own widths; this caps running text only.
   The value is empirical, not nominal: ch is the advance of "0", which is
   wider than the average letter in running English, so a cap of N ch fits
   MORE than N characters. That reasoning is correct and is why the unit
   behaves the way it does below.

   WHAT IT ACTUALLY BUYS, with its provenance, because the figure that stood
   here said "roughly 70" and was never sourced:

      element      #what-we-do p on index.html. NAMED, rather than taken as
                   the first .section p carrying a cap, which on that page is
                   the home float's paragraph and is capped separately
      width        1440
      convention   characters INCLUDING spaces, which is what the 45-75
                   guidance counts. Excluding them understates a line by
                   about 16% and caused two seats to hold 135 and 156 for
                   the same line earlier
      result       4 lines, 65 longest, 61.7 mean

   THE NUMBER WAS WRONG AND THE CHOICE IS STILL RIGHT. Read those as two
   statements, because a bare correction from 70 to 65 reads as "the
   reasoning was faulty" and invites someone to reopen a settled decision.
   The problem 52ch was solving is stated above: body copy running to 101
   characters. The target is the 45-75 band, counting spaces. 65 longest and
   61.7 mean sits inside that band, and nearer its middle than the claimed 70
   would have been. So the justification was wrong and the value it justified
   is better than advertised.

   THE MIRROR OF THE HOME FLOAT CAP, AND THE PAIR IS THE USEFUL PART. There,
   R3-26 moved the number and the CHOICE FAILED with it, because the value
   had been fitted to an outcome, so the value had to change. Here the number
   moves and the choice SURVIVES, so the value must NOT change. Whenever a
   figure that justified a decision is corrected, say which of those two
   happened, or the next reader has to work it out and may get it wrong.

   CHARACTER COUNTS ARE TEXT-DEPENDENT, so the figure names its element. A
   different paragraph under the same cap gives a different count, which is
   why "roughly 70" could not be repaired by swapping in another bare number.

   R3-26 CHANGED THE FACE AND THIS ONE DID NOT MOVE. That is the surprising
   result and the reason it is written down. 52ch went from 462.72px in
   Helvetica to 524.88px in Inter, +13.4%, and the characters it buys are
   IDENTICAL, 65 longest and 61.7 mean in both. Inter's zero-advance grew in
   the same proportion as its lowercase, so the unit absorbed the entire
   change and no edit was needed here. Contrast the home float cap, which had
   been FITTED to an outcome rather than derived, and had to be re-swept.

   THIS VALUE IS REPEATED IN TWO OTHER RULES AND THEY DO NOT KNOW ABOUT THIS
   NOTE. Counted: 52ch appears three times in this file, here on the shared
   prose group, again on `.hero .tagline`, and again inline on
   `.page-hero .lead`. The two copies restate the number rather than
   referring to it, so changing the measure here changes one of three and the
   other two silently keep the old value. Nothing is wrong today, because all
   three agree. Anyone altering the measure should alter all three, or make
   it a custom property first.

   THE 96-AT-68ch FIGURE IS HISTORICAL AND DOES NOT RECONCILE WITH THE ONE
   ABOVE. It is left in place rather than deleted, and flagged rather than
   trusted, because one measured figure sitting beside one unsourced figure
   is the most deceptive pairing available: the unsourced one inherits the
   credibility of its neighbour. The arithmetic:

      68ch -> 96 characters    1.41 characters per ch
      52ch -> 65 characters    1.25 characters per ch

   For the same text in the same face those should be near-constant. They are
   13% apart, which is close to the ~16% this file attributes elsewhere to
   counting WITHOUT spaces against counting WITH them. So the likeliest
   explanation is that 96 was counted on a different convention, or on
   different text, and it is a testable hypothesis rather than a mystery:
   re-measure 68ch on #what-we-do p at 1440 including spaces, and if it lands
   near 85 the convention was the difference. Until someone does, treat 96 as
   an artefact of unknown provenance and do not cite it.

   The 101 characters in the first paragraph is inherited in the same way and
   has the same status.

   A NUMBER WITHOUT ITS CONVENTION IS NOT A NUMBER. This is the third time on
   this project that a figure has been wrong only because two conventions
   were in use: 135 against 156 for one line, twelve against fifteen
   row-shaped strings, and now this. State the convention beside the value
   every time. */
.section p,
.section li,
.page-hero p,
.lead,
.tagline {
  max-width: 52ch;
}

/* R3-19: KM ruled the Governance intro spans the full width as one column. The
   centre asked only for less empty space on the right; KM went further.

   MEASURED BEFORE CHANGING ANYTHING, because the last cap removed on this
   project turned out to be inert. At 1440 the paragraph was 463px inside a
   1116px content column: 653px empty on the RIGHT, 0 on the left. Lifting this
   52ch measure alone takes it to 1116px; lifting .wrap's own max-width alone
   leaves it at 463px. So 52ch binds here and .wrap does not.

   Scoped to one section on one page rather than loosening the prose measure,
   which every other page still wants. NOTE THE COST: 1116px of 16px text is
   a 149-character line, measured with a Range, well over double a
   comfortable measure. That is a
   legibility price KM has chosen, not an improvement. */
.section-full-measure p { max-width: none; }

/* R3-20: KM, "good remove the unnecessory gap in the bottom of this text".

   THE GAP IS 192px AT 1440, NOT THE ~300 ESTIMATED FROM THE SCREENSHOT, and it
   accounts for itself exactly: 16px paragraph margin-bottom + 88px this
   section's padding-bottom + 88px the next section's padding-top = 192.

   WIDENING THE TEXT DID NOT CAUSE IT. Measured at full width and at 52ch the
   gap is 192px both ways. What changed is what sits above it: the paragraph
   went from 8 lines to 3, so the same spacing now reads as a hole. The
   spacing rules never moved.

   The unnecessary half is the DOUBLING. This section pays 88px below itself
   and the next section pays 88px above itself, for one boundary. Dropping
   this section's contribution leaves the site's normal single section gap.
   .section itself is untouched: it dresses 34 sections across 22 pages.

   Written as .section.section-full-measure so it outranks the .section
   padding overrides in the 960 and 560 media queries, which appear later in
   this file and would otherwise win at those widths. */
.section.section-flush-bottom { padding-bottom: 0; }
.section .card p, .section .programme-card p, .section .person-card p,
.section .theme-card p, .section .path-card p, .section .message-card p,
.section .facility-card p, .section .empty-state p, .section td p,
.banner p, .stat-grid p {
  max-width: none;
}

.eyebrow {
  display: inline-block;
  text-transform: uppercase;
  font-size: var(--fs-micro);
  font-weight: 600;
  letter-spacing: 0.12em;
  color: var(--red-dark);
  margin-bottom: 12px;
}

.em-red { color: var(--red-dark); font-weight: 600; }

/* Spacing rhythm. 8px base. Section-level padding is symmetric unless
   there is a structural reason not to: the footer's lower space comes from
   .footer-bottom, and the home hero stays deliberately distinct.
   Steps in use at section level: 56, 64, 72, 88, 128. */
.section { padding: 88px 0; }
.section-soft { background: var(--bg-soft); }
.section-head { max-width: 720px; margin: 0 0 48px; }
.section-head.center { margin-left: auto; margin-right: auto; text-align: center; }

.btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 13px 26px;
  border-radius: var(--radius-sm);
  font-size: var(--fs-body);
  font-weight: 600;
  border: 1.5px solid transparent;
  cursor: pointer;
  transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
}

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

.btn-outline { border-color: rgba(255,255,255,0.6); color: var(--white); }
.btn-outline:hover { border-color: var(--white); background: rgba(255,255,255,0.08); }

.btn-ghost { border-color: var(--border); color: var(--ink); }
.btn-ghost:hover { border-color: var(--red); color: var(--red-dark); }

/* R3-24: the guidebook button on research-joint-phd-apply.html. KM chose the
   variant that sits INSIDE the entry-requirements column rather than at the
   foot of the whole section, so this spaces it from the list above it, not
   from the section.

   24px and not the section's 40px: the button is a sibling of .req-list
   inside the column, so it takes the rhythm of the list it follows. UI/UX's
   mock carried both values as .gb-foot 40px and .gb-foot.gb-col 24px; only
   the column variant was built, so only its value is here. A second class
   that no markup uses is a rule that looks load-bearing and is not.

   No colour, size or weight is set. The button is the site's existing
   .btn .btn-primary at line 222, already live on index.html and
   contact.html. White on --red measures 4.68:1, and at 14px/600 it is NORMAL
   text under WCAG rather than large, so the bar is 4.5:1 and not 3.0:1. If
   this ever renders at anything but 14px/600 that threshold question
   reopens. */
.guidebook-foot { margin-top: 24px; }

.link-arrow {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-weight: 600;
  color: var(--red-dark);
  font-size: var(--fs-body);
}
/* R2-19: two partnership links stacked under the overview text.
   .link-arrow is inline-flex, so without this they sit side by side. */
.link-stack { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }

.link-arrow svg { transition: transform 120ms ease; }
.link-arrow:hover svg { transform: translateX(3px); }

/* news-events.html keeps h4 in its footer: that page never had a skipped
   level, and the standing restriction there allows only the landmark and
   the skip link below the header. The selector matches both so its footer
   is styled identically to the other nineteen. One tag change closes the
   gap whenever the restriction lifts. */

/* Heading levels map one-to-one onto the scale: every h1 is 34px, every h2
   28px, every h3 20px, at a given breakpoint. The appearance pins that
   previously held promoted headings at their old size are gone.
   No exceptions: the footer column labels, which used to be the one, are
   no longer headings.
   Component rules keyed to a tag are widened with :is(), which keeps the
   specificity identical to the single-tag selector it replaces. */
/* ---------- skip link ----------
   First focusable element on every page. Off-screen until focused, so it
   costs sighted users nothing, and fixed rather than absolute so it is
   reachable wherever the page is scrolled to. Without it a screen reader
   or keyboard user tabs past up to 24 navigation links on every page. */
.skip-link {
  position: fixed;
  top: 10px;
  left: 10px;
  z-index: 100;
  transform: translateY(-300%);
  padding: 12px 18px;
  background: var(--white);
  color: var(--ink);
  font-size: var(--fs-body);
  font-weight: 600;
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-card);
  /* No transition. A skip link has to be on screen the instant it takes
     focus; animating it means a fast tab can land while it is still
     off-screen. */
}
/* :focus rather than :focus-visible. The link is off-screen, so it can only
   be reached by keyboard or by assistive technology in the first place, and
   it has to appear for every one of those routes. */
.skip-link:focus {
  transform: translateY(0);
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
}

/* The landmark takes focus from the skip link, so it needs tabindex="-1"
   in the markup. It is a destination, not a control, and ringing the whole
   content area would be noise rather than an indicator. */
main[tabindex="-1"]:focus { outline: none; }

/* ---------- focus ---------- */
/* Every interactive element gets the same ring. :focus-visible means a mouse
   click never shows it, only keyboard and programmatic focus. */
:focus-visible {
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
}
/* Navy disappears on the dark hero, the black banner and the lightbox
   backdrop, so those invert to white to keep the indicator readable. */
.hero :focus-visible,
.page-hero :focus-visible,
.banner :focus-visible,
.lightbox :focus-visible {
  outline-color: var(--white);
}

/* ---------- header ---------- */
.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  background: var(--white);
  border-bottom: 1px solid var(--border);
}

.site-header .wrap {
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* Minimum breathing space between the lockup and the nav. With
     space-between this is a floor, not a fixed value, so the pair cannot
     collapse the way it used to at narrow desktop widths. */
  gap: 40px;
  height: var(--header-h);
}

.brand { display: flex; align-items: center; }

/* The lockup carries the wordmark, so the header no longer sets the centre's
   name as text. Height drives the size and width follows the 3.88:1 viewBox;
   the width and height attributes on the img reserve the box so the header
   does not reflow while the SVG loads.
   45px is the CEILING, not a preference. The nav breakpoint below is derived
   from the lockup width: nav + logo + 64 wrap + 40 floor sets it. Under Inter
   that is 881.3 + 174.73 + 64 + 40 = 1160 of LAYOUT width, and the breakpoint
   adds a scrollbar gutter on top. At 46px the logo is 178.63px and the floor
   moves up with it. Measured by stepping one pixel at a time, not calculated,
   and with real scrollbars. Raising this
   means moving the breakpoint and re-deriving everything below it. */
.brand-logo {
  display: block;
  height: 45px;
  width: auto;
  /* R3-26 guard: the mark SCALES, it never squashes. Measured at viewport
     1131 with the old breakpoint: the box is 130.72 x 45 and without this the
     painted mark is 130.72 x 45 too, a ratio of 2.9049 against its natural
     3.8832, a 25% horizontal squash. With contain it paints 130.72 x 33.66 at
     3.8832, letterboxed inside the same box.

     THE OBVIOUS FIX DOES NOT WORK AND flex-shrink IS RETIRED IN BOTH FORMS.
     On this element it does nothing at all: .brand is the flex item that
     shrinks, and the image is then dragged down by img { max-width: 100% }
     above while height: 45px holds the height. On .brand it does work and
     overflows the page by up to 12px instead.

     This is a GUARD, not the fix. The breakpoint below is what closes the
     band. This is here so that if any future change reopens one, the brand
     mark degrades by getting smaller rather than by getting wrong.

     UNSCOPED ON PURPOSE, so it covers the footer lockup too. On a box that
     already matches the natural ratio it is a no-op, so it cannot change
     anything that is not already being squeezed. Worth knowing the footer is
     closer to that than it looks: its brand column is 180px at 961 against a
     174.73px lockup, about 5px of headroom. */
  object-fit: contain;
}

.main-nav { display: flex; align-items: center; gap: 10px; }

.nav-item { position: relative; }

.nav-link {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 10px 10px;
  /* NAV-03, amended to 16px. Pointed at the scale step rather than
     restating the number, so the nav cannot drift off the scale again. */
  font-size: var(--fs-lead);
  font-weight: 500;
  color: var(--ink);
  border-radius: var(--radius-sm);
  white-space: nowrap;
}
.nav-link:hover { color: var(--red-dark); }
.nav-item.current .nav-link {
  color: var(--red-dark);
  font-weight: 600;
  border-bottom: 2px solid var(--red);
  border-radius: 0;
}
.nav-link .chev { width: 11px; height: 11px; opacity: 0.6; }

.nav-dropdown {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  min-width: 240px;
  background: var(--white);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-card);
  padding: 8px;
  opacity: 0;
  visibility: hidden;
  transform: translateY(4px);
  transition: opacity 120ms ease, transform 120ms ease, visibility 120ms ease;
}

.nav-item:hover .nav-dropdown {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

/* Keyboard opening must be instant. visibility is a discrete property, so a
   120ms transition does not flip it until halfway through, and for those
   60ms the children are still visibility:hidden and therefore skipped by
   Tab. Dropping the transition here makes the panel focusable on the same
   frame as the trigger. Mouse hover keeps its fade. */
.nav-item:focus-within .nav-dropdown {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
  transition: none;
}

.nav-dropdown a {
  display: block;
  padding: 9px 12px;
  font-size: var(--fs-body);
  color: var(--ink);
  border-radius: 5px;
}
.nav-dropdown a:hover { background: var(--bg-soft); color: var(--red-dark); }

/* R3-18: the centre asked for "Programme details" and "How to apply" as sub
   tabs UNDER "Joint PhD programme". Both were already in the dropdown; they
   were flat SIBLINGS of it, which reads as three peer topics rather than one
   topic with two parts. The request was about level, not existence, which is
   why the 2026-09-22 closure that found both present was correct and still
   missed this.

   Indentation rather than a nested list, because .nav-dropdown and
   .mobile-nav-sub are flat divs of anchors throughout and a real <ul> here
   would be the only one on the site. NOTE THE LIMIT: this conveys the
   hierarchy visually and not semantically, so a screen reader still hears
   three peers. Raised rather than silently accepted. */
.nav-dropdown a.is-sub { padding-left: 26px; }

.nav-cta {
  margin-left: 8px;
  padding: 9px 18px;
  font-size: var(--fs-body);
}

/* ---------- mobile navigation ----------
   CSS only, no script. <details>/<summary> rather than a checkbox: summary
   is a native disclosure widget, so the browser exposes expanded and
   collapsed to assistive tech and gives Enter and Space toggling without
   JavaScript, and a closed <details> subtree is not rendered, which keeps
   the panel links out of the tab order while the menu is shut.
   Hidden above the breakpoint, so the desktop header is untouched. */
.mobile-nav { display: none; position: static; }

.mobile-nav-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  min-height: 44px;
  min-width: 44px;
  padding: 10px 12px;
  margin-right: -12px;
  border-radius: var(--radius-sm);
  font-size: var(--fs-body);
  font-weight: 600;
  color: var(--ink);
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  list-style: none;
}
/* Suppress the default disclosure triangle in both engines. */
.mobile-nav-toggle::-webkit-details-marker { display: none; }
.mobile-nav-toggle::marker { content: ""; }

.mobile-nav-toggle svg { width: 22px; height: 22px; flex-shrink: 0; }
.mobile-nav-toggle .icon-close { display: none; }
.mobile-nav[open] .mobile-nav-toggle .icon-open { display: none; }
.mobile-nav[open] .mobile-nav-toggle .icon-close { display: block; }
.mobile-nav-toggle:hover { color: var(--red-dark); }

/* Full-screen panel, seated directly under the header and its border so the
   toggle stays visible and doubles as the close control. */
.mobile-nav-panel {
  position: fixed;
  top: calc(var(--header-h) + 1px);
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--bg-page);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 4px 0 40px;
}

.mobile-nav-group { border-bottom: 1px solid var(--border); }

.mobile-nav-link {
  display: flex;
  align-items: center;
  min-height: 44px;
  padding: 12px 32px;
  font-size: var(--fs-lead);
  font-weight: 600;
  color: var(--ink);
}
.mobile-nav-group.current > .mobile-nav-link { color: var(--red-dark); }

/* Children sit inline and indented rather than behind a second tap. */
.mobile-nav-sub { padding-bottom: 6px; }
.mobile-nav-sub a {
  display: flex;
  align-items: center;
  min-height: 44px;
  padding: 10px 32px 10px 48px;
  font-size: var(--fs-body);
  color: var(--body);
}
.mobile-nav-link:hover,
.mobile-nav-sub a:hover { background: var(--bg-soft); color: var(--red-dark); }

.mobile-nav-cta {
  display: inline-flex;
  margin: 24px 32px 0;
}

/* The panel scrolls, so an outset ring could be clipped at its edges. An
   inset ring on full-bleed rows reads the same and cannot overflow. */
.mobile-nav-panel a:focus-visible { outline-offset: -3px; }

/* ---------- hero ---------- */
/* HERO SCRIM: 50% of the original values. SET BY KM's EXPLICIT DECISION on
   2026-09-21, with measured contrast failures accepted. This is not an
   oversight and not a value to "correct" without asking KM first.

   Measured rendered, glyph-masked, seven widths, on the release tree,
   2026-09-21. Rendered means browser-composited pixels, not alpha arithmetic
   over the CSS values. Glyph-masked means the pixels lying under the glyph
   SHAPES, not those under the text's bounding box, and INTERIORS ONLY:
   opaque glyph pixels, with antialiased edge pixels excluded.

   That last exclusion is not a detail. A rule that admits edge pixels and then
   computes contrast as though each were full-strength type will eventually
   find one bad pixel in any photograph. It did: an earlier version of this
   comment recorded an interior failure at 3.03 that was a single specular
   highlight under a cyan antialiasing fringe, one pixel in 3049. A ratio
   quoted without the pixel count behind it cannot be sanity-checked. The
   figures 3.02, 3.09 and 3.17 were once recorded here as three defensible
   sampling methods. They are not three methods. They are ONE rule read at
   three settings of an undeclared knob, the COVERAGE THRESHOLD: the fraction
   of a glyph's pixels that must be opaque before it counts as interior.
   Swept on a single rule it gives 3.00, 3.12, 3.16 and 3.16 at cover 0.90,
   0.95, 0.98 and 0.995, the same element with the same spread in the same
   order as those three. Quote the coverage alongside the ratio, or the ratio
   does not identify a measurement.

   TWELVE failures, all of them on this hero. The interior scrim below has
   none. If you count fewer than twelve here, a row has been lost; the list is
   ordered widest to narrowest and every width from 1131 down appears.

     1131  h1       2.72 against 3.0
     1024  h1       2.66 against 3.0
     1024  tagline  4.26 against 4.5
      768  h1       2.72 against 3.0
      768  tagline  4.24 against 4.5
      560  h1       2.76 against 3.0
      560  tagline  3.18 against 4.5
      390  eyebrow  3.38 against 4.5
      390  h1       2.83 against 3.0
      390  tagline  3.20 against 4.5
      320  eyebrow  3.13 against 4.5
      320  tagline  3.76 against 4.5

   1440 passes, at x1.05 on the h1. 320 h1 passes at 3.28, x1.09. The interior
   scrim at .page-hero.has-image is the same decision and has NO failure: its
   worst is the lead at 560, 8.17 against 4.5.

   The values that DO pass everywhere are 75% of the originals, measured the
   same day: 0.54/0.60/0.69 here and 0.51/0.54/0.585 at
   .page-hero.has-image, worst case 5.26:1. They were not chosen. KM wanted
   the page lighter and accepted the failures to get it.

   The eyebrow at .hero .eyebrow went #FF8A83 to #FFFFFF in the same change,
   for its own reason: at #FF8A83 it measured 4.43 against 4.5 at 320px on
   b68a9ff, the tree this branched from, before any scrim change, a failure
   independent of this decision. KM ruled it in as a MITIGATION, not a fix,
   on 2026-09-21.

   Under the 50% scrim it is a mitigation rather than a fix. Measured on this
   tree, coral against white, same scrim:

       560   coral 4.24 FAIL   ->  white 9.67 PASS
       390   coral 1.48 FAIL   ->  white 3.38 FAIL
       320   coral 1.36 FAIL   ->  white 3.13 FAIL

   The two white figures at 390 and 320 are interiors-only. The four others
   predate that rule and are pending re-measurement; they will move in the
   direction of reading better, not worse, and none of them changes a verdict.

   So it removes the 560 failure entirely, which is why 560 is absent from the
   list above, and it lifts 390 and 320 without clearing 4.5. The eyebrow
   passes at 1440, 1131, 1024, 768 and 560, and fails at 390 and 320.

   Do not record this as closing the 320px defect.

   THE 560 TRANSITION IS A CLIFF RATHER THAN A SLOPE: x2.15 at 560 and x0.75
   at 390. banner.jpg crops hard below 560 and the eyebrow lands on a bright
   region it does not touch above it. This is a property of the photograph and
   not of the eyebrow, so it applies to anything sitting in that band.
   Sampling only wide viewports will make this hero look comfortable
   everywhere. */
.hero {
  position: relative;
  color: var(--white);
  background: linear-gradient(180deg, rgba(10,10,12,0.36) 0%, rgba(10,10,12,0.40) 55%, rgba(10,10,12,0.46) 100%),
              url("../images/banner.jpg") center / cover no-repeat;
  padding: 128px 0 112px;
}

.hero .eyebrow { color: #FFFFFF; }

.hero h1 {
  color: var(--white);
  font-size: var(--fs-h1);
  max-width: 820px;
  margin-bottom: 20px;
}

.hero .tagline {
  font-size: var(--fs-lead);
  line-height: 1.6;
  color: rgba(255,255,255,0.86);
  max-width: 52ch;
  margin-bottom: 36px;
}

.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; }

/* ---------- who we are / partners ---------- */
.about-grid {
  display: grid;
  grid-template-columns: 1.1fr 0.9fr;
  gap: 64px;
  align-items: start;
}

.partner-note {
  font-size: var(--fs-small);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--muted);
  margin-bottom: 18px;
}

.partner-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 14px;
}

.partner-card {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--white);
  transition: box-shadow 120ms ease, transform 120ms ease;
}
.partner-card:hover { box-shadow: var(--shadow-card); transform: translateY(-2px); }

/* D2: the wide partner card. Held as a class, not an inline style, so the
   560px breakpoint can drop it to a single column. */
.partner-card-wide { grid-column: span 2; }

.partner-card .photo {
  height: 96px;
  overflow: hidden;
}
.partner-card .photo img { width: 100%; height: 100%; object-fit: cover; }

.partner-card .name {
  padding: 12px 14px 14px;
  font-size: var(--fs-small);
  font-weight: 600;
  color: var(--ink);
  line-height: 1.4;
}

/* ---------- vision / mission ---------- */
.vm-panel {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 32px;
}

.vm-item + .vm-item { margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--border); }
.vm-item .label {
  text-transform: uppercase;
  font-size: var(--fs-micro);
  font-weight: 700;
  letter-spacing: 0.1em;
  color: var(--red-dark);
  margin-bottom: 8px;
}
.vm-item p { margin: 0; font-size: var(--fs-body); }

/* ---------- what we do ---------- */
.split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 64px;
  align-items: center;
}

.split img {
  border-radius: var(--radius);
  width: 100%;
  aspect-ratio: 3 / 2;
  object-fit: cover;
}

/* A crest is not a photograph and must not be cropped. The rule above forces a
   3:2 box and covers it, which on a portrait crest shows the middle 46% and
   cuts the name off top and bottom. Measured at 1280, 1024, 700, 560 and 390.

   Only the FIT changes. The box keeps its dimensions at every width, so the
   two-column split is untouched and no layout decision is taken here. The crest
   letterboxes inside it, and the letterboxing is invisible because the page
   background and this crest's own background are both #FFFFFF. If that page
   background ever stops being white, this crest shows a white rectangle. */
.split img.crest { object-fit: contain; }

/* R3-25: the booklet call to action, the last block on the homepage. Built
   from SPEC-booklet-cta.md, which governs where it and UI/UX's mock disagree.

   THE TITLE IS AN h2 RENDERED AT h3 SIZE, and that is deliberate rather than
   an oversight. The element is chosen for the document outline and the size
   for the look, and they do not have to agree. An h3 would not create a level
   skip, because the heading before it is the partners h2, but it would file
   this block UNDER "Our collaborative partners in Sri Lanka" in the outline,
   saying it is part of that section when it is a separate one. The mock uses
   h3; the spec is right and the mock is wrong.

   NO SECTION TITLE, per KM. Not a removal: this section has never had one. It
   is the last block on the page, so a headless closing invitation is a normal
   shape rather than a stray.

   R3-29 SUPERSEDES THE FIGURE BELOW WITH 160px, AND RETIRES THE RULE IT WAS
   CHOSEN UNDER. KM ruled 160 with both numbers in front of it: the cover goes
   from 152px tall to 225px, and the SECTION GROWS BY 47px, 178 to 225. Above
   a crossover at about 128px the COVER sets the row height rather than the
   text, so the constraint below, that the cover must not be what sets the row
   height, is deliberately no longer satisfied. KM chose presence over page
   height. Do not "restore" a smaller cover to make the old rule true again.

   The figure is predictable rather than fitted, for the reason recorded
   below: the text column is a constant 177px across the whole swept range,
   because the 52ch prose cap and not the cover width governs the paragraph.
   So the relationship is monotonic and 160 does what it says.

   THE ORIGINAL 108px REASONING IS KEPT BELOW because it documents why the
   loop it assumed does not exist here, which is still true and still the
   reason nobody should re-derive this from the spec.

   108px THUMBNAIL, and the number is right while the reason for it is not.
   The rule is that the cover must not be what sets the row height. The spec
   derives it from a feedback loop: the image width sets the text column width,
   which sets how the description wraps, which sets the height the image must
   stay under. THAT LOOP DOES NOT EXIST HERE. Measured on the built page, the
   description is capped by the site's own 52ch prose measure at every width
   the spec swept:

      viewport   text column   paragraph   what sets the wrap
      1440        624px         463px      the 52ch cap
      1024        624px         463px      the 52ch cap
       961        624px         463px      the 52ch cap
       768        553px         463px      the 52ch cap
       660        445px         445px      the column, at last

   So above about 660 the thumbnail does not affect the wrap at all, the text
   height is constant, and the margin is simply text minus image. That makes
   the relationship monotonic here, and the spec's warning that it is not, and
   its finding that 112px is the largest safe value by one pixel, describe the
   mock rather than this page. The real limit is around 123px.

   108px is kept anyway. It is well inside the limit either way, it is the
   value KM saw, and re-deriving a larger thumbnail is a look decision nobody
   has asked for. Margin of text over image measured here: +25px at 1440, 1280,
   1024 and 961, and +23px at 768, against the spec's predicted +7px. */
.booklet-cta {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 28px;
  align-items: center;
  max-width: 760px;
  /* R3-29: centred HORIZONTALLY in the content column, per KM. The block sat
     hard left with 356px empty to its right at 1440, 200px at 1024 and 137px
     at 961; auto margins split that evenly. NOT a vertical change: the block
     is already align-items: center, so the cover is centred against the text
     today and a vertical rule would move nothing. */
  margin: 0 auto;
}
.booklet-cta img {
  display: block;
  width: 160px;
  height: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}
.booklet-cta h2 { margin: 0 0 8px; font-size: var(--fs-h3); }
.booklet-cta p  { margin: 0 0 20px; color: var(--muted); }
.booklet-meta   { font-size: var(--fs-small); color: var(--muted); margin-left: 14px; }

/* 560, NOT the spec's 640. 640 would be a new breakpoint in a stylesheet that
   has refused them repeatedly, and nothing requires it: with the query removed
   the two-column layout survives to 320px with no overflow and the button
   still inside its column, so the stack point is a look, not a necessity.
   Measured unaided at 640 the text column is 425px and at 560 it is 369px,
   both comfortable. 560 is the site's existing mobile boundary. */
@media (max-width: 560px) {
  .booklet-cta { grid-template-columns: 1fr; gap: 18px; justify-items: start; }
}

/* R2-02: "Our research ecosystem", HOME.docx section 3. The centre's mock
   sets the title in red on near-black. #E2231A on #002A6A measures 2.26:1,
   which fails AA, so the heading is white and the red is kept for the rule
   under each figure, where it carries no text. Figures sit on --fs-h1 and
   labels on --fs-body, so the scale stays at seven sizes. */
.ecosystem { background: var(--navy); }
.ecosystem h2 { color: var(--white); }

.eco-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
}
.eco-item {
  text-align: center;
  padding: 4px 22px;
  border-left: 1px solid rgba(255,255,255,0.24);
}
.eco-item:first-child { border-left: none; }

.eco-figure {
  font-size: var(--fs-h1);
  font-weight: 700;
  line-height: 1.15;
  color: var(--white);
}
.eco-figure::after {
  content: "";
  display: block;
  width: 34px;
  height: 3px;
  background: var(--red);
  margin: 12px auto 14px;
}
.eco-label { font-size: var(--fs-body); color: var(--white); }

@media (max-width: 780px) {
  .eco-grid { grid-template-columns: repeat(2, 1fr); row-gap: 30px; }
  .eco-item:nth-child(odd) { border-left: none; }
}
@media (max-width: 430px) {
  .eco-grid { grid-template-columns: 1fr; row-gap: 26px; }
  .eco-item { border-left: none; }
}

/* R2-06, REVERSED by KM on 2026-09-21: the partner panel spans the full
   container, aligned with the programme cards above it.

   The 760px cap this replaces was right while the panel was two columns beside
   the About text. Once R3-01 put all seven partners in one row it became the
   thing KM objected to: the panel sat inset about 200px each side while the
   three programme cards directly above ran the full width, and a narrow panel
   under a wide one reads as a failure rather than as a choice.

   There is deliberately NO max-width here. .partner-grid now fills .wrap,
   which is exactly what .programme-grid does, so the two align by construction
   rather than by holding a matching number in two places. Measured at nine
   widths from 1440 down to 390: both edges agree to the pixel at every one,
   and if --container ever changes they move together. Do not reintroduce a cap
   to tidy the panel. The D2 span on the NWSDB card is untouched. */

/* R3-01: the seven partners in one row, at 961px and up. KM's decision on
   2026-09-21, taken twice, the second time with the measured crest sizes in
   front of it.

   Crest size follows card width, because object-fit: contain scales each crest
   until width or height runs out, whichever comes first. The photo box is
   132px tall with 10px of padding, so height runs out at 112px.

   At full container width the card is 147.4px and width no longer binds: all
   seven crests paint at 112px, equal. Narrower than that the cards shrink,
   width binds again, and the crests go unequal because each has a different
   aspect ratio. Measured: 110.3 to 112 at a 1180px viewport, 90.3 to 112 at
   1024, 82.3 to 112 at 961. NWSDB is always smallest because it is the only
   crest wider than it is tall, 208x186 against portrait or square for the six
   universities. Nothing is wrong with the file.

   overflow-wrap IS LOAD-BEARING, NOT TIDYING, and it does not fully solve the
   problem it is here for. "Jayewardenepura" has an intrinsic width of 109.8px
   and holds no space to break at. At 1150px and wider the text box is 111px or
   more and the name sets on two clean lines. BELOW 1150, down to this rule's
   own 961px breakpoint, the box is narrower than the word and the name breaks
   mid-word: "Jayewardene/pura" at 970, and worst at 1140, which strands a
   single "a" on the second line. Removing this declaration does not fix that
   band, it makes it worse, because the name then spills outside its card.

   THE 961-1149 BAND IS A KNOWN OPEN ISSUE, not an oversight, and it is
   strictly better than it was: under the 760px cap every width from 961 up
   broke, and Peradeniya broke too. Every remedy costs something. Raising the
   breakpoint to 1150 drops that band to two columns, a layout KM has not seen.
   Shrinking the name font only in the band desynchronises it from every other
   card on the site. hyphens: auto turns a bare break into a hyphenated one but
   needs a lang attribute and still breaks the word. Left for KM to choose.

   Scoped to a media query for the same reason as every other rule in this
   block: .partner-grid is two columns in the tablet band and one column under
   560, both at (0,1,0), and this selector is (0,2,0). Unscoped it wins
   everywhere, and measured at 320px that gives crests of 33px. */
/* R3-01b: centring is NOT scoped to desktop. It was, and the name alignment
   flipped at 961, which is what KM saw on a device. The names centre at every
   width. overflow-wrap stays with it: it is load-bearing at 961 to 1149, where
   "Jayewardenepura" is wider than its text box, and harmless elsewhere. */
.partner-section .partner-card .name {
  text-align: center;
  overflow-wrap: break-word;
}

@media (min-width: 961px) {
  .partner-section .partner-grid { grid-template-columns: repeat(7, 1fr); }
  .partner-section .partner-card-wide { grid-column: auto; }
}

/* R3-01b: NWSDB's two-track span is released below 961 as well as above. Kept,
   it makes the last card about twice the width of the others around a crest
   that does not grow to match, so it reads as an empty white panel rather than
   a card: measured 16.5% full at 960 against 26.2% for the other six. The
   crest paints at 112.0px at EVERY width from 320 to 960, because the 132px
   photo box binds on height, so there is nothing to cap and a crest cap would
   fix nothing.

   Released on its own the card is the right width but sits alone at the left of
   its row, which is the orphan shape KM has objected to twice. So it is also
   centred in its row.

   width, NOT max-width. A grid item with auto margins shrinks to fit, and
   max-width let this one settle at 295px against the other cards' 373px. The
   explicit width matches them to the pixel across the band. */
@media (max-width: 960px) {
  .partner-section .partner-card-wide { grid-column: auto; }
}
@media (min-width: 561px) and (max-width: 960px) {
  .partner-section .partner-card-wide {
    grid-column: 1 / -1;
    width: calc((100% - 14px) / 2);
    margin-left: auto;
    margin-right: auto;
  }
}

/* The seven files HOME.docx calls logos are photographs of each institution's
   entrance signage, so the sign is the content of the picture. cover inside a
   96px strip cut straight through it: Moratuwa lost its name, Peradeniya
   showed mostly grass. contain on white shows each sign whole, and is also
   how this panel will behave unchanged if the centre later supplies the crest
   logos their own layout reference shows. */
.partner-section .partner-card .photo {
  height: 132px;
  background: var(--white);
  padding: 10px;
}
.partner-section .partner-card .photo img { object-fit: contain; }

/* ---------- programme cards ---------- */
.programme-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 24px;
}

.programme-card {
  background: var(--white);
  border: 1px solid var(--border);
  border-top: 3px solid var(--red);
  border-radius: var(--radius);
  padding: 28px 26px 26px;
  display: flex;
  flex-direction: column;
  box-shadow: var(--shadow-card);
}

.programme-card .photo {
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-sm);
  overflow: hidden;
  margin-bottom: 18px;
}
.programme-card .photo img { width: 100%; height: 100%; object-fit: cover; display: block; }

.programme-card .icon {
  width: 42px;
  height: 42px;
  border-radius: 9px;
  background: #FDECEB;
  color: var(--red);
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 18px;
}
.programme-card .icon svg { width: 22px; height: 22px; }

.programme-card :is(h2, h3) { margin-bottom: 10px; }
.programme-card p { font-size: var(--fs-body); flex-grow: 1; }
.programme-card .link-arrow { margin-top: 6px; }

.programme-card .meta {
  font-size: var(--fs-micro);
  color: var(--muted);
  margin: -6px 0 14px;
}

/* A short row of stat cards should keep card proportions rather than
   stretch across the container. Two cards in a 1180px row were rendering
   546px wide each and reading as empty panels. Capping the track and
   aligning to the start keeps them the size of every other card.
   Note: a single card in .people-grid needs no equivalent, because the
   fixed five-track grid already leaves it at its natural 206px. Was 263px at
   four tracks; R3-07 changed the track count, not this conclusion. */
.stat-grid {
  grid-template-columns: repeat(auto-fit, minmax(220px, 300px));
  justify-content: start;
}
/* The stat figure is a number, not a heading. Held on the scale at the h1
   step, and no longer marked up as an h3, so H3 carries one size only. */
.stat-figure {
  font-size: var(--fs-h1);
  font-weight: 700;
  line-height: 1.15;
  color: var(--black);
  margin-bottom: 4px;
}

/* ---------- booklet banner ---------- */
.banner {
  background: var(--black);
  color: var(--white);
  border-radius: var(--radius);
  padding: 44px 48px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 32px;
  flex-wrap: wrap;
}
.banner :is(h2, h3) { color: var(--white); margin-bottom: 6px; }
.banner p { color: rgba(255,255,255,0.72); margin: 0; max-width: 480px; }

/* ---------- supporting images ----------
   Photographs that illustrate a section rather than carry it. Uncapped
   these ran to 705px tall, a third of the viewport, above content that is
   mostly numbers and tables. */
.section-image {
  width: 100%;
  /* A flat 380px height capped the desktop case but inflated the phone one:
     at 350px wide the photo went from 233px tall to 380px, which is the
     opposite of the point. A ratio with a ceiling caps the wide case and
     leaves narrow widths at their natural proportion. */
  aspect-ratio: 3 / 2;
  max-height: 380px;
  object-fit: cover;
  border-radius: var(--radius);
  display: block;
}

/* ---------- footer ---------- */
/* R3-30 part three: the footer spans the viewport. KM: "maybe the footer
   content can span the whole width of the site so so content can fit freely".

   CONTENT EDGE TO EDGE, NOT A BACKGROUND-ONLY CHANGE, and the distinction is
   load-bearing. Widening only the background saves ZERO height, because
   nothing re-wraps. The whole saving comes from the columns having more room,
   which only happens if the content moves out of --container too.

   TWO THINGS KM ACCEPTED WITH IT. The footer now sits about 130px outside the
   alignment of every other section on the page, permanently, which is the
   point: it reads as a distinct band. Do not narrow it back for consistency
   and do not add a compensating rule. And it buys NOTHING below 1180, because
   there the container is already the viewport.

   The 32px side padding on .wrap is kept, so content does not touch the
   viewport edge. */
.site-footer .wrap { max-width: none; }

.site-footer {
  background: var(--bg-soft);
  /* Navy top rule. Recolour only: the weight is unchanged so the footer
     does not shift by a pixel. */
  border-top: 1px solid var(--navy);
  /* R3-30: 64 -> 44. Part one of three, and the only one that costs
     nothing: it removes height at EVERY width and changes no type,
     no wrapping and no alignment. */
  padding: 44px 0 0;
}

.footer-grid {
  display: grid;
  /* R3-23: the last track is a FRACTION again, and the earlier note saying it
     must be `auto` is superseded rather than wrong. Both are recorded because
     the reason it flipped is the thing worth keeping.

     The old note: KM asked for the contacts hard against the right-hand edge,
     and a fraction made Contact merely the rightmost of three wide tracks with
     its text ending 169.9px short of the container edge, while auto sized the
     track to the block and put the text exactly on the edge, 0.0px at five
     widths. All true, and it was true of a contact block ONE column wide.

     R3-23 makes that block TWO columns, so it is now wide enough to fill a
     1.25fr track. Measured on the built page, text to container edge: 0.0px as
     built, 143.6px with this ratio and a one-column contact block, 0.1px with
     this ratio and the two-column block. So the fraction only works BECAUSE of
     the split below, and the split only works because of the fraction: with
     `auto` retained, the auto track absorbs the widened block and the brand
     column collapses to 87px at 1024, taking the rag to 292.

     THE TWO HALVES ARE NOT SEPARABLE. Reverting either one alone leaves a
     worse footer than reverting both, which is why they landed in one commit.
     Below 961 this value is overridden anyway by the stacked layout. */
  grid-template-columns: 1fr 0.8fr 1.25fr;
  gap: 40px 44px;
  padding-bottom: 32px;   /* R3-30: 48 -> 32, same reasoning as above. */
}

/* Footer column labels. Not headings: they label link groups, they are a
   component style rather than a step of the type scale, and as headings
   they put 60 identical entries into the outline across the site. Being
   divs, they no longer inherit font-weight and line-height from the shared
   heading rule, so both are stated here. Everything else is as it was, and
   the scale is now exception-free. */
.footer-grid .footer-heading {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  font-size: var(--fs-small);
  font-weight: 700;
  line-height: 1.15;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--navy);
  margin-bottom: 18px;
}

.footer-grid p, .footer-grid a { font-size: var(--fs-body); }
.footer-grid ul { list-style: none; margin: 0; padding: 0; }
.footer-grid li { margin-bottom: 10px; }

/* R3-17: KM, from a screenshot, "This site map is not a great look, can you
   put them in two coloumns". The footer Site list, six links in one tall
   column, becomes two.

   COLUMN-COUNT, NOT A GRID. CSS multicol fills DOWN then ACROSS, so the nav's
   existing order survives: Home/People/Research in the first column and
   Education/Opportunities/News & events in the second. A two-track grid fills
   across then down and would reorder the menu to Home/People, Research/
   Education, which is a different menu.

   CSS-ONLY ON PURPOSE. The Site block is an unclassed <div> and its markup is
   byte-identical across all 22 pages. There is exactly one <ul> inside
   .footer-grid, so this needs no HTML change and that byte-identity is kept
   for free rather than by editing 22 files the same way.

   NOTE THE MIN-CONTENT FLOOR. The Site track is 0.6fr, and an fr track floors
   at min-content. A one-column list's min-content is its widest item; a
   two-column one is about twice that plus the gap. If that floor rises above
   the track's share the track grows and steals width from the `auto` contact
   track, which is what keeps the contact block flush right. Measured at every
   width rather than assumed. */
.footer-grid ul {
  column-count: 2;
  /* 16px, not 24. The Site track is narrowest just above the 960 breakpoint:
     191px at 961 rising to 199 at 1024 and 208 at 1050. "News & events" is the
     longest label at 93px, so two columns need the track to be 186px plus the
     gap. At 24px the longest label wraps from 1050 down; at 16px only from
     1024 down. Swept 24/20/16/12/8: no gap clears the band, because at 961 the
     track floors at 191px and two columns of 93 plus any gap will not fit. */
  column-gap: 16px;
}
.footer-grid a:hover { color: var(--red-dark); }

/* R3-23: even the footer columns up. KM, "Even the columns up - stylesheet
   only, 1 file", then "Take both" on the contact split once the cost was put.

   THE !important IS A RULING, NOT LAZINESS. The brand paragraph is pinned by
   an INLINE style attribute, style="max-width: 320px;", on all 22 pages. An
   inline declaration outranks every selector in a stylesheet, so the only way
   to widen it without touching markup is !important. The alternative was to
   strip the attribute from 22 files; KM chose the one-file route knowing that.
   The inline attributes are deliberately LEFT IN PLACE: removing them as well
   would make this declaration inert while still looking load-bearing.

   This is the only !important added; the stylesheet had four before and has
   five now.

   Deliberately OUTSIDE the media query. Only the column ratio is restated
   responsively. The 320px pin applies at every width and does its worst at
   900, so holding this back to 961 would make the cheap option look worse
   than it is for no reason.

   MEASURED on the built page, empty space to the right of the brand
   paragraph: 191px to 6px at 1440, 78px to 2px at 1024, 31px to 7px at 961,
   33px to 4px at 390.

   46ch IS A CEILING THAT IS NEVER REACHED, AND THAT IS NOT A DEFECT. Do not
   tune this number expecting an effect. The brand column is 337px at 1440,
   243px at 1024 and 180px at 961; 46ch is 464px in Inter and 409px in
   Helvetica, so the cap is INERT at every width in both faces and anything
   from about 38ch up behaves identically. What the rule actually does is
   release the inline 320px pin, and the !important is the whole mechanism.
   Swept to confirm: 46, 44, 42, 40 and 38ch all give 337px at 1440; 36ch
   gives 318px and starts making things worse. */
.footer-grid > div > p { max-width: 46ch !important; }

.footer-contact .person { margin-bottom: 20px; }
.footer-contact .person:last-child { margin-bottom: 0; }
.footer-contact .person .role { line-height: 1.35; }
/* R3-30 part two: the contact column down one step each, 14 to 13 and 13
   to 12. Both are EXISTING tokens, --fs-small and --fs-micro, so the type
   scale stays at seven sizes and gains no exception. 12px is still NORMAL
   text under WCAG, large beginning at 18.66px bold, so the contrast bar
   stays 4.5:1 rather than dropping to 3.0:1. */
.footer-contact .person .role { font-weight: 600; color: var(--ink); font-size: var(--fs-small); }
.footer-contact .person .email { color: var(--muted); font-size: var(--fs-micro); }

/* R3-23 part two: the contact column runs its three blocks across two tracks
   instead of down one, which is what actually evens the columns up. Column
   bottoms 156/117/235 become 132/117/187 at 1440, so the rag goes 118 to 70.

   NO WRAPPER ELEMENT. UI/UX's mock put a <div class="people"> round the three
   .person blocks and hung the grid on that; no page here has one, on any of
   the 22, so that rule would have matched nothing while
   `.footer-contact .person{margin-bottom:0}` still applied, leaving the three
   blocks touching with the grid gap that was meant to replace their spacing
   never arriving. .footer-contact's own children are the heading and the three
   blocks, so it can be the grid itself with the heading spanning. Within 2px
   of the wrapper version at every width, and no markup change.

   THE COST, ACCEPTED BY KM RATHER THAN OVERLOOKED. Contact text that does not
   wrap today wraps here: "Distinguished Professor Sujeeva Setunge" at every
   width, plus "Centre Director, RMIT University", "Programme Lead, RMIT
   University", "RMIT-Sri Lanka R&D Centre" and the street line at 1024 and
   961. Part of how this column gets shorter is that the names break. The gain
   is also mostly at the wide end: rag 118 to 70 at 1440, but 109 to 104 at
   1024 and 109 to 95 at 961.

   Scoped to 961 and up. Below that the footer stacks and nothing is beside
   anything. */
@media (min-width: 961px) {
  .footer-contact {
    display: grid;
    grid-template-columns: 1fr 1fr;
    column-gap: 32px;
    row-gap: 20px;
    align-content: start;
  }
  .footer-grid .footer-contact .footer-heading { grid-column: 1 / -1; margin-bottom: 0; }
  .footer-contact .person { margin-bottom: 0; }
}

.footer-bottom {
  border-top: 1px solid var(--border);
  padding: 20px 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 12px;
  font-size: var(--fs-small);
  color: var(--muted);
}

/* A badge must not wrap. "Pending" fitted on one line by luck of its length;
   "Coming soon" is 114px against the 78px it replaced and broke onto two lines
   inside the flex rows of .req-list, on two of three items at every width and
   on all three below 400px. Measured by counting line boxes rather than by
   reading the rendered height, which is misleading here: the badge is a flex
   child and stretches to the row, so a two-line row gives a tall badge whether
   or not its own text wrapped. Verified at 320px on every page carrying this
   class: no overflow and no badge wider than its row. */
.placeholder-tag {
  white-space: nowrap;
  display: inline-block;
  font-size: var(--fs-micro);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
  background: var(--white);
  border: 1px dashed var(--border);
  padding: 3px 8px;
  border-radius: 4px;
  margin-left: 8px;
}

/* The badge is uppercase by default, which suits a one-word status like
   PENDING. "Coming soon" is a sentence and reads as shouting in caps, so the
   2+2 document rows opt out. Kept as a modifier rather than changing the base
   rule, because the footer badge on these same pages is still "Pending" and
   still wants the uppercase treatment. */
.placeholder-tag.is-sentence { text-transform: none; }

/* R3-16: KM, from a screenshot of both 2+2 pages, "coming soon button should
   be on all slighed to the right hand side of the box".

   .req-list li is display:flex, so the badge is already a flex item sitting
   immediately after its label, and a short label leaves it short of the edge.
   Only the middle row is actually wrong: "Credit transfer requirements" is the
   short one and its badge stops 51px early at 1440 and 768, while the two long
   labels already push theirs flush. At 390 the labels wrap and all three are
   flush already. margin-left:auto absorbs the free space so every badge ends at
   the card's content edge whatever its label does.

   SCOPED TO .req-list li. A bare .placeholder-tag rule would also reach the six
   badges on index, login and people-governance, which are NOT in a flex row and
   must not move.

   flex-shrink:0 is precautionary rather than corrective: the badge already
   carries white-space:nowrap and a flex item will not shrink below min-content,
   so it measures 103px at every width both before and after. It is here so a
   later change to either of those cannot silently start squeezing it. */
.req-list li .placeholder-tag {
  margin-left: auto;
  flex-shrink: 0;
  /* KM, asked as its own question: make them all the same height. As a flex
     item the badge takes the default align-self and STRETCHES to the row, so
     it stood 32px tall beside a two-line label, 48px beside a three-line one
     and 22px beside a one-line one. flex-start lets it size to its own
     content, which is the height it has everywhere else on the site. */
  align-self: flex-start;
}

/* ---------- page hero (interior pages) ---------- */
.page-hero {
  background: var(--black);
  color: var(--white);
  padding: 56px 0;
}

/* GLB-04: photographic banner with a legibility scrim.
   Same gradient as .hero. Image supplied per page via --banner. */
/* 50% of the original, per KM 2026-09-21. See the note above .hero.
   Measured interiors-only: NO failure here. Worst is the lead at 560px,
   8.17 against 4.5. Checked, not skipped. */
.page-hero.has-image {
  background: linear-gradient(180deg, rgba(10,10,12,0.34) 0%, rgba(10,10,12,0.36) 55%, rgba(10,10,12,0.39) 100%),
              var(--banner) center / cover no-repeat;
}
/* The default hero type is deliberately dim, which is what forced the first
   scrim to near-black. Brighten the type on photographic heroes instead, so
   the photograph stays visible and the text still passes contrast. */
.page-hero.has-image .breadcrumb,
.page-hero.has-image .breadcrumb a { color: #FFFFFF; }
.page-hero.has-image .eyebrow { color: #FFFFFF; }
.page-hero.has-image .lead { color: #FFFFFF; }

.breadcrumb {
  font-size: var(--fs-small);
  color: rgba(255,255,255,0.55);
  margin-bottom: 16px;
}
.breadcrumb a { color: rgba(255,255,255,0.75); }
.breadcrumb a:hover { color: var(--white); }

.page-hero .eyebrow { color: #FF8A83; }
.page-hero h1 { color: var(--white); font-size: var(--fs-h1); margin-bottom: 14px; }
.page-hero .lead { color: rgba(255,255,255,0.78); max-width: 52ch; font-size: var(--fs-lead); margin: 0; }

/* ---------- research theme cards ---------- */
/* R3-10: card grids stretch their rows. KM's decision, taken after R3-05
   showed what start was doing on the themes page.

   align-items was start, so a card kept its own height and a row of cards
   ended at as many different heights as it had cards. That was invisible for
   as long as every heading happened to take the same number of lines.
   Measured before this change, at 1440:

     research-joint-phd, 5 cards   580/553   553/526   499
     news-events gallery, 8 cards  362       362/385/408/335
     news-events, 2 cards          522/476

   R3-05 fixed it for the themes grid alone because that was the change KM had
   asked for at the time. This applies the same thing to the rest, which is
   what KM asked for once it was shown the figures.

   R3-05 keeps its own align-items even though this makes it redundant today.
   If this base rule is ever put back to start, the themes page should not
   silently regress with it. */
.theme-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 24px;
  align-items: stretch;
}

.theme-card {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--white);
  box-shadow: var(--shadow-card);
  display: flex;
  flex-direction: column;
}

/* A fixed pixel height made the crop depend on the viewport: these framed
   at 3.24:1 on a 1440px screen and 2.0:1 on a tablet, so the same card lost
   different parts of the same photograph on different devices. A ratio
   crops identically everywhere, and 16:9 keeps about three quarters of a
   4:3 source where the old band kept about two fifths. */
.theme-card .photo { aspect-ratio: 16 / 9; overflow: hidden; }
.theme-card .photo img { width: 100%; height: 100%; object-fit: cover; }

.theme-card .body { padding: 22px 24px 24px; display: flex; flex-direction: column; flex-grow: 1; }

/* R2-13: the interview item links to YouTube and no still was supplied with
   it. The only portrait of the speaker on the site is 225x225, too small to
   fill a 16:9 card without upscaling, which the image standard forbids, and
   fetching the YouTube thumbnail would put a Google asset on the page. So the
   card carries a play panel rather than a borrowed or stretched photograph.
   It is a link, not an embed: no iframe, no third-party request. */
.video-still {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 16 / 9;
  background: var(--navy);
  color: var(--white);
}
.video-still svg { width: 54px; height: 54px; }
.video-still:hover { background: var(--black); }

.theme-card .eyebrow { margin-bottom: 8px; }
.theme-card :is(h2, h3) { margin-bottom: 10px; }

.theme-card details { margin-top: auto; }

.theme-card summary {
  cursor: pointer;
  list-style: none;
  font-size: var(--fs-body);
  font-weight: 600;
  color: var(--red-dark);
  padding-top: 10px;
  border-top: 1px solid var(--border);
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.theme-card summary::-webkit-details-marker { display: none; }

.theme-card summary .chev { width: 13px; height: 13px; transition: transform 160ms ease; }
.theme-card details[open] summary .chev { transform: rotate(180deg); }

.project-list {
  list-style: none;
  margin: 14px 0 0;
  padding: 0;
}
.project-list li {
  font-size: var(--fs-small);
  color: var(--body);
  padding: 7px 0;
  border-top: 1px solid var(--border);
  line-height: 1.5;
}
.project-list li:first-child { border-top: none; }

/* RES-01: theme overview disclosure. Sits above the project list, so the
   overview keeps the auto top margin that pins the pair to the card foot
   and the project list follows it directly. Uses --fs-small so the type
   scale stays at seven sizes. */
.theme-card .theme-overview { margin-top: auto; }
.theme-card details + details { margin-top: 0; }
.theme-card .theme-overview p {
  font-size: var(--fs-small);
  color: var(--body);
  margin: 14px 0 0;
  max-width: none;
}

/* R3-05: six research themes in two rows of three instead of three rows of
   two. KM's decision, with the narrower cards accepted. Label as
   SPEC-themes-three-across.md names it; the About NWSDB rule briefly held
   R3-05 and moved to R3-08 to give it back, because that rule had no spec and
   leaving it here would have pushed panel width and people five per row off
   their own labels as well.

   align-items IS PART OF THE CHANGE, not tidying. .theme-grid is align-items:
   start, so cards do not stretch to their row. At two across that was
   invisible because every heading took exactly two lines and the cards came
   out equal by COINCIDENCE, with nothing enforcing it. At three across the
   headings take three to five lines and the cards in a row end at three
   different heights: 446/462/478 and 430/446/462 as specced. Both
   declarations or neither.

   SCOPED BY CLASS, NOT BY :not(.events-grid) AS THE SPEC HAS IT. That selector
   is broader than the spec's own analysis, which reasons only about the six
   cards on this page. Measured, it also reaches two grids the spec does not
   mention:

     research-joint-phd.html   5 cards, would become 3 + 2
     news-events.html          2 cards in a plain theme-grid, would become
                               2 of 3 tracks with the third left empty

   The two-card grid is the blocking one: three tracks hold two cards at
   two-thirds width beside a visibly empty column. Both of those pages would
   also have gained something, because align-items: stretch fixes raggedness
   they carry today, measured 580/553 and 522/476. That is a real improvement
   and not what KM asked for, so it is recorded here and left for KM rather
   than taken quietly alongside an approved change. */
@media (min-width: 961px) {
  .theme-grid.themes-3 {
    grid-template-columns: repeat(3, 1fr);
    align-items: stretch;
  }
}

/* R3-13: news and events as one list, built from news-hybrid-mock.html.

   The page had two card grids, Events and News. It now has one list of ten
   rows: seven open a photo gallery, two link to an outside story, one holds a
   single photograph and has nothing to open.

   EVERY RULE IS SCOPED UNDER .news-list, and that is not tidiness. Three of
   the mock's class names already exist on this site with other meanings:
   .src is the hidden publications panel data on two pages, 22 elements that
   js/panel.js reads by id; .chev is used 107 times, on nav links and theme
   card summaries; .row is used by the drawer's own header. An unscoped .src
   or .chev rule would reach all of them. The mock's .src is renamed .row-src
   here for the same reason.

   THE GALLERY KEEPS THE CLASS NAME event-gallery rather than the mock's .gal.
   The inline lightbox on that page binds to '.event-gallery img' and groups
   photographs with closest('.event-gallery'). Renaming the container would
   break the lightbox silently, with no error and no visible symptom until
   somebody clicked a photograph. That script references nothing else
   structural, so moving the gallery from inside a card into a list row is
   invisible to it.

   Each row is a NATIVE details disclosure, so every gallery opens with
   JavaScript unavailable. That is the existing rule on this site and it is
   what the card version already did; this moves the mechanism rather than
   replacing it. */
.news-list {
  background: var(--white);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}
.news-list .row {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  text-decoration: none;
  color: inherit;
}
.news-list > *:last-child,
.news-list > *:last-child > summary { border-bottom: 0; }

/* R3-15: the centre asked for the news items to be separated from the events,
   "add some space or separate headings"; KM chose headings, inside the one list.

   THESE ARE REAL h2 ELEMENTS. The page had one h2 and fifteen h3, so the two
   group headings restore the level the original two-grid page had and that the
   single list removed. A styled span was the defect fixed in 10ac1b6 and is not
   repeated here. Heading LEVEL is structure, heading SIZE is presentation, so
   sizing these down to the house label idiom costs nothing semantically.

   A bare h2 arrives here carrying font-size:var(--fs-h2) at 28px, a 16px bottom
   margin and line-height 1.15 from the global element rule. The margin alone
   would break the card's flush rows, so every one of those is reset rather than
   partly overridden. Scoped under .news-list: no other h2 on the site changes. */
.news-list .list-head {
  margin: 0;
  padding: 12px 16px;
  font-size: var(--fs-micro);
  font-weight: 700;
  line-height: 1.4;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
  background: var(--bg-soft);
  border-bottom: 1px solid var(--border);
}

.news-list details.row { display: block; padding: 0; }
.news-list details.row > summary {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 12px 16px;
  cursor: pointer;
  list-style: none;
  border-bottom: 1px solid var(--border);
}
.news-list details.row > summary::-webkit-details-marker { display: none; }
.news-list details.row[open] > summary { background: var(--bg-soft); }
.news-list details.row > summary:hover,
.news-list a.row:hover { background: var(--bg-soft); }

.news-list .th {
  width: 76px;
  height: 56px;
  flex: 0 0 76px;
  object-fit: cover;
  border-radius: var(--radius-sm);
  display: block;
  background: var(--bg-soft);
}
.news-list .th.noimg { border: 1px dashed var(--border); }

.news-list .tt {
  flex: 1;
  min-width: 0;
  font-size: var(--fs-body);
  font-weight: 600;
  color: var(--ink);
  line-height: 1.35;
  /* The row title is an h3, so that the ten items keep their place in the
     document outline. Everything the heading brings with it is overridden
     above, and this margin is the one that would otherwise show: the base
     h1,h2,h3,h4 rule sets margin 0 0 16px and would add 16px to every row. */
  margin: 0;
}
.news-list .row-src {
  display: block;
  font-weight: 400;
  font-size: var(--fs-micro);
  color: var(--muted);
  margin-top: 2px;
}
.news-list .yr {
  flex: 0 0 52px;
  font-size: var(--fs-small);
  color: var(--muted);
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.news-list .yr.none { opacity: 0.45; }
.news-list .act {
  flex: 0 0 auto;
  font-size: var(--fs-small);
  font-weight: 600;
  color: var(--red);
  display: flex;
  align-items: center;
  gap: 5px;
  white-space: nowrap;
}
.news-list .act.muted { color: var(--muted); font-weight: 400; }
.news-list .chev { width: 14px; height: 14px; transition: transform 160ms ease; }
.news-list details.row[open] .chev { transform: rotate(180deg); }

/* The gallery only lays out when its row is open. The mock's author recorded
   why this is scoped to [open]: a closed gallery still reports a rect from
   getBoundingClientRect, because Chromium hides details content with
   content-visibility, so a rect measurement cannot see the hiding. The
   unscoped version worked too; this is the clearer one to read. */
.news-list details.row[open] > .event-gallery {
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  padding: 14px 16px 18px;
  margin-top: 0;
  background: var(--bg-soft);
  border-bottom: 1px solid var(--border);
}

@media (max-width: 560px) {
  .news-list details.row > summary,
  .news-list a.row,
  .news-list .row { flex-wrap: wrap; gap: 10px; }
  .news-list .tt { flex: 1 1 100%; order: 2; }
  .news-list .th { order: 1; }
  .news-list .yr { order: 1; flex: 0 0 auto; text-align: left; }
  .news-list .act { order: 3; flex: 1 1 100%; }
  .news-list details.row[open] > .event-gallery {
    grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  }
}

/* NEWS-01: inline event photo gallery. Medium size, no lightbox. */
/* News events: four across so the lead photo is cropped less.
   Scoped to .events-grid so research-themes and research-facilities keep two. */
.theme-grid.events-grid { grid-template-columns: repeat(4, 1fr); gap: 18px; }
.events-grid .theme-card .photo { height: auto; aspect-ratio: 4 / 3; }
.events-grid .theme-card .body { padding: 16px 18px 18px; }
@media (max-width: 1100px) { .theme-grid.events-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px)  { .theme-grid.events-grid { grid-template-columns: 1fr; } }

.event-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  gap: 8px;
  margin-top: 14px;
}
.event-gallery img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: var(--radius-sm);
  display: block;
  cursor: zoom-in;
}

/* ---------- people cards ---------- */
.people-group + .people-group { margin-top: 48px; }
.people-group-label {
  font-size: var(--fs-small);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--muted);
  margin-bottom: 20px;
  padding-bottom: 14px;
  border-bottom: 1px solid var(--border);
}

/* R3-07: five people per row instead of four. KM's decision, site-wide rather
   than scoped to Centre Leadership, with the 5+2 trailing row on the
   seven-person section accepted rather than rebalanced.

   This only takes effect at 1201px and up. Every narrower band already has its
   own override further down this file, 3 columns at 1200, 2 at 960 and 1 at
   560, and those are unchanged. Edited in place for that reason: appended as a
   new rule it would sit after all three and five columns would reach the
   phone layout.

   contact.html is deliberately untouched by this and cannot be reached from
   here. Its grid carries an INLINE grid-template-columns, which beats any
   selector, and that inline style is load-bearing: the section holds two
   cards, and in a fixed multi-track grid they render narrow beside empty
   tracks, reading as a row that failed to fill rather than as a pair. At five
   tracks that would be worse than at four, not better. Do not simplify it to
   match this rule. */
.people-grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 22px;
}

.person-card { text-align: left; }
.person-card .photo {
  width: 100%;
  aspect-ratio: 1 / 1;
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--bg-soft);
  margin-bottom: 14px;
}
/* Sources run from 0.71 to 1.16 in aspect ratio. The square frame already
   normalised the box; the focal point did not, so a tall portrait cropped
   about its middle and lost the top of the head. Biasing upward puts faces
   at a common height across the grid. */
.person-card .photo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center 28%;
}
.person-card .pname { font-size: var(--fs-lead); font-weight: 700; color: var(--ink); margin-bottom: 2px; }
.person-card .prole { font-size: var(--fs-small); color: var(--muted); line-height: 1.5; }

/* Awaiting-photo state. CURRENTLY UNUSED, and kept deliberately.

   It existed because one coordinator's portrait had not been supplied and the
   file in place was a generic silhouette; framing that as a deliberate hold
   stopped it reading as a broken photograph. The centre delivered the real
   portrait on 2026-09-21, PC-7 was replaced, and the class came off that card
   with it, so no markup carries it today.

   Left in place because the situation recurs: thirteen people are on this page
   and a future card can arrive without a photograph. Anything that removes it
   should check first that no card needs it, rather than removing it for being
   unreferenced.

   Replaces a .person-card.is-open rule that no markup ever used, and whose
   class name collided with the gallery lightbox's own is-open state. */
.person-card .photo.is-awaiting {
  background: var(--bg-soft);
  border: 1px dashed var(--border);
}
.person-card .photo.is-awaiting img {
  object-fit: contain;
  object-position: center;
  padding: 17%;
  opacity: 0.4;
  /* The silhouette file carries its own white ground. Multiplying drops it
     into the panel so the placeholder reads as one surface. */
  mix-blend-mode: multiply;
}

/* ---------- governance ---------- */
.message-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
}
.message-card {
  border: 1px dashed var(--border);
  border-radius: var(--radius);
  padding: 26px 22px;
  background: var(--bg-panel);
}
.message-card svg { width: 22px; height: 22px; color: var(--muted); margin-bottom: 14px; }
.message-card p { font-size: var(--fs-small); color: var(--muted); margin: 0 0 4px; }
.message-card .who { font-size: var(--fs-micro); font-weight: 600; color: var(--ink); }

/* DEC-05: two of the five leadership messages now carry supplied text. The
   card was styled for placeholders, dashed border and muted type, which
   would read as "not yet supplied" on a real message. A supplied card gets
   a solid border and body-colour type. The three pending cards are
   unchanged. No new colour or size token. */
.message-card.is-supplied {
  border-style: solid;
  background: var(--white);
}
.message-card.is-supplied p { color: var(--body); }
.message-card.is-supplied svg { color: var(--red); }

/* ---------- data tables ---------- */
.data-table-wrap {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  overflow-x: auto;
}
table.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-body);
}
.data-table caption { display: none; }
.data-table thead th {
  text-align: left;
  font-size: var(--fs-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
  font-weight: 700;
  background: var(--bg-soft);
  padding: 13px 20px;
  border-bottom: 1px solid var(--border);
}
.data-table td {
  padding: 14px 20px;
  border-bottom: 1px solid var(--border);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.data-table tbody tr:last-child td { border-bottom: none; }
.data-table tbody tr:hover { background: var(--bg-soft); }
.data-table .num { font-weight: 700; }
.data-table .num a { color: var(--red-dark); }
.data-table .num a:hover { text-decoration: underline; }

/* R3-27: the three cohort tables on people-phd-students.html share one set of
   column positions. KM: "Alignemnet issues. get it fixed no need for mock".

   THE CAUSE. table-layout defaults to `auto`, which sizes each table's columns
   to ITS OWN content, so three tables with the same headers solved three
   different layouts. The first table's STATUS cell holds TWO pills, "31 in
   registration process" and "4 research in progress"; the other two hold one.
   Measured at 1440, column x positions were:

      registration   155.5   289.4   628.6
      in progress    155.5   343.4   819.4
      completed      155.5   344.9   825.0

   Note the second and third do not agree with each other either, by 1.5px and
   5.6px. The fault was described as one table being out of step; it is three
   tables each solving separately, and two of them happening to be close.

   SCOPED TO A CLASS, NOT TO .data-table. .data-table is used on TWO pages:
   three times here and once on research-publications.html, whose columns are
   Year / Journal publications / Conference proceedings and which nobody asked
   about. There is no per-page hook to scope with either: both pages have a
   bare <body> and an identical <main id="main">. So the scope is a class on
   the three tables, which is countable, 3 occurrences on 1 page.

   12/28/60 CHOSEN FROM A SWEPT TABLE, not picked to look right. Seven schemes
   at 1440, 1024 and 390, judged on alignment, on the white left beside the
   four-digit year, and on pill wrapping against its BASELINE. The baseline
   matters: at 390 ten pills ALREADY wrap today, worst three lines, so the
   constraint is not to make it worse rather than to prevent it.

      scheme            aligned   year white @1440   pills @390
      as built          NO         97.6             10 pills, worst 3 lines
      equal thirds      yes       335.0             10 pills, worst 4  WORSE
      12/28/60          yes        97.4              1 pill,  worst 2  BETTER
      14/30/56          yes       119.7              2 pills, worst 2
      10/24/66          yes        75.1              none, but "2026" overflows
                                                     its column by 3px at 390
      88/200px          yes        51.7             10 pills, worst 4  WORSE
      96/240px          yes        59.7             10 pills, worst 4  WORSE
      7/22ch            yes        20.3             10 pills, worst 4  WORSE

   EQUAL THIRDS WAS THE BASELINE EVERY OTHER CANDIDATE HAD TO BEAT, because
   under fixed layout undeclared columns share the space equally and three
   tables align with no number chosen by anyone. It aligns and it fails twice:
   335px of white beside a four-digit year, and worse wrapping at 390. The
   absolute-width schemes fail for one reason, that they cannot shrink, so at
   390 they starve the STATUS column.

   12/28/60 leaves the year column at 133.7px against today's 133.9, so the
   widest table is unchanged and the other two move to meet it. Wrapping at
   390 IMPROVES, from ten pills to one.

   The drawer links are 20.1 x 19px at 390 in every scheme and in the baseline,
   because an inline anchor is sized by its text and not by its column.

   SCOPED TO 781 AND UP, AND THAT IS A REAL LIMIT rather than caution. The
   cells carry 20px of padding EACH SIDE at every width, with no media query
   reducing it, so a column's CONTENT box is its width minus 40px. At 12% the
   year column runs out of content box before it runs out of column: measured
   by stepping the viewport, a four-digit year last fits at 720 and overflows
   its cell below that, by 16.4px at 390 where the column is 40px wide and the
   padding alone is 40px.

   One percentage cannot serve both ends. The year needs 76.3px at every
   width, which is 23% of the table at 390 and would be 256px at 1440 against
   today's 134. So the fixed layout applies from 781, the complement of the
   existing 780 breakpoint and the nearest one above the measured floor of
   720. Below it the tables revert to auto and behave exactly as they ship
   today, which means the smaller misalignment at phone widths REMAINS: 13px
   on the third column at 390. KM's report was a desktop screenshot; this
   fixes what was reported and not more.

   THE FIRST SWEEP SAID 12% WAS FINE AT 390 AND IT WAS WRONG. It compared the
   column WIDTH against the content width and concluded 40px held 36.3px of
   year. The content box was 0px. Measuring a box that contains padding
   against content that does not is the same error as measuring a container
   instead of its contents, which is what this whole item is about. */
@media (min-width: 781px) {
  .cohort-table { table-layout: fixed; }
  .cohort-table th:nth-child(1) { width: 12%; }
  .cohort-table th:nth-child(2) { width: 28%; }
  .cohort-table th:nth-child(3) { width: 60%; }
}

.status-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: var(--fs-micro);
  font-weight: 600;
  padding: 4px 10px;
  border-radius: 4px;
}
.status-pill.in-progress { background: #FDECEB; color: var(--red-dark); }
.status-pill.completed { background: #EFF6EE; color: #2E7D32; }
.status-pill.registration { background: var(--bg-soft); color: var(--ink); border: 1px solid var(--border); }

/* ---------- partner / link lists ---------- */
.partner-list-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
}
.partner-link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 14px 16px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font-size: var(--fs-small);
  font-weight: 600;
  color: var(--ink);
  background: var(--white);
}
.partner-link svg { width: 14px; height: 14px; color: var(--muted); flex-shrink: 0; }
.partner-link:hover { border-color: var(--red); color: var(--red-dark); }
.partner-link:hover svg { color: var(--red-dark); }
.partner-link.no-link {
  color: var(--ink);
  cursor: default;
  border-style: dashed;
}
.partner-link.no-link .tag {
  font-size: var(--fs-micro);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  flex-shrink: 0;
}
.partner-link.no-link:hover { border-color: var(--border); color: var(--ink); }

/* ---------- facilities ---------- */
.facility-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 24px;
}

/* R3-11: the six facility cards in two rows of three, matching the themes page.
   KM's decision from a screenshot.

   NO align-items HERE ON PURPOSE. .facility-grid never set it, so it inherits
   the initial value normal, which for a grid container behaves as stretch and
   is already correct. R3-10 had to add stretch explicitly to .theme-grid only
   because that rule had been set to start. Adding a declaration here would
   imply this grid had been broken and would leave the next reader looking for
   the bug it fixed. Verified instead: computed align-items is normal and no
   row is ragged at any width.

   NO NEW BREAKPOINT. 961 is the complement of the existing tablet band, and
   the rules further down already take this grid to two columns at 960 and one
   at 560, which is the same ladder the themes page uses.

   THIS DEPENDS ON R3-09 AND SHOULD NOT SHIP WITHOUT IT. Narrower cards push
   some headings to a third line, so on its own this makes the headings worse.
   With R3-09 the pair is better than before at 1024 and 961, 97px down to
   69px, and 5px taller at 1440, 64px to 69px, which buys the loss of a whole
   row. */
@media (min-width: 961px) {
  .facility-grid { grid-template-columns: repeat(3, 1fr); }
}
.facility-card {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--white);
  box-shadow: var(--shadow-card);
}
.facility-card .photo { aspect-ratio: 16 / 9; overflow: hidden; }
.facility-card .photo img { width: 100%; height: 100%; object-fit: cover; }
.facility-card .body { padding: 20px 22px 22px; }
.facility-card :is(h2, h3) { margin-bottom: 6px; }
.facility-card p { font-size: var(--fs-small); margin: 0; }

/* ---------- programme detail (Joint PhD "how it works" panels) ---------- */
.path-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 24px;
}
.path-card {
  border: 1px solid var(--border);
  border-top: 3px solid var(--red);
  border-radius: var(--radius);
  padding: 26px 26px 24px;
  background: var(--white);
}
.path-card :is(h2, h3) { margin-bottom: 12px; }
.path-card ul { margin: 14px 0 0; padding-left: 18px; }
.path-card li { font-size: var(--fs-body); margin-bottom: 8px; line-height: 1.55; color: var(--body); }

.req-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 10px;
}
/* R3-31: the centre, "could you give them a polished colour treatment that
   matches the website banner and logo? Please keep the existing text and
   layout." Fill taken to white, border a 22% TINT of the navy.

   REWRITTEN, NOT APPENDED TO. The first version of this block said "navy
   outline" and quoted 13.64 and 12.64 as the figures it was approved on. Both
   were true of full navy and both became false when KM retuned the border, so
   leaving them under a corrected rule would have left a comment that describes
   a page that no longer exists.

   KM chose the lightest of a six-step ladder, as built then 80, 65, 50, 35 and
   22 per cent, after being shown that 22% is the step closest to the warm grey
   these boxes carried before the treatment. So the intent is a visible outline
   that does not read as a navy rule.

   #C7D0DE IS EXACTLY 22% NAVY IN WHITE, checked rather than trusted:
   0.22 * (0,42,106) + 0.78 * (255,255,255) = (199,208,222) = #C7D0DE. That
   exactness is what makes the re-deriving form below legitimate.

   WHY TWO DECLARATIONS AND NOT ONE. color-mix re-derives from --navy, which a
   frozen literal cannot: retune the logo colour and the boxes follow. But an
   invalid colour invalidates the WHOLE border shorthand, and this is the only
   rule setting a border on these boxes, so the failure mode where color-mix is
   unsupported is NO BORDER AT ALL, not a wrong one. The literal carries the
   shorthand and the longhand overrides it, so an engine that cannot parse
   color-mix drops only the second line and still paints #C7D0DE at 1px.

   CONTRAST, recomputed here rather than carried over:

      background        border       the grey it replaced
      white  #FFFFFF    1.55:1       1.27:1
      soft   #F7F6F5    1.44:1       1.17:1
      panel  #FAF9F8    1.48:1       1.20:1

   THAT FALL FROM 13.64 TO 1.55 IS NOT AN ACCESSIBILITY REGRESSION, and it
   should not be "fixed" by darkening the border back. WCAG 1.4.11 governs
   colour needed to IDENTIFY a component; these are static list items, not
   controls, and the border is decorative. The figures that do matter, text and
   icon against the fill, are untouched by this change and are asserted in the
   verification rather than assumed. The new border is also LIGHTER than navy
   but DARKER than the grey it replaced, so this is a step back toward the
   original weight and not past it.

   COLOUR ONLY. The 1px stays, and that is not a style preference: the border
   is in the box model, so 1px to 2px pushes the last box 10px down the page
   and eats 2px of content width rather than widening the box, because these
   are stretched grid items. A weight change is a layout change wearing a
   colour change's clothes, and the centre's condition forbids it.

   NOT ELEMENT-QUALIFIED, AND THIS IS THE WHOLE TRAP. education-2plus2 and
   education-cinec-2plus2 put their <li> directly inside a <div class=
   "req-list"> with no <ul> at all. That is invalid HTML, it renders correctly
   and CSS does not care, so .req-list li reaches them. `ul.req-list li` would
   silently drop 6 of the centre's 28 boxes, on two of the five pages they
   named, with no error anywhere. The file already carries
   `.path-card ul.req-list` below, so the tidier selector is the house habit
   here: do not follow it. 35 boxes over six pages, KM ruled "take all six",
   and research-joint-phd-details took .req-list at 1c27839 on 24 September, so
   its 7 pathway cards are part of that 35 deliberately.

   Three different backgrounds sit behind these boxes, so the fill is invisible
   on the 21 white items and visible on the 8 soft and 6 panel ones. On
   research-joint-phd, the page the centre screenshotted, the fill was ALREADY
   identical to the panel behind it, which is why their own word for them is
   "outlined boxes" and why fill-to-white is coherent rather than a second
   change: every box becomes a true outline. */
.req-list li {
  display: flex;
  gap: 10px;
  font-size: var(--fs-body);
  color: var(--body);
  padding: 14px 16px;
  border: 1px solid #C7D0DE;
  border-color: color-mix(in srgb, var(--navy) 22%, var(--white));
  border-radius: var(--radius-sm);
  background: var(--white);
}
.req-list svg { width: 16px; height: 16px; color: var(--red); flex-shrink: 0; margin-top: 2px; }

/* R3-14: the two pathway lists on research-joint-phd-details.html take the
   Entry Requirements card treatment. They are inside .path-card, which already
   styles its own lists, and those rules beat or tie .req-list:

     .path-card ul (0,1,1) outranks .req-list (0,1,0), so padding-left:18px
     survives and indents every card by 18px.
     .path-card li (0,1,1) ties .req-list li, and margin-bottom:8px and
     line-height:1.55 appear ONLY in .path-card li, so they apply whatever the
     source order. 8px on top of the grid's 10px gap spaces the cards 18px
     apart where the pattern spaces them 10px.

   So the class alone gives a near-copy, not a copy. Scoped under .path-card
   rather than loosened globally, so every other .req-list is untouched. The
   ul keeps its 14px top margin: that is this page's existing spacing below the
   paragraph, and changing it would move the card for no reason. */
.path-card ul.req-list { padding-left: 0; }
.path-card ul.req-list li { margin-bottom: 0; line-height: normal; }

/* R3-04: the 2+2 pages put their documents beside the prose instead of under
   it. KM's instruction. The section's content sat in a 760px wrap inside a
   1180px container, leaving 210px dead to the left and 448px to the right,
   which is lopsided rather than merely narrow.

   EACH COLUMN IS ONE GRID ITEM. That is the whole reason .edu-col exists and
   it is not cosmetic. SPEC-2plus2-two-column.md placed the four children
   individually instead, with nth-of-type for the prose and grid-row 1/span 2
   for the documents. A grid shares its rows between columns, so that ties the
   closing note's position to the height of the PROSE: it starts after the
   left column's second row ends, not under the documents it refers to.
   Measured on the build that shipped from the spec, at 1440: 80px between the
   two prose paragraphs, where every other paragraph pair on the site gets 16,
   and 310px between the documents and the note. Do not flatten this markup
   back out, and do not try to repair it with row-gap; the fault is the row
   sharing, not the spacing.

   Scoped to min-width 961 so it collapses to a single column in the tablet
   band and below, documents under prose, which is the correct reading order
   and must stay that way. The two .edu-col wrappers become plain blocks there
   and change nothing.

   align-items is START, not center. Centre misaligns the two columns because
   they are different heights.

   The 52ch prose measure is deliberately KEPT. Lifting it fills the left
   column but takes the line to about 88 characters, and it buys no width
   back. */
@media (min-width: 961px) {
  .edu-split {
    max-width: 1180px;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 64px;
    align-items: start;
  }
}

/* R3-08: the About NWSDB section puts its strengths list beside the prose.
   KM's instruction, "the bottom items can be pushed to secon column".

   Before this the section used NONE of the right half of the container:
   measured, 0% of its 886px of content painted right of the midpoint, so the
   page read as one narrow column with 600px of nothing beside it. That became
   visible once the intro was left-aligned, and it is the same emptiness.

   WHY THE LAST LIST AND NOT BOTH. Moving both lists was measured too. It
   scores as well on right-hand emptiness, because its right column runs the
   full height, but it leaves the LEFT column at 261px against 637px, so the
   hole moves rather than closing. This split balances at 443 against 431.

   EACH COLUMN IS ONE GRID ITEM, as on the 2+2 pages. A grid shares rows
   between columns, so placing these five children individually would tie each
   one's row to the height of whatever sits beside it. That produced an 80px
   gap between paragraphs and a 310px gap above a closing note there, and this
   section has more children and so more ways to stagger.

   The label travels with the list it titles. Below 961 the columns collapse in
   source order, so it still sits directly above its own list rather than
   stranded under the first one. Its 28px top margin belongs to that collapsed
   order only: beside the prose it is the column's first element and has to
   start level with the section head, which is why the inline style came off
   the markup. An inline margin cannot be overridden from here. */
.nwsdb-col > .partner-note { margin-top: 28px; }

@media (min-width: 961px) {
  .nwsdb-split {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 64px;
    align-items: start;
  }
  .nwsdb-split .nwsdb-col > .partner-note { margin-top: 0; }
}

/* ---------- empty state (Scholarships / Jobs / News) ---------- */
.empty-state {
  border: 1px dashed var(--border);
  border-radius: var(--radius);
  padding: 64px 32px;
  text-align: center;
  background: var(--bg-panel);
}
.empty-state svg { width: 32px; height: 32px; color: var(--muted); margin: 0 auto 18px; }
.empty-state :is(h2, h3) { margin-bottom: 8px; }
.empty-state p { color: var(--muted); max-width: 46ch; margin: 0 auto; }

/* ---------- login ---------- */
.login-wrap {
  display: grid;
  grid-template-columns: 0.9fr 1.1fr;
  gap: 56px;
  align-items: start;
}
.login-card {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 32px 30px;
  background: var(--white);
  box-shadow: var(--shadow-card);
}
.login-card :is(h2, h3) { margin-bottom: 22px; }
.form-field { margin-bottom: 16px; }
.form-field label {
  display: block;
  font-size: var(--fs-micro);
  font-weight: 600;
  color: var(--ink);
  margin-bottom: 7px;
}
.form-field input {
  width: 100%;
  padding: 11px 13px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font-size: var(--fs-body);
  font-family: var(--font);
  background: var(--bg-soft);
  color: var(--muted);
}
.form-field input:disabled { cursor: not-allowed; }
/* LOG-01. A placeholder for the SharePoint destination, which was never
   supplied. Deliberately not a link and not a button: there is nothing to
   point at, a dead href is worse than an obvious placeholder, and this
   page carries no form, no credential field and no login mechanism. The
   dashed border and the Pending tag match how the rest of the site marks
   an unconfirmed item. */
.login-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px;
  margin: 20px 0 0;
}
.btn.is-pending {
  color: var(--muted);
  border-color: var(--border);
  border-style: dashed;
  cursor: default;
}

.login-note {
  font-size: var(--fs-micro);
  color: var(--muted);
  margin-top: 18px;
  padding-top: 16px;
  border-top: 1px solid var(--border);
}

.feature-list { list-style: none; margin: 20px 0 0; padding: 0; display: grid; gap: 14px; }
.feature-list li { display: flex; gap: 12px; }
.feature-list .icon {
  width: 34px; height: 34px; border-radius: 8px;
  background: #FDECEB; color: var(--red);
  display: flex; align-items: center; justify-content: center;
  flex-shrink: 0;
}
.feature-list .icon svg { width: 18px; height: 18px; }
.feature-list .ftext :is(h3, h4) { margin: 0 0 3px; }
.feature-list .ftext p { margin: 0; font-size: var(--fs-small); }

/* ---------- responsive ---------- */
@media (max-width: 1200px) {
  .people-grid { grid-template-columns: repeat(3, 1fr); }
}

/* Nav breakpoint. Measured, and re-measured every time the header changed.

   R3-26 MOVED IT TWICE OVER: once because Inter widened the nav, and once
   because the old number compared two different quantities. Read the second
   part before changing this.

   THE DERIVATION. The nav cannot shrink. Under Inter it is 881.3px wide
   against Helvetica's 849.1px, the logo lockup is 174.7px, the wrap adds 32px
   each side and the brand-to-nav floor is 40px:

       881.3 + 174.7 + 64 + 40 = 1160.0

   Stepped one pixel at a time with the breakpoint rule deleted from the
   CSSOM: the logo is still full at a LAYOUT width of 1160 and first shrinks
   at 1159. Under Helvetica the same sweep gives 1128, reproducing the
   previous note exactly, so the method is unchanged and only the face moved.

   AND NOW THE PART THAT WAS WRONG BEFORE. A media query's width is the
   VIEWPORT width, which INCLUDES the scrollbar gutter. The floor above is a
   LAYOUT width, which EXCLUDES it. The old breakpoint set a viewport number
   from a layout measurement, so on any machine with a classic scrollbar it
   fired about 15px of layout too late and left a band in which the desktop
   nav was still shown and the brand mark was already being squashed.
   Verified: matchMedia (max-width:1130px) is false at viewport 1131 while the
   layout width there is 1116.

   THE OFFSET IS AN UPPER BOUND, NOT A TYPICAL VALUE, and that is deliberate.
   This browser measures 15px. 17px is also common across Windows versions and
   browsers, and Firefox has differed historically. The error is asymmetric:
   being a few pixels EARLY means the mobile nav appears on a slightly wider
   viewport than it needed to, which nobody can see, while being a few pixels
   LATE is a distorted brand mark on a client's site. So 17 rather than 15.

       1159 last squeezing layout width + 17 gutter = 1176, plus a pixel = 1177

   On a machine with no scrollbar at all this fires about 17px early, which
   costs nothing.

   One shared breakpoint for both navigations, so showing two at once is
   impossible by construction.

   Trend, not a one-off: 960 inherited, 1000 once measured, 1070 after the
   header spacing, 1120 at NAV-03 15.5px, 1130 at NAV-03 16px in Helvetica,
   1177 at NAV-03 16px in Inter. Recorded as finding H4b; do not add a nav
   item without reading it.

   THE ONLY CHECK THAT CAN FAIL HERE IS A RENDERED SWEEP WITH REAL
   SCROLLBARS. An assertion that this file contains a particular pixel value
   passes on a page whose logo is visibly squashed, and so does the arithmetic
   above, which is correct. Playwright's default launch passes
   --hide-scrollbars, which makes layout width equal viewport width and closes
   the band completely, so the defect is invisible to any check run with the
   defaults. */
@media (max-width: 1177px) {
  .main-nav { display: none; }
  .mobile-nav { display: block; }
}

@media (max-width: 960px) {
  /* One redefinition of the scale, so each heading level still resolves to
     exactly one size at this breakpoint. */
  :root {
    --fs-h1:      28px;
    --fs-h2:      24px;
    --fs-h3:      18px;
  }
  .about-grid, .split { grid-template-columns: 1fr; gap: 40px; }
  .programme-grid { grid-template-columns: 1fr; }
  /* Two tracks for three children would leave one alone on a second row,
     which is the orphan shape KM has rejected elsewhere. The brand takes the
     full width instead, and Site and Contact share the row below it, keeping
     Contact on the right. */
  .footer-grid { grid-template-columns: 1fr 1fr; }
  .footer-grid > *:first-child { grid-column: 1 / -1; }
  .theme-grid { grid-template-columns: 1fr; }
  .people-grid { grid-template-columns: repeat(2, 1fr); }
  .message-grid { grid-template-columns: 1fr; }
  .partner-list-grid { grid-template-columns: 1fr 1fr; }
  .facility-grid { grid-template-columns: 1fr; }
  .path-grid { grid-template-columns: 1fr; }
  .login-wrap { grid-template-columns: 1fr; gap: 36px; }
}

/* ---------- tablet band ----------
   Nothing was ever designed between 560 and 960px. Card grids dropped
   straight from three or four columns to one, so an iPad in portrait was
   showing 704px-wide cards built for about 370px. Two columns across the
   band, and the section rhythm eases off with the narrower page. */
@media (min-width: 561px) and (max-width: 960px) {
  .programme-grid,
  .theme-grid,
  .path-grid,
  .facility-grid,
  .message-grid,
  .partner-grid,
  .partner-list-grid { grid-template-columns: repeat(2, 1fr); }
  .people-grid { grid-template-columns: repeat(2, 1fr); }
  .section { padding: 72px 0; }
  .section-image { max-height: 320px; }
  .hero { padding: 96px 0 80px; }
}

@media (max-width: 560px) {
  .wrap { padding: 0 20px; }
  .hero { padding: 72px 0 64px; }
  /* Panel rows track the wrap padding so the menu lines up with the page. */
  .mobile-nav-link { padding: 12px 20px; }
  .mobile-nav-sub a { padding: 10px 20px 10px 36px; }
.mobile-nav-sub a.is-sub { padding-left: 56px; }
  .mobile-nav-cta { margin: 24px 20px 0; }
  .partner-card-wide { grid-column: auto; }
  .section { padding: 64px 0; }
  .partner-grid { grid-template-columns: 1fr; }
  .footer-grid { grid-template-columns: 1fr; }
  .banner { flex-direction: column; align-items: flex-start; }
  .people-grid { grid-template-columns: 1fr; }
  .partner-list-grid { grid-template-columns: 1fr; }
}

/* Event gallery lightbox. No library, no third-party script. */
.lightbox {
  position: fixed; inset: 0; z-index: 999;
  background: rgba(10,10,12,0.94);
  display: none; align-items: center; justify-content: center;
}
.lightbox[open], .lightbox.is-open { display: flex; }
.lightbox img {
  max-width: 92vw; max-height: 82vh;
  border-radius: var(--radius); display: block;
}
.lightbox .lb-close, .lightbox .lb-prev, .lightbox .lb-next {
  position: absolute; background: none; border: 0; color: var(--white);
  font-size: 34px; line-height: 1; cursor: pointer; padding: 14px 18px;
}
.lightbox .lb-close { top: 8px; right: 12px; font-size: 30px; }
.lightbox .lb-prev { left: 6px; }
.lightbox .lb-next { right: 6px; }
.lightbox .lb-count {
  position: absolute; bottom: 18px; left: 0; right: 0; text-align: center;
  color: rgba(255,255,255,0.75); font-size: 13px;
}
@media (max-width: 560px) {
  .lightbox .lb-prev, .lightbox .lb-next { font-size: 28px; padding: 10px 12px; }
}

/* ---------- anchor targets ----------
   The header is sticky, so an in-page jump would otherwise land with the
   target sitting underneath it. Applied to sections and headings that carry
   an id rather than to [id] wholesale, so the lightbox and the SVG title
   nodes are untouched. */
main[id],
section[id],
.section[id],
h1[id], h2[id], h3[id], h4[id] {
  scroll-margin-top: calc(var(--header-h) + 24px);
}

/* ---------- reduced motion ----------
   scroll-behavior: smooth is set on html, with nothing to opt out of it.
   Honour the operating system setting instead. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}


/* ---------- list side panel (research-publications, people-phd-students) ----
   Behaviour lives in js/panel.js. The triggers are anchors to the PDF that
   the script upgrades, so everything here is presentation only and the page
   still works with the stylesheet loaded and the script missing.

   NOT named .num. The pages already use .num on the <td> that holds the
   number, and a bare .num rule here would restyle all 14 cells of the
   publications table. */

.list-trigger {
  font-weight: 700;
  color: var(--red-dark);
  text-decoration: none;
  border-bottom: 2px solid currentColor;
  padding: 0 1px;
}
.list-trigger:hover { background: #FDECEB; }

.list-scrim {
  position: fixed;
  inset: 0;
  background: rgba(16,16,16,0.35);
  opacity: 0;
  pointer-events: none;
  transition: opacity 220ms ease;
  z-index: 49;
}
.list-scrim.is-open { opacity: 1; pointer-events: auto; }

.list-panel {
  position: fixed;
  inset: 0 0 0 auto;
  /* R3-06: the drawer is 760px rather than 580px, so that every row in the
     PhD cohort tables sets on one line. KM's decision, one width for all
     content rather than per content type, with longer lines in the
     single-column lists accepted.

     730px IS THE FLOOR AND IT HAS ZERO SLACK. At 730 the binding cell is
     "University of Sri Jayewardenepura" in the 2022-completed cohort: it
     needs 198.0px and the column gives it exactly 198.0px. One pixel
     narrower and it wraps. 760 is the floor plus about 9.5px in that cell.

     The column grows at roughly 0.32px per 1px of panel, so margin here is
     expensive: a further 22px of panel buys about one more character. 760 is
     the point where the drawer still occupies about half a 1440px screen.

     DO NOT ROUND THIS TO 750. The binding value comes from the partner
     university list, which is fixed at six names, rather than from student
     names, which change every cohort. Exposure is low but not zero, and if a
     longer institution is ever added this figure needs re-deriving rather
     than nudging.

     min(..., 100%) is kept so phones are unaffected. */
  width: min(760px, 100%);
  background: var(--white);
  box-shadow: -8px 0 30px rgba(16,16,16,0.18);
  transform: translateX(100%);
  transition: transform 220ms ease;
  display: flex;
  flex-direction: column;
  z-index: 50;
}
.list-panel.is-open { transform: none; }
/* display:flex above overrides the user-agent [hidden]{display:none},
   so without this the drawer is never really hidden: it sits off-screen
   on a transform and can still take pointer events. */
.list-panel[hidden] { display: none; }

.list-panel .panel-head {
  padding: 16px 20px 14px;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}
.list-panel .panel-head .row { display: flex; align-items: flex-start; gap: 12px; }
.list-panel .panel-head h2 { font-size: var(--fs-h3); margin: 0; flex: 1; }
.list-panel .panel-close {
  font: inherit;
  font-size: var(--fs-h3);
  line-height: 1;
  background: none;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  cursor: pointer;
  color: var(--body);
  padding: 2px 9px;
}
.list-panel .panel-close:hover { background: var(--bg-soft); color: var(--ink); }

.panel-counts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; }
.cnt {
  font-size: var(--fs-micro);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  padding: 2px 7px;
  border-radius: 4px;
}
.cnt.ok  { background: #E8F2E8; color: #1D5B2A; }
.cnt.bad { background: #FDECEB; color: #A3160F; }
.cnt-note { font-size: var(--fs-micro); color: var(--muted); }

.list-panel .panel-body { overflow: auto; padding: 16px 20px 28px; }
.list-panel .panel-body .meta { font-size: var(--fs-small); color: var(--muted); margin: 0 0 14px; max-width: none; }

.cites { padding-left: 1.3em; margin: 0; }
.cites li { margin-bottom: 12px; font-size: var(--fs-small); line-height: 1.55; }

/* A DOI is one unbroken token of about 42 characters and 93% of the 143
   entries end in one. That, not the tables, is what overflows a 320px panel. */
.cite, .cites li, .doi { overflow-wrap: anywhere; }

.doi {
  display: inline-block;
  font-size: var(--fs-micro);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--navy);
  text-decoration: none;
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 1px 5px;
}
.doi:hover { background: var(--navy); color: var(--white); }
.nolink {
  display: inline-block;
  font-size: var(--fs-micro);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--muted);
  border: 1px dashed var(--border);
  border-radius: 4px;
  padding: 1px 5px;
}

table.lst { border-collapse: collapse; width: 100%; font-size: var(--fs-small); }
table.lst th, table.lst td {
  text-align: left;
  padding: 8px 10px;
  border-bottom: 1px solid var(--border);
  vertical-align: top;
}
table.lst th {
  font-size: var(--fs-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
}
table.lst tfoot td { border-top: 2px solid var(--ink); border-bottom: none; }

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

/* Below 560px the four-column tables stop being tables and stack, with each
   cell labelled from its data-label. The header row stays in the DOM, hidden
   visually, so it is still a table to assistive technology. */
@media (max-width: 560px) {
  table.lst thead {
    position: absolute; width: 1px; height: 1px;
    overflow: hidden; clip: rect(0 0 0 0);
  }
  table.lst, table.lst tbody, table.lst tr, table.lst td { display: block; width: 100%; }
  table.lst tr { border-bottom: 1px solid var(--border); padding: 8px 0; }
  table.lst td { border: none; padding: 2px 0 2px 42%; position: relative; }
  table.lst td::before {
    content: attr(data-label);
    position: absolute;
    left: 0;
    width: 38%;
    font-size: var(--fs-micro);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--muted);
  }
  table.lst tfoot td { border-top: 2px solid var(--ink); }
}
