/* ============================================================================
   PORTFOLIO — DESIGN SPEC v2.0.0 (two themes: Paper light + Night dark)
   ----------------------------------------------------------------------------
   Two palettes, one layout. Paper is the light theme and the default; Night
   carries over the previously-canonical dark palette unchanged. Typography,
   spacing, geometry and motion are shared — only the ground and the accent
   change, so toggling the theme never re-flows the page.

   THEME ARCHITECTURE
   Every colour is declared exactly once, as a --l-* (light) and --d-* (dark)
   pair. Selecting a theme means mapping one set onto the active custom
   properties, and that mapping happens in exactly three places below. A
   palette change is therefore one edit, never a hunt.

     :root                                → light default
     :root[data-theme="dark"]             → explicit choice: dark
     @media dark :root:not([data-theme…]) → OS preference, unless overridden

   Anti-cliché constraints honored by this sheet:
     · no gradients, no glass, no glow, no shadows
     · no oversized radii — Paper is square, Night keeps 4px/6px
     · no purple wash, no warm beige ground
     · no accent-coloured left borders on cards
     · status is never carried by colour alone
   ========================================================================= */

/* ============================================================================
   OD-LAYOUT-PRIMITIVES v1 — structure only: display, flex/grid, overflow, wrapping, ratio.
   Put this @layer first; product CSS outside the layer always wins.
   ========================================================================= */
@layer od-layout {
  :where(.od-stack,.od-row,.od-row-top,.od-cluster,.od-grid,.od-field,.od-stat,.od-cell,.od-tile) > :where(*) { min-width: 0; }

  /* containers */
  .od-stack   { display: flex; flex-direction: column; gap: var(--od-gap, 8px); }
  .od-row     { display: flex; align-items: center; gap: var(--od-gap, 8px); }
  .od-row-top { display: flex; align-items: flex-start; gap: var(--od-gap, 8px); }
  .od-cluster { display: flex; flex-wrap: wrap; align-items: center; gap: var(--od-gap, 8px); }
  .od-fill    { flex: 1 1 0; min-width: 0; }
  .od-fixed   { flex: none; }
  .od-grid    { display: grid; gap: var(--od-gap, 12px); grid-template-columns: repeat(var(--od-cols, 3), minmax(0, 1fr)); }

  /* stacked information: each piece is its own line — never sibling inline spans */
  .od-stat, .od-field, .od-cell { display: grid; gap: var(--od-gap, 2px); }
  :where(.od-stat,.od-field,.od-cell) > :where(*) { display: block; }
  .od-tile { display: grid; grid-template-rows: auto 1fr; }
  :where(.od-tile) > :where(*) { display: block; }

  /* media keeps its intrinsic ratio by default; never a distorted or cropped
     cover by accident. height:auto lets the ratio win over an <img height="…">
     attribute. Cropping to a uniform box is an explicit opt-in: set --od-ratio
     AND add .od-media-cover together (per the core rule, only where a crop is
     deliberate — tile thumbs, decorative fills — never on full-frame content). */
  .od-media { display: block; width: 100%; height: auto; aspect-ratio: var(--od-ratio, auto); }
  .od-media-cover { object-fit: cover; }

  /* data text only — authored copy is rewritten to its budget instead */
  .od-truncate { display: block; max-width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
  .od-clamp-2, .od-clamp-3 { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
  .od-clamp-2 { -webkit-line-clamp: 2; }
  .od-clamp-3 { -webkit-line-clamp: 3; }
  .od-lines-2 { min-height: calc(2 * 1.4em); }   /* block-level reservation for a shared baseline */
  .od-nowrap  { white-space: nowrap; }           /* 数字+单位 · 价格+起 · 日期+星期 */
  .od-keep    { word-break: keep-all; overflow-wrap: anywhere; } /* CJK labels break at spaces / <wbr> first */

  /* screen skeleton (optional): bars take their own space, the middle scrolls — no padding guessed for chrome */
  .od-screen { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; height: 100%; }
  .od-scroll { overflow-y: auto; overscroll-behavior: contain; min-height: 0; }

  /* horizontal rail: the next item peeks at the edge; trailing room survives overflow */
  .od-rail { display: flex; gap: var(--od-gap, 8px); overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: none;
             padding-inline: var(--od-rail-pad, 16px); scroll-padding-inline: var(--od-rail-pad, 16px); }
  .od-rail::-webkit-scrollbar { display: none; }
  :where(.od-rail) > :where(*) { flex: none; scroll-snap-align: start; }
  :where(.od-rail) > :where(:last-child) { margin-inline-end: var(--od-rail-pad, 16px); }

  .od-spacer { flex: none; visibility: hidden; pointer-events: none; }
  .od-touch  { min-width: 44px; min-height: 44px; }
}
/* /OD-LAYOUT-PRIMITIVES v1 */

/* ============================================================================
   1. PALETTE — every colour value, declared exactly once
   ========================================================================= */
:root {
  /* ---- Paper (light) ---- */
  --l-ground: #ffffff;
  --l-surface: #f5f6f7;
  --l-surface-raised: #eceef0;
  --l-border: #dfe2e5;
  --l-border-strong: #b9bfc5;
  --l-rule: #101418;
  --l-text: #101418;
  --l-text-muted: #5b6672;
  --l-accent: #0f7a4a;
  --l-accent-strong: #0b5f39;
  --l-accent-muted: #2f6b52;
  --l-scrim: rgba(16, 20, 24, 0.06);

  /* ---- Night (dark) — the previously-canonical palette, unchanged ---- */
  --d-ground: #0b0e11;
  --d-surface: #14181d;
  --d-surface-raised: #1b2127;
  --d-border: #242c34;
  --d-border-strong: #36414c;
  --d-rule: #4a555f;
  --d-text: #e8ecef;
  --d-text-muted: #93a1ad;
  --d-accent: #5fd39a;
  --d-accent-strong: #8ae6ba;
  --d-accent-muted: #3d8f68;
  --d-scrim: rgba(255, 255, 255, 0.06);
}

/* ============================================================================
   2. THEME SELECTION — the only three places a theme is chosen
   ========================================================================= */

/* default: Paper */
:root {
  color-scheme: light;

  --ground: var(--l-ground);
  --surface: var(--l-surface);
  --surface-raised: var(--l-surface-raised);
  --border: var(--l-border);
  --border-strong: var(--l-border-strong);
  --rule: var(--l-rule);
  --text: var(--l-text);
  --text-muted: var(--l-text-muted);
  --accent: var(--l-accent);
  --accent-strong: var(--l-accent-strong);
  --accent-muted: var(--l-accent-muted);
  --scrim: var(--l-scrim);

  /* Paper is square: no rounded cards, no rounded controls. */
  --r-control: 0px;
  --r-card: 0px;
}

/* explicit visitor choice: Night */
:root[data-theme="dark"] {
  color-scheme: dark;

  --ground: var(--d-ground);
  --surface: var(--d-surface);
  --surface-raised: var(--d-surface-raised);
  --border: var(--d-border);
  --border-strong: var(--d-border-strong);
  --rule: var(--d-rule);
  --text: var(--d-text);
  --text-muted: var(--d-text-muted);
  --accent: var(--d-accent);
  --accent-strong: var(--d-accent-strong);
  --accent-muted: var(--d-accent-muted);
  --scrim: var(--d-scrim);

  --r-control: 4px;
  --r-card: 6px;
}

/* OS preference — applies only while no explicit choice is in force */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;

    --ground: var(--d-ground);
    --surface: var(--d-surface);
    --surface-raised: var(--d-surface-raised);
    --border: var(--d-border);
    --border-strong: var(--d-border-strong);
    --rule: var(--d-rule);
    --text: var(--d-text);
    --text-muted: var(--d-text-muted);
    --accent: var(--d-accent);
    --accent-strong: var(--d-accent-strong);
    --accent-muted: var(--d-accent-muted);
    --scrim: var(--d-scrim);

    --r-control: 4px;
    --r-card: 6px;
  }
}

/* ============================================================================
   3. TYPOGRAPHY / SPACING / MOTION — shared by both themes
   ========================================================================= */
:root {
  --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
    "Liberation Mono", monospace;
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
    Arial, sans-serif;

  --fs-name: clamp(2.5rem, 1.3rem + 5vw, 4.25rem);
  --fs-stat: clamp(1.625rem, 1.1rem + 2vw, 2.25rem);
  --fs-title: clamp(1.5rem, 1.15rem + 1.4vw, 2.125rem);
  --fs-lede: 1.0625rem;
  --fs-body: 1rem;
  --fs-small: 0.875rem;
  --fs-meta: 0.8125rem;
  --fs-label: 0.6875rem;

  --lh-heading: 1.3;
  --lh-body: 1.65;

  --sp-1: 4px;
  --sp-2: 8px;
  --sp-3: 12px;
  --sp-4: 16px;
  --sp-6: 24px;
  --sp-8: 32px;
  --sp-12: 48px;
  --sp-16: 64px;

  --wrap: 1080px;
  --topbar-h: 57px;

  /* backing tile for white-ink raster marks — belongs to the mark itself, not
     to either theme, which is why it sits here with the shared tokens. */
  --mark-tile: #0b0e11;

  --dur-enter: 160ms;
  --dur-exit: 110ms;
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);

  /* How far the reader stands from a turning card. Large is flatter and
     quieter, small throws the card at you; 1600px reads as an object turning
     in place rather than spinning on a table. */
  --flip-perspective: 1600px;

  /* Shortest a card may be. The detail face scrolls inside whatever height the
     summary needs, so this only has to stop a brief project from turning into
     a sliver — not reserve room for the longest detail on the page. */
  --card-min-h: 200px;
}

/* ============================================================================
   4. RESET / BASE
   ========================================================================= */
*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--topbar-h) + var(--sp-4));
}

body {
  margin: 0;
  background: var(--ground);
  color: var(--text);
  font-family: var(--font-sans);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
  /* Deliberately NO background/color transition on body. A theme switch that
     fades the ground reports the OLD ground to getComputedStyle for the whole
     animation, which is exactly how every brand mark kept the treatment it
     had on the theme we just left. Switching is instant by design. */
}

h1,
h2,
h3,
h4 {
  margin: 0;
  font-family: var(--font-sans);
  font-weight: 600;
  line-height: var(--lh-heading);
  letter-spacing: -0.01em;
}

p {
  margin: 0;
}

ul,
ol,
dl,
dd {
  margin: 0;
  padding: 0;
}

li {
  list-style: none;
}

code {
  font-family: var(--font-mono);
  font-size: 0.9em;
  color: var(--accent);
  background: var(--scrim);
  border: 1px solid var(--border);
  border-radius: var(--r-control);
  padding: 0.05em 0.35em;
}

a {
  color: var(--accent);
  text-decoration: none;
}

a:hover {
  color: var(--accent-strong);
  text-decoration: underline;
  text-underline-offset: 3px;
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--r-control);
}

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

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

.skip-link {
  position: absolute;
  left: var(--sp-4);
  top: -64px;
  z-index: 100;
  padding: var(--sp-2) var(--sp-4);
  background: var(--accent);
  color: var(--ground);
  font-family: var(--font-sans);
  font-size: var(--fs-meta);
  font-weight: 600;
  transition: top var(--dur-enter) var(--ease-out);
}

.skip-link:focus {
  top: var(--sp-2);
  color: var(--ground);
  text-decoration: none;
}

.icon {
  width: 16px;
  height: 16px;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.wrap {
  width: 100%;
  max-width: var(--wrap);
  margin-inline: auto;
  padding-inline: var(--sp-6);
}

/* ============================================================================
   5. BUTTONS
   ========================================================================= */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  min-height: 40px;
  padding: var(--sp-2) var(--sp-4);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-control);
  background: transparent;
  color: var(--text);
  font-family: var(--font-sans);
  font-size: var(--fs-meta);
  font-weight: 500;
  line-height: 1.2;
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--dur-enter) var(--ease-out),
    border-color var(--dur-enter) var(--ease-out),
    color var(--dur-enter) var(--ease-out);
}

.btn:hover {
  background: var(--scrim);
  border-color: var(--accent);
  color: var(--text);
  text-decoration: none;
}

.btn:active {
  background: var(--surface-raised);
}

.btn-primary {
  background: var(--text);
  border-color: var(--text);
  color: var(--ground);
  font-weight: 600;
}

.btn-primary:hover {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--ground);
}

.btn[disabled],
.btn[aria-disabled="true"] {
  opacity: 0.45;
  cursor: not-allowed;
}

.btn.is-copied {
  border-color: var(--accent);
  color: var(--accent);
}

/* ============================================================================
   6. TOP BAR + THEME CONTROL
   ========================================================================= */
.topbar {
  position: sticky;
  top: 0;
  z-index: 50;
  background: var(--ground);
  border-bottom: 1px solid var(--border);
}

.topbar-inner {
  display: flex;
  align-items: center;
  gap: var(--sp-6);
  height: var(--topbar-h);
  max-width: var(--wrap);
  margin-inline: auto;
  padding-inline: var(--sp-6);
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  font-family: var(--font-sans);
  font-size: var(--fs-meta);
  font-weight: 600;
  color: var(--text);
  min-width: 0;
}

.brand:hover {
  color: var(--accent);
  text-decoration: none;
}

/* the terminal prompt glyph — the one deliberate piece of mono */
.brand-mark {
  font-family: var(--font-mono);
  color: var(--accent);
  font-weight: 700;
}

.brand-name {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* While the hero h1 is on screen the topbar name is redundant, so it steps
   aside. Removed from the layout entirely rather than hidden with visibility,
   which would leave the name's width reserved and an odd gap in the bar. The
   link keeps its own aria-label, so its accessible name is unaffected. */
/* Hidden by default, and revealed again only once the hero h1 has scrolled
   past. The default has to be the hidden state, set by the .js class before
   first paint — inverting it makes the name paint and then vanish a frame
   later, which reads as a blink. */
html.js .brand-name {
  display: none;
}

html.js .brand-name.is-brand-shown {
  display: inline;
}

.nav {
  margin-left: auto;
  min-width: 0;
}

.nav-list {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
}

.nav-link {
  display: inline-block;
  padding: var(--sp-1) 0;
  font-family: var(--font-sans);
  font-size: var(--fs-meta);
  color: var(--text-muted);
  border-bottom: 1px solid transparent;
  transition: color var(--dur-enter) var(--ease-out),
    border-color var(--dur-enter) var(--ease-out);
}

.nav-link:hover {
  color: var(--text);
  text-decoration: none;
  border-bottom-color: var(--border-strong);
}

.nav-link[aria-current="true"] {
  color: var(--accent);
  border-bottom-color: var(--accent);
}

/* ---- the theme switch -----------------------------------------------------
   One control, one job. The label states the ACTION ("switch to night mode"),
   not the current state, so a screen reader announces what the press will do.
   Both glyphs are always in the DOM; CSS shows the one for the inactive theme. */
.theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 1px solid var(--border-strong);
  border-radius: var(--r-control);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  transition: background var(--dur-enter) var(--ease-out),
    border-color var(--dur-enter) var(--ease-out),
    color var(--dur-enter) var(--ease-out);
}

.theme-toggle .icon {
  width: 20px;
  height: 20px;
  stroke-width: 1.4;
}

.theme-toggle:hover {
  background: var(--scrim);
  border-color: var(--accent);
  color: var(--accent);
}

.theme-toggle .icon-sun,
.theme-toggle .icon-moon {
  display: none;
}

:root:not([data-theme="dark"]) .theme-toggle .icon-moon {
  display: block;
}

:root[data-theme="dark"] .theme-toggle .icon-sun {
  display: block;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle .icon-moon {
    display: none;
  }
  :root:not([data-theme="light"]) .theme-toggle .icon-sun {
    display: block;
  }
}

/* ============================================================================
   7. SECTION SHELL
   ========================================================================= */
.section {
  padding-block: var(--sp-16);
  border-top: 1px solid var(--border);
  scroll-margin-top: var(--topbar-h);
}

.hero {
  padding-block: var(--sp-16) var(--sp-12);
  border-top: 0;
}

.section-head {
  margin-bottom: var(--sp-12);
}

.section-index {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  letter-spacing: 0.16em;
  color: var(--accent-muted);
  margin-bottom: var(--sp-2);
}

.section-title {
  font-family: var(--font-sans);
  font-weight: 700;
  font-size: var(--fs-title);
  letter-spacing: -0.03em;
  margin-bottom: var(--sp-4);
}

.section-lede {
  color: var(--text-muted);
  font-size: var(--fs-lede);
  max-width: 58ch;
}

.section-empty {
  font-family: var(--font-sans);
  font-size: var(--fs-small);
  color: var(--text-muted);
  border: 1px dashed var(--border-strong);
  border-radius: var(--r-card);
  padding: var(--sp-6);
}

/* ============================================================================
   8. HERO
   ========================================================================= */
.hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--sp-12);
}

.hero-name {
  font-family: var(--font-sans);
  font-weight: 700;
  font-size: var(--fs-name);
  letter-spacing: -0.035em;
  margin-bottom: var(--sp-3);
  word-break: break-word;
}

.hero-role {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--sp-2);
  font-family: var(--font-sans);
  margin-bottom: var(--sp-6);
}

.hero-role-title {
  font-size: 1rem;
  color: var(--accent);
}

.hero-summary {
  max-width: 46ch;
  font-size: 1.25rem;
  line-height: 1.5;
  /* Was split across the summary and an approach line that sat between this
     and the buttons. The spacing belongs here now that it is the last line
     before the actions. */
  margin-bottom: var(--sp-8);
}

.hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3);
  margin-bottom: var(--sp-8);
}

.hero-links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3) var(--sp-6);
}

.hero-link {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  font-family: var(--font-sans);
  font-size: var(--fs-small);
  color: var(--text-muted);
}

.hero-link:hover {
  color: var(--accent);
  text-decoration: none;
}

/* ============================================================================
   9. PROOF — a figure-and-detail table, not a stack of cards
   ========================================================================= */
.proof-list {
  display: grid;
  gap: var(--sp-6);
}

.proof-item {
  display: grid;
  grid-template-columns: 180px minmax(0, 1fr);
  gap: var(--sp-2) var(--sp-8);
  align-items: baseline;
  padding-bottom: var(--sp-6);
  border-bottom: 1px solid var(--border);
}

.proof-item:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

.proof-item .od-stat {
  grid-row: 1 / span 3;
  align-self: start;
}

.proof-value {
  font-family: var(--font-sans);
  font-size: var(--fs-stat);
  font-weight: 600;
  line-height: 1.1;
  color: var(--accent);
  letter-spacing: -0.02em;
}

.proof-label {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.proof-title {
  font-family: var(--font-sans);
  font-size: 1.0625rem;
  color: var(--text);
}

.proof-detail {
  font-size: var(--fs-small);
  color: var(--text-muted);
}

.proof-link {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* ============================================================================
   10. PANEL
   ========================================================================= */
.panel {
  background: transparent;
  border: 1px solid var(--border);
  border-radius: var(--r-card);
  padding: var(--sp-6);
}

.panel-title {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-muted);
  font-weight: 500;
  margin-bottom: var(--sp-4);
}

.panel-title-spaced {
  margin-top: var(--sp-6);
  padding-top: var(--sp-4);
  border-top: 1px solid var(--border);
}

/* ============================================================================
   11. LOGO BAND
   ========================================================================= */
.logo-band {
  margin-top: var(--sp-12);
  padding-top: var(--sp-6);
  border-top: 1px solid var(--rule);
}


/* Heading for the "Built with" logo band. Distinct from the old .rail, which
   was the skills filter group's wrapper and went with the filter bar. */
.rail-title {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-muted);
  font-weight: 500;
  margin-bottom: var(--sp-4);
}

.logo-rail {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3) var(--sp-6);
}

.logo-mark {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  min-width: 0;
}

.logo-mark img {
  width: 32px;
  height: 32px;
  flex: none;
}

.logo-name {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
  white-space: nowrap;
}

.chip-mark {
  width: 18px;
  height: 18px;
  flex: none;
  /* object-fit defaults to fill, which stretches a replaced element to the box
     and ignores its aspect ratio - so a 177x288 raster came out visibly
     squashed in a square chip. contain is what keeps a mark's proportions.
     Every source is also padded square at build time, so this only ever
     matters as a backstop. */
  object-fit: contain;
}

/* Marks that need to read at size: protocol and institution logos beside a
   role, a project, or a degree. */
.chip-mark.mark-lg {
  width: 36px;
  height: 36px;
}

/* A white-ink raster cannot be recoloured without falsifying it, so it gets
   an opaque backing tile instead. The tile is the page's own dark ground: on
   Night it merges into the page, on Paper it reads as a logo tile. The box
   size never changes, so nothing shifts when a theme flips. */
.mark-tile {
  display: inline-flex;
  flex: none;
}

/* The padding this used to carry was subtracted from the mark rather than
   added to it: box-sizing is border-box, so 4px of inset on an 18px chip left
   the artwork 10px and made every tiled mark read as visibly smaller than the
   un-tiled ones beside it. The tile is there to back a white-ink raster, not to
   frame it — so the mark now fills its box and is the same size as every other
   chip. */
.chip-mark.is-tiled {
  background: var(--mark-tile);
  border-radius: 4px;
}

/* organisation line: protocol mark + name, as one row */
.org-line,
.card-name-wrap {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  min-width: 0;
}

/* The whole mark + name is one link. It keeps the accent colour the name
   already had, so turning a role into a link changes no colour on the page. */
.org-link {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  min-width: 0;
  color: var(--accent);
  text-decoration: none;
}

.org-link:hover {
  color: var(--accent-strong);
  text-decoration: underline;
  text-underline-offset: 3px;
}

.org-line .role-company {
  min-width: 0;
  overflow-wrap: anywhere;
}

.chip-name {
  min-width: 0;
}

/* ============================================================================
   12. EXPERIENCE
   ========================================================================= */
/* ============================================================================
   SHIPPED WORK — the expanded half of a role
   ----------------------------------------------------------------------------
   There is no chart here and no separate timeline section. Each role row is
   its own chapter, and this is what opens beneath it: the work that shipped
   under that employer, one entry per line, each with its own disclosure.

   The Gantt was removed rather than restyled. A Gantt earns its space by
   showing concurrency — overlapping bars, gaps, dependencies — and none of
   these entries overlap, so the chart was twenty-two independent rectangles
   drawing a list. The axis, bands and caption lede stay in content.js and
   stop rendering.

   Two levels of disclosure, chapter then item, because that is the shape of
   the data: an employer, then the work. The second level opens one entry at a
   time, so a role never reveals twelve paragraphs at once.
   ========================================================================= */

/* --- the shipped group inside a role's panel --------------------------- */

/* A group heading inside the list. Reuses the "Shipped" label styling; the
   top margin keeps it from reading as another entry. */

/* --- one entry: a single line, expandable -------------------------------- */

/* --- narrow ---------------------------------------------------------------
   At a phone width the date range no longer fits beside the title, so it drops
   to its own line rather than being squeezed. The row stays a 44px target. */
/* ============================================================================
   EXPERIENCE
   ========================================================================= */
.timeline {
  display: grid;
  gap: var(--sp-6);
}

.role {
  display: grid;
  grid-template-columns: 20px minmax(0, 1fr);
  gap: var(--sp-4);
  padding-bottom: var(--sp-6);
  border-bottom: 1px solid var(--border);
}

.role:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

/* Sits in its own grid column, so it cannot baseline-align to the title beside
   it the way the rest of the row does. 14px × 2 is the 28px line box the 17px
   role title occupies, which drops the chevron onto the same line instead of
   leaving it floating above. */
.role-marker {
  font-family: var(--font-mono);
  font-size: var(--fs-small);
  line-height: 2;
  color: var(--accent-muted);
  user-select: none;
  align-self: start;
}

/* Centred, not baseline-aligned. The mark is a 24px replaced element, and a
   flex container's baseline is taken from its first item's baseline — a
   replaced element's baseline is its bottom edge. Baseline-aligning the org
   line to the role title therefore parked the mark's *bottom* on the title's
   baseline, which floated the whole logo and name about half a line above the
   title it belongs to. Centring puts the mark, the name and the title on one
   optical line instead. */
.role-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2) var(--sp-4);
  margin-bottom: var(--sp-2);
}

.role-title {
  font-family: var(--font-sans);
  font-weight: 600;
  font-size: 1.0625rem;
}

/* Role and organisation share one line; the period and the location share
   the other. Two lines of metadata where it used to be three, and the mark
   sits at24px beside the title instead of commanding a line of its own. */
.role-titles {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-1) var(--sp-3);
  min-width: 0;
}

.org-line .chip-mark {
  width: 24px;
  height: 24px;
}

.role-when {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--sp-1) var(--sp-3);
}

.role-place {
  font-family: var(--font-sans);
  font-size: var(--fs-meta);
  color: var(--text-muted);
}

.role-company {
  font-family: var(--font-sans);
  font-size: var(--fs-small);
  color: var(--accent);
}

.role-period {
  font-family: var(--font-sans);
  font-size: var(--fs-meta);
  color: var(--text-muted);
}

/* The blocks a role is made of — summary, highlights, nested entries, stack.
   It had no rule at all, so the children stacked with nothing between them and
   the stack chips butted straight up against the last highlight. */
.role-body {
  display: grid;
  gap: var(--sp-4);
}

.role-summary {
  color: var(--text);
  max-width: var(--measure, 68ch);
}

.bullets {
  display: grid;
  gap: var(--sp-2);
  max-width: 68ch;

  /* The one region that may give up height. Shrinking past the content is what
     lets a card stay the size of its summary while the detail runs three times
     longer; the bullets scroll inside the card instead of the card growing to
     hold them. A long bullet cut mid-sentence is the cue that there is more
     below — no gradient wash needed to say it. */
  flex: 1 1 auto;
  min-block-size: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--border-strong) transparent;
}

.bullet {
  position: relative;
  padding-left: var(--sp-4);
  color: var(--text-muted);
  font-size: var(--fs-small);
}

.bullet::before {
  content: "–";
  position: absolute;
  left: 0;
  color: var(--accent-muted);
}

/* --- the opened half of a role --------------------------------------------
   The same disclosure the project cards use, so the two read as one page. A
   highlight that already names itself before an em dash keeps that name as a
   heading; one that is a plain sentence renders as description alone. Either
   way there is no bullet glyph — the term is what marks the item. */

.role-points {
  display: grid;
  gap: var(--sp-4);
}

.role-point {
  display: grid;
  gap: var(--sp-1);
}

.role-point-term {
  margin: 0;
  font-size: var(--fs-small);
  font-weight: 600;
  line-height: 1.35;
  color: var(--text);
}

.role-point-text {
  margin: 0;
  font-size: var(--fs-small);
  color: var(--text-muted);
}

/* This disclosure is the only control a role exposes, so on a touch screen it
   carries the full 44px target rather than the 32px the card disclosures use. */
/* nested employers, now inside the expanded half */
.role-entries {
  display: grid;
  gap: var(--sp-4);
  margin: 0;
  padding-left: var(--sp-4);
  border-left: 1px solid var(--border);
}

.role-entry {
  display: grid;
  gap: var(--sp-1);
}

.role-entry-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--sp-2) var(--sp-3);
}

.role-entry-title {
  font-family: var(--font-sans);
  font-size: var(--fs-small);
  color: var(--text);
}

.role-entry-period {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  color: var(--text-muted);
  white-space: nowrap;
}

.role-entry-detail {
  font-size: var(--fs-small);
  color: var(--text-muted);
  max-width: 68ch;
}

/* ============================================================================
   13. CHIPS
   ========================================================================= */
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  align-items: center;
}

.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 2px var(--sp-2);
  border: 1px solid transparent;
  border-radius: var(--r-control);
  background: transparent;
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  color: var(--text-muted);
  white-space: nowrap;
}

.chips .chip + .chip {
  padding-left: var(--sp-3);
  border-left-color: var(--border);
}

/* ============================================================================
   14. PROJECT CARDS
   ========================================================================= */
/* Cards in a row share one height. That is what puts every Source link and
   every turn control on the same line: without it each card is as tall as its
   own description, and a two-line card floats its foot a full line above its
   neighbour's. The row is sized by the tallest *summary* now — the detail face
   is out of flow — so this costs some slack at the bottom of a short card and
   buys a grid a reader can actually scan. */
.cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  align-items: stretch;
  gap: var(--sp-8);
}

.card {
  min-width: 0;
  background: transparent;
  border: 0;
  border-top: 2px solid var(--rule);
  padding: var(--sp-6) 0 0;

  /* A column, so the grid can stretch the card to its row and the face inside
     can stretch with it. As a plain block the card filled the row but its
     content sat at the top of it, which is what left a short card's Source
     link floating above its neighbours'. */
  display: flex;
  flex-direction: column;

  /* The projection the card turns in. This has to sit on the card, as the
     parent of the rotated element — see `.card-inner` below for what happens
     without it. */
  perspective: var(--flip-perspective);
}

.card:hover {
  border-top-color: var(--accent);
}

/* The kind was a second flex item in a space-between row, so it sat hard
   right whenever it fit beside the name and dropped to the left on the next
   line whenever it did not — which made its alignment flip with the viewport
   rather than with anything about the card. It is always its own row now, and
   always aligned with the title above it. */
.card-head {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--sp-1);
}

.card-name {
  font-family: var(--font-sans);
  font-weight: 600;
  font-size: 1.0625rem;
  min-width: 0;
  overflow-wrap: anywhere;
}

.card-kind {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.card-body {
  display: grid;
  gap: var(--sp-3);
  align-content: start;
  min-width: 0;
}

.card-desc {
  font-size: var(--fs-small);
  color: var(--text-muted);
}

/* The reason a repository is not linkable — context the chip in the card foot
   cannot carry. Quieter than the description and monospaced, because it is
   metadata about the project rather than part of the pitch. */
.card-note {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-meta);
  color: var(--text-muted);
}

/* The card turns over rather than growing, so opening one does not push the
   whole grid row taller.

   The height belongs to the front face alone. The back used to share the same
   grid cell, which made the cell the taller of the two — and since the detail
   runs two to three and a half times the length of the summary, every card was
   sized to its detail and most of that box was empty paper the reader saw on
   every visit whether they opened the card or not. `.card-back` is taken out
   of flow below so it overlays the front instead of sizing it, and its
   overflow scrolls inside the card.

   The projection lives on `.card`, one level up, and that is the whole trick.
   Without a perspective anywhere above it the browser projects `rotateY`
   orthographically: the card narrows to a vertical line at the halfway point
   and unfolds mirrored, which reads as a rendering fault rather than an object
   moving in depth. That was what the turn looked like before — a squash, not a
   flip. */
.card-inner {
  position: relative;
  display: grid;
  grid-template-areas: "face";

  /* Grows into whatever height the grid gave the card, but keeps its content
     as the floor — `flex: 1` would set a zero basis and let a tall summary
     overflow its own card. */
  flex: 1 1 auto;

  transform-style: preserve-3d;
  transform: translateZ(0);
  transition: transform var(--dur-enter) var(--ease-out);
}

.card.is-flipped .card-inner {
  transform: rotateY(180deg);
}

/* Hovering lifts the card toward the reader by a hair, which is what makes it
   feel like an object with two sides instead of a region of the page. The
   depth is small enough that 1600px barely scales it, so a lifted card cannot
   overlap its neighbour. The flipped selector is second and more specific, and
   translates before rotating — after a 180° turn the local Z axis points away
   from the reader, so the other order would push the card backwards. */
@media (hover: hover) {
  .card:hover .card-inner {
    transform: translateZ(14px);
  }

  .card.is-flipped:hover .card-inner {
    transform: translateZ(14px) rotateY(180deg);
  }
}

/* A flex column, not a grid, because the foot is pinned with an auto margin and
   that needs a flex line to absorb it. As a grid with `align-content: start`
   the rows are content-sized, the box holds no free space, and the margin
   silently does nothing.

   The floor is here so a card with a one-line description still gives the
   detail a usable box to scroll in, rather than turning into a sliver. */
.card-face {
  grid-area: face;
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  min-block-size: var(--card-min-h);
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}

/* Out of flow, and exactly the size of the front. This is the whole fix for
   the oversized card: the detail no longer contributes a single pixel to the
   height of the page, it just covers the summary while the card is turned. */
.card-back {
  position: absolute;
  inset: 0;
  transform: rotateY(180deg);
}

/* The back is the same hairline surface as the front, so it is given its own
   internal rule rather than a panel — a box here would be a box the rest of the
   page does not use. That rule is what tells the reader they have landed on a
   different side instead of further down the same one. */
.card-back-head {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  padding-bottom: var(--sp-2);
  border-bottom: 1px solid var(--border);
}

/* The project name, restated. Once the card has turned, the bullets are
   anonymous without it, and it is the one thing on this face that says which
   project the reader is still inside. */
.card-back-label {
  font-family: var(--font-mono);
  font-size: var(--fs-label);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text);
}

/* The turn control. Square, because the rest of the page is square: this used
   to be the one circle on a page built from hairline rules and right angles.
   The chevron carries the meaning, the label carries it for anyone who cannot
   see the arrow. */
.card-flip {
  position: relative;
  display: inline-grid;
  place-items: center;
  inline-size: 34px;
  block-size: 34px;
  margin-inline-start: auto;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--r-control);
  background-color: transparent;
  color: var(--text-muted);
  cursor: pointer;
  transition:
    color var(--dur-enter) var(--ease-out),
    background-color var(--dur-enter) var(--ease-out),
    border-color var(--dur-enter) var(--ease-out);
}

.card-flip:hover {
  color: var(--accent);
  background-color: var(--scrim);
  border-color: var(--accent);
}

.card-flip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* The drawn button stays 34px so it does not crowd the links beside it; the hit
   area grows to 44px underneath. Absolutely positioned, so it adds no grid row
   to the `inline-grid` button it hangs off. */
.card-flip::after {
  content: "";
  position: absolute;
  inset: -5px;
}

.card-flip-icon {
  inline-size: 18px;
  block-size: 18px;
}

/* Pinned to the bottom of whichever face is showing, so the links and the turn
   control sit on one baseline across a row instead of floating at whatever
   height the copy happens to end. */
.card-foot {
  display: flex;
  flex: none;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-3);
  margin-top: auto;
}

.card-links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3);
}

.card-link {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* A private repository occupies the slot a public one would, with the
   affordance removed: same type, no accent, no underline, no pointer. It has
   to be visibly a link that cannot be followed rather than a missing link —
   the accent and the underline are the only things telling the reader this is
   a source and not a caption. */
.card-link-locked {
  color: var(--text-muted);
  cursor: default;
}

/* ============================================================================
   15. SKILL FILTER BAR
   ========================================================================= */







.search input {
  flex: 1 1 0;
  min-width: 0;
  border: 0;
  background: transparent;
  color: var(--text);
  font-family: var(--font-sans);
  font-size: var(--fs-meta);
  min-height: 34px;
}

.search input::placeholder {
  color: var(--text-muted);
}

.search input:focus {
  outline: none;
}


/* ============================================================================
   16. SKILL GROUPS
   ========================================================================= */
.skill-groups {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: var(--sp-8);
}

/* No rule above the group. The skill list is one thing, not five ruled-off
   sections — the group heading is enough to separate them. */
.skill-group {
  background: transparent;
  border: 0;
  padding: var(--sp-6) 0 0;
  min-width: 0;
}

.skill-group[hidden] {
  display: none;
}

.skill-group-title {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-muted);
  font-weight: 500;
  padding-bottom: var(--sp-3);
  margin-bottom: var(--sp-4);
  border-bottom: 1px solid var(--border);
}

.skill-list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-4);
}

/* ============================================================================
   17. EMPTY STATE
   ========================================================================= */



/* ============================================================================
   18. EDUCATION
   ========================================================================= */
.edu-list {
  display: grid;
  gap: var(--sp-4);
  max-width: 68ch;
}

.edu-row {
  display: grid;
  grid-template-columns: 20px minmax(0, 1fr);
  gap: var(--sp-4);
  padding-bottom: var(--sp-4);
  border-bottom: 1px solid var(--border);
}

/* Exactly one content child for the second grid column. The degree row, its
   mark and the school line all live in here, so nothing can wrap onto a new
   grid row and get squeezed into the 20px marker column. */
.edu-body {
  min-width: 0;
}

.edu-row:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

.edu-marker {
  font-family: var(--font-mono);
  font-size: var(--fs-small);
  line-height: 1.7;
  color: var(--accent-muted);
  user-select: none;
}

.edu-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-2) var(--sp-4);
}

.edu-degree {
  font-family: var(--font-sans);
  font-weight: 600;
  font-size: var(--fs-small);
  color: var(--text);
}

.edu-period {
  font-family: var(--font-sans);
  font-size: var(--fs-meta);
  color: var(--text-muted);
  white-space: nowrap;
}

.edu-school {
  font-size: var(--fs-small);
  color: var(--text-muted);
}

/* ============================================================================
   19. CONTACT
   ========================================================================= */
.contact-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
  gap: var(--sp-8);
  align-items: start;
}

.contact-list {
  display: grid;
  gap: var(--sp-3);
}

.contact-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-3) var(--sp-4);
  padding: var(--sp-3) 0;
  border-bottom: 1px solid var(--border);
}

/* contact-row marks sit slightly larger than chip marks */
.contact-row .chip-mark {
  width: 20px;
  height: 20px;
}

/* The mark column is reserved whether or not a row has a mark. A row missing
   one used to start 20px + one gap later, so its label and its value both sat
   to the right of the rows that had a logo. */
.contact-mark {
  width: 20px;
  height: 20px;
  flex: none;
  display: inline-grid;
  place-items: center;
  color: var(--text-muted);
}

.contact-glyph .icon {
  width: 18px;
  height: 18px;
}

.contact-label {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-muted);
  width: 84px;
  flex: none;
}

.contact-value {
  flex: 1 1 180px;
  min-width: 0;
  font-family: var(--font-sans);
  font-size: var(--fs-small);
  color: var(--text);
  overflow-wrap: anywhere;
}

.contact-row .btn {
  margin-left: auto;
  min-height: 30px;
  padding: var(--sp-1) var(--sp-3);
  font-size: var(--fs-label);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.glance-row {
  display: grid;
  grid-template-columns: 108px minmax(0, 1fr);
  gap: var(--sp-3);
  padding-block: var(--sp-2);
  border-bottom: 1px solid var(--border);
}

.glance-row:last-child {
  border-bottom: 0;
}

.glance-row dt {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-muted);
  padding-top: 3px;
}

.glance-row dd {
  font-size: var(--fs-small);
  color: var(--text);
  min-width: 0;
  overflow-wrap: anywhere;
}

.lang-list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
}

.lang-item {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--sp-2);
  padding: 2px var(--sp-3);
  border: 1px solid var(--border);
  border-radius: var(--r-control);
}

.lang-name {
  font-family: var(--font-sans);
  font-size: var(--fs-meta);
  color: var(--text);
}

.lang-level {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* ============================================================================
   20. FOOTER / NOSCRIPT
   ========================================================================= */
.footer {
  border-top: 1px solid var(--border);
  padding-block: var(--sp-8);
}

.footer-inner {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--sp-2) var(--sp-6);
}

.footer-note {
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  color: var(--text-muted);
}

/* Licence attribution for third-party marks. Full width so it reads as its
   own line of small print rather than as part of the footer sentence. */
.footer-credits {
  flex-basis: 100%;
  display: grid;
  gap: var(--sp-1);
  margin-top: var(--sp-3);
  padding-top: var(--sp-3);
  border-top: 1px solid var(--border);
  font-family: var(--font-sans);
  font-size: var(--fs-label);
  line-height: 1.6;
  color: var(--text-muted);
}

.footer-credits:empty {
  display: none;
}

.noscript-note {
  margin: var(--sp-6);
  padding: var(--sp-4);
  border: 1px solid var(--border-strong);
  border-radius: var(--r-card);
  font-family: var(--font-sans);
  font-size: var(--fs-small);
}

/* ============================================================================
   21. RESPONSIVE — 1440 / 1024 / 768 / 640 / 375
   ========================================================================= */
@media (min-width: 1440px) {
  :root {
    --wrap: 1160px;
  }
}

@media (max-width: 1024px) {
  .contact-grid {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--sp-6);
  }
}

/* the proof table must not push its own context off a phone screen */
@media (max-width: 640px) {
  .proof-item {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--sp-1);
  }

  .proof-item .od-stat {
    grid-row: auto;
    margin-bottom: var(--sp-2);
  }
}

@media (max-width: 768px) {
  .section {
    padding-block: var(--sp-12);
  }

  .hero {
    padding-block: var(--sp-8) var(--sp-12);
  }

  .section-head {
    margin-bottom: var(--sp-8);
  }

  .topbar-inner {
    gap: var(--sp-4);
  }

  .nav-list {
    gap: var(--sp-3);
    flex-wrap: wrap;
    row-gap: 0;
  }

  .nav-link {
    font-size: var(--fs-label);
  }

  .cards,
  .skill-groups {
    grid-template-columns: minmax(0, 1fr);
  }


  .role-head,
  .edu-head,
  .card-head {
    gap: var(--sp-1);
  }

  .card-kind {
    text-align: left;
  }
}

@media (max-width: 480px) {
  .wrap,
  .topbar-inner {
    padding-inline: var(--sp-4);
  }

  .brand-name {
    display: none;
  }

  .nav {
    margin-left: 0;
  }

  .hero-actions .btn {
    flex: 1 1 100%;
  }

  .contact-row {
    flex-direction: column;
    align-items: flex-start;
  }

  .contact-row .btn {
    margin-left: 0;
    width: 100%;
  }

  .contact-label {
    width: auto;
  }

  .panel {
    padding: var(--sp-4);
  }

  .glance-row {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--sp-1);
  }
}

/* ============================================================================
   22. MOTION
   ========================================================================= */
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }

  /* The global rule above already takes the duration to nothing, which is all a
     card turn needs: the card still turns, it just arrives rather than
     performs. Reduced-motion users get the change, not the choreography. */
  .card-inner {
    transition: none;
  }

  /* The hover lift is the one movement here the reader did not ask for, so it
     is dropped. Both states are pinned back explicitly — leaving them to fall
     back on `.card-inner` would snap a flipped card flat under the cursor. */
  .card:hover:not(.is-flipped) .card-inner,
  .card.is-flipped:hover .card-inner {
    transform: rotateY(0deg);
  }
}