/* Third Angle, component layer. Every colour comes from tokens.css. No literals. */

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

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

/*
 * Sticky footer. On a short page (an empty document library, a 404) the footer
 * otherwise floats half way up with dead canvas below it, which reads as a
 * truncated render rather than as a short page. dvh, not vh: on mobile Safari
 * 100vh is the address-bar-hidden height, so vh leaves a gap that appears only
 * on a phone.
 */
body {
  margin: 0;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  background: var(--canvas);
  color: var(--text-body);
  font-family: var(--f-body);
  font-weight: var(--w-body);
  font-size: 17px;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  /* A part number, a CAN id, or a long URL must fold rather than push the page
     sideways. break-word, not anywhere: it only breaks when there is no other
     opportunity, so ordinary prose is untouched. */
  overflow-wrap: break-word;
  /* Checkboxes and radios are the last native controls that ship a hard blue,
     which is on the ban list. One declaration retints every one of them. */
  accent-color: var(--accent);
}

/*
 * min-width: 0 is load bearing, not defensive.
 *
 * body is a flex column, so main is a flex item, and a flex item's automatic
 * minimum size is its content's min-content width. A table with a min-width
 * inside main therefore pushed main wider than the viewport even though the
 * table sat in an overflow:auto wrapper that was scrolling correctly on its
 * own. The symptom was a page that scrolled sideways with no element whose
 * bounding box was outside the viewport, which is the least debuggable form
 * this bug takes.
 */
body > main { flex: 1 0 auto; min-width: 0; }

/* ---------------------------------------------------------- type */

h1, h2, h3, h4 {
  font-family: var(--f-display);
  color: var(--text-strong);
  text-wrap: balance;
  margin: 0;
  font-weight: var(--w-head);
}
h1 { font-size: clamp(1.75rem, 1.2rem + 2.4vw, 2.5rem); line-height: 1.08; letter-spacing: -.022em; }
/* h2 and h3 were fixed sizes while h1 was fluid, so on a 320px phone the
   subheads crowded the display face they are supposed to sit under. */
h2 { font-size: clamp(1.375rem, 1.09rem + 1.05vw, 1.625rem); line-height: 1.16; letter-spacing: -.016em; }
h3 { font-size: clamp(1.1875rem, 1.05rem + .5vw, 1.3125rem); line-height: 1.25; letter-spacing: -.010em; }
h4 { font-size: 1.0625rem; line-height: 1.35; letter-spacing: -.004em; }

.display-1 {
  font-family: var(--f-display);
  font-weight: var(--w-strong);
  /*
   * The cap has to clear the window, not the old measure.
   *
   * At 5rem the headline stopped growing at about a 1000px window, so on a
   * 2560px screen its longest line filled 63% of the page and simply stopped,
   * with the title block sitting under the empty right end of it. 7rem keeps
   * the line between 90% and 98% of the page across every width from 1100 to
   * 2560, which is what "the title is the width of the page" means.
   */
  font-size: clamp(2.625rem, 1.2rem + 6.2vw, 7rem);
  /*
   * pretty, not balance. Balance evens the lines out, which is right for a
   * heading sitting in a column and wrong for one that is supposed to reach
   * both edges: it held the first line back to match the second and cost about
   * ten points of fill at every width.
   */
  text-wrap: pretty;
  line-height: .94;
  letter-spacing: -.036em;
  color: var(--text-strong);
  text-wrap: balance;
  margin: 0;
}

p { margin: 0 0 1.05em; }
.lede { font-size: 1.1875rem; line-height: 1.60; letter-spacing: -.002em; }

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

strong { font-weight: var(--w-med); color: var(--text-strong); }

/*
 * Search hit highlight. The browser default for <mark> is a hard yellow that
 * belongs to no palette, and over the dark canvas it is a glaring rectangle
 * with near-black text. Both halves come from tokens, and the underline is a
 * second, non-colour channel so the hit survives forced-colors mode.
 */
mark {
  background: var(--mark-bg);
  color: var(--mark-ink);
  padding: 0 .12em;
  border-radius: 2px;
  box-shadow: inset 0 -2px 0 var(--accent);
}
@media (forced-colors: active) {
  mark { background: Mark; color: MarkText; box-shadow: none; text-decoration: underline; }
}

/* Mono is a real information channel, never decoration. Ligatures off on
   identifiers so a part number containing -- or != can still be retyped. */
.mono, code {
  font-family: var(--f-mono);
  font-variant-ligatures: none;
  font-stretch: 82%;
  letter-spacing: .02em;
}
code {
  font-size: .78em;
  background: var(--sunken);
  border: 1px solid var(--hairline);
  border-radius: var(--r-control);
  padding: .1em .35em;
  color: var(--text-strong);
}

.eyebrow {
  font-family: var(--f-mono);
  font-size: .68rem;
  font-stretch: 80%;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 var(--s-2);
}


/* Anything with digits that line up in a column. */
.tnum, td.num, .metric-value {
  font-variant-numeric: tabular-nums lining-nums;
}

/* ---------------------------------------------------------- a11y */

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  /* A contrasting ring in the page colour, so the accent outline stays visible
     against a card as well as against the canvas. */
  box-shadow: 0 0 0 4px var(--canvas);
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--accent);
  color: var(--on-accent);
  padding: var(--s-3) var(--s-4);
  z-index: 100;
  font-family: var(--f-mono);
  font-size: .75rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  text-decoration: none;
}
.skip-link:focus { left: 0; }

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

/* Focused cards must never tuck under the sticky header, WCAG 2.4.11. */
[id] { scroll-margin-top: calc(var(--header-h, 64px) + var(--s-2)); }

/* ---------------------------------------------------------- layout */

/*
 * The gutter is padding-inline, not the padding shorthand, so a page that sets
 * its own vertical rhythm cannot silently delete it. A template writing
 * `padding: var(--s-7) 0` on a .wrap used to leave the 404 and 500 pages with
 * their text flush against the edge of a phone screen.
 */
/*
 * The page is the window.
 *
 * This used to cap at 1180px and centre, which put two grey margins down the
 * sides of a wide monitor and made the header float in the middle of a screen
 * it was supposed to span. The gutter stays — text hard against the glass is
 * not "using the width", it is a bug — but nothing is centred and nothing is
 * capped, so a 2560px window is 2560px of page.
 */
.wrap {
  max-width: none;
  margin: 0;
  padding-inline: clamp(18px, 4vw, 48px);
  width: 100%;
}
/* Vertical page rhythm, as a class rather than a per-template inline style, so
   there is nothing left to hand-roll into the padding shorthand by mistake.
   Both are block-axis only: the gutter is never theirs to touch. */
/* The gap between the header and the first line of a page. Was 52px, which
   pushed the eyebrow and the headline down far enough to look like a mistake
   on a laptop screen. */
.pad-top     { padding-block-start: var(--s-5); }
.pad-page-lg { padding-block: var(--s-6); }

/*
 * Text runs the full width of the page.
 *
 * This was a 68ch measure, which is the classic typographic advice and is the
 * right default for a page of continuous prose. It is not what this site is:
 * the paragraphs are short, they sit beside tables, metric rows, and card
 * grids that all run the full width, and a paragraph stopping at half the
 * window beside a full width rule reads as a rendering fault rather than as
 * typography. Asked for explicitly, and the right call for these pages.
 *
 * Nothing caps it now: .wrap runs the window, so on a wide monitor the longest
 * line is the window less its gutter. That is the width that was asked for.
 */
.prose { max-width: none; }
.measure { max-width: none; }

.stack   { display: flex; flex-direction: column; gap: var(--s-4); }
.stack-lg{ display: flex; flex-direction: column; gap: var(--s-6); }

/*
 * The step between sections: 32px of margin above the rule and 32px of padding
 * below it. This was 52 and 52, which put 104px between the end of one section
 * and the first line of the next. On a laptop that is a third of the window
 * spent on a gap, and it was the single most reported thing on the site.
 */
.section { padding-top: var(--s-5); margin-top: var(--s-5); border-top: 1px solid var(--hairline); }
.section:first-of-type { border-top: 0; margin-top: 0; }

/*
 * A block's own trailing margin must not be added to the gap a section already
 * sets.
 *
 * A lede ends with 20px of paragraph margin and the section under it opens with
 * 32px of padding, so the space between a page's opening paragraph and its
 * first heading came out at 52px — and with no rule drawn above a first
 * section, that is 52px of nothing. Every other gap on the page between a rule
 * and the content under it is 32px. This makes them the same.
 */
:has(+ .section) { margin-block-end: 0; }

/*
 * And the same at the other end: a block that OPENS a section does not add its
 * own margin either.
 *
 * .section already spends 32px of padding getting clear of its rule, and
 * `.prose h2` adds 32px more of its own. On a page whose outer wrapper carries
 * .prose — /attributions is the clearest case, four sections in a short page —
 * every section boundary therefore came out at 96px rather than the 64px the
 * rule above was written to produce, and the page read as mostly gap.
 *
 * The heading is not always the direct child, because a section may open with
 * a .prose wrapper around its content, so both shapes are named.
 */
.section > :first-child,
.section > .prose:first-child > :first-child { margin-top: 0; }

/*
 * Kept as an explicit `none` rather than deleted. `.section.prose` used to cap
 * its children, and a future edit that reintroduces a measure should have to
 * change this line on purpose rather than inherit one by accident.
 */
/* A section whose dividing line has been switched off in the admin. The
   spacing stays: the rule is the decoration, the gap is the structure. */
.section.no-rule { border-top: 0; }

.section.prose { max-width: none; }
.section.prose > * { max-width: none; }

.scroll-x { overflow-x: auto; }

/*
 * Any table wide enough to overflow scrolls inside its own box rather than
 * pushing the document sideways. tabindex and role are set in the template so
 * a keyboard user can reach the scroll area; -webkit-overflow-scrolling keeps
 * momentum on iOS.
 */
.table-wrap {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  max-width: 100%;
  /* So the wrapper itself can be narrower than the table it scrolls. */
  min-width: 0;
  /*
   * containment, and it is doing real work.
   *
   * overflow-x: auto alone was not enough. A table with a min-width inside it
   * still contributed that width to the document's scrollable area, so the
   * whole page could be dragged sideways into several hundred pixels of empty
   * canvas while the wrapper itself scrolled correctly. Nothing had a bounding
   * box outside the viewport, overflow-x: clip on an ancestor did not stop it,
   * and even the root element would not clip it, because the root's overflow is
   * propagated to the viewport rather than applied. Containment is the
   * mechanism that actually isolates the box.
   */
  contain: layout paint;
}
.table-wrap:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ---------------------------------------------------------- header */

.site-header {
  position: sticky; top: 0; z-index: 40;
  background: var(--canvas);
  border-bottom: 1px solid var(--hairline);
}
.site-header .bar {
  display: flex; align-items: center; gap: var(--s-3) var(--s-4);
  min-height: var(--header-h);
  flex-wrap: wrap;
}
.brand {
  display: inline-flex; align-items: stretch; gap: var(--s-3);
  text-decoration: none; color: var(--text-strong);
  margin-right: auto;
  min-width: 0;
  transition: color var(--t-hover) var(--ease);
}
.brand svg { display: block; flex: none; }
/*
 * The two lines fill the mark's height exactly: the wordmark's cap height at
 * the top of the mark, the strapline's baseline at the bottom. Centring them
 * instead left both lines floating inside a taller box, which is the version
 * of this that looks like nobody checked.
 */
.brand > span {
  display: flex; flex-direction: column; justify-content: space-between;
  min-width: 0;
}
/*
 * One object, one colour. The strapline carried --text-muted, so hovering the
 * logo turned three of its four parts orange and left the fourth grey.
 */
.brand:hover, .brand:focus-visible { color: var(--accent); }
.brand:hover .sub, .brand:focus-visible .sub { color: inherit; }
.brand .wordmark {
  font-family: var(--f-display);
  font-weight: var(--w-strong);
  letter-spacing: -.02em;
  font-size: 1.0625rem;
  line-height: 1;
}
.brand .sub {
  font-family: var(--f-mono);
  font-size: .56rem;
  font-stretch: 78%;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--text-muted);
  display: block;
  transition: color var(--t-hover) var(--ease);
}

.nav { display: flex; gap: var(--s-3) var(--s-4); align-items: center; flex-wrap: wrap; min-width: 0; }
.nav a {
  font-family: var(--f-mono);
  font-size: .66rem;
  font-stretch: 80%;
  letter-spacing: .12em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--text-muted);
}
.nav a:hover, .nav a[aria-current="page"] { color: var(--text-strong); }

/*
 * Tap targets on a phone.
 *
 * The chrome is set in a small mono face with no padding, which reads correctly
 * and gives a finger nothing to hit: measured at 390px, the nav links were
 * 17.4px tall, the theme buttons 21px, and the footer links 12px, against the
 * 40px a touch target is supposed to clear.
 *
 * Real height, not a pseudo-element reaching past the box. The reach was tried
 * first and it collides with itself: the nav wraps to two rows at 390px and
 * three at 320px, so a target growing 14px up and down covers the row above it
 * and steals its taps. Measured seven such thefts.
 *
 * Which makes the header taller, so below 700px it stops being sticky. A
 * sticky header was already 171px at 320px, a fifth of the screen, and paying
 * another 60px of it to hold a nav in place is the wrong trade on a phone: it
 * costs a fifth of every page to save one scroll to the top.
 */
@media (max-width: 700px) {
  .site-header { position: static; }
  /* No row gap: each link now carries its own height, and a gap on top of that
     is space between rows of targets rather than between rows of text. */
  .nav { gap: 0 var(--s-4); }
  .nav a,
  .site-footer a {
    display: inline-flex;
    align-items: center;
    min-height: 40px;
  }
  .site-footer .wrap { gap: 0 var(--s-4); }
  .theme-form button { min-height: 40px; padding-inline: 10px; }
  /* The header no longer covers anything, so an anchor does not need to clear
     it. A little room so the target does not sit against the top edge. */
  [id] { scroll-margin-top: var(--s-3); }
}

/* Theme control is a real form, so it works with JavaScript disabled. */
.theme-form {
  display: flex; gap: 2px; flex: none;
  border: 1px solid var(--hairline); border-radius: var(--r-control); padding: 2px;
}
.theme-form button {
  font-family: var(--f-mono); font-size: .58rem; letter-spacing: .1em; text-transform: uppercase;
  background: none; border: 0; border-radius: 2px; cursor: pointer;
  color: var(--text-muted); padding: 4px 7px; font-stretch: 80%;
  white-space: nowrap;
}
.theme-form button[aria-pressed="true"] { background: var(--sunken); color: var(--text-strong); }
.theme-form button:hover { color: var(--text-strong); }

/*
 * Narrow header. Below this width the brand, six nav links, and the three state
 * theme control cannot share one row: they used to overflow the viewport and
 * give every page on the site a horizontal scrollbar on a phone. The nav takes
 * its own full width row underneath, and the brand and the theme control share
 * the row above it.
 *
 * The header is two rows tall here, so --header-h is restated: it is what
 * scroll-margin-top is derived from, and a stale value tucks a focused card
 * under the bar.
 */
@media (max-width: 720px) {
  :root { --header-h: 108px; }
  .site-header .bar { padding-block: var(--s-2); }
  .nav { order: 3; flex: 1 0 100%; }
}

/* At the narrowest sizes the wordmark, the strapline under it, and the mark
   together are wider than the space left beside the theme control, so the
   strapline is dropped rather than allowed to push the layout apart. */
@media (max-width: 400px) {
  .brand svg { width: 54px; height: auto; }
  .brand .sub { display: none; }
  .brand .wordmark { font-size: 1rem; }
  .theme-form button { padding: 5px 6px; letter-spacing: .06em; }
}

/* ---------------------------------------------------------- hero */

/*
 * padding-block, never the four value shorthand.
 *
 * .hero always sits on .wrap, and .wrap is what supplies the page gutter. A
 * `padding: X 0 Y` here silently sets padding-inline to 0, and the hero, which
 * is the first thing anybody sees, is the one block on the site whose text
 * runs into the edge of the window. Same defect as the 404 page had.
 */
/*
 * No bottom padding, and that is deliberate rather than an omission.
 *
 * The hero is followed by a .section, and .section already puts 32px above its
 * own rule. A bottom padding here is added to that, not absorbed by it, so the
 * hero used to end 84px above the first dividing line while every other section
 * on the site ends 32px above its own. The gap read as a hole.
 *
 * padding-block, never the padding shorthand: the gutter is .wrap's and a
 * shorthand here silently deletes it. See the note on .wrap.
 */
.hero { padding-block: var(--s-5) 0; display: grid; gap: var(--s-4) var(--s-5); }
@media (min-width: 900px) {
  /*
   * The headline spans both columns; the lede and the title block share the
   * row under it. Asymmetric, content left weighted, and not a centred hero.
   *
   * This used to be two columns with the whole heading block in the left one,
   * which meant the headline broke to five lines while the right column stayed
   * empty from the top of the page down to the title block. start, not end:
   * both cells begin at the same line.
   */
  .hero { grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); align-items: start; }
  .hero-head { grid-column: 1 / -1; }
}
.hero .lede { color: var(--text-muted); margin-top: 0; }

.titleblock {
  border: 1px solid var(--rule);
  border-radius: var(--r-card);
  background: var(--surface);
  font-family: var(--f-mono);
  font-size: .62rem;
  font-stretch: 78%;
  letter-spacing: .04em;
}
.titleblock div {
  display: flex; justify-content: space-between; gap: var(--s-4);
  padding: var(--s-2) var(--s-3);
  border-bottom: 1px solid var(--hairline);
}
.titleblock div:last-child { border-bottom: 0; }
.titleblock dt, .titleblock .k { color: var(--text-muted); text-transform: uppercase; letter-spacing: .1em; }
.titleblock dd, .titleblock .v { color: var(--text-strong); text-align: right; margin: 0; }

/* ---------------------------------------------------------- facet chips */

.chips { display: flex; flex-wrap: wrap; gap: 6px; }
/*
 * The row is eight chips and it has to fit on one line.
 *
 * A single chip wrapping onto a second line reads as an accident rather than as
 * a layout, and the row keeps landing just over: at 7px of inline padding, a
 * 0.1em letter-spacing and an 8px gap it measured 1113px in a 1084px column.
 * Tightened by 66px in three places rather than 29px in one, so there is real
 * headroom rather than a fit that the next label breaks. The target is still
 * 44px tall for a finger, which is what the vertical padding is for.
 */
.chip {
  display: inline-flex; align-items: center; gap: 4px;
  border: 1px solid var(--hairline);
  border-radius: var(--r-control);
  padding: 5px 6px;
  background: var(--surface);
  font-family: var(--f-mono);
  font-size: .62rem;
  font-stretch: 80%;
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--text-strong);
  text-decoration: none;
  transition: border-color var(--t-hover) var(--ease), background-color var(--t-hover) var(--ease);
}
.chip:hover { border-color: var(--accent); color: var(--text-strong); }
.chip[aria-current="true"] {
  border-color: var(--accent);
  background: var(--accent-wash);
  color: var(--text-strong);
  /* A second, non colour channel, so the active filter survives forced-colors
     mode and is not conveyed by colour alone. */
  box-shadow: inset 2px 0 0 var(--accent);
  font-weight: var(--w-med);
}
.chip .dot { width: 9px; height: 9px; border-radius: 2px; flex: none; }

/* ---------------------------------------------------------- work grid */

/*
 * min() inside minmax is what makes an auto-fill grid genuinely fluid. A bare
 * minmax(280px, 1fr) cannot go narrower than 280px, so once the available
 * width drops below that, the track keeps its floor and pushes the document
 * sideways. min(280px, 100%) lets the last step collapse to the container.
 */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
  gap: var(--s-3);
  /* Hovering the gutter must not dim the whole grid. */
  pointer-events: none;
}
.grid > * { min-width: 0; }
.card {
  pointer-events: auto;
  display: flex; flex-direction: column; gap: var(--s-2);
  border: 1px solid var(--hairline);
  border-radius: var(--r-card);
  background: var(--surface);
  padding: var(--s-3) var(--s-4) var(--s-4);
  text-decoration: none;
  /* Declared on the base selector so the return trip animates too.
     Properties enumerated, never `all`. */
  transition:
    border-color var(--t-hover) var(--ease),
    background-color var(--t-group) var(--ease),
    box-shadow var(--t-hover) var(--ease),
    color var(--t-group) var(--ease);
}
.card .card-title {
  font-family: var(--f-display);
  font-weight: var(--w-med);
  font-size: 1.0625rem;
  line-height: 1.25;
  color: var(--text-strong);
  transition: color var(--t-group) var(--ease), font-weight var(--t-group) var(--ease);
}
/*
 * The meta row, wherever it appears. This was scoped to `.card .card-meta`,
 * so the same class on a document row inherited body type instead: a 9px
 * badge next to 17px text, which is the mismatch you see before you can name
 * it. One component, one definition.
 */
.card-meta {
  font-family: var(--f-mono);
  font-size: .58rem;
  font-stretch: 80%;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--text-muted);
  display: flex; flex-wrap: wrap; gap: 4px 10px;
  /*
   * center, not the default stretch. A badge is taller than bare text because
   * it carries a border and padding, and stretching the plain spans to match
   * leaves their text sitting at the top of a tall box while the badge's text
   * is centred in its own. The boxes line up and the words do not, which is
   * the version of this bug that survives a screenshot review.
   */
  align-items: center;
}
.card .card-summary { font-size: .9375rem; line-height: 1.55; color: var(--text-body); margin: 0; }
.card .card-title, .card .card-summary, .card .card-meta { min-width: 0; }

.card:hover, .card:focus-visible {
  border-color: var(--accent);
  box-shadow: inset 2px 0 0 var(--accent);
}

/*
 * Highlight and dim.
 * Token swap, not opacity. See DESIGN.md rule 3 and tests/contrast.test.mjs.
 * Both a :hover and a :focus-within branch so keyboard traversal gets identical
 * emphasis, and a [data-filtering] branch so search and facet filtering reuse it.
 */
.grid:has(> .card:hover) > .card:not(:hover),
.grid:has(> .card:focus-within) > .card:not(:focus-within),
.grid[data-filtering] > .card[data-match="false"] {
  background: var(--dim-surface);
  border-color: var(--dim-border);
  color: var(--dim-text);
}
.grid:has(> .card:hover) > .card:not(:hover) .card-title,
.grid:has(> .card:focus-within) > .card:not(:focus-within) .card-title,
.grid[data-filtering] > .card[data-match="false"] .card-title {
  color: var(--dim-title);
  font-weight: var(--w-dim);
}
.grid:has(> .card:hover) > .card:not(:hover) .card-summary,
.grid:has(> .card:focus-within) > .card:not(:focus-within) .card-summary,
.grid[data-filtering] > .card[data-match="false"] .card-summary,
/* The meta row and the status badge are most of a card's ink, so leaving them
   at full strength made the dim read as a rendering fault rather than focus. */
.grid:has(> .card:hover) > .card:not(:hover) .card-meta,
.grid:has(> .card:focus-within) > .card:not(:focus-within) .card-meta,
.grid[data-filtering] > .card[data-match="false"] .card-meta,
.grid:has(> .card:hover) > .card:not(:hover) .badge,
.grid:has(> .card:focus-within) > .card:not(:focus-within) .badge,
.grid[data-filtering] > .card[data-match="false"] .badge {
  color: var(--dim-text);
  border-color: var(--dim-border);
}
/* Saturation drop on imagery is what makes it read as focus rather than a
   broken render. Blur is not used: it destroys legibility and is a flagged tell. */
.grid:has(> .card:hover) > .card:not(:hover) img,
.grid[data-filtering] > .card[data-match="false"] img {
  filter: saturate(.30) contrast(.97);
}

@media (prefers-reduced-motion: reduce) {
  /* The state change still happens. It is information, not decoration.
     Nothing moves. */
  .card, .card-title, .chip { transition-duration: 1ms !important; }
}

@media (forced-colors: active) {
  /* Author colours are stripped here, so carry the state on a second channel. */
  .grid:has(> .card:hover) > .card:not(:hover),
  .grid:has(> .card:focus-within) > .card:not(:focus-within),
  .grid[data-filtering] > .card[data-match="false"] { color: GrayText; border-color: ButtonBorder; }
  .card:hover, .card:focus-visible { border-color: Highlight; }
}

/* ---------------------------------------------------------- metrics */

.metrics {
  /*
   * The separators are borders on the cells, not the container showing through
   * one pixel gaps.
   *
   * With the gap technique, a row that is not completely filled — three metrics
   * in a two column grid, four in a three column grid — leaves the container's
   * background covering the empty slot at full size, and it reads as a broken
   * cell rather than as absence. Measured: a 279x69 flat block beside "SINCE"
   * on /professional at 1024, and 142x69 on /education at 320.
   *
   * Each cell draws its own top and left line and pulls itself a pixel up and
   * left, so the first row and column land under the container's own border and
   * every interior edge carries exactly one line. An empty slot now shows the
   * surface, which is what it is.
   */
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(120px, 100%), 1fr));
  gap: 0;
  background: var(--surface);
  border: 1px solid var(--hairline);
  border-radius: var(--r-card);
  overflow: hidden;
}
.metric {
  background: var(--surface);
  padding: var(--s-3) var(--s-4);
  /* Centred. A metric is a number and its name, read as one object, and a row
     of them left aligned in cells of different widths reads as a table that
     lost its columns. */
  text-align: center;
  /* One line per interior edge: each cell draws its top and left and pulls
     itself under the container's border, which overflow: hidden then clips. */
  border-top: 1px solid var(--hairline);
  border-left: 1px solid var(--hairline);
  margin: -1px 0 0 -1px;
}
.metric-value {
  font-family: var(--f-display);
  font-weight: var(--w-strong);
  font-size: 1.5rem;
  line-height: 1.05;
  color: var(--text-strong);
  letter-spacing: -.02em;
}
.metric-label {
  font-family: var(--f-mono);
  font-size: .56rem;
  font-stretch: 80%;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-top: 5px;
}

/* ---------------------------------------------------------- misc */

/* Inside a meta row every chip is the same size as the words beside it. A
   badge that is 0.02rem smaller than its neighbours reads as a mistake. */
.card-meta .badge { font-size: inherit; letter-spacing: inherit; }

.badge {
  /*
   * inline-flex, not inline-block, so the text centres.
   *
   * The badge sits in a flex row beside .chip, which stretches every item to
   * the tallest. A block badge then flows its text at its own line height
   * inside a taller box, so the words hug the top: measured, 3.7px above the
   * centre the chips' text sits on, which reads as one item out of line.
   */
  display: inline-flex;
  align-items: center;
  font-family: var(--f-mono);
  font-size: .56rem;
  font-stretch: 80%;
  letter-spacing: .12em;
  text-transform: uppercase;
  border: 1px solid var(--hairline);
  border-radius: var(--r-control);
  padding: 3px 7px;
  color: var(--text-muted);
}
.badge[data-status="specification"] { border-color: var(--copper); color: var(--copper); }
.badge[data-status="shipped"], .badge[data-status="competed"] { border-color: var(--patina); color: var(--patina); }
.badge[data-status="in-progress"] { border-color: var(--accent); color: var(--accent-ink); }
/*
 * Whether a row is on the public site is a state like any other on that row, so
 * it is a chip like any other. It was the one plain lowercase word in a table
 * of chips, which made the most consequential column the easiest to skim past.
 */
.badge[data-live="yes"] { border-color: var(--patina); color: var(--patina); }
.badge[data-live="draft"] { border-color: var(--rule); color: var(--text-muted); }

/* ---- the operator's bar --------------------------------------------------
 * Shown on the public site to whoever is signed in to the admin, and to nobody
 * else. Deliberately unlike the site: a working tool sitting on top of the
 * page, not part of the design being looked at.
 *
 * Sticky, because the reason it exists is to be reachable without scrolling
 * back to anything. It sits above .site-header, which is sticky at top: 0, so
 * this takes that spot and the header is pushed under it by --op-bar-h.
 */
.op-bar {
  position: sticky; top: 0; z-index: 70;
  display: flex; align-items: center; gap: var(--s-4);
  padding: 6px clamp(18px, 4vw, 48px);
  background: var(--text-strong); color: var(--surface);
  font-family: var(--f-mono); font-size: .62rem; font-stretch: 80%;
  letter-spacing: .1em; text-transform: uppercase;
}
.op-who { opacity: .72; margin-right: auto; text-transform: none; letter-spacing: .04em; }
.op-link { color: var(--surface); text-decoration: none; border-bottom: 1px solid transparent; }
.op-link:hover, .op-link:focus-visible { border-bottom-color: currentColor; }
/* The header stops being the topmost sticky element while the bar is there. */
.op-bar ~ .site-header { top: var(--op-bar-h, 29px); }

@media (max-width: 700px) {
  /* The address is the first thing to go: the two links are the point. */
  .op-who { display: none; }
}
@media print { .op-bar { display: none; } }

.rail { border-left: 2px solid var(--accent); padding-left: var(--s-4); }

/*
 * A block of prose brings its own leading and trailing margins, so a container
 * with any padding of its own ends up with two gaps at each end and looks like
 * it was padded twice. The container decides its padding; the text inside it
 * starts and stops at the edges.
 */
.prose > :first-child, .rail > :first-child, .measure > :first-child { margin-top: 0; }
.prose > :last-child, .rail > :last-child, .measure > :last-child { margin-bottom: 0; }

/* The row layout lives on the inner .wrap, which is where the gutter is. The
   footer element only supplies the rule, the rhythm, and the type. */
.site-footer {
  flex: none;
  /*
   * 32px above the rule and 20px of type under it. This was 84px above and 52
   * below, which put a screen's worth of nothing between the last thing on a
   * page and the one line that closes it.
   */
  margin-top: var(--s-5);
  border-top: 1px solid var(--hairline);
  padding-block: var(--s-4);
  font-family: var(--f-mono);
  font-size: .6rem;
  font-stretch: 80%;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.site-footer .wrap {
  display: flex; justify-content: space-between; gap: var(--s-2) var(--s-4); flex-wrap: wrap;
}
.site-footer a { color: var(--text-muted); }

/* ---------------------------------------------------------- collage
 *
 * Justified rows, packed by flexbox rather than by the server. Each tile
 * carries its aspect ratio as --ar, which gives it a natural width at the row
 * height and a proportional share of whatever is left over. Every tile in a row
 * lands on the same height and the row fills the width exactly, at any viewport
 * width, with no JavaScript and no stored order. Adding a photograph re-packs
 * everything below it for free. See src/collage.js.
 */

.collage {
  /*
   * The wall is a rectangle of a known shape holding absolutely positioned
   * slots, not a flow of rows.
   *
   * Justified rows pack perfectly at any width with no JavaScript, which is why
   * this was built that way first, and they can only vary a tile in one
   * direction: every tile in a row is the same height, so the wall reads as a
   * stack of strips. The three collages this page is meant to look like all
   * have four or five size classes over a sixteen-to-one area range and tiles
   * that align locally and to nothing globally, which no row layout can do.
   *
   * So src/collage.js packs the slots and writes each one as four percentages,
   * and --wall-ar is the shape it packed them into. Percentages of a box whose
   * height comes from aspect-ratio: the browser still decides every real pixel,
   * and the wall still fills whatever width the window turns out to have.
   */
  --wall-ar: 1;
  position: relative;
  aspect-ratio: var(--wall-ar);
  /*
   * A hairline, not a gutter.
   *
   * Measured on all three references: one pixel at 387px wide, three at 500px,
   * and zero on the densest. That is a quarter of a percent of the width. A
   * comfortable 8-16px gutter is the single change that would stop the wall
   * reading as one surface and turn it back into a set of framed pictures.
   */
  --collage-gap: clamp(2px, 0.25vw, 4px);
  /*
   * How much a hovered tile grows, and how much the rest gives way.
   *
   * A scale, not a change of size. Nothing here is in the flow, so a scale
   * cannot move a neighbour, cannot change the page height, and returns the
   * wall exactly to where it was. It also keeps the tile's shape, so the
   * hovered photograph is the same photograph, larger, rather than a wider
   * crop of it.
   */
  --tile-hover: 1.14;
  /* Hovering the gap must not disturb the tiles either side of it. */
  pointer-events: none;
}

.collage-item {
  pointer-events: auto;
  position: absolute;
  left: var(--l);
  top: var(--t);
  width: var(--w);
  height: var(--h);
  margin: 0;
  /*
   * The gap is padding inside each slot, not a gap property, because the slots
   * are positioned rather than laid out. Half on each side, so two neighbours
   * together make one whole gap and the wall's outer edge gets half of one.
   */
  padding: calc(var(--collage-gap) / 2);
  /*
   * No border and no rounded corner, which is a reversal from every other card
   * on the site. None of the references has either: the wall is meant to read
   * as one continuous field of photographs, and a hairline border around a
   * 3px gap is a second line beside the first.
   */
  border: 0;
  /*
   * A tile against the edge of the wall grows inward, not outward. The server
   * writes --ox and --oy for the four edges and the four corners; everything
   * else is centred. See the note in src/collage.js.
   */
  transform-origin: var(--ox, 50%) var(--oy, 50%);
  /* Properties enumerated, never `all`, and declared on the base selector so
     the return trip animates too. See DESIGN.md, Motion. */
  transition: transform var(--t-group) var(--ease);
}

.collage-item img {
  display: block;
  width: 100%;
  height: 100%;
  /*
   * cover, not contain. contain would letterbox every photograph whose shape
   * does not match its slot and reintroduce exactly the dead space this layout
   * exists to remove. All three references crop hard and unapologetically; the
   * crop is what makes a wall of mixed shapes read as one surface.
   */
  object-fit: cover;
  object-position: center;
  background: var(--sunken);
  transition:
    filter var(--t-group) var(--ease),
    box-shadow var(--t-hover) var(--ease);
}

/*
 * Hover, and focus, which has to behave identically or the wall is unusable
 * from a keyboard.
 *
 * The tile lifts over its neighbours instead of pushing them aside. That is
 * only safe because nothing is in the flow: no row moves, the page does not
 * change height, and there is nothing to reflow.
 */
.collage-item:hover,
.collage-item:focus-visible {
  transform: scale(var(--tile-hover));
  z-index: 2;
}

.collage-item:hover img,
.collage-item:focus-visible img {
  box-shadow: inset 0 0 0 2px var(--accent), var(--lift-shadow);
}

/*
 * And the rest of the wall settles back, so the hovered photograph is the only
 * one at full strength. A saturation drop, never opacity, and never a scale:
 * scaling the others down would open a visible gap between every tile and undo
 * the one continuous surface the hairline gap exists to create.
 */
.collage:has(.collage-item:hover) .collage-item:not(:hover) img,
.collage:has(.collage-item:focus-visible) .collage-item:not(:focus-visible) img {
  filter: saturate(.55) contrast(.98);
}

.collage-item figcaption {
  position: absolute;
  left: calc(var(--collage-gap) / 2);
  right: calc(var(--collage-gap) / 2);
  bottom: calc(var(--collage-gap) / 2);
  padding: var(--s-3) var(--s-3) var(--s-2);
  font-size: .78rem;
  line-height: 1.35;
  color: var(--on-shade);
  background: linear-gradient(to top, var(--shade-strong), var(--shade-none));
  opacity: 0;
  transition: opacity var(--t-group) var(--ease);
}
.collage-item:hover figcaption,
.collage-item:focus-visible figcaption { opacity: 1; }

/*
 * The full width of the window, not the reading measure.
 *
 * The wall sits outside .wrap and supplies its own gutter, using the same
 * clamp .wrap uses so the two line up exactly. It has to be its own rule
 * rather than nothing at all, because running the photographs to the glass
 * made this the one block on the site that did not line up with the text above
 * and below it.
 */
.collage-full {
  /*
   * The page gutter, not the tile gap. Full width, not full bleed.
   *
   * MARGIN, not padding, and that is not a preference.
   *
   * An absolutely positioned child resolves its offsets and its percentage
   * width against its ancestor's PADDING box, not its content box. With the
   * gutter as padding, `left: 0%` landed on the outer edge of the gutter and
   * `width: 100%` spanned it: the tiles ignored the padding entirely and the
   * wall ran to the glass. A margin takes the width off the element itself, so
   * the padding box is the wall and the gutter is real again.
   */
  margin-inline: clamp(18px, 4vw, 48px);
  padding-inline: 0;
  padding-block: 0;
}

@media (max-width: 700px) {
  .collage {
    --collage-gap: 4px;
    aspect-ratio: auto;
    position: static;
    column-count: 2;
    column-gap: var(--collage-gap);
    pointer-events: auto;
  }
  .collage-item {
    /* The packed slot is discarded wholesale: a rectangle computed to sit
       beside seven others is a sliver when only two fit across. */
    position: static;
    width: auto;
    height: auto;
    padding: 0;
    aspect-ratio: var(--ar);
    margin-bottom: var(--collage-gap);
    break-inside: avoid;
  }
  .collage-item figcaption {
    /*
     * static, and this is the load bearing line.
     *
     * The tile above is position: static in this block, which removes the
     * containing block the caption was absolutely positioned against — so all
     * twelve captions resolved against the initial containing block instead
     * and stacked in one full width pile near the top of the page, on top of
     * the photographs and each other. Measured at 390px: twelve captions, all
     * at top 863 left 0 width 390, sixty six pairwise overlaps. The print
     * block already did this correctly; the screen block was missing the one
     * declaration.
     */
    position: static;
    opacity: 1;
    /* In the flow now, so it sits under its photograph rather than over it,
       and the gradient that existed to lift it off the image is not needed. */
    background: none;
    color: var(--text-muted);
    padding: 6px 2px 0;
  }
  /* Nothing reflows on a tap, and a scale under a finger is just a jump. */
  .collage-item:hover, .collage-item:focus-visible { transform: none; }
}

/* One column only at the narrowest sizes, where two columns would be about
   140px each and the photographs would be postage stamps. A 390px phone gets
   two, which is what a photo grid is expected to look like. */
@media (max-width: 359px) {
  .collage { column-count: 1; }
}
/* Three columns once there is room for them. */
@media (min-width: 560px) and (max-width: 700px) {
  .collage { column-count: 3; }
}

@media (prefers-reduced-motion: reduce) {
  /* The state change still happens. It is information, not decoration. */
  .collage-item, .collage-item img, .collage-item figcaption { transition-duration: 1ms !important; }
}

@media (forced-colors: active) {
  /* The accent ring is a box-shadow, and box-shadows are dropped in forced
     colours. An outline is not. */
  .collage-item:hover img, .collage-item:focus-visible img { outline: 2px solid Highlight; }
  .collage-item figcaption { background: Canvas; color: CanvasText; }
}

@media print {
  /* The packed slots are percentages of a wall as tall as the screen makes it,
     which on paper is a single enormous page. Print at a fixed grid instead,
     each photograph at its own shape and nothing cropped. */
  .collage {
    display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px;
    position: static; aspect-ratio: auto;
  }
  .collage-item {
    position: static; width: auto; height: auto; padding: 0;
    aspect-ratio: var(--ar); break-inside: avoid;
  }
  .collage-item figcaption { position: static; opacity: 1; background: none; color: var(--text-muted); }
}

/* ---------------------------------------------------------- profile cards */

/* GitHub and LinkedIn panels. Two columns where there is room, one where there
   is not, and the collapse is on the container so it works inside a narrow
   column on a wide screen. */
.profile-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
  gap: var(--s-4);
  /* stretch, not start: two cards of different heights beside each other read
     as one of them having failed to load. */
  align-items: stretch;
}
.profile-grid > * { min-width: 0; display: flex; flex-direction: column; }
/*
 * The BUTTON ROW takes the slack, not whatever happens to be last.
 *
 * As `:last-child` this pushed the final block of the shorter card to the
 * bottom of it, which put the whole height difference between the two cards
 * into one gap in the middle of the shorter one — 82px of nothing between a
 * heading and the chips under it. Scoped to .btn-row, the cards still end level
 * on their buttons and a card without buttons simply ends where its content
 * does.
 */
.profile-grid > * > .btn-row:last-child { margin-top: auto; }

.profile-head {
  display: flex; gap: var(--s-3); align-items: center;
  margin-bottom: var(--s-4);
  padding-bottom: var(--s-3);
  border-bottom: 1px solid var(--hairline);
}
.profile-mark { flex: none; display: flex; color: var(--text-strong); }
/* The name and the handle on one line where there is room for them, because
   two lines for four words is the panel's whole first impression. The title
   takes the whole row so the handle has an edge to sit against. */
.profile-title {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px var(--s-3);
  min-width: 0;
  flex: 1 1 auto;
}
/* The handle sits against the right edge of the panel, opposite the mark, so
   the two panels read as a matched pair rather than as two ragged left
   columns. On a narrow panel it wraps to its own line and stays right. */
.profile-handle { margin-left: auto; }
.profile-head .profile-name {
  font-family: var(--f-display); font-weight: var(--w-med);
  font-size: 1.15rem; color: var(--text-strong);
}
.profile-handle { font-size: .78rem; }

/* A repository. Not a .card: those are project records and carry a different
   contract. */
.repo-list { display: flex; flex-direction: column; gap: 1px; background: var(--hairline);
  border: 1px solid var(--hairline); border-radius: var(--r-card); overflow: hidden; }
.repo {
  display: block; text-decoration: none; background: var(--surface);
  padding: var(--s-3) var(--s-4);
  transition: background-color var(--t-hover) var(--ease);
}
.repo:hover { background: var(--sunken); }
.repo-name { font-family: var(--f-mono); font-size: .82rem; font-stretch: 84%;
  color: var(--accent-ink); display: block; }
.repo:hover .repo-name { color: var(--accent); }
.repo-desc { font-size: .9rem; color: var(--text-body); margin: 3px 0 0; }
.repo-meta { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 6px;
  font-family: var(--f-mono); font-size: .58rem; font-stretch: 80%;
  letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted); }
.repo-lang-dot { width: 8px; height: 8px; border-radius: 2px; display: inline-block;
  background: var(--text-muted); margin-right: 5px; vertical-align: baseline; }

/* ---------------------------------------------------------- education */

/*
 * Facts about an institution, across the page.
 *
 * This was a titleblock: a bordered two column box capped at 34rem, sitting in
 * the left half of a 1084px column with mono type at 0.62rem. It is a drawing
 * convention and it suits a five row summary in a hero; it does not suit five
 * facts that somebody is actually trying to read.
 */
.facts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
  gap: var(--s-3) var(--s-5);
  margin: var(--s-4) 0 0;
}
.facts > div { min-width: 0; }
.facts dt {
  font-family: var(--f-mono); font-size: .58rem; font-stretch: 80%;
  letter-spacing: .12em; text-transform: uppercase; color: var(--text-muted);
  margin-bottom: 2px;
}
.facts dd { margin: 0; color: var(--text-strong); font-size: 1rem; }

/* A heading with a control on the same line, wrapping to two when it must. */
.section-head {
  display: flex; flex-wrap: wrap; align-items: baseline;
  gap: var(--s-2) var(--s-4); margin-top: var(--s-5);
}
.section-head h3 { margin: 0; }
.sortbar {
  display: flex; align-items: center; gap: var(--s-2);
  margin-left: auto;
  font-family: var(--f-mono); font-size: .58rem; font-stretch: 80%;
  letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted);
}
.sortbar select { font-size: .8rem; padding: 3px 6px; }

/*
 * Activities and awards, as many columns as the window has room for.
 *
 * The previous layout put each name in a fixed narrow column, which broke
 * "California Scholarship Federation" over three lines and left the right half
 * of the page empty next to it.
 */
.entry-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
  gap: var(--s-3) var(--s-5);
  margin-top: var(--s-3);
}
.entry {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 2px;
  padding-block: var(--s-3);
  border-top: 1px solid var(--hairline);
  min-width: 0;
}
.entry-title {
  font-family: var(--f-display); font-weight: var(--w-med);
  color: var(--text-strong); font-size: 1rem;
}
.entry p { color: var(--text-body); font-size: .95rem; }

.term-block { border-top: 1px solid var(--hairline); padding-top: var(--s-4); margin-top: var(--s-4); }
.term-block:first-of-type { border-top: 0; margin-top: 0; padding-top: 0; }

.course-list { display: grid; gap: 1px; background: var(--hairline);
  border: 1px solid var(--hairline); border-radius: var(--r-card); overflow: hidden; }
.course {
  background: var(--surface);
  display: grid;
  /*
   * 8.5rem, sized to the longest label rather than to the average one.
   *
   * Each row is its own grid, so the column cannot be `auto`: that would size
   * per row and the codes would not line up down the list. It is a fixed track,
   * and a fixed track has to fit the longest thing that goes in it — which is
   * "Computer Science" at about 124px, not "MECH&AE 101" at 85px. At 7rem the
   * one longest label wrapped to two lines while every other row was one, which
   * reads as that row being different rather than as that label being long.
   */
  grid-template-columns: minmax(0, 8.5rem) minmax(0, 1fr) auto;
  gap: var(--s-2) var(--s-4);
  align-items: baseline;
  padding: var(--s-3) var(--s-4);
}
@media (max-width: 640px) {
  .course { grid-template-columns: minmax(0, 1fr) auto; }
  .course .course-code { grid-column: 1 / -1; }
}
.course-code {
  font-family: var(--f-mono); font-size: .72rem; font-stretch: 82%;
  letter-spacing: .04em; color: var(--text-muted); font-variant-numeric: tabular-nums lining-nums;
  /* One line, always. If a longer label is ever added the track above is what
     changes, not the wrapping: a code broken across two lines is unreadable
     as a code. */
  white-space: nowrap;
}
/* The right hand column of a class row: units, then the term. */
.course-meta {
  display: flex; gap: 5px; align-items: baseline; white-space: nowrap;
  font-size: .66rem;
  color: var(--text-muted);
}

.course-title { color: var(--text-strong); }
.course-note { display: block; font-size: .85rem; color: var(--text-muted); margin-top: 2px; }

/* Status is carried on a second channel as well as colour: the badge text says
   it outright, so it survives forced-colors and a monochrome print. */
.badge[data-course="completed"] { border-color: var(--patina); color: var(--patina); }
.badge[data-course="in-progress"] { border-color: var(--accent); color: var(--accent-ink); }
.badge[data-course="planned"] { border-color: var(--hairline); color: var(--text-muted); }

/* ---------------------------------------------------------- documents */

.doc-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: var(--s-3) var(--s-4);
  align-items: start;
  padding: var(--s-4) 0;
  border-bottom: 1px solid var(--hairline);
}
@media (max-width: 640px) { .doc-row { grid-template-columns: minmax(0, 1fr); } }
.doc-row h3 { margin: 0 0 4px; font-size: 1.0625rem; }
.doc-row .card-meta { align-items: center; }
.doc-row .doc-desc { margin: 0; color: var(--text-muted); font-size: .95rem; }

/*
 * The inline PDF viewer.
 *
 * An <object> rather than an <iframe>: it falls back to its own children when
 * the browser has no PDF plugin, which is most mobile browsers, so the download
 * and open-in-a-tab controls appear there automatically instead of a blank box.
 */
/*
 * Hidden below this width, and that is not a responsive compromise.
 *
 * Mobile browsers overwhelmingly have no inline PDF renderer, so an <object>
 * there is an 80vh box of nothing with two buttons stranded in the middle of
 * it. The same two buttons are already above the frame, where they are the
 * whole control on a phone. On a desktop browser that also cannot render one,
 * the fallback inside still does its job.
 */
.pdf-frame { display: none; }
@media (min-width: 900px) {
  .pdf-frame {
    display: block;
    width: 100%;
    height: min(80vh, 900px);
    min-height: 420px;
    border: 1px solid var(--hairline);
    border-radius: var(--r-card);
    background: var(--sunken);
  }
}
/*
 * The snapshot's stand-in for the reader.
 *
 * A single file with no server behind it cannot answer for the document's
 * bytes, so scripts/snapshot.mjs swaps the <object> for its own fallback
 * children. Same frame, but only as tall as the message inside it: a fixed
 * 900px box holding one line and two buttons is the empty panel this exists to
 * avoid.
 */
@media (min-width: 900px) {
  .pdf-frame-static { height: auto; min-height: 0; }
}

.pdf-fallback { padding: var(--s-5); text-align: center; color: var(--text-muted); }
.pdf-note { color: var(--text-muted); font-size: .85rem; }
@media (min-width: 900px) { .pdf-note { display: none; } }

/* ---------------------------------------------------------- controls
 *
 * Forms, buttons, and panels live here rather than in admin.css.
 *
 * They used to live in the admin sheet, which the public layout does not load.
 * The public contact form therefore rendered as raw user-agent widgets: labels
 * inline beside 170px default-width inputs anchored to the left of a full width
 * panel, with an unstyled Send button. It looked like a broken stylesheet
 * because it was one. Anything a public page can use belongs in this file, and
 * admin.css keeps only what is genuinely admin-specific.
 */

.panel {
  border: 1px solid var(--hairline);
  border-radius: var(--r-card);
  background: var(--surface);
  padding: var(--s-4);
}

.field { display: flex; flex-direction: column; gap: 5px; margin-bottom: var(--s-4); min-width: 0; }
.field:last-child { margin-bottom: 0; }
.field > label {
  font-family: var(--f-mono); font-size: .6rem; font-stretch: 80%;
  letter-spacing: .12em; text-transform: uppercase; color: var(--text-muted);
}
.field .hint { font-size: .82rem; color: var(--text-muted); line-height: 1.45; }

/*
 * One selector for every text-entry control, so a field added later cannot
 * quietly fall back to the user-agent widget. width:100% plus the global
 * border-box is what makes an input fill its column instead of sitting at its
 * default size against the left edge.
 *
 * The floor of 16px is not cosmetic: iOS Safari zooms the viewport on focus
 * for any input below 16px, and the page never zooms back out.
 */
.input,
.field input[type="text"], .field input[type="email"], .field input[type="password"],
.field input[type="search"], .field input[type="url"], .field input[type="tel"],
.field input[type="number"], .field input[type="date"], .field input[type="file"],
.field textarea, .field select {
  font-family: var(--f-body);
  font-size: max(1rem, 16px);
  line-height: 1.4;
  padding: 9px 11px;
  border-radius: var(--r-control);
  border: 1px solid var(--hairline);
  background: var(--surface);
  color: var(--text-body);
  width: 100%;
  /*
   * A control is as wide as the text it holds, not as wide as the window.
   *
   * This was max-width: 100%, which meant "fill the column" — correct while
   * .wrap capped the column at 1180px. With the page running the window, a
   * single line input measured 1478px at 1600 and 2438px at 2560: a title of
   * forty characters floating in two thirds of a metre of empty box, with the
   * caret too far from the label to associate the two. The form stopped
   * reading as a column at all.
   *
   * So the cap moves onto the control. Everything else about the layout is
   * unchanged: the field still fills a narrow column, a two-up row still
   * splits, and nothing is centred.
   */
  max-width: var(--field-max, 38rem);
  min-width: 0;
  transition: border-color var(--t-hover) var(--ease);
}
/* An editing surface earns more room than a one-line answer, and a body of
   markdown is genuinely easier to write wide. Still bounded. */
.field textarea, textarea.input { --field-max: 60rem; }
/*
 * A textarea that is the whole point of its form runs the whole width of it.
 *
 * 60rem is the right cap for a field sitting among others; it is the wrong one
 * for the field the form exists to collect. On the contact form the three short
 * fields span 1462px at a 1600px window and the message box stopped at 960 —
 * two thirds — so it read as two columns under a row of three. Set on the
 * textarea, not the field, because the rule above declares the property on the
 * element and an inherited value would lose to it.
 */
.field.field-full textarea { --field-max: none; }
.input:hover,
.field input:hover, .field textarea:hover, .field select:hover { border-color: var(--rule); }
.input:focus-visible,
.field input:focus-visible, .field textarea:focus-visible, .field select:focus-visible {
  border-color: var(--accent);
}
.field input::placeholder, .field textarea::placeholder, .input::placeholder {
  color: var(--text-muted);
  opacity: 1;   /* Firefox dims placeholders by default, below AA. */
}
.field textarea, textarea.input { min-height: 8rem; resize: vertical; line-height: 1.55; }
.field select, select.input {
  /* A native select keeps its own disclosure arrow; only the box is restyled. */
  cursor: pointer;
}
.field input[type="file"] { padding: 7px 9px; cursor: pointer; }

/* A checkbox or radio beside its own wording, rather than under a field label. */
.check {
  display: flex; gap: var(--s-2); align-items: flex-start;
  margin-bottom: var(--s-4); font-size: .95rem; line-height: 1.45;
}
.check input { margin-top: .3em; flex: none; width: 1rem; height: 1rem; }
.check .hint { display: block; color: var(--text-muted); font-size: .85rem; }

.btn {
  font-family: var(--f-mono); font-size: .66rem; font-stretch: 80%;
  letter-spacing: .12em; text-transform: uppercase;
  padding: 9px 15px; border-radius: var(--r-control); cursor: pointer;
  border: 1px solid var(--accent); background: var(--accent); color: var(--on-accent);
  text-decoration: none; display: inline-flex; align-items: center; justify-content: center;
  line-height: 1.2;
  text-align: center;
  /* A button label is one line. "Take Off" broken across two inside a 33px
     button reads as two buttons that failed to separate. */
  white-space: nowrap;
  transition: border-color var(--t-hover) var(--ease), filter var(--t-hover) var(--ease);
}
.btn:hover { filter: brightness(1.06); color: var(--on-accent); }
.btn-quiet { background: transparent; color: var(--text-body); border-color: var(--hairline); }
.btn-quiet:hover { border-color: var(--accent); color: var(--text-strong); filter: none; }
.btn-danger { background: transparent; color: var(--ch-business); border-color: var(--ch-business); }
.btn-sm { padding: 5px 9px; font-size: .58rem; }
/* A row of buttons wraps rather than overflowing a narrow column. */
.btn-row { display: flex; gap: var(--s-2); flex-wrap: wrap; align-items: center; }

/*
 * Forty pixels on a phone, on every button including the small ones.
 *
 * Measured at 390px: a .btn was 33px and a .btn-sm 27px, against the 40px a
 * touch target is supposed to clear. The admin is where this matters most,
 * because it is a form-heavy surface operated one-handed, but the rule is the
 * same on both sides of the login and the buttons are the same component.
 *
 * min-height rather than padding, so the type does not move and a button in a
 * tight row does not suddenly become taller than the field beside it.
 */
@media (max-width: 700px) {
  .btn, .btn-sm { min-height: 40px; }
  /* The admin's brand link is a single small line of type in the header. */
  .brand { min-height: 40px; align-items: center; }
}

/*
 * Two columns on a comfortable width, one column when there is not room. The
 * collapse is on the container, not the viewport, so the same class works
 * inside a narrow panel on a wide screen.
 *
 * The gap and the field's own margin both applied, so once the row collapsed on
 * a phone the two fields inside it sat 40px apart while the field after the row
 * sat 20px away. The row owns the spacing; the fields inside it do not.
 */
.row2 {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr));
  gap: var(--s-4);
  margin-bottom: var(--s-4);
}
.row2 > .field { margin-bottom: 0; }
.row2:last-child { margin-bottom: 0; }

.flash {
  border-left: 2px solid var(--patina);
  padding: var(--s-2) var(--s-3);
  background: var(--sunken);
  border-radius: var(--r-control);
  margin-bottom: var(--s-4);
  font-size: .92rem;
}
.flash.error { border-left-color: var(--ch-business); color: var(--text-strong); }
.flash p:last-child { margin-bottom: 0; }

/* A search box and its button. Used by /search and by the in-document search,
   which each carried their own copy of these declarations inline. */
/* The field takes the width and the button takes what it needs. */
.searchbar { display: flex; gap: var(--s-2); flex-wrap: wrap; }
.searchbar .input { flex: 1 1 18rem; min-width: 0; }

/*
 * Forms run the page width like everything else, and the fields inside them
 * are laid out in columns rather than stretched. A 1084px single line input is
 * a target the eye has to travel; three of them side by side is a form.
 */
.row3 {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr));
  gap: var(--s-3) var(--s-4);
}
.searchbar .input { flex: 1 1 14rem; }
.searchbar .btn { flex: none; }

/* Prevents the reduced-motion rule below from being the only place these are
   named, and keeps UI feedback under the 200ms budget in DESIGN.md. */
@media (prefers-reduced-motion: reduce) {
  .btn, .input, .field input, .field textarea, .field select { transition-duration: 1ms !important; }
}

/*
 * Resume and print. Also gives a browser generated PDF for free.
 *
 * The palette is swapped in tokens.css rather than hardcoded here, so a reader
 * printing with the dark theme active does not get its ink values on white
 * paper. Colours below come from tokens like everywhere else.
 *
 * .chips is NOT hidden. It carries the status badge and the discipline links
 * on a project page, which is content: hiding it lost the reader the whole
 * per-discipline framing of the page they printed. Only the controls that
 * cannot work on paper are dropped, and the resume marks its own action row
 * .no-print.
 */
@media print {
  .site-header, .site-footer, .skip-link, .theme-form, .no-print { display: none !important; }
  body { background: var(--canvas); color: var(--text-body); font-size: 11pt; }
  a { text-decoration: underline; }
  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 9pt; word-break: break-all; }
  .card, .section, .discipline-row, .res-row, figure { page-break-inside: avoid; }
  h1, h2, h3 { break-after: avoid-page; }
  .card, .panel, .metrics, .metric { box-shadow: none !important; }
  @page { margin: 0.6in; }
}

/* ---------------------------------------------------------- project page */

.discipline-row {
  display: grid;
  grid-template-columns: minmax(0, 11rem) minmax(0, 1fr);
  gap: var(--s-4);
  align-items: baseline;
}
@media (max-width: 720px) {
  .discipline-row { grid-template-columns: 1fr; gap: var(--s-1); }
}

.figure-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
  gap: var(--s-3);
}
.figure-grid > * { min-width: 0; }

/* The discipline index row: icon, text, count. The text column carries the
   measure; the row keeps the full width so the count stays flush right. */
.facet-row-link { text-decoration: none; }
.facet-row-link .facet-text { max-width: none; }
.facet-row-link .facet-label {
  font-family: var(--f-display); font-weight: var(--w-med); font-size: 1.1rem;
  color: var(--text-strong); display: block;
}
.facet-row-link .facet-blurb { color: var(--text-muted); font-size: .95rem; }
.facet-row-link:hover .facet-label { color: var(--accent-ink); }

.prose h2 { margin-top: var(--s-5); }
.prose h3 { margin-top: var(--s-4); }
.prose ul, .prose ol { padding-left: 1.2rem; }
.prose li { margin-bottom: .4em; }

/* ---------------------------------------------------------- document library */

.doc-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
  gap: var(--s-3);
  pointer-events: none;
}
.doc-grid > * { min-width: 0; }
.doc-card {
  pointer-events: auto;
  display: grid;
  grid-template-columns: 10px minmax(0, 1fr);
  border: 1px solid var(--hairline);
  border-radius: var(--r-card);
  background: var(--surface);
  text-decoration: none;
  overflow: hidden;
  transition:
    border-color var(--t-hover) var(--ease),
    background-color var(--t-group) var(--ease);
}
/* A spine rather than a page-one thumbnail. Rendering one needs a canvas
   binding, which is a native dependency this project deliberately avoids, and
   the first page of a governance document is a title page that tells a reader
   nothing they cannot already see in the title. */
.doc-card .doc-spine { background: var(--copper); display: block; }
.doc-card .doc-body {
  display: flex; flex-direction: column; gap: var(--s-2);
  padding: var(--s-3) var(--s-4) var(--s-4);
}
.doc-card:hover, .doc-card:focus-visible { border-color: var(--accent); }
.doc-card:hover .doc-spine { background: var(--accent); }
.doc-grid:has(> .doc-card:hover) > .doc-card:not(:hover) { background: var(--dim-surface); border-color: var(--dim-border); }
.doc-grid:has(> .doc-card:hover) > .doc-card:not(:hover) .card-title { color: var(--dim-title); }
.doc-grid:has(> .doc-card:hover) > .doc-card:not(:hover) .card-summary,
.doc-grid:has(> .doc-card:hover) > .doc-card:not(:hover) .card-meta { color: var(--dim-text); }

/* ---------------------------------------------------------------- resume */

/* A resume is a list of label/value rows, which a paragraph handles badly. */
.res-row {
  display: grid;
  grid-template-columns: minmax(0, 13rem) minmax(0, 1fr);
  gap: var(--s-4);
  margin: 0 0 .5em;
  align-items: baseline;
}
@media (max-width: 720px) { .res-row { grid-template-columns: 1fr; gap: 2px; } }
.res-row .res-k { font-family: var(--f-display); font-weight: var(--w-med); color: var(--text-strong); }
.res-row .res-v { color: var(--text-muted); }

.prose h3 { font-size: 1.0625rem; margin-top: var(--s-4); }

/*
 * An eyebrow is a label for the heading under it, so the pair is always 8px
 * apart and the eyebrow owns that gap.
 *
 * After the .prose heading rules, and not next to .eyebrow, because that is the
 * rule it has to beat: inside prose a heading carries its own top margin to
 * separate it from the paragraph above, adjacent margins collapse to the larger
 * of the two, and the same specificity earlier in the file loses. An eyebrow
 * above an h2 in prose was pushed to 32px while the identical pair outside
 * prose sat at 8px, so the two read as different components.
 */
.eyebrow + h1, .eyebrow + h2, .eyebrow + h3, .eyebrow + h4 { margin-top: 0; }

/* The honeypot. Moved off screen rather than display:none, which some bots
   now specifically check for before deciding whether to fill a field. */
.honeypot {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
