/* Shared across every page: the language-switcher component, the full cross-document
   View Transitions setup, and the shared non-homepage layout container. Kept in one file
   (rather than duplicated per page) specifically so markup/CSS drift between pages can never
   desync the transition names or the page width — a mismatch here is exactly what causes an
   unmatched element to fall back to a full-page crossfade, or one inner page to silently drift
   out of alignment with the rest of the site. */

/* Roboto/Roboto Condensed — real .woff2 files in this same folder, loaded once and cached by the
   browser across every page. Previously each page embedded these same three fonts as base64 data
   URIs directly in its own inline <style> block: ~143KB of identical font data duplicated on
   every single page load, uncacheable since it wasn't a separate request. Moving them here (a)
   removes that per-page duplication, (b) lets the browser cache the file once for the whole site,
   and (c) drops ~25% of the previous byte weight, since base64 inflates binary size by ~33% and
   these are the actual binary files instead. url() paths below resolve relative to this CSS
   file's own location (assets/), so they work unchanged whether the including page is at the
   site root or under tc/ — no ../ path juggling needed here, unlike the HTML token-replace
   pattern used elsewhere in this project. */
@font-face{ font-family:'Roboto'; font-weight:400; font-display:swap; src:url(roboto-400.woff2) format('woff2'); }
@font-face{ font-family:'Roboto'; font-weight:700; font-display:swap; src:url(roboto-700.woff2) format('woff2'); }
@font-face{ font-family:'Roboto Condensed'; font-weight:700; font-display:swap; src:url(roboto-condensed-700.woff2) format('woff2'); }

/* Shared non-homepage layout container — every inner page (About/Contact/Projects/news/etc.)
   shares one content width and one responsive gutter. The homepage keeps its own marketing
   layout and does not use this system. */
:root{
  --inner-page-max:1040px;
  --inner-page-gutter:24px;
  /* The global header — and, as of 2026-08, the homepage's own content frame — share the
     inner-page content's gutter, but get their own named max-width token: same computed value
     (1040px content + 24px gutter each side = 1088px), kept separate so these components read as
     related but distinct contracts, not borrowing an unrelated component's variable by
     coincidence. Because this references --inner-page-gutter rather than a hard number, it still
     tracks the mobile override below automatically. */
  --site-frame-max: calc(var(--inner-page-max) + var(--inner-page-gutter) * 2);
}
@media (max-width:600px){ :root{ --inner-page-gutter:20px; } }

.inner-page-container{
  width: min(calc(100% - (var(--inner-page-gutter) * 2)), var(--inner-page-max));
  margin-inline: auto;
}

/* narrower reading measure for prose-heavy content (individual project descriptions).
   Nested as a child of .inner-page-container with margin-inline:0 so its left edge stays
   aligned with the breadcrumb/title above, rather than centring independently. */
.prose{ max-width:920px; margin-inline:0; }

/* Two intentional content measures inside the shared 1040px outer grid: a centred 920px
   reading column for continuous prose, and the full 1040px for modules that benefit from more
   horizontal space (project cards, tables, galleries). Both share the same central axis. */
.reading-container{ width:min(100%,920px); margin-inline:auto; }
.wide-module{ width:100%; }

/* About TESIP reads as continuous prose, so it uses the centred 920px reading column rather
   than the full 1040px width — scoped to .about-wrap specifically so pages that keep a
   different treatment (e.g. SMTP/PL's lead paragraph) are unaffected. */
.about-wrap .prose{ width:min(100%,920px); max-width:none; margin-inline:auto; }
.about-wrap blockquote.pull{ width:100%; max-width:none; }

/* non-homepage hero: breadcrumb + title form one centred, vertically compact group within the
   .page-header banner, independent of whichever width system the body content below uses. */
.page-header__content{
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  text-align:center;
}
.page-header .breadcrumb{ display:flex; justify-content:center; flex-wrap:wrap; }

/* Fixed layout track for the ordinary breadcrumb+<h1> hero (every page with a visible breadcrumb —
   see .page-header__content--breadcrumb on the markup side). Standard/.is-compact/.is-long each
   have a different font-size and line-height, and Traditional Chinese runs a taller line-height
   again on top of that, so the breadcrumb/title group's total content height — and therefore its
   vertical position inside the fixed-height .page-header banner — used to vary page to page purely
   because of which tier a given page's title happened to use. A 2-row grid fixes this: row 1 is
   the breadcrumb, a fixed 12px gap, then row 2 is a title track tall enough for the *tallest*
   normal single-line heading (measured off the standard tier's own line-height, including TC's
   taller value) via `minmax(track, auto)` — every single-line title, whichever tier it uses, sits
   in a track of the same height, so the breadcrumb above it never moves; a title that genuinely
   wraps to two lines is still free to grow the row via the `auto` in minmax(). Deliberately not a
   title-tier-specific transform/margin/padding compensation (rejected — that fixes today's known
   tiers but silently breaks again the next time a new tier or a two-line title is added; the grid
   track is what actually generalises). Never applies to the enrolment-hero-* component (used only
   by the three Qualtrics endpoint pages, which have no breadcrumb at all) — that component has its
   own self-contained translateY optical correction instead, see the enrolment-hero-* component
   note above. */
.page-header__content--breadcrumb{
  --hero-title-track: clamp(2.21rem, calc(1.625rem + 1.885vw), 2.99rem);
  display:grid;
  grid-template-rows: auto minmax(var(--hero-title-track), auto);
  row-gap:12px;
  align-items:center;
  justify-items:center;
  text-align:center;
}
.page-header__content--breadcrumb .breadcrumb{ margin:0; }

/* Breadcrumb typography — one shared rule for every item (parent links, and the current-page
   item where a section-index page renders it as plain text, marked semantically with
   aria-current="page" rather than a visual treatment) so nothing reads as bolder, darker, larger,
   or differently spaced than its neighbours; only the separator below is deliberately quieter.
   Previously duplicated byte-for-byte across 56 page-local <style> blocks (removed from all of
   them, 2026-08, in favour of this single definition) with a real inconsistency baked in: linked
   items were font-weight:600 while a plain-text current item had no explicit weight at all, so
   the two ends of the same trail read at different weights. Deliberately excludes the heavier
   Chinese heading tracking used by hero titles (see the hero-title tier system below) — a
   breadcrumb is wayfinding chrome, not a heading, and should stay visually quiet in both
   languages. */
.breadcrumb,
.breadcrumb a,
.breadcrumb [aria-current="page"]{
  font-family:var(--sans); font-size:0.82rem; font-weight:500; line-height:1.4;
  letter-spacing:normal; color:var(--primary-dark);
}
.breadcrumb a{ transition:color .15s ease; }
.breadcrumb a:hover, .breadcrumb a:focus-visible{ color:var(--primary); text-decoration:underline; text-underline-offset:3px; }
.breadcrumb a:focus-visible{ outline:2px solid var(--primary); outline-offset:2px; border-radius:2px; }
/* English breadcrumb text reads too light at the shared 500 weight, so it's bumped to 600 for
   English only (2026-08). Traditional Chinese keeps the base 500 — already correct at Noto Sans
   TC's weight — restated explicitly here rather than left as an implicit fallthrough, so a future
   change to the base rule can't silently drag Chinese along with an English-only adjustment. */
html[lang="en"] .breadcrumb,
html[lang="en"] .breadcrumb a,
html[lang="en"] .breadcrumb [aria-current="page"]{ font-weight:600; }
html[lang^="zh-Hant"] .breadcrumb,
html[lang^="zh-Hant"] .breadcrumb a,
html[lang^="zh-Hant"] .breadcrumb [aria-current="page"]{ font-weight:500; letter-spacing:normal; }

/* Separator: a real SVG chevron, not a text glyph — its size and stroke are controlled directly
   instead of riding on Roboto/Noto Sans TC/fallback-font glyph metrics, so the same chevron renders
   identically in both languages by construction rather than needing separate per-language tuning.
   Replaced the old plain "›" text glyph (2026-08), which read as too small/weak at every font-size
   and opacity combination tried. Kept the existing .crumb-sep class name — only its content and
   rules changed — rather than a `__separator` BEM-style rename, since it's already used
   consistently sitewide. translateY(0.01em) is a small optical nudge only, not a line-height
   change: the SVG itself does not affect `.breadcrumb`'s line-height since it's sized in em off the
   surrounding text, not given an explicit height that could exceed the line box. */
.breadcrumb .crumb-sep{
  display:inline-flex; align-items:center; justify-content:center; flex:0 0 auto;
  margin-inline:0.42em; color:currentColor; opacity:0.65; transform:translateY(0.01em);
}
.breadcrumb .crumb-sep svg{ display:block; width:0.8em; height:0.8em; overflow:visible; }
.breadcrumb .crumb-sep path{ fill:none; stroke:currentColor; stroke-width:2.1; stroke-linecap:round; stroke-linejoin:round; }

/* Hero title tier system — every inner-page hero <h1> is sized from here, never from page-local
   CSS. Sizing is a function of (a) hierarchy — how deep the page sits below the homepage — and
   (b) title length: the default (Standard) tier fits every section-index page (About/Contact/
   Enrolment/Current Projects/Past Projects/Case Sharing). .is-compact is one level deeper
   (programme detail pages, individual case-sharing pages, SMTP intention forms) or a title that
   would otherwise wrap awkwardly at Standard size. .is-long is for the small number of titles too
   long to read comfortably at .is-compact size. Adding a page-local font-size for .page-header h1
   defeats this system — pick a tier class instead, and add a new tier here (not on the page) if
   none fits.

   Each tier is ONE continuous calc()-based clamp() across every viewport width (2026-08) —
   previously a separate 601–1119px media-query override sat on top of these same three rules,
   which meant two independently-tuned curves met at 601px and 1120px with no guarantee they'd
   agree at either seam; a title could and did get smaller at 1120px than it was at 1119px. A
   single clamp() per tier can't produce that: its preferred value is a strictly increasing
   function of viewport width by construction, so width-vs-size monotonicity (never smaller on a
   wider viewport) holds everywhere between the floor and ceiling, with no breakpoint boundary to
   disagree across. The floors/ceilings are unchanged from the original tier values; only the
   preferred-value formula (now rem-plus-vw via calc(), not a bare vw multiple) was raised so the
   mid-range no longer sits close to the floor while the banner itself is still tall. */
.page-header h1{
  margin:0; text-align:center; font-weight:700;
  font-size:clamp(1.7rem, calc(1.25rem + 1.45vw), 2.3rem); line-height:1.25;
}
.page-header h1.is-compact{ font-size:clamp(1.5rem, calc(1.15rem + 1.45vw), 2.1rem); line-height:1.3; }
.page-header h1.is-long{ font-size:clamp(1.3rem, calc(1rem + 1vw), 1.85rem); line-height:1.35; }

/* Traditional Chinese heading tracking: bold Noto Sans TC's dense, full-width glyphs read as
   slightly compressed at these sizes, so a restrained letter-spacing opens them up without
   touching weight or size. Scoped narrowly to specific heading elements only (hero <h1>, project
   listing-card titles, case-sharing article titles) — deliberately NOT a blanket
   html[lang^="zh-Hant"]{letter-spacing:...} rule, which would loosen body copy, nav labels,
   buttons, metadata and dates along with it. English pages are untouched; font-feature-settings
   ("pwid" proportional glyph widths) is deliberately avoided too, since it can space punctuation
   and mixed Chinese/English content inconsistently. TC line-heights run slightly taller than EN
   at every tier since full-width glyphs need more breathing room. */
html[lang^="zh-Hant"] .page-header h1{ letter-spacing:0.04em; line-height:1.3; }
html[lang^="zh-Hant"] .page-header h1.is-compact{ line-height:1.35; }
html[lang^="zh-Hant"] .page-header h1.is-long{ line-height:1.4; }

/* Trailing school-year qualifier inside a hero <h1> (e.g. "Programme Name (2026/27)") — reduces
   just the year string via a genuine font-size step so it reads as secondary to the programme
   name, never the whole title. Inherits the heading's own font stack/weight/colour; only sizing
   is touched. white-space:nowrap keeps the year intact if it wraps to its own line at narrow
   widths; vertical-align nudges it back up to the programme-name baseline since a smaller
   font-size otherwise sits low. Not scaleY()/scale() — a real size change, not a distortion. */
.hero-title__year{
  display:inline-block; white-space:nowrap;
  font-size:0.84em; line-height:1; letter-spacing:0; vertical-align:0.04em;
}
/* Enrolment-form hero component — shared by every individual SMTP enrolment page (Foundation/
   Advanced/School Supervisor, EN+TC) that hosts a real Qualtrics iframe. Deliberately its own
   self-contained system rather than a use of the .page-header h1 tier system above: it needs a
   flex layout (for the segment/column-gap spacing model below) and its own font-size curve, both
   of which the tier system has no equivalent for. Ported from the standalone corporate-leader
   intention-form pages, where this system was originally developed and proven, then generalised
   here so the live site's own enrolment pages and any future ones share one definition instead of
   drifting apart per page. Every selector that touches the <h1> is deliberately over-specified
   (`.page-header h1.enrolment-hero-title`, not just `.enrolment-hero-title`) so it reliably beats
   the plain `.page-header h1` / TC tracking rules above regardless of source order — a bare class
   selector has lower specificity than that descendant selector and would silently lose the
   cascade otherwise. */
.enrolment-hero-heading{ width:min(100%, 920px); margin-inline:auto; text-align:center; transform:translateY(4px); }
.enrolment-hero-eyebrow{ margin:0 0 10px; font-size:0.9rem; font-weight:600; line-height:1.4; color:var(--primary); }
.page-header h1.enrolment-hero-title{
  margin:0; display:flex; align-items:baseline; justify-content:center; flex-wrap:wrap; column-gap:0.22em;
  font-family:var(--display); font-size:clamp(1.8rem, 3vw, 2.35rem); font-weight:700;
  line-height:1.18; color:var(--primary-dark); text-align:center; text-wrap:balance;
}
.enrolment-hero-title__segment{ display:block; }
.enrolment-hero-type{ margin:12px 0 0; font-size:1rem; font-weight:600; line-height:1.4; color:var(--ink-muted); }
/* Marks the outer .page-header as an enrolment hero (vs. the plain breadcrumb+h1 hero other
   inner pages use) so the min-height:0/content-driven-height mobile override below can target
   only these pages, not every .page-header sitewide. */
@media (max-width:700px){
  .page-header.enrolment-page-header{ min-height:0; padding-block:22px; }
  .enrolment-hero-heading{ width:100%; transform:translateY(3px); }
  .enrolment-hero-eyebrow{ margin-bottom:8px; font-size:0.8rem; }
  .page-header h1.enrolment-hero-title{
    flex-direction:column; align-items:center; row-gap:0; column-gap:0;
    font-size:clamp(1.45rem, 5.5vw, 1.7rem); line-height:1.16;
  }
  .enrolment-hero-type{ margin-top:8px; font-size:0.9rem; line-height:1.35; }
}
html[lang^="zh-Hant"] .page-header h1.enrolment-hero-title{ line-height:1.3; letter-spacing:0.04em; }
@media (max-width:700px){
  html[lang^="zh-Hant"] .page-header h1.enrolment-hero-title{
    font-size:clamp(1.5rem, 5.8vw, 1.72rem); line-height:1.22; letter-spacing:0.02em;
  }
}
/* Reusable hook for a future Chinese title whose first segment ends in a closing corner bracket
   (「…」) — the bracket already carries its own optical space, and the shared column-gap:0.22em
   above would double up with it. No current page needs this (none of the three live titles use a
   quoted segment), but it's kept ready rather than re-derived later: add
   enrolment-hero-title--tight-after-quote to the <h1> alongside enrolment-hero-title when that
   day comes. Never a ::before pseudo-element or a fixed em-width — those were tried on the
   standalone pages first and produced a gap that combined with the HTML whitespace text node
   between the spans and TC letter-spacing, growing instead of shrinking; column-gap is immune to
   both since flex gap ignores inter-element whitespace entirely. */
html[lang^="zh-Hant"] .page-header h1.enrolment-hero-title--tight-after-quote{ column-gap:0; }

/* .project-card__content h2 is used on the Ongoing/Past Projects listing pages; h3 is the
   homepage's own copy of the same card component — both need the (more restrained, since these
   run smaller and card widths are fixed) tracking. */
html[lang^="zh-Hant"] .project-card__content h2,
html[lang^="zh-Hant"] .project-card__content h3{ letter-spacing:0.025em; }
html[lang^="zh-Hant"] .sharing-topic{ letter-spacing:0.03em; }

/* Global content frame (2026-08): the same horizontal frame for the header on every page, and
   now also for the homepage's own content — hero, video, stats band, programme section, project
   cards, news, and footer all wrap in the same .wrap class, so one rule aligns all of them with
   the header in one move. Previously the header was scoped to body.inner-page only (leaving the
   homepage's header measurably wider than every inner page) and the homepage separately defined
   its own .wrap at 1120px page-locally; both are gone now in favour of this single unconditional
   rule. body.home-page is a dedicated body class (added 2026-08) so this only ever widens/narrows
   .wrap on the homepage — inner pages' own .wrap usage (header, footer) is untouched by this
   selector and continues to be resolved by the more specific rules already in place for those
   (the footer's own body.inner-page-scoped width below, in particular, is deliberately left
   alone — a separate component, out of scope for this change). */
.site-header .header-inner,
body.home-page .wrap{
  max-width: var(--site-frame-max);
  margin-inline: auto;
  padding-inline: var(--inner-page-gutter);
}
body.inner-page .site-footer > .wrap{
  max-width: calc(var(--inner-page-max) + var(--inner-page-gutter) * 2);
}

/* Current Projects introduction: uses the full inner-page width rather than an independently
   narrower measure, so the English copy can sit on one line at normal desktop widths. */
.projects-intro{ width:100%; max-width:none; margin:0 0 40px; text-align:left; }

/* Reusable Past Project detail-page template: a main content column plus a restrained metadata
   sidebar, collapsing to one column at narrower widths. Prototyped on the "Reflection and
   Practice of Professional Leadership" page; classes are deliberately generic (not tied to any
   one project) so future Past Project pages can reuse this CSS without duplicating it. This is
   a distinct system from the Ongoing Project template — the two are kept technically separable. */
.project-detail-grid{
  display:grid; grid-template-columns:minmax(0,1fr) minmax(220px,260px);
  gap:52px; align-items:start;
}

.project-detail-content p{
  font-size:1.02rem; color:var(--ink); margin:0 0 20px; line-height:1.75;
  text-align:justify; text-align-last:left;
  -webkit-hyphens:none; -ms-hyphens:none; hyphens:none;
  overflow-wrap:normal; word-break:normal;
}
html[lang="zh-Hant"] .project-detail-content p{ line-height:1.9; }

/* Section subheadings within the article column (2026-08, added when the first Past/PL-template
   page grew from two plain paragraphs to a proper H2 outline — see the project documentation §6). Quiet by
   design: sized and coloured as a step up from body text, not a second h1 — the page's own h1
   still carries the primary heading weight. No top margin on the first h2 (it's the article's
   own first child); subsequent h2s get a deliberately larger top margin than a paragraph's own
   bottom margin so the two collapse to a clear, consistent gap between sections regardless of
   how many paragraphs precede them. */
.project-detail-content h2{
  font-size:1.15rem; color:var(--primary-dark); margin:0 0 10px;
}
.project-detail-content h2:not(:first-child){ margin-top:32px; }

/* sidebar reads as part of the page architecture, not a separate card — a soft faded indigo
   rail (a pseudo-element, not a hard border) rather than a solid grey table-like divider;
   generous internal spacing, small muted labels, normal-weight values, no tinted background,
   border or shadow around the whole sidebar. align-self:start (with .project-detail-grid's own
   align-items:start) keeps the <aside> from stretching to the full row height, so the rail's
   ::before can size itself to the metadata content rather than the taller article column. */
.project-meta{ align-self:start; padding-left:0; padding-bottom:24px; }
/* the rail is attached to the <dl> (not .project-meta) so its solid section is based on the
   actual metadata-content height: the pseudo-element is deliberately --meta-rail-fade taller
   than the <dl>, so the solid colour runs the full height of the real content and the fade
   happens entirely in the extra space below the last line — never across it, never beyond it. */
.project-meta dl{
  --meta-rail-fade: 24px;
  position:relative; margin:0; padding-left:28px;
}
.project-meta dl::before{
  content:""; position:absolute; pointer-events:none;
  top:0; left:0;
  width:2px; height:calc(100% + var(--meta-rail-fade));
  border-radius:999px;
  background:linear-gradient(to bottom,
    rgba(48,58,163,0.28) 0,
    rgba(48,58,163,0.28) calc(100% - var(--meta-rail-fade)),
    rgba(48,58,163,0) 100%);
}
.project-meta dl > div + div{ margin-top:30px; }
.project-meta dt{
  margin:0 0 6px; color:var(--ink-faint); font-size:0.72rem; font-weight:700;
  line-height:1.4; letter-spacing:0.06em; text-transform:uppercase;
}
.project-meta dd{ margin:0; color:var(--ink); font-size:0.95rem; line-height:1.6; }
html[lang="zh-Hant"] .project-meta dt{ letter-spacing:0.03em; text-transform:none; }

@media (max-width:760px){
  .project-detail-grid{ grid-template-columns:1fr; gap:28px; }
  .project-meta{
    order:-1; padding:20px 22px;
    border:1px solid var(--border); border-radius:14px;
    background:transparent; box-shadow:none;
  }
  .project-meta dl{ display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:20px; padding-left:0; }
  .project-meta dl::before{ display:none; }
  .project-meta dl > div + div{ margin-top:0; }
}
@media (max-width:480px){
  .project-meta dl{ grid-template-columns:1fr; }
  .project-meta dl > div + div{ margin-top:18px; }
}

/* Active nav state for every page under Projects (Current Projects listing, Past Projects
   listing, and every project-detail page) now uses the exact same mechanism as every other
   top-level item: a class="current" on the active element, plus a real nested
   <span class="nav-active-indicator"> inside .nav-dropdown-text (not a pseudo-element, and not
   a border-bottom) so there is only ever one underline, it spans only the text, and it shares
   one view-transition-name with every other page's indicator for cross-document morphing. A
   previous version scoped this via <body class="section-projects"> and a ::after pseudo-element
   on .nav-dropdown-label — that produced a second, longer underline whenever the dropdown's own
   hover/focus-within border-bottom was active at the same time as the section-level state. Both
   the border-bottom mechanism and the body-class scoping have been removed. The Projects active
   state is handled centrally, here in shared.css, via .nav-dropdown-label.current (below) plus
   the same .nav-active-indicator span every other top-level item uses (see nav.primary-nav > a
   .current above) — no page template defines its own copy of either selector, confirmed by a
   2026-08 stocktake across every template in revamped-tesip-web (template)/. This shared pair is
   the established baseline; a page-level override reintroducing either selector would itself be
   the regression, not a variation to reconcile with. */

/* reusable "Back to …" link for detail-page templates: a contextual text link (not a button)
   with a lightweight SVG chevron rather than an arrow character, so weight and alignment stay
   consistent across fonts/OSes/languages. Reused as-is when this becomes "Back to Past
   Projects"／「返回過往項目」 — only the link text and destination change per page. This is the
   single shared implementation: a page only ever needs to add whichever content-width wrapper it
   belongs to and its own vertical margin (see e.g. .case-detail-shell > .back-link,
   .form-content-guide .back-link) — never its own copy of the icon-positioning rules. Confirmed via
   a sitewide stocktake (2026-08) that no page-level CSS anywhere reproduces or overrides
   .back-link__icon's position — every proj-*.html, case-sharing-*.html (via .case-detail-shell)
   and enrolment-smtp*.html (via .form-content-guide) only ever adds margin/wrapper-width rules on
   top of this shared implementation.

   Base/default (2026-08, superseding the earlier "desktop default + mobile override" structure):
   the SAFE IN-FLOW arrangement below is now the unconditional default at every width — icon and
   label as two ordinary flex children with an 8px gap, starting wherever the anchor naturally sits
   in the surrounding content flow. This one arrangement covers mobile, tablet AND narrow desktop;
   there is no longer a separate "mobile-only" rule; the wide-desktop, text-aligned, gutter-hanging
   treatment is instead layered on top ONLY at (min-width:1120px) below.

   Why 1120px and not the 600px mobile breakpoint: the desktop treatment hangs the icon
   (position:absolute, right:100%) roughly 23px (15px icon + 8px gap) to the LEFT of the anchor's
   own box, entirely outside whatever content column the anchor sits in. That overhang needs an
   outer gutter wide enough to absorb it safely — the shared inner-page content column caps out at
   1040px (--inner-page-max) with a 24px gutter each side above 600px, so the container reaches its
   max width (and therefore has genuine SPARE outer margin, not just its minimum gutter) only once
   the viewport is meaningfully wider than 1040px + 24px×2 = 1088px. 1120px was chosen as a round
   number just past that point — enough spare margin for the icon overhang plus a comfortable safety
   margin to the viewport edge, without being so large that ordinary laptop-width viewports (which
   the OLD >600px rule incorrectly treated as "desktop enough") get an edge-hugging chevron. Below
   1120px, exact desktop text-column alignment is a deliberately accepted trade against the icon
   ever coming close to the viewport edge.

   Long-label wrapping: if a label wraps to a second line at in-flow widths, align-items:center
   (used below) centres the icon against the whole wrapped block rather than just its first line,
   which can look odd. Not applied pre-emptively — only add it for a label actually confirmed (by
   real testing, not guesswork) to wrap:
     .back-link{ align-items:flex-start; }
     .back-link__icon{ margin-top:0.18em; }
   "Back to Structured Manager Training Programme"／"返回有系統校董培訓課程" (the enrolment-smtp*.html
   back link) is the longest label on the site by a wide margin and the most likely candidate to
   need this — worth checking first, at in-flow widths only (it would never wrap at ≥1120px, where
   the icon no longer shares a line box with the text at all). */
.back-link{
  display:inline-flex; align-items:center; gap:8px;
  min-height:44px; /* comfortable tap target covering both icon and text, per WCAG target-size guidance */
  color:var(--primary); font-size:0.88rem; font-weight:600; line-height:1.4;
  text-decoration:none; margin-bottom:24px;
}
.back-link__icon{
  position:static; width:15px; height:15px; margin:0; transform:none;
  flex:none;
  fill:none; stroke:currentColor; stroke-width:1.8;
  stroke-linecap:round; stroke-linejoin:round;
  transition:transform 160ms ease;
}
.back-link span{
  text-decoration:underline; text-decoration-color:transparent; text-underline-offset:3px;
  transition:text-decoration-color 160ms ease;
}
.back-link:hover .back-link__icon{ transform:translateX(-1px); }
.back-link:hover span{ text-decoration-color:currentColor; }
.back-link:focus-visible{ outline:2px solid rgba(48,58,163,0.42); outline-offset:5px; border-radius:3px; }

@media (min-width:1120px){
  .back-link{ position:relative; gap:0; min-height:0; }
  .back-link__icon{ position:absolute; right:100%; top:50%; margin-right:8px; transform:translateY(-50%); }
  .back-link:hover .back-link__icon{ transform:translateY(-50%) translateX(-2px); }
}

@media (prefers-reduced-motion:reduce){
  .back-link__icon, .back-link span{ transition:none; }
  .back-link:hover .back-link__icon{ transform:none; }
}
@media (min-width:1120px) and (prefers-reduced-motion:reduce){
  .back-link:hover .back-link__icon{ transform:translateY(-50%); }
}

/* Branded fallback for project cards with no dedicated thumbnail (or whose thumbnail fails to
   load): full-bleed gradient colour blocks with the TESIP symbol as a faint central watermark.
   Rendered as a real <img> rather than a CSS mask — mask-image referencing an external SVG is
   unreliable (and fails outright in some browsers) when the page is opened via file:// instead
   of served over http(s), which is how these pages get previewed locally. filter:brightness(0)
   invert(1) turns the source artwork white regardless of its own fill colour, so the same
   tesip-logo-no_text-grey.svg asset serves every gradient variant, unmodified.
   .project-placeholder carries its own grid-area:1/1 so it shares the same cell as the real
   thumbnail (.project-card__media > .project-card__thumbnail, defined per page) without either
   element needing `position`. That matters: a `position`-ed element always paints above
   non-positioned siblings regardless of DOM order, so giving the placeholder `position:relative`
   (an earlier version of this rule did) permanently hid the real thumbnail behind it even after
   it loaded successfully — only an actually-failed <img> (via onerror) should ever reveal it. */
.project-placeholder{
  grid-area:1/1; display:grid; place-items:center;
  width:100%; height:100%; overflow:hidden;
}
.project-placeholder__mark{
  display:block;
  width:clamp(92px,30%,132px); max-width:38%; max-height:48%; height:auto;
  object-fit:contain; object-position:center;
  opacity:0.20;
  filter:brightness(0) invert(1);
  pointer-events:none; user-select:none;
}

/* Only two variants remain in the component system for now — indigo and amber. An earlier
   green and a blue-orange "mixed" variant were removed as part of narrowing the approved
   palette; re-add future variants here (not as page-specific CSS) if the palette expands again. */
.project-placeholder--indigo{
  background:
    radial-gradient(circle at 18% 20%, rgba(255,255,255,0.16), transparent 34%),
    linear-gradient(135deg, #7889d7 0%, #4d5fbb 52%, #2e3994 100%);
}
.project-placeholder--amber{
  background:
    radial-gradient(circle at 22% 78%, rgba(255,255,255,0.18), transparent 34%),
    linear-gradient(135deg, #f3c66d 0%, #e9a04e 52%, #c9754c 100%);
}

/* Global navigation header — logo, primary nav, "Projects" dropdown, mobile toggle. Single
   canonical definition for the whole site (2026-08 audit): this block used to be duplicated
   verbatim inside every page's own <style>, which let real drift creep in unnoticed — e.g. three
   Traditional Chinese intention-form pages ended up with nav.primary-nav > a at font-weight:600
   instead of the TC-wide 500, and the "Projects" submenu's current-item highlight existed in two
   different forms on two pages and was missing everywhere else. Centralising here means every
   page renders this component identically by construction, not by discipline. Do not add a
   page-local override for any selector below — a genuinely page-specific need almost never
   applies to the global header; extend this block instead. */
header.site-header{ border-bottom:1px solid var(--border); background:#fff; position:sticky; top:0; z-index:40; }
/* .site-header .header-inner (not bare .header-inner): every one of the 56 inner-page templates
   defines its own page-local `.wrap{ padding:0 24px; ... }` for its own content column, and
   `.header-inner` also carries the `wrap` class for its horizontal width. That local rule is a
   plain class selector at the same specificity as a bare `.header-inner{...}` here, declared later
   in the document (the page's own <style> always follows the shared.css <link>) — so on every
   inner page it silently won the cascade and zeroed the header's own padding-top/padding-bottom,
   while the homepage (the only template with no local `.wrap` rule of its own) rendered the
   intended padding untouched. This is what actually caused the homepage header to look taller than
   every inner-page header (2026-08 bug, found via a full stocktake) — not a homepage-only
   override, the opposite: an inner-page-only collision. Fixed the same way the width rule below
   already protects itself — `.site-header .header-inner` (two classes, specificity 0,2,0) outranks
   a bare `.wrap{padding:...}` (one class, 0,1,0) regardless of source order, so this can never
   regress silently again even if a future template's local reset changes shape. */
.site-header .header-inner{ display:flex; align-items:center; justify-content:space-between; padding-block:22px; gap:16px; min-height:100px; }
.brand{ display:flex; align-items:center; gap:10px; flex:0 1 auto; min-width:0; }
.brand img{ height:56px; width:auto; max-width:100%; -webkit-user-drag:none; user-select:none; }
nav.primary-nav{ display:flex; align-items:center; gap:28px; flex-wrap:nowrap; }
nav.primary-nav > a{ position:relative; font-size:0.92rem; font-weight:600; color:var(--ink-muted); padding:6px 0; border-bottom:2px solid transparent; white-space:nowrap; line-height:1; }
nav.primary-nav > a:hover{ color:var(--primary); border-color:var(--primary); }
nav.primary-nav > a.current{ color:var(--primary); font-weight:700; }
/* dedicated indicator element (not the border-bottom above) so the cross-document View
   Transitions API can move/resize it independently of the label text between page loads */
.nav-active-indicator{ position:absolute; left:0; right:0; bottom:0; height:2px; background:var(--primary); border-radius:1px; view-transition-name:nav-active-indicator; }
.nav-toggle{ display:none; }
.nav-toggle-btn{ display:none; }

.nav-dropdown{ position:relative; }
.nav-dropdown::after{
  content:"";
  position:absolute;
  z-index:9;
  top:100%;
  left:-10px;
  right:-10px;
  height:12px;
}
.nav-dropdown-label{
  cursor:default; font-size:0.92rem; font-weight:600; color:var(--ink-muted);
  display:flex; align-items:center; gap:6px;
  white-space:nowrap; line-height:1;
}
.nav-dropdown-text{ position:relative; display:inline-flex; padding:6px 0; line-height:1; }
.nav-dropdown-label .nav-chevron{ transition:transform .15s ease; flex:0 0 auto; }
.nav-dropdown:hover .nav-dropdown-label, .nav-dropdown:focus-within .nav-dropdown-label{ color:var(--primary); }
.nav-dropdown-label.current{ color:var(--primary); font-weight:700; }
.nav-dropdown:hover .nav-chevron, .nav-dropdown:focus-within .nav-chevron{ transform:rotate(180deg); }
.nav-submenu{
  list-style:none; margin:0; padding:8px; position:absolute; top:calc(100% + 10px); left:0;
  background:#fff; border:1px solid var(--border); border-radius:10px;
  box-shadow:0 14px 30px rgba(20,20,40,0.12); min-width:190px; z-index:10;
  opacity:0; visibility:hidden; pointer-events:none; transform:translateY(-6px);
  transition:opacity .18s ease, transform .18s ease, visibility 0s linear .14s;
}
.nav-dropdown:hover .nav-submenu, .nav-dropdown:focus-within .nav-submenu{
  opacity:1; visibility:visible; pointer-events:auto; transform:translateY(0);
  transition:opacity .18s ease, transform .18s ease, visibility 0s linear 0s;
}
.nav-submenu a{ display:block; padding:9px 10px; border-radius:6px; font-size:0.88rem; font-weight:600; color:var(--ink); }
.nav-submenu a.current{ background:rgba(40,53,154,0.08); color:var(--primary); }
.nav-submenu:has(a:not(.current):hover) a.current,
.nav-submenu:has(a:not(.current):focus-visible) a.current{ background:transparent; color:var(--ink); }
.nav-submenu a:hover,
.nav-submenu a:focus-visible{ background:var(--primary-soft); color:var(--primary); }

@media (max-width:1180px){ nav.primary-nav{ gap:18px; } }

/* Actual rendered mobile header height (padding-block:26px×2 + 52px content, the taller of the
   52px logo/38px burger button, box-sizing:border-box already folds the padding in + 1px
   border-bottom) — used to size the burger-menu panel and backdrop below without hard-coding that
   arithmetic in three different places. A static value rather than a JS-measured one deliberately:
   the mobile header's own box model is fixed by the CSS immediately above/below, not by variable
   content, so there is nothing here a measurement would catch that this value doesn't already
   predict. Update this alongside any future change to the mobile .header-inner padding/content
   height (see assets/nav-overlay.js, which relies on this same value for its scroll-lock header
   placeholder sizing only indirectly, via the real getBoundingClientRect() of the header itself —
   only the CSS max-height/backdrop-inset calculations below actually read this variable). */
:root{ --mobile-header-height:105px; }

@media (max-width:1024px){
  /* Whole-panel open/close (2026-08 rework): driven entirely by the JS-toggled .is-open class on
     nav.primary-nav itself now, not the `.nav-toggle:checked ~ nav.primary-nav` sibling selector —
     assets/nav-overlay.js is the one authoritative source of truth for open/closed state (see its
     setMobileNavigationOpen()), and mixing a CSS-only trigger back in alongside it is exactly the
     "two systems, one bug" shape that let the backdrop/scroll-lock/history desync in the first
     place. No separate height/max-height animation competes with the Projects submenu's own grid
     animation below — the panel is simply `height:auto` and lets its content (including the
     currently-expanded-or-not submenu) determine its natural height each frame; only opacity/
     transform/visibility are transitioned here. Collapse (base rule) is quicker with an
     accelerating curve; expand (.is-open) is slightly slower with a decelerating curve — same
     asymmetric-easing rationale as the submenu animation below. */
  nav.primary-nav{
    position:absolute; top:100%; left:0; right:0; background:#fff; border-bottom:1px solid var(--border);
    flex-direction:column; align-items:flex-start; box-shadow:0 12px 24px rgba(20,20,40,0.08);
    z-index:901; height:auto; max-height:0; overflow:hidden; padding-block:0;
    opacity:0; visibility:hidden; pointer-events:none; transform:translateY(-6px);
    transition:max-height 210ms cubic-bezier(0.4,0,1,1), padding 210ms cubic-bezier(0.4,0,1,1),
      opacity 160ms ease, transform 210ms cubic-bezier(0.4,0,1,1), visibility 0s linear 210ms;
  }
  nav.primary-nav > a{ width:100%; padding:10px 0; border-bottom:1px solid var(--border); }
  /* No more fixed 420px cap (2026-08) — that clipped Enrolment/Contact us once the Projects
     submenu was open and pushed the panel's natural content height past it. max-height is capped
     at the viewport height actually available below the header, and the panel becomes
     independently scrollable (overflow-y:auto) once content exceeds that — never the page behind
     it (see html.mobile-nav-open/body.mobile-nav-open below). 100dvh is preferred over 100vh where
     supported since mobile browser chrome (address bar show/hide) changes the visible viewport
     height; the plain 100vh declaration first is a harmless fallback for browsers without dvh
     support, immediately overridden by the dvh line on any browser that has it. */
  nav.primary-nav.is-open{
    max-height:calc(100vh - var(--mobile-header-height));
    max-height:calc(100dvh - var(--mobile-header-height));
    padding:8px 24px 16px; overflow-y:auto; overscroll-behavior:contain; -webkit-overflow-scrolling:touch;
    opacity:1; visibility:visible; pointer-events:auto; transform:translateY(0);
    transition:max-height 260ms cubic-bezier(0.22,1,0.36,1), padding 260ms cubic-bezier(0.22,1,0.36,1),
      opacity 200ms ease, transform 260ms cubic-bezier(0.22,1,0.36,1), visibility 0s linear 0s;
  }
  .nav-toggle-btn{
    display:inline-flex; align-items:center; justify-content:center; width:38px; height:38px; border-radius:10px;
    border:1px solid var(--border); background:#fff; cursor:pointer; flex:0 0 auto;
  }
  /* 22px -> 26px (2026-08): three lines of bilingual logo text read as slightly cramped between
     the viewport edge and the hero banner at mobile widths. Logo size, burger size, and
     horizontal alignment are all untouched — only the vertical breathing room around them grows.
     .site-header .header-inner (not bare .header-inner) for the same reason as the base rule
     above — otherwise a page-local `.wrap{padding:0 24px}` reset silently zeroes this padding on
     every inner page at exactly the width this override is meant to apply at. */
  .site-header .header-inner{ position:relative; flex-wrap:nowrap; padding-block:26px; min-height:98px; }
  .brand img{ height:52px; }
  .nav-dropdown{ width:100%; border-bottom:1px solid var(--border); }
  .nav-dropdown::after{ display:none; }
  .nav-dropdown-label{ width:100%; padding:10px 0; }
  /* Collapsed-by-default Projects submenu (2026-08), animated on a CSS-grid row rather than
     height:0 <-> height:auto (unreliable to transition directly) — a technique that needs a
     dedicated wrapper between .nav-dropdown and the actual <ul>, so assets/nav-overlay.js creates
     .mobile-submenu-shell at runtime around the existing (unmodified) <ul class="nav-submenu">
     rather than requiring a markup change across every one of the 56+ page templates. On desktop
     this shell has no styling at all (only ever styled inside this mobile media query), so it
     cannot affect .nav-submenu's existing absolute-positioned dropdown-panel appearance there —
     .nav-submenu's own `position:absolute` continues to resolve against .nav-dropdown (its nearest
     positioned ancestor), not this plain, unpositioned wrapper. grid-template-rows: 0fr -> 1fr is
     what actually animates to the content's natural height with no hard-coded pixel value; the
     inner element needs `min-height:0; overflow:hidden` so it can genuinely collapse to zero rather
     than a 1fr *row* still reporting the content's own intrinsic minimum size. Asymmetric easing to
     match the outer panel: expand decelerates (cubic-bezier(0.22,1,0.36,1), 260ms — "quick start,
     smooth finish"), collapse accelerates and is faster (cubic-bezier(0.4,0,1,1), 210ms). */
  .mobile-submenu-shell{
    display:grid; grid-template-rows:0fr; opacity:0;
    transition:grid-template-rows 210ms cubic-bezier(0.4,0,1,1), opacity 130ms ease-in;
  }
  .mobile-submenu-shell > .nav-submenu{ min-height:0; overflow:hidden; }
  .nav-dropdown.is-expanded .mobile-submenu-shell{
    grid-template-rows:1fr; opacity:1;
    transition:grid-template-rows 260ms cubic-bezier(0.22,1,0.36,1), opacity 180ms ease-out;
  }
  .nav-submenu{ opacity:1; visibility:visible; pointer-events:auto; transform:none; position:static; box-shadow:none; border:none; padding:0 0 8px 12px; margin:0; }
  /* Rotates in sync with the submenu's own expand/collapse curve above — a shared 230ms
     decelerating curve for both directions is close enough that the chevron never visibly finishes
     much earlier/later than the panel, without needing its own separate expand-vs-collapse split. */
  .nav-chevron{ transform:rotate(0deg); transition:transform 230ms cubic-bezier(0.22,1,0.36,1); }
  .nav-dropdown.is-expanded .nav-chevron{ transform:rotate(180deg); }
  .lang-pill{ margin-top:6px; }

  /* Scroll lock + header pin for the open burger menu — independent of (never shares state or
     class names with) the schedule modal's own copy in assets/schedule-modal.css/.js, so neither
     system can regress the other; see assets/nav-overlay.js's file header comment for the full
     Chrome position:sticky rationale this mirrors. */
  html.mobile-nav-open,
  body.mobile-nav-open{ overflow:hidden; overscroll-behavior:none; }
  .mobile-nav-header-placeholder{ width:100%; }
  header.site-header.mobile-nav-header-pinned{
    position:fixed !important; top:0; left:0; right:var(--mobile-nav-scrollbar-width, 0px); width:auto; z-index:40;
  }
  /* Sits inside header.site-header (see nav-overlay.js) so its z-index compares against
     nav.primary-nav's (901) within that element's own stacking context, rather than against the
     header's own z-index:40 as seen from outside it — a plain document.body-appended backdrop here
     would otherwise render *above* the nav panel it's meant to sit behind. inset's top offset is
     the same --mobile-header-height used for the panel's own max-height, so the backdrop begins
     exactly where the header ends rather than dimming the header itself. Explicit closed state
     (opacity:0 + visibility:hidden + pointer-events:none, not opacity alone — opacity:0 by itself
     still intercepts clicks) — .is-visible is added/removed by assets/nav-overlay.js, which also
     owns the hidden-attribute timing (hidden only becomes true again once this fade-out transition
     — or its JS timeout fallback — has actually finished, never eagerly on the same tick the class
     is removed), so a mid-fade backdrop is never both invisible-looking and still clickable. */
  .mobile-nav-backdrop{
    position:fixed; inset:var(--mobile-header-height) 0 0 0; z-index:900;
    background:rgba(15,17,35,0.32);
    opacity:0; visibility:hidden; pointer-events:none;
    transition:opacity 180ms ease, visibility 0s linear 180ms;
  }
  .mobile-nav-backdrop.is-visible{
    opacity:1; visibility:visible; pointer-events:auto;
    transition:opacity 180ms ease, visibility 0s linear 0s;
  }
  .mobile-nav-backdrop[hidden]{ display:none; }
}

/* Reduced motion: every mobile-nav transition above becomes an instant snap. JS never depends on a
   transition actually running to reach a correct end state — showBackdrop()/hideBackdrop() in
   assets/nav-overlay.js race a transitionend listener against a plain setTimeout fallback (the
   same "whichever fires first, exactly once" pattern schedule-modal.js already uses for its own
   open/close sequencing), so disabling these transitions here can never leave the backdrop, the
   submenu, or the outer panel stuck in a half-applied state. */
@media (prefers-reduced-motion:reduce){
  nav.primary-nav,
  .mobile-submenu-shell,
  .nav-chevron,
  .mobile-nav-backdrop{
    transition:none !important;
  }
}

/* Traditional Chinese nav labels read visually bolder than Latin script at the same numeric
   weight, so TC primary-nav/dropdown/submenu labels use one step lighter than the EN default
   above — this was already the site-wide intent (33 of 36 TC pages had it), just not enforced. */
html[lang^="zh-Hant"] nav.primary-nav > a,
html[lang^="zh-Hant"] .nav-dropdown-label,
html[lang^="zh-Hant"] .nav-submenu a{ font-weight:500; }

/* language switcher — one sliding indicator (z-index:1) sits behind two always-present labels
   (z-index:2); isolation:isolate gives the pill its own stacking context so those z-indexes are
   meaningful, and both labels stay in the DOM regardless of which language is active. */
.lang-pill{
  position:relative; display:grid; grid-template-columns:repeat(2,minmax(0,1fr));
  padding:3px; border-radius:999px; background:var(--bg-alt); isolation:isolate;
}
.lang-option{
  position:relative; z-index:2;
  min-width:0; padding:5px 14px;
  color:var(--ink-muted); text-align:center; white-space:nowrap; font-size:0.8rem; font-weight:700;
}
.lang-option.active{ color:#fff; }
.lang-active-indicator{
  position:absolute; z-index:1;
  top:3px; bottom:3px; left:3px;
  width:calc(50% - 3px); border-radius:999px; background:var(--primary);
  view-transition-name:language-active-indicator;
}
.lang-active-indicator.is-tc{ left:50%; }
.lang-option-en{ view-transition-name:language-label-en; }
.lang-option-tc{ view-transition-name:language-label-tc; }

/* cross-document View Transitions: pages stay separate static documents, but same-origin
   navigations between participating pages get a native browser-driven transition. */
@view-transition{ navigation:auto; }
@media (prefers-reduced-motion:reduce){ @view-transition{ navigation:none; } }

/* the root snapshot must not crossfade — only the elements below animate independently,
   so header/logo/nav text swap instantly with the rest of the page */
::view-transition-old(root), ::view-transition-new(root){ animation:none; }

/* nav active-underline: simple matched-group morph + short unmatched fade (only ever present
   on the page that has a current top-level nav item, so this rarely needs to disappear) */
::view-transition-group(nav-active-indicator){ animation-duration:240ms; animation-timing-function:cubic-bezier(.22,.8,.25,1); }
::view-transition-old(nav-active-indicator), ::view-transition-new(nav-active-indicator){ animation-duration:190ms; }

/* language-switch navigation only: the destination page's inline head script re-applies this
   class from sessionStorage before first paint, so the underline is excluded from the transition
   and simply appears in its correct static position instead of morphing from the old page's
   (different-language) underline position. Normal same-language navigation is unaffected. */
html.is-language-switching .nav-active-indicator{ view-transition-name:none; }

/* reduced motion: the indicator still appears in the correct static position (it's a real,
   normally-positioned element, not something that only exists via animation) — this only turns
   off the sliding morph and the chevron's rotation transition. */
@media (prefers-reduced-motion:reduce){
  .nav-active-indicator{ view-transition-name:none; }
  .nav-chevron{ transition:none; }
}

/* language indicator: geometry-only morph, no old/new crossfade of its own — two overlapping
   purple snapshots fading over each other is what was visually swallowing the labels */
::view-transition-group(language-active-indicator){ z-index:1; animation-duration:240ms; animation-timing-function:cubic-bezier(.22,.8,.25,1); }
::view-transition-old(language-active-indicator){ animation:none; opacity:0; }
::view-transition-new(language-active-indicator){ animation:none; opacity:1; }

/* language labels: explicitly stacked above the indicator group (ordinary DOM z-index doesn't
   apply across the View Transition top layer), with overlapping fades so the incoming label is
   already opaque before the outgoing one finishes fading — no zero-opacity interval either way */
::view-transition-group(language-label-en), ::view-transition-group(language-label-tc){ z-index:2; animation:none; }
::view-transition-old(language-label-en), ::view-transition-new(language-label-en),
::view-transition-old(language-label-tc), ::view-transition-new(language-label-tc){ mix-blend-mode:normal; }
::view-transition-old(language-label-en), ::view-transition-old(language-label-tc){ animation:language-label-old 140ms ease-out both; }
::view-transition-new(language-label-en), ::view-transition-new(language-label-tc){ animation:language-label-new 180ms ease-in both; }
@keyframes language-label-old{ from{ opacity:1; } to{ opacity:0; } }
@keyframes language-label-new{ from{ opacity:0; } 35%{ opacity:1; } to{ opacity:1; } }

/* Enrolment pages: a lightweight text-only card for routing choices (which programme / which
   sub-programme to enrol in) — deliberately separate from .project-card, which always reserves
   a media column. Reused across the enrolment root page and the SMTP enrolment hub. The whole
   card is a single <a> (matching .project-card's convention) so keyboard focus, new-tab and
   screen-reader behaviour all work like any other link; a card with no destination yet uses a
   plain, non-interactive <div> instead (see .option-card--pending). */
.option-list{ display:flex; flex-direction:column; gap:20px; }
.option-card{
  display:flex; flex-direction:column; align-items:flex-start; gap:10px;
  color:inherit; text-decoration:none;
  background:#fff; border:1px solid #e8eaf2; border-radius:16px; padding:28px 32px;
  box-shadow:0 3px 14px rgba(23,28,61,0.035);
  transition:transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
a.option-card:hover{ transform:translateY(-2px); border-color:rgba(48,58,163,0.32); box-shadow:0 10px 26px rgba(23,28,61,0.08); }
a.option-card:hover .option-card__cta, a.option-card:focus-visible .option-card__cta{ background:var(--primary-dark); }
a.option-card:focus-visible{ outline:3px solid rgba(48,58,163,0.35); outline-offset:3px; }
@media (prefers-reduced-motion:reduce){ .option-card{ transition:none; } a.option-card:hover{ transform:none; } }
.option-card__period{
  display:inline-flex; align-self:flex-start; padding:4px 10px; border-radius:999px;
  background:var(--primary-soft); color:var(--primary); font-size:0.72rem; font-weight:700;
}
.option-card h2{ margin:0; color:var(--primary-dark); font-size:1.15rem; line-height:1.35; }
.option-card p{
  margin:0; color:var(--ink-muted); font-size:0.94rem; line-height:1.65;
  text-align:justify; text-align-last:left;
  -webkit-hyphens:none; -ms-hyphens:none; hyphens:none;
  overflow-wrap:normal; word-break:normal;
}
html[lang="zh-Hant"] .option-card p{ line-height:1.85; text-align:left; }
.option-card__cta{
  display:inline-flex; align-items:center; gap:6px; margin-top:6px;
  padding:10px 22px; border-radius:999px;
  background:var(--primary); color:#fff; font-weight:700; font-size:0.86rem;
  transition:background-color 150ms ease;
}

/* not yet open for enrolment: same shape and spacing, but visibly inert — no hover affordance,
   a muted badge instead of the primary-coloured CTA button, matching the established
   .project-card--pending convention used for not-yet-linkable project cards. */
.option-card--pending{ cursor:default; }
.option-card--pending .option-card__cta{
  background:transparent; color:var(--ink-faint); border:1px solid var(--border);
  cursor:default; pointer-events:none;
}
