/* ============================================================
   site.css — shared shell for every expri.ai page

   Extracted from the inline <style> block in index.html when the
   site went from one page to five (home, /exps, /consulting, /academy, /about).
   Keeping it inline would have meant three copies drifting apart.

   Loaded BEFORE background.css, which still wins on anything
   ambient. Do not reorder those two links.
   ============================================================ */

@font-face {
  font-family: 'Manrope';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url('/fonts/manrope-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
                 U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Manrope';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url('/fonts/manrope-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
                 U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
                 U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  --ink:        #0A0B0D;
  --paper:      #F3F2EF;
  --muted:      #85888F;
  /* Was #5E626A, which only reached 3.3:1 on the page ink and failed
     WCAG AA for the small text using it (index numbers, host lines,
     colophon). #7B7E86 clears 4.5:1. */
  --muted-soft: #7B7E86;
  --rule:       rgba(243, 242, 239, 0.13);
  --rule-soft:  rgba(243, 242, 239, 0.07);
  /* VERMILLION. Previous values: #4BD9E4 / rgba(75,217,228,0.05).
     Hue 3deg, which is the seal's own hue — but held lighter than the
     seal's #E22B27 on purpose. Contrast scales with luminance, and the
     accent carries hover states on small text over the painting, where
     there is the least contrast to spare. Relative to the cyan this
     replaced: #FF5A50 keeps 51% of its luminance, #E22B27 only 32%.
     Going darker than this is possible but each step costs legibility
     on the /exps host lines, which sit mid-page over the brightest
     part of the image. */
  --accent:     #FF5A50;
  --tint:       rgba(255, 90, 80, 0.05);

  --gutter: clamp(1.375rem, 5vw, 4.5rem);
  --measure: 1120px;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

/* THE ONLY !important IN THIS FILE, AND IT IS LOAD-BEARING.
   `[hidden] { display: none }` is a USER-AGENT rule, so ANY author rule
   that sets display beats it. .row is display:grid and .service-card is
   display:grid, which meant roster.js could set .hidden all day and
   nothing disappeared — the search box looked wired up and filtered
   nothing. Author-level plus !important is the only thing that holds
   against every current and future display rule.
   Do not remove this without giving roster.js another way to hide a
   row. */
[hidden] { display: none !important; }

html { -webkit-text-size-adjust: 100%; }

/* The idea burst throws glyphs several hundred pixels sideways. `clip`
   rather than `hidden`: hidden would make the body a scroll container
   and break scroll anchoring; clip just refuses to scroll to them. */
body { overflow-x: clip; }

body {
  background: var(--ink);
  color: var(--paper);
  font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
  font-weight: 400;
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  min-height: 100svh;
  display: flex;
  justify-content: center;
}

/* A translucent paper wash, not the accent and not a solid block. A
   saturated red fought the text it was meant to reveal; solid white
   then inverted it to dark-on-light, which is just as loud a jump.
   At 0.22 the band reads as the text being lit rather than replaced,
   so the glyphs keep their own colour and stay put. */
::selection {
  background: rgba(243, 242, 239, 0.22);
  color: var(--paper);
}

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

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

.shell {
  width: 100%;
  max-width: var(--measure);
  padding: 0 var(--gutter);
  display: flex;
  flex-direction: column;
  min-height: 100svh;
}

/* ---------- micro-label ---------- */
.label {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted-soft);
}

/* ---------- masthead ---------- */
.masthead {
  display: flex;
  /* Centre, not baseline: the logo is a 3D box with no text baseline,
     so baseline alignment would hang it off its bottom edge. */
  align-items: center;
  justify-content: space-between;
  gap: 1.5rem;
  /* Tightened from clamp(1.75rem, 4vw, 2.75rem). */
  padding: clamp(1.25rem, 2.6vw, 1.75rem) 0;
  border-bottom: 1px solid var(--rule-soft);
}

.brand {
  display: flex;
  align-items: center;
  gap: clamp(0.625rem, 1.5vw, 0.875rem);
}

/* THE LOGO IS THE CUBE, from 2026-08-22. The seal image it replaced is
   not deleted — it is displayed and explained in "The marks" on /about/,
   waiting for a new job.
   This cube is NOT the one cube.js drives. It has its own class names on
   purpose, so the script cannot grab it: a logo that can be dragged out
   of alignment is a bug, and every page carries this one. CSS keyframes
   only, and it inherits .face / .glyph from the block further down. */
.brand-cube {
  --cube: clamp(1.5rem, 2.4vw, 1.875rem);
  width: var(--cube);
  height: var(--cube);
  flex: none;
  display: block;
  perspective: 320px;
}
.brand-cube-tip,
.brand-cube-body {
  display: block;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
}
/* Held at a fixed tilt rather than tumbling on both axes — at 30px a
   second axis reads as jitter, not as depth. */
.brand-cube-tip { transform: rotateX(-14deg); }
.brand-cube-body { animation: cube-spin 24s linear infinite; }
/* Scaled down from the 6px the big cube uses; at this size 6px rounds
   the corners into a lozenge. */
.brand-cube .face { border-radius: 2px; }
.brand-cube .glyph { width: 68%; height: 68%; }

@media (prefers-reduced-motion: reduce) {
  .brand-cube-body { animation: none; transform: rotateY(-24deg); }
}

.wordmark {
  /* Up from a flat 1.0625rem, and now scaling with the viewport so it
     holds its weight against the seal beside it. */
  font-size: clamp(1.1875rem, 1.7vw, 1.4375rem);
  font-weight: 800;
  letter-spacing: -0.025em;
}
.wordmark i { font-style: normal; color: var(--accent); }

.nav {
  display: flex;
  gap: clamp(1.125rem, 3vw, 2rem);
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted-soft);
}
.nav a { transition: color 220ms ease; }
.nav a:hover { color: var(--accent); }
/* The page you are already on reads as present, not clickable. */
.nav a[aria-current="page"] { color: var(--paper); }

/* The old .hero (home) block lived here. Deleted 2026-08-22: the home
   page now opens with .thesis-hero instead, and nothing referenced .hero
   any more. Recover it from git if a plain headline+lede hero is wanted
   again. */

/* ---------- page head (inner pages) ---------- */
.page-head {
  padding: clamp(2.25rem, 6vh, 4rem) 0 clamp(1.875rem, 4vh, 2.75rem);
  max-width: 40rem;
}
.page-head .label { display: block; margin-bottom: 1.125rem; }
.page-head h1 {
  font-size: clamp(2.125rem, 6vw, 3.5rem);
  font-weight: 800;
  line-height: 1.02;
  letter-spacing: -0.04em;
  text-wrap: balance;
}
.page-head p {
  margin-top: clamp(1.125rem, 2.5vw, 1.5rem);
  font-size: clamp(0.9375rem, 1.8vw, 1.0625rem);
  line-height: 1.65;
  color: var(--muted);
  text-wrap: pretty;
}

/* ---------- section head ---------- */
.section-head {
  display: flex;
  align-items: center;
  gap: 1.25rem;
  margin-bottom: clamp(1.25rem, 2.5vw, 1.75rem);
}
/* The section-head is a flex row with a rule that eats the remaining
   space, so a tag has to be placed before that rule, not after it. */
.section-head .tag { order: 1; }
.section-head::after { order: 2; }

.section-head::after {
  content: '';
  flex: 1;
  height: 1px;
  background: var(--rule-soft);
}

/* The home-page CARDS block lived here — .cards, .card, .card-num,
   .card-name, .card-lede, .card-desc, .card-foot, ~90 lines. Deleted
   2026-08-22: the six cards retired into /exps/ as its grid view, which
   restyles the .row markup instead of duplicating it, so nothing
   referenced any of this. Recover from git if a card grid is wanted
   again. .service-card is unrelated and still in use. */

/* ---------- arrow ---------- */
.arrow {
  color: inherit;
  transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1), color 220ms ease;
}
.arrow svg { display: block; }
.row:hover .arrow,
.row:focus-visible .arrow { transform: translate(3px, -3px); }

/* ---------- tag ----------
   Colour is pinned rather than inherited, so it stays neutral while
   the line beside it goes accent on hover. A tag is a statement of
   fact, not a link affordance. */
.tag {
  flex: none;
  font-size: 0.625rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted-soft);
  border: 1px solid var(--rule);
  border-radius: 100px;
  padding: 0.1875rem 0.4375rem 0.125rem;
  line-height: 1;
}

/* ============================================================
   ROWS  —  the /exps detail list

   The editorial index kept for the page whose job is detail. The
   home cards are for discovery; this is where the longer copy goes.
   ============================================================ */

.row {
  display: grid;
  /* 12rem, not 8.5rem. Sized for the longest Exp name: originally
     "UrbanScope", then "MarketMind" — single words at 1.75rem/700 that
     cannot wrap, so a narrow column made them overrun the blurb beside
     them. Both have since been renamed, and nothing on the page is
     longer than five characters today, so this is currently headroom
     rather than a requirement. Kept because it costs nothing and the
     next long name would silently break the row again. */
  grid-template-columns: 3.25rem minmax(0, 12rem) minmax(0, 1fr) auto;
  align-items: start;
  gap: 1.5rem;
  padding: clamp(1.5rem, 3vw, 2.125rem) 0.875rem clamp(1.5rem, 3vw, 2.125rem) 0.5rem;
  margin: 0 -0.875rem 0 -0.5rem;
  border-top: 1px solid var(--rule);
  border-radius: 3px;
  transition: background-color 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.rows > :last-child { border-bottom: 1px solid var(--rule); }

.row:hover,
.row:focus-visible { background-color: var(--tint); }

.num {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  color: var(--muted-soft);
  font-variant-numeric: tabular-nums;
  padding-top: 0.5rem;
  transition: color 220ms ease;
}
.row:hover .num,
.row:focus-visible .num { color: var(--accent); }

.name {
  font-size: clamp(1.375rem, 3vw, 1.75rem);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.15;
}

.blurb {
  font-size: 0.9375rem;
  line-height: 1.65;
  color: var(--muted);
  max-width: 32rem;
  text-wrap: pretty;
}

.host {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.625rem;
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--muted-soft);
  transition: color 220ms ease;
}
.row:hover .host,
.row:focus-visible .host { color: var(--accent); }

.row .arrow { padding-top: 0.4375rem; color: var(--muted-soft); }
.row:hover .arrow,
.row:focus-visible .arrow { color: var(--accent); }

/* ---------- thesis hero (home) ---------- */
/* THIS IS THE HOME PAGE'S HERO NOW. It was /about/'s until 2026-08-22,
   when the thesis moved to the front door and /about/ became the story
   page — hence the rename from .about-hero. The cube shares its row, so
   it is sized a step under what a full-measure headline could take. */
/* A single column since the cube left for the masthead. It was a
   two-track grid with an empty second track, which cost nothing but
   described a layout that no longer exists. */
.thesis-hero {
  max-width: 46rem;
  padding: clamp(2.25rem, 6vh, 4rem) 0 clamp(1.75rem, 4vh, 2.5rem);
}
/* NO max-width. It was 34rem, left over from when this column held prose
   — and it WAS the bug: a 544px box cannot hold a headline that needs
   802px, so the nowrap text overflowed its own container and ran straight
   across the cube. The column holds a label and one headline now; the
   grid track is the only limit it needs. */
.thesis-copy { min-width: 0; }
.thesis-hero .label { display: block; margin-bottom: 1.125rem; }
/* ONE LINE, AND THE SIZE IS MEASURED NOT GUESSED.
   "Real intelligence, amplified." is 12.536 em wide in Manrope at
   wght 800 with this -0.042em tracking — read out of the shipped
   woff2's own advance widths, not estimated.
   Two limits bind from opposite ends. Above ~1120 the shell stops
   growing while vw keeps going, so the REM term caps it: 856px of
   room there, i.e. 68.3px of type. Just above the stack breakpoint
   the room is only 751px, i.e. 59.9px, so the VW term caps it.
   min(3.75rem, 5.7vw) clears both, with 10-16% spare at every width
   between.
   RAISING EITHER NUMBER BREAKS THE SINGLE LINE, and the failure is
   ugly — nowrap text does not shrink, it overflows sideways over the
   cube. See the note on .thesis-copy above. */
.thesis-hero h1 {
  font-size: min(3.75rem, 5.7vw);
  font-weight: 800;
  line-height: 1.0;
  letter-spacing: -0.042em;
  white-space: nowrap;
}
/* Paper, not accent. A red full stop reads as a
   status light rather than as punctuation. */
.thesis-hero h1 em { font-style: normal; color: var(--paper); }

/* ---------- prose (about) ---------- */
.prose { max-width: 38rem; }
.prose p {
  font-size: clamp(0.9375rem, 1.7vw, 1.0625rem);
  line-height: 1.7;
  color: var(--muted);
  text-wrap: pretty;
}
.prose p + p { margin-top: 1.125rem; }
.prose p strong { color: var(--paper); font-weight: 600; }
/* Tight. Reads as the next thing said rather than a separate slab.
   RESTORED: this was dropped in the 2026-08-22 /about rewrite, which left
   the market note at the foot of /exps/ with no top padding. /about no
   longer uses it; /exps/ does. */
.prose-section { padding-top: clamp(1.25rem, 2.5vh, 1.625rem); }
/* .label is inline by default and the prose blocks set one above the copy. */
.prose .label { display: block; margin-bottom: 1.125rem; }

/* ---------- tenets ---------- */
/* Own class rather than the .num used by the /exps rows: that one is
   global and carries a grid-row override in the 760px block. */
.tenets {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(1.75rem, 4vw, 3rem);
  margin-top: clamp(2.25rem, 5vh, 3.25rem);
  padding: clamp(2.25rem, 5vh, 3.25rem) 0 0;
  border-top: 1px solid var(--rule-soft);
}
/* Grid children default to min-width:auto, which lets a long unbroken
   word push the track wider than its share. */
.tenet { min-width: 0; }

.tenet-num {
  display: block;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  margin-bottom: 0.875rem;
}
.tenet-name {
  font-size: 1rem;
  font-weight: 700;
  line-height: 1.3;
  letter-spacing: -0.015em;
  color: var(--paper);
  text-wrap: balance;
}
.tenet-body {
  margin-top: 0.625rem;
  font-size: 0.875rem;
  line-height: 1.65;
  color: var(--muted);
  text-wrap: pretty;
}

/* ============================================================
   TUMBLING CUBE  —  /about

   Six faces: 億 想 天 开 on the sides, AI and RI on top and bottom.
   Built in CSS rather than shipped as a GIF — no raster to go soft on
   a retina screen, a few hundred bytes instead of a few hundred KB,
   and it can hold still for prefers-reduced-motion, which a GIF cannot.

   DELIBERATELY SMALL. It sits beside the headline as a footnote to the
   name, not as the subject of the page, and its explanation is folded
   away behind a disclosure (.cube-note) instead of running down the
   page as a caption.

   NO WEBFONT IS LOADED FOR THE CJK GLYPHS, and none is needed on the
   cube: the four seal characters are traced vector outlines (see .glyph
   below), so they render identically everywhere. The note below the
   cube still sets 异想天开 as real text and DOES fall back to the OS
   font — it will look different across platforms, and shows tofu on a
   machine with no CJK font at all. That is deliberate: the note is
   prose you should be able to select and copy, and the Latin gloss
   beside it carries the meaning if the glyphs fail.
   ============================================================ */

.cube-panel {
  /* Anchor for the absolutely-positioned .cube-note. */
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* The cube tips forward on its X axis, so at the extremes of the tip
     its lower edge reaches further down the page than its resting box
     suggests — the caption needs clearance from where the cube actually
     gets to, not from where it sits still. */
  gap: clamp(1.25rem, 3vw, 2rem);
  margin: 0;
}

.cube-stage {
  /* clamp(7rem, 12vw, 9.5rem) when the cube had a column to itself,
     then 4.5-6.5rem when it moved beside the headline, now this: Peter
     asked twice for subtle, and the second ask was "not very
     noticeable ... rather than on your face". At 4rem it is a mark
     beside the headline, not a companion to it. The h1 size above is
     computed against THIS value — changing one means recomputing the
     other. */
  --cube: clamp(3rem, 4.8vw, 4rem);
  width: var(--cube);
  height: var(--cube);
  perspective: 900px;
  /* Dragging across the cube otherwise starts a text selection, which
     paints a highlight block across a face mid-spin. The faces are
     decorative — the note below is the copy worth selecting. */
  -webkit-user-select: none;
  user-select: none;
  /* Stops the long-press callout menu on iOS. */
  -webkit-touch-callout: none;
}

/* Only once cube.js has confirmed it can run. Without the script the
   keyframes below stay in charge, so the cube still turns. */
.cube-js .cube-stage {
  cursor: grab;
  /* pan-y, not none. `none` did let a vertical drag tip the cube — and
     also swallowed every attempt to scroll the page with a finger that
     happened to land on it, which on a phone is most of them. Horizontal
     drag still spins; vertical belongs to the document. */
  touch-action: pan-y;
}
.cube-js .cube-stage.is-grabbed { cursor: grabbing; }
.cube-js .cube-tip,
.cube-js .cube { animation: none; }

/* Two nested rotations rather than one. The inner element spins on Y at
   a steady rate, which is what carries the four characters past the
   viewer in reading order; the outer element tips slowly on X so the
   top and bottom faces come into view without disturbing that order. */
.cube-tip {
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  animation: cube-tip 31s ease-in-out infinite;
}

.cube {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  animation: cube-spin 19s linear infinite;
}

/* NEGATIVE, so the faces travel leftward past the viewer. A news ticker
   moves its text leftward and you read it left-to-right; spinning the
   other way makes the same four characters read right-to-left. */
@keyframes cube-spin {
  from { transform: rotateY(0deg); }
  to   { transform: rotateY(-360deg); }
}
@keyframes cube-tip {
  0%, 100% { transform: rotateX(-12deg); }
  42%      { transform: rotateX(-68deg); }
  78%      { transform: rotateX(34deg); }
}

.face {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  /* Darkens the painting behind it, like the Exp cards. A pale face
     would add light under the glyph, and --photo-veil has no headroom. */
  background-color: rgba(8, 9, 11, 0.38);
  border: 1px solid var(--rule);
  border-radius: 6px;
  backface-visibility: hidden;
}

/* The four Chinese faces are VECTOR TRACES of the seal, not text. They
   were traced from the 篆书 renders in assets/seal/*.png, so they no
   longer depend on the visitor having a CJK font — and no seal-script
   font had to be licensed or shipped to get seal-script letterforms.
   Painted with background-color through a mask, so the colour is a CSS
   value rather than something baked into the file. */
.glyph {
  width: 64%;
  height: 64%;
  background-color: var(--paper);
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-size: contain;
          mask-size: contain;
}
.g-yi    { -webkit-mask-image: url('/assets/seal/yi.svg');    mask-image: url('/assets/seal/yi.svg'); }
.g-xiang { -webkit-mask-image: url('/assets/seal/xiang.svg'); mask-image: url('/assets/seal/xiang.svg'); }
.g-tian  { -webkit-mask-image: url('/assets/seal/tian.svg');  mask-image: url('/assets/seal/tian.svg'); }
.g-kai   { -webkit-mask-image: url('/assets/seal/kai.svg');   mask-image: url('/assets/seal/kai.svg'); }

/* The Latin faces are set in Manrope at the site's own tracking, so they
   read as part of the brand rather than as two more glyphs. */
/* 0.75, up from 0.28 — the Latin faces were carrying about a third of
   the visual mass of the seal faces beside them.
   Not sized to match the glyph box outright: the seal characters fill
   64% of the face in BOTH dimensions, and giving "AI" a 64% cap height
   needs 0.889 x cube, which runs the pair to 87% of the face width and
   crowds the border. 0.75 lands the pair at ~73% wide and 54% tall,
   which reads as equal presence — two open letterforms need more room
   than a dense square to weigh the same. */
.face.latin span {
  font-family: inherit;
  font-size: calc(var(--cube) * 0.75);
  font-weight: 800;
  letter-spacing: -0.04em;
  color: var(--accent);
}

/* Decreasing rotateY brings the RIGHT face to the front, so the phrase is
   laid out front -> right -> back -> left to arrive in reading order. */
.f-front  { transform: rotateY(0deg)   translateZ(calc(var(--cube) / 2)); }
.f-left   { transform: rotateY(-90deg) translateZ(calc(var(--cube) / 2)); }
.f-back   { transform: rotateY(180deg) translateZ(calc(var(--cube) / 2)); }
.f-right  { transform: rotateY(90deg)  translateZ(calc(var(--cube) / 2)); }
.f-top    { transform: rotateX(90deg)  translateZ(calc(var(--cube) / 2)); }
.f-bottom { transform: rotateX(-90deg) translateZ(calc(var(--cube) / 2)); }

/* The cube caption and its hover disclosure lived here — .cube-panel,
   .cube-caption, .cube-key, .cube-note, .roman. Deleted 2026-08-22 when
   the cube became the masthead logo: its explanation is plain visible
   prose in "The marks" on /about/ now, so there is nothing to disclose.
   28 rules, recoverable from git if a disclosure is ever wanted again. */

/* ---------- thesis hero: narrow ---------- */
/* 940px, not 900. The narrowest width at which the one-line headline
   still fits beside the cube is ~940 (751px of room against 12.536em of
   type). Stacking at 900 left a 40px band where nowrap was still on but
   no longer fitted — and that overflows rather than wraps. Stack before
   it, not after. */
@media (max-width: 940px) {
  /* Below the stack the headline has the full width and no cube beside
     it, so nowrap stops being achievable and stops being needed. */
  .thesis-hero h1 {
    white-space: normal;
    font-size: clamp(2rem, 7.5vw, 3.25rem);
    line-height: 0.98;
    text-wrap: balance;
  }
}

@media (max-width: 780px) {
  .tenets { grid-template-columns: 1fr; gap: clamp(1.5rem, 5vw, 2rem); }
}

@media (prefers-reduced-motion: reduce) {
  /* Held at an angle that still reads as a cube rather than a square. */
  .cube-tip { animation: none; transform: rotateX(-14deg); }
  .cube     { animation: none; transform: rotateY(-24deg); }
  .cube-note { transition: opacity 1ms linear, visibility 0s; transform: none; }
}

/* ============================================================
   SERVICE CARD  —  the Faber block on the home page

   Deliberately NOT a seventh .card. Two reasons, one structural and
   one editorial. Structural: the cards grid is six dividing evenly
   three-across, and a seventh brings back the orphan-row problem the
   comment above describes. Editorial: the ecosystem is Exps you use;
   Faber and Ludus are the same principle applied to your own operations.
   A wide band with an accent edge reads as a different KIND of thing
   at a glance, which a tagged card in the grid would not.
   ============================================================ */

.service-card {
  display: grid;
  grid-template-columns: minmax(0, 17rem) minmax(0, 1fr);
  gap: clamp(1rem, 3vw, 2.75rem);
  padding: clamp(1.375rem, 2.4vw, 1.875rem);
  border: 1px solid var(--rule);
  /* The one visual tell that separates a service from an Exp. */
  border-left: 2px solid rgba(255, 90, 80, 0.45);
  border-radius: 4px;
  background-color: rgba(8, 9, 11, 0.22);
  transition: background-color 260ms cubic-bezier(0.22, 1, 0.36, 1),
              border-color 260ms ease;
}
/* Matches .card — a tint layer over the dark base, never a lighter
   background colour, and no lift. */
.service-card:hover,
.service-card:focus-visible {
  background-image: linear-gradient(var(--tint), var(--tint));
  border-color: rgba(255, 90, 80, 0.32);
  border-left-color: var(--accent);
}

.service-head { display: block; }
.service-name {
  display: block;
  font-size: clamp(1.375rem, 2.6vw, 1.625rem);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.15;
}
.service-lede {
  display: block;
  margin-top: 0.4375rem;
  font-size: 0.9375rem;
  font-weight: 600;
  line-height: 1.4;
  color: var(--muted);
}

.service-body { display: flex; flex-direction: column; }
.service-desc {
  font-size: 0.875rem;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}
.service-foot {
  margin-top: auto;
  padding-top: clamp(1rem, 2vw, 1.375rem);
  display: flex;
  align-items: center;
  gap: 0.625rem;
  font-size: 0.75rem;
  font-weight: 500;
  color: var(--muted-soft);
  transition: color 220ms ease;
}
.service-card:hover .service-foot,
.service-card:focus-visible .service-foot { color: var(--accent); }
.service-foot .arrow { margin-left: auto; }
.service-card:hover .arrow,
.service-card:focus-visible .arrow { transform: translate(3px, -3px); }

/* Stacked, not side by side. Two cards would sit two-across happily
   enough, but each would then be half the width of the band above it and
   read as a smaller version of an Exp card — which is the one thing this
   block exists NOT to look like. Full width keeps them a different shape. */
.service-stack {
  display: grid;
  gap: clamp(0.875rem, 1.6vw, 1.25rem);
}

/* The section itself needs the same air the ecosystem above it has. */
.service { padding-top: clamp(2.5rem, 6vh, 4rem); }

/* ============================================================
   CONSULTING PAGE  —  /consulting
   ============================================================ */

/* Sits under the page-head lede as a quiet aside, not a second lede. */
.name-gloss {
  margin-top: clamp(0.875rem, 2vw, 1.125rem);
  font-size: 0.8125rem;
  line-height: 1.6;
  color: var(--muted-soft);
}
/* page-head p is set larger and lighter; this overrides both. */
.page-head p.name-gloss { font-size: 0.8125rem; }

/* .tenets is shared with /about, where the grid is the whole section and
   needs no heading. Here it carries one, so the heading has to span all
   three tracks rather than sit in the first. */
.tenets-head {
  grid-column: 1 / -1;
  margin-bottom: clamp(0.25rem, 1vw, 0.75rem);
}

.framework { padding-top: clamp(2.5rem, 6vh, 3.75rem); }

/* Same shape as the /exps .row — number, name, body — so the two
   detail lists on the site read as one family. Not a table: the third
   column of the source table is an outcome per phase, which stacks
   under its own phase far more legibly than it aligns across three. */
.phase {
  display: grid;
  grid-template-columns: 3.25rem minmax(0, 14rem) minmax(0, 1fr);
  align-items: start;
  gap: 1.5rem;
  padding: clamp(1.375rem, 2.8vw, 1.875rem) 0.5rem;
  border-top: 1px solid var(--rule);
}
.phases > :last-child { border-bottom: 1px solid var(--rule); }

.phase-num {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  padding-top: 0.3125rem;
}
.phase-name {
  font-size: 1.0625rem;
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1.3;
  text-wrap: balance;
}
.phase-what {
  font-size: 0.9375rem;
  line-height: 1.65;
  color: var(--muted);
  max-width: 32rem;
  text-wrap: pretty;
}
/* The outcome is the part a prospect is actually buying, so it is set
   apart from the description rather than run on from it. */
.phase-out {
  margin-top: 0.875rem;
  padding-left: 0.875rem;
  border-left: 1px solid var(--rule);
  font-size: 0.875rem;
  line-height: 1.6;
  color: var(--paper);
  max-width: 32rem;
  text-wrap: pretty;
}
.phase-out-label {
  display: block;
  margin-bottom: 0.25rem;
  font-size: 0.625rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted-soft);
}

.whys { padding-top: clamp(2.5rem, 6vh, 3.75rem); }
.why-list { list-style: none; max-width: 42rem; }
.why-list li {
  padding: 0.9375rem 0 0.9375rem 1.125rem;
  border-left: 1px solid var(--rule);
  font-size: 0.9375rem;
  line-height: 1.65;
  color: var(--muted);
  text-wrap: pretty;
}
.why-list li + li { margin-top: 0.375rem; }
.why-list strong { color: var(--paper); font-weight: 600; }

/* The "in design" qualifier on /about/. Set apart from the paragraph it
   qualifies rather than run into it, because a disclaimer that reads as
   part of the pitch is not a disclaimer. */
.status-note {
  display: flex;
  align-items: baseline;
  gap: 0.625rem;
  margin-top: 1.125rem;
  padding: 0.875rem 1rem;
  border: 1px solid var(--rule);
  border-left: 2px solid rgba(255, 90, 80, 0.45);
  border-radius: 4px;
  font-size: 0.8125rem;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}
.status-note .tag { align-self: flex-start; }

/* ---------- people (about) ----------
   STYLED AHEAD OF USE. The markup this dresses is sitting commented out
   in about/index.html, waiting on real names — so the rules exist now
   and uncommenting is the only step left. Without them, the first
   person added would render as an unstyled list. */
.people { list-style: none; max-width: 42rem; }
.person {
  padding: clamp(0.875rem, 2vw, 1.125rem) 0;
  border-top: 1px solid var(--rule-soft);
}
.people > :last-child { border-bottom: 1px solid var(--rule-soft); }
.person-name {
  display: block;
  font-size: 1.0625rem;
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--paper);
}
.person-role {
  display: block;
  margin-top: 0.1875rem;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent);
}
.person-note {
  display: block;
  margin-top: 0.4375rem;
  font-size: 0.875rem;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

/* ---------- consulting: the ask ---------- */
.cta {
  margin-top: clamp(2.75rem, 6vh, 4rem);
  padding: clamp(1.75rem, 3.5vw, 2.5rem);
  border: 1px solid var(--rule);
  border-radius: 4px;
  background-color: rgba(8, 9, 11, 0.22);
}
.cta-head {
  font-size: clamp(1.375rem, 3.2vw, 1.875rem);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.15;
}
.cta-body {
  margin-top: 0.75rem;
  max-width: 34rem;
  font-size: 0.9375rem;
  line-height: 1.65;
  color: var(--muted);
  text-wrap: pretty;
}
.cta-link {
  display: inline-flex;
  align-items: center;
  gap: 0.625rem;
  margin-top: clamp(1.125rem, 2.5vw, 1.5rem);
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--accent);
  transition: color 220ms ease;
}
.cta-link:hover { color: var(--paper); }
.cta-link:hover .arrow { transform: translate(3px, -3px); }

/* ---------- consulting + service: narrow ---------- */
@media (max-width: 860px) {
  .service-card { grid-template-columns: 1fr; gap: 1.125rem; }
  .phase {
    grid-template-columns: 2.5rem minmax(0, 1fr);
    gap: 0.75rem 1rem;
  }
  .phase-num  { grid-row: 1; padding-top: 0.1875rem; }
  .phase-name { grid-row: 1; }
  .phase-body { grid-column: 2 / -1; grid-row: 2; }
}

/* ============================================================
   EXPRI 100  —  /expri100

   An S&P-style heat map, with one important difference that shapes
   the whole thing: THERE IS NO SIZE VARIABLE. A market heat map sizes
   its rectangles by market cap and colours them by daily change. Expri
   has no cap and no daily change, and inventing either would be a lie
   told in pixels — so the encoding is only what is actually true:

     AREA   each Exp's weight, as a share of its row. WEIGHTS ARE
            PLACEHOLDER — Peter's instruction was to vary them by no
            more than 50% until there is real data, and the intended
            variable is eventually people using it and private worth.
            Spread is exactly 1.5x, min to max. The numbers live in the
            markup's --cols, not here.
     COLOUR availability, as an ORDINAL one-hue ramp — open, private,
            not yet. Ordered, so it takes a ramp rather than
            categorical hues.

   The ramp is the site's own vermillion at three lightness steps,
   computed in OKLCH rather than picked by eye, and validated: monotone
   lightness, adjacent dL >= 0.06, faintest step still 2.12:1 against
   the page so it cannot vanish, hue spread 2deg. Chroma is held low on
   purpose — these are large blocks, and saturated fill belongs on small
   marks, not on slabs.

   Status is ALSO written on every tile as text, so the encoding is
   never colour alone, and /exps/ is the table view.
   ============================================================ */

:root {
  /* OKLCH L 0.40 / 0.47 / 0.54 at C 0.030 / 0.055 / 0.079, H 27deg.
     Contrast on the page ink: 2.12:1, 2.81:1, 3.73:1.
     --paper on each of them: 8.31:1, 6.25:1, 4.71:1 — so tile text is
     the same colour on every step and still clears AA on the lightest. */
  --heat-1: #57423F;   /* soon    */
  --heat-2: #774F4A;   /* private */
  --heat-3: #975C56;   /* open    */
}

/* ---------- key ---------- */
.map-key {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.75rem clamp(1.25rem, 3vw, 2.25rem);
  padding-bottom: clamp(1.125rem, 2.5vw, 1.5rem);
}
.key-scale {
  display: flex;
  align-items: center;
  gap: 0.875rem;
  flex-wrap: wrap;
}
.key-label {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted-soft);
}
.key-step {
  display: inline-flex;
  align-items: center;
  gap: 0.4375rem;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--paper);
}
.sw {
  width: 0.875rem;
  height: 0.875rem;
  border-radius: 3px;
  /* Hairline, so the faintest step still has an edge against the page. */
  box-shadow: inset 0 0 0 1px var(--rule);
}
.sw-0 { background: transparent; box-shadow: none; border: 1px dashed var(--rule); }
.sw-1 { background: var(--heat-1); }
.sw-2 { background: var(--heat-2); }
.sw-3 { background: var(--heat-3); }

.key-note {
  flex: 1 1 20rem;
  font-size: 0.75rem;
  line-height: 1.6;
  color: var(--muted-soft);
  text-wrap: pretty;
}

/* ---------- the map ---------- */
/* TWO ROWS, EACH SPLIT BY WEIGHT. Every proportion comes from a --cols
   custom property set inline in the markup, which is the single place
   the weights live; the stylesheet only says "use them". Custom
   property rather than an inline grid-template-columns so the narrow
   breakpoint below can still override it — an inline template would
   need !important to beat.
   minmax(0, Nfr) rather than plain Nfr: a bare fr track floors at
   min-content, so a long Exp name would quietly widen its own tile and
   break the very proportion the tile is meant to encode. */
.map {
  display: grid;
  gap: clamp(0.75rem, 1.6vw, 1.125rem);
}
.map-row {
  display: grid;
  grid-template-columns: var(--cols);
  gap: clamp(0.75rem, 1.6vw, 1.125rem);
}

.sector { min-width: 0; }
.sector-name {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  margin-bottom: 0.5rem;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted-soft);
}
.sector-name span {
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.04em;
  color: var(--accent);
}
/* Programs are not Exps. A rule says so without spending the colour
   channel, which availability has already claimed. */
.s-programs .sector-name { border-left: 2px solid rgba(255, 90, 80, 0.45); padding-left: 0.5rem; }

.sector-tiles {
  display: grid;
  grid-template-columns: var(--cols);
  gap: clamp(0.375rem, 0.8vw, 0.5rem);
}

/* ---------- a tile ---------- */
.tile {
  position: relative;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  /* RAISED from clamp(6rem, 13vh, 8.25rem). The hover overlay was being
     clipped: the narrowest tile is ~137px wide, which at 11px leaves
     about 20 characters a line, so a 50-character note needs three lines
     plus the name above it. 8rem is the floor that fits that without
     scrolling inside a tile. */
  /* Raised again on 2026-08-22 (6rem -> 8rem -> this). Tiles are now
     roughly square at the widths they render at — 154-228px across
     against 160-224px tall — which is the proportion a heat map cell
     wants, and it gives the hover note room it was short of. */
  min-height: clamp(10rem, 20vh, 14rem);
  padding: 0.875rem 0.9375rem;
  border-radius: 4px;
  /* No border colour of its own: the fill IS the encoding, and an
     outline would read as a fourth step. A hairline inset keeps the
     faintest tile from bleeding into the page. */
  box-shadow: inset 0 0 0 1px rgba(243, 242, 239, 0.08);
  color: var(--paper);
  transition: box-shadow 220ms ease, filter 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* CLOSED IS NOT A FOURTH RAMP STEP, and cannot be. The ramp's window
   is fenced at both ends — L 0.40 is the first step visible against
   the page, L 0.54 the last that still takes --paper text at AA — and
   four steps inside it would need dL 0.047, under the 0.06 floor.
   So closed is drawn as absence instead: no fill, a dashed rule, muted
   text. It reads as "not on the scale", which is exactly right, and it
   borrows the language the unbuilt panel already uses. */
.heat-0 {
  background-color: transparent;
  box-shadow: none;
  border: 1px dashed var(--rule);
  color: var(--muted-soft);
}
.heat-0:hover,
.heat-0:focus-visible { filter: none; border-color: var(--accent); box-shadow: none; }

.heat-1 { background-color: var(--heat-1); }
.heat-2 { background-color: var(--heat-2); }
.heat-3 { background-color: var(--heat-3); }

/* No lift and no shadow — the same gesture the cards refuse. Brightening
   the fill is the honest hover for a heat map: it reads as "this cell",
   not as "this cell moved". */
.tile:hover,
.tile:focus-visible {
  filter: brightness(1.18);
  box-shadow: inset 0 0 0 1px var(--accent);
}

.tile-num {
  font-size: 0.625rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  font-variant-numeric: tabular-nums;
  opacity: 0.7;
}
.tile-name {
  margin-top: 0.1875rem;
  font-size: clamp(1rem, 1.9vw, 1.1875rem);
  font-weight: 700;
  letter-spacing: -0.025em;
  line-height: 1.1;
}
.tile-state {
  margin-top: auto;
  font-size: 0.625rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  opacity: 0.85;
}

/* THE HOVER DETAIL. A heat-map cell earns a tooltip; this is one, drawn
   inside the tile so it cannot overflow the grid or need a script. It
   covers the tile rather than pushing it, so nothing reflows. */
.tile-more {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: flex-end;
  padding: 0.875rem 0.9375rem;
  font-size: 0.6875rem;
  font-weight: 500;
  line-height: 1.5;
  text-wrap: pretty;
  background-color: rgba(8, 9, 11, 0.86);
  opacity: 0;
  transition: opacity 200ms ease;
}
.tile:hover .tile-more,
.tile:focus-visible .tile-more { opacity: 1; }

/* ---------- the unbuilt remainder ---------- */
.rest-panel {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.5rem 0.75rem;
  min-height: clamp(10rem, 20vh, 14rem);
  padding: 0.875rem 1rem;
  border: 1px dashed var(--rule);
  border-radius: 4px;
}
/* Smaller than it was: the panel lost a third of its width when
   Commerce moved into this row, and a 2.75rem figure in a 376px box
   shouted louder than the eight real tiles beside it. */
.rest-count {
  font-size: clamp(1.375rem, 2.6vw, 1.875rem);
  font-weight: 800;
  letter-spacing: -0.04em;
  line-height: 1;
  color: var(--muted-soft);
  font-variant-numeric: tabular-nums;
}
.rest-note {
  max-width: 16rem;
  font-size: 0.75rem;
  line-height: 1.55;
  color: var(--muted-soft);
  text-wrap: pretty;
}

/* ---------- map: narrow ---------- */
/* The twelve-column proportion is the encoding, so it is kept as long as
   it fits. Below this the blocks stack and the area relationship is
   carried by tile COUNT alone, which is the honest degradation. */
@media (max-width: 880px) {
  /* Weight proportions are the encoding, so they are kept as long as
     they fit. Below this the sectors stack full width and the encoding
     survives inside each sector only — the honest degradation, since a
     1.05fr tile beside a 3.45fr one is unreadable on a phone anyway. */
  .map-row { grid-template-columns: 1fr; }
  .tile { min-height: 8.5rem; }
}

@media (prefers-reduced-motion: reduce) {
  .tile-more { transition: none; }
}

/* ============================================================
   VIEW SWITCH  —  /exps

   List and grid are the SAME markup. Two radios sit ahead of the
   roster and a sibling selector restyles it, so each Exp is written
   once and the two views cannot drift apart. No script: this is the
   one interaction on the site that a checked pseudo-class does
   completely, and adding JS for it would be adding a second script to
   a site that has one.
   ============================================================ */

/* The sibling selectors below all hang off the two radios, so this
   wrapper is what keeps them in scope. */
.roster-wrap { display: block; }

/* Off-screen rather than display:none — a hidden radio is not
   focusable, and the labels are the only way to operate this. */
.vsw {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden; clip-path: inset(50%);
  white-space: nowrap; border: 0;
}

.roster-bar {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: clamp(1rem, 2vw, 1.375rem);
}
.roster-bar .label { margin-right: 0.25rem; }
.roster-bar label {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted-soft);
  cursor: pointer;
  padding-bottom: 0.1875rem;
  border-bottom: 1px solid transparent;
  transition: color 220ms ease, border-color 220ms ease;
}
.roster-bar label:hover { color: var(--accent); }
/* The checked view reads as present, the way the current nav item does. */
#v-list:checked ~ .roster-bar label[for="v-list"],
#v-grid:checked ~ .roster-bar label[for="v-grid"] {
  color: var(--paper);
  border-bottom-color: var(--accent);
}
/* Keyboard focus lands on the radio, which is off-screen, so the ring has
   to be drawn on its label instead. */
.vsw:focus-visible ~ .roster-bar label[for="v-list"],
.vsw:focus-visible ~ .roster-bar label[for="v-grid"] {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

/* GRID VIEW. The row's own children are reused: the number, the name and
   the blurb stack instead of sitting in tracks, and .host inside the
   blurb becomes the card's foot for free. */
#v-grid:checked ~ .roster {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(0.875rem, 1.6vw, 1.25rem);
}
#v-grid:checked ~ .roster > :last-child { border-bottom: 0; }
#v-grid:checked ~ .roster .row {
  display: flex;
  flex-direction: column;
  gap: 0.4375rem;
  margin: 0;
  padding: clamp(1.25rem, 2.2vw, 1.75rem);
  border: 1px solid var(--rule);
  border-radius: 4px;
  background-color: rgba(8, 9, 11, 0.22);
}
#v-grid:checked ~ .roster .row:hover,
#v-grid:checked ~ .roster .row:focus-visible {
  background-image: linear-gradient(var(--tint), var(--tint));
  border-color: rgba(255, 90, 80, 0.32);
}
#v-grid:checked ~ .roster .num { padding-top: 0; }
#v-grid:checked ~ .roster .name { font-size: clamp(1.375rem, 2.6vw, 1.625rem); }
#v-grid:checked ~ .roster .blurb { font-size: 0.875rem; max-width: none; }
/* Pushed to the bottom so the foots line up across a row of cards. */
#v-grid:checked ~ .roster .host { margin-top: auto; padding-top: 1rem; }
#v-grid:checked ~ .roster .arrow { display: none; }

@media (max-width: 980px) {
  #v-grid:checked ~ .roster { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 620px) {
  #v-grid:checked ~ .roster { grid-template-columns: 1fr; }
}

/* ---------- roster tools (/exps) ----------
   Inserted by roster.js, so these rules only ever have anything to dress
   when the script has run. Ordered search-then-index-then-count, in one
   row above the list, because a filter that sits beside or below the
   thing it filters reads as unrelated to it. */
/* At the foot of the page now, below the programmes. Air above it and a
   rule to separate it from the content it filters. */
.roster-tools {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem 1.25rem;
  margin-top: clamp(1.5rem, 3vw, 2.25rem);
  padding-top: clamp(1.25rem, 2.5vw, 1.75rem);
  border-top: 1px solid var(--rule-soft);
}
.roster-search {
  flex: 1 1 14rem;
  max-width: 22rem;
  padding: 0.5rem 0.75rem;
  font: inherit;
  font-size: 0.875rem;
  color: var(--paper);
  background-color: rgba(8, 9, 11, 0.42);
  border: 1px solid var(--rule);
  border-radius: 4px;
  transition: border-color 220ms ease;
}
.roster-search::placeholder { color: var(--muted-soft); }
.roster-search:focus { outline: none; border-color: var(--accent); }
/* Safari paints its own cancel affordance; let it, but keep it on-palette. */
.roster-search::-webkit-search-cancel-button { filter: grayscale(1) opacity(0.6); }

.roster-tools-slot:empty { display: none; }
.roster-index { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.roster-letter {
  min-width: 1.75rem;
  padding: 0.25rem 0.4375rem;
  font: inherit;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted-soft);
  background: none;
  border: 1px solid transparent;
  border-radius: 3px;
  cursor: pointer;
  transition: color 220ms ease, border-color 220ms ease;
}
.roster-letter:hover { color: var(--accent); }
.roster-letter[aria-pressed="true"] {
  color: var(--paper);
  border-color: var(--rule);
}

.roster-count {
  margin-top: 0.75rem;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted-soft);
  font-variant-numeric: tabular-nums;
}
.roster-empty {
  padding: 1.5rem 0;
  font-size: 0.9375rem;
  color: var(--muted);
}

/* ---------- the marks (/about) ---------- */
.mark-pair {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(1.5rem, 4vw, 3rem);
}
.mark { margin: 0; min-width: 0; }
/* A fixed box for both, so a raster seal and a 3D cube sit on one line
   even though nothing about their dimensions matches. */
.mark-art {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  min-height: 7.5rem;
  margin-bottom: 1.125rem;
}
.seal-art { width: clamp(4rem, 7vw, 5.5rem); height: auto; display: block; }
.mark-name {
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: -0.015em;
  color: var(--paper);
}
.mark-name span[lang="zh"] {
  margin-left: 0.375rem;
  font-weight: 500;
  color: var(--muted-soft);
}
.mark-note {
  margin-top: 0.625rem;
  font-size: 0.8125rem;
  line-height: 1.65;
  color: var(--muted);
  text-wrap: pretty;
}
/* Unscoped from .mark-note: the exponent notation now appears in the
   thesis copy on the home page too, which is where it actually argues
   the point rather than just captioning the cube. */
.formula { font-weight: 700; color: var(--paper); white-space: nowrap; }

/* The nudge under the cube. Set apart from the prose above it and in the
   accent, because it is the only line in "The marks" that asks the reader
   to DO something rather than telling them what they are looking at. */
.mark-cue {
  margin-top: 0.75rem;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted-soft);
}
.mark-cue strong { color: var(--accent); font-weight: 700; }
.formula sup {
  font-size: 0.68em; line-height: 0; vertical-align: super; color: var(--accent);
}
.marks { padding-top: clamp(2.5rem, 6vh, 3.75rem); }
/* The cube on /about/ is the draggable one, sized to be looked at.
   position:relative so the spark field can be anchored to it; no
   overflow, because the sparks are supposed to leave. */
.marks .cube-stage {
  --cube: clamp(4rem, 7vw, 5.5rem);
  position: relative;
}
/* Tapping it says "this does something" before you know what. */
.marks .cube-stage { cursor: pointer; }
.cube-js .marks .cube-stage { cursor: grab; }
.cube-js .marks .cube-stage.is-grabbed { cursor: grabbing; }

/* ---------- 億 — the detonation ----------
   A tap on the /about/ cube throws out a hundred million ideas, at the
   scale a browser can actually hold. Five layers, because one layer of
   anything reads as an effect and five read as an event:

     .fx-flash   a radial bloom, gone in 120ms
     .fx-ring    three shockwaves on staggered delays
     .spark      210 dots, embers and streaks — the energy
     .bit        84 glyphs — operators, logic, binary, EXP, AI, RI
     .bit-seal   14 of 億 想 天 开, as their traced masks

   The glyphs are the point. Confetti says "something happened"; a
   tumbling ∑ and a 開 say what happened. They are thrown further than
   the sparks and live longer, so once the energy has burnt off, the
   readable things are what is still in the air.

   THE CUBE IS FLARED, NEVER TRANSFORMED — see the note in cube.js.
   Everything here is transform + opacity + filter, all compositor
   properties, and the whole field is torn out of the DOM after 2.9s. */

.fx {
  position: absolute;
  left: 50%;
  top: 48%;
  width: 0;
  height: 0;
  pointer-events: none;
  z-index: 4;
}

.fx-flash {
  position: absolute;
  left: 0; top: 0;
  width: 24rem; height: 24rem;
  margin: -12rem 0 0 -12rem;
  border-radius: 50%;
  background: radial-gradient(circle,
              rgba(255,255,255,0.85) 0%,
              rgba(255,90,80,0.55) 26%,
              rgba(255,90,80,0) 68%);
  opacity: 0;
  animation: fx-flash 700ms cubic-bezier(0.1, 0.8, 0.3, 1) both;
}
@keyframes fx-flash {
  0%   { transform: scale(0.05); opacity: 0; }
  9%   { opacity: 1; }
  100% { transform: scale(2.6); opacity: 0; }
}

.fx-ring {
  position: absolute;
  left: 0; top: 0;
  width: 30rem; height: 30rem;
  margin: -15rem 0 0 -15rem;
  border: 2px solid rgba(255, 90, 80, 0.75);
  border-radius: 50%;
  opacity: 0;
  animation: fx-ring 1100ms cubic-bezier(0.12, 0.72, 0.24, 1) var(--d, 0s) both;
}
@keyframes fx-ring {
  0%   { transform: scale(0.04); opacity: 0; border-width: 3px; }
  8%   { opacity: 0.9; }
  100% { transform: scale(var(--rs, 1)); opacity: 0; border-width: 1px; }
}

/* ---- the energy ---- */
.spark {
  position: absolute;
  left: 0; top: 0;
  width: 3px; height: 3px;
  border-radius: 50%;
  background: var(--paper);
  opacity: 0;
  will-change: transform, opacity;
  animation: spark-fly var(--t, 1.2s) cubic-bezier(0.14, 0.7, 0.2, 1)
             var(--d, 0s) both;
}
.spark-hot {
  width: 4px; height: 4px;
  background: var(--accent);
  box-shadow: 0 0 8px rgba(255, 90, 80, 0.9);
}
/* Rotated to lie along its own path — a motion line pointing anywhere
   else reads as debris rather than speed. */
.spark-streak {
  width: 1.5px; height: 15px;
  border-radius: 1px;
  background: linear-gradient(rgba(243,242,239,0), var(--paper));
}
.spark-ember {
  width: 2px; height: 2px;
  background: rgba(255, 160, 120, 0.9);
  box-shadow: 0 0 6px rgba(255, 120, 90, 0.7);
}
@keyframes spark-fly {
  0%   { transform: translate3d(0,0,0) rotate(var(--rot,0deg)) scale(0.15); opacity: 0; }
  6%   { opacity: 1; }
  62%  { opacity: 0.85; }
  100% { transform: translate3d(var(--dx), var(--dy), 0)
                    rotate(var(--rot,0deg)) scale(var(--s, 0.6));
         opacity: 0; }
}

/* ---- the ideas ---- */
.bit {
  position: absolute;
  left: 0; top: 0;
  font-size: 0.9375rem;
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--paper);
  white-space: nowrap;
  opacity: 0;
  will-change: transform, opacity;
  animation: bit-fly var(--t, 1.6s) cubic-bezier(0.13, 0.68, 0.22, 1)
             var(--d, 0s) both;
}
.bit-hot { color: var(--accent); text-shadow: 0 0 10px rgba(255,90,80,0.55); }

/* The emoji arrive in full colour from the system font. No colour and no
   text-shadow of ours — both are ignored by a colour emoji glyph anyway,
   and a shadow would only fringe it. Sized up hard: an emoji rendered at
   symbol size is an unreadable smudge, and half the joke is recognising
   which one just went past.
   The explicit stack matters. Manrope is declared with a unicode-range
   that excludes all of this, so the fallback would resolve anyway — but
   naming the emoji fonts means the browser does not walk the whole stack
   400 times mid-animation. */
.bit-emo {
  font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji',
               'Twemoji Mozilla', sans-serif;
  font-size: 1.5rem;
  text-shadow: none;
  color: initial;
}

/* The four seal characters ride out as their traced masks, not as text:
   no CJK font is loaded anywhere on this site and they must not arrive
   as tofu. Same SVGs the cube faces use, so nothing new is fetched. */
.bit-seal {
  width: 1.25rem; height: 1.25rem;
  background-color: var(--paper);
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center;  mask-position: center;
  -webkit-mask-size: contain;     mask-size: contain;
}

@keyframes bit-fly {
  0%   { transform: translate3d(0,0,0) rotate(0deg) scale(0.15); opacity: 0; }
  8%   { opacity: 1; }
  68%  { opacity: 0.9; }
  100% { transform: translate3d(var(--dx), var(--dy), 0)
                    rotate(var(--spin, 360deg)) scale(var(--s, 1));
         opacity: 0; }
}

/* The cube's own reaction. FILTER ONLY — cube.js owns `transform` on
   .cube-tip and rewrites it every frame; a CSS animation on the same
   property outranks an inline style and would wrench the cube out of
   its rotation. */
.marks .cube-stage.is-burst .cube-tip {
  animation: cube-flare 900ms cubic-bezier(0.16, 0.8, 0.3, 1) both;
}
@keyframes cube-flare {
  0%   { filter: none; }
  10%  { filter: brightness(3.4) drop-shadow(0 0 26px rgba(255,90,80,0.95)); }
  100% { filter: none; }
}

@media (prefers-reduced-motion: reduce) {
  /* cube.js returns before creating anything, so this is a second line
     of defence rather than the mechanism. */
  .fx { display: none; }
  .marks .cube-stage.is-burst .cube-tip { animation: none; }
}

@media (max-width: 700px) {
  .mark-pair { grid-template-columns: 1fr; }
  .mark-art { min-height: 0; }
}

/* ---------- next link ---------- */
.next {
  display: inline-flex;
  align-items: center;
  gap: 0.625rem;
  margin-top: clamp(1.375rem, 3vh, 2rem);
  font-size: 0.8125rem;
  font-weight: 600;
  color: var(--muted-soft);
  transition: color 220ms ease;
}
.next:hover { color: var(--accent); }
.next:hover .arrow { transform: translate(3px, -3px); }

/* ---------- colophon ---------- */
/* margin-top:auto stays: .shell is min-height 100svh, so without it a
   short page leaves the dead space BELOW the footer instead of above,
   which looks worse. What was actually oversized is the padding. */
.colophon {
  margin-top: auto;
  padding: clamp(2.25rem, 5vh, 3.25rem) 0 clamp(1.25rem, 2.6vw, 1.75rem);
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1.5rem;
  color: var(--muted-soft);
  font-size: 0.75rem;
  font-weight: 500;
}

/* ============================================================
   NARROW
   ============================================================ */

@media (max-width: 760px) {
  .row {
    grid-template-columns: 2.5rem minmax(0, 1fr) auto;
    gap: 0.75rem 1rem;
    align-items: baseline;
  }
  .num  { grid-row: 1; padding-top: 0.125rem; }
  .name { grid-row: 1; }
  .row .arrow { grid-row: 1; padding-top: 0.125rem; }
  .blurb {
    grid-column: 2 / -1;
    grid-row: 2;
    margin-top: -0.25rem;
  }
}

/* FIVE NAV ITEMS NOW. Measured off the shipped woff2 at wght 600, 11px,
   0.16em tracking: EXPRI 100 67px + EXPS 34 + CONSULTING 88 + ACADEMY 65 +
   ABOUT 46, plus gaps = ~427px, against a brand block of ~80px. That fits
   down to about 560px and overruns at 520 — which is where this breakpoint
   already sat, so adding the fifth item cost nothing. Convenient, not
   planned.
   Wrapping is the honest fix: the alternative is a hamburger, which this
   site has no JS budget or appetite for. Right-aligned so the second line
   stays under the first rather than drifting to the middle.
   A SIXTH ITEM DOES NOT FIT. At ~500px of nav the wrap point moves above
   640px, which is a phone-and-small-tablet regression. If /expri100/
   becomes the home page, drop it from the nav and the room comes back. */
@media (max-width: 520px) {
  .nav {
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: 0.4375rem 1rem;
  }
}

@media (max-width: 420px) {
  .masthead { gap: 0.75rem; align-items: flex-start; }
  .nav { gap: 0.375rem 0.875rem; letter-spacing: 0.1em; }
}

@media (prefers-reduced-motion: reduce) {
  * { transition-duration: 0.01ms !important; }
}


/* ============================================================
   TOUCH AND SMALL SCREENS

   A pass over everything built on 2026-08-22, which was designed and
   measured at desktop widths. Two of these are real bugs rather than
   polish, and both are invisible on a desktop:

     1. A 14px form field makes iOS Safari ZOOM THE PAGE on focus. The
        search box was 0.875rem. Anything under 16px does it.
     2. .tile-more only appeared on :hover, and a touch device has no
        hover — so every tile description on /expri100/ was unreachable
        on a phone. Tapping the tile follows its link instead.

   `hover: none` rather than a width query for both, because the
   question is what the input device can do, not how wide it is.
   ============================================================ */

@media (hover: none) {
  /* THE DESCRIPTION STOPS BEING AN OVERLAY and becomes part of the tile.
     No pointer means no way to summon it, so it has to be there already. */
  .tile-more {
    position: static;
    inset: auto;
    display: block;
    opacity: 1;
    padding: 0;
    margin-top: 0.4375rem;
    background-color: transparent;
    font-size: 0.6875rem;
    line-height: 1.45;
    color: rgba(243, 242, 239, 0.72);
  }
  /* .tile-state was pinned to the bottom by margin-top:auto; with copy
     under it that shove is wrong. */
  .tile-state { margin-top: 0.5rem; }
  .tile { justify-content: flex-start; }

  /* 28x24px was a mouse target. Thumbs need ~44. */
  .roster-letter { min-width: 2.75rem; padding: 0.5rem 0.5rem; }
  .roster-bar label { padding: 0.375rem 0; }
}

@media (max-width: 620px) {
  /* THE iOS ZOOM TRAP. Any focused input under 16px makes Safari scale
     the whole page and leave it there. 1rem is the fix and the only fix
     — a viewport with maximum-scale=1 would do it too, at the price of
     blocking pinch-zoom for everyone, which is not a trade worth making. */
  .roster-search { font-size: 1rem; max-width: none; }

  /* No !important needed: the inline style sets the CUSTOM PROPERTY
     --cols, not grid-template-columns, so this is one .sector-tiles rule
     against another and the later one in the file wins.
     Weight proportions are already gone below 880px (the sectors stack);
     below this the tile COUNT stops fitting too. Markets at three across
     on a 375px screen is ~105px a tile, which cannot hold a name and a
     description. Two across, then one. */
  .sector-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .tile { min-height: 9.5rem; }

  /* The dashed panel does not need to match a tile's height when it is
     the only thing on its row. */
  .rest-panel { min-height: 0; padding: 1rem; }
  .rest-count { font-size: 1.5rem; }

  .map-key { gap: 0.625rem 1rem; }
  .roster-tools { gap: 0.75rem; }
}

@media (max-width: 430px) {
  .sector-tiles { grid-template-columns: 1fr; }
  .tile { min-height: 0; }
  .tile-name { font-size: 1.125rem; }

  /* Five nav items over two lines already; tighten so they do not become
     three. */
  .nav { gap: 0.375rem 0.75rem; }

  /* The burst throws glyphs half a screen wide here — keep the section
     from feeling cramped around it. */
  .marks .cube-stage { --cube: 3.5rem; }
}
