/* Figtree stands in for Proxima Nova, which is a licensed commercial face. */
@font-face {
  font-family: "Figtree";
  src: url("fonts/figtree.woff2") format("woff2");
  font-weight: 300 900;
  font-style: normal;
  font-display: swap;
}

/* The three display faces the name cycles through. All open-licensed (OFL,
   copies in fonts/licenses) and self-hosted, because none of them exist on
   Windows, iOS, or Android — left to the system these fell back to generic
   sans and the cycle looked broken everywhere but a Mac.
   Each is subset to the seven glyphs in "Arnav Arora", so all three together
   come to about 4.5KB. Re-subset if the heading text ever changes. */
@font-face {
  font-family: "JetBrains Mono Web";
  src: url("fonts/jetbrainsmono.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "VT323 Web";
  src: url("fonts/vt323.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* Genuinely an italic, so it's declared as one and the script asks for it. */
@font-face {
  font-family: "IM Fell Web";
  src: url("fonts/imfell.woff2") format("woff2");
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}

:root {
  /* one source of truth for the frame inset — the footer aligns to it */
  --frame:  0.85rem;
  /* main's side padding, shared so the footer can line up with the text
     column whenever it sits in the flow */
  --gutter: 2.75rem;
  /* A cool grey ground rather than the old parchment. Same lightness, so the
     page is no brighter than before, but neutral instead of tan — the warm
     paper was pulling against the cool blue of the links and of dark mode. */
  --paper:  #e6e9ee;
  --ink:    #1a1d22;
  /* The darker blue is now the only blue. On the parchment the lighter #4a7eb8
     managed just 3.32:1 and a second, darker token had to carry small text;
     on this ground the darker one clears 4.5:1 everywhere, so links, the rule
     under the name and the visitor count can all share it. */
  --accent: #3a6595;
  --rule:   #a8b0bc;
  --hair:   #c3cad3;
  /* Brand colours, used only on hover for their own links. Left at their
     Michigan/DoorDash values — maize sits at 2.49:1 here, as it did on the
     parchment, so it reads as a highlight rather than as body text. */
  --maize:  #c08b00;
  --dash:   #d01400;
  /* The footer sentence, as a colour rather than an opacity, so the number
     inside it can be a different colour at full strength. Re-mixed against the
     new ground and nudged darker to hold up at 0.75rem. */
  --muted:  #6e747d;
  /* Kept as its own token so the dark block can still diverge, but on this
     ground it resolves to the same blue as --accent. */
  --accent-ink: #3a6595;
}

/* Same block twice: once gated on the system preference (skipped when an
   explicit light choice overrides it), once gated on the explicit dark
   choice itself. Duplicated rather than shared so each selector stays a
   single rule the cascade can reorder freely — a combined selector would tie
   their specificity together for no benefit. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --paper:  #0a0c0f;
    --ink:    #e8ecf2;
    --accent: #7fa8dd;
    --rule:   #2a3038;
    --hair:   #1e2329;
    --maize:  #ffcb05;
    --dash:   #ff5a3c;
    --muted:  #84878c;
    /* Already 7.97:1 on the dark paper, so the link accent serves as-is. */
    --accent-ink: #7fa8dd;
  }
}

:root[data-theme="dark"] {
  --paper:  #0a0c0f;
  --ink:    #e8ecf2;
  --accent: #7fa8dd;
  --rule:   #2a3038;
  --hair:   #1e2329;
  --maize:  #ffcb05;
  --dash:   #ff5a3c;
  --muted:  #84878c;
  --accent-ink: #7fa8dd;
}

/* Live only for the ~500ms a click on .theme-toggle spends swapping
   data-theme (added and timed out from there in index.html), so the crossfade
   doesn't slow down ordinary link/icon hovers the rest of the time. !important
   is what lets one blanket rule outrun every element's own hover-transition
   shorthand without having to duplicate this list into each of them. */
.theme-transitioning,
.theme-transitioning *,
.theme-transitioning *::before,
.theme-transitioning *::after {
  transition: background-color 0.45s ease, color 0.45s ease,
    border-color 0.45s ease, fill 0.45s ease, stroke 0.45s ease !important;
}

* { box-sizing: border-box; }

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

body {
  margin: 0;
  min-height: 100vh;
  /* svh is the viewport with the mobile browser's toolbars showing — their
     smallest state. 100vh on iOS means the toolbar-hidden height, which would
     push the footer below the fold until you scrolled. */
  min-height: 100svh;
  /* Column flex so main can absorb the slack and carry the footer to the
     bottom. Only bites where the footer is in the flow: on desktop it's
     position:fixed, and absolutely positioned children aren't flex items. */
  display: flex;
  flex-direction: column;
  background: var(--paper);
  color: var(--ink);
  font-family: "Figtree", "Proxima Nova", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  font-size: 1.15rem;
  font-weight: 400;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* thin frame inset from the viewport edge */
body::before {
  content: "";
  position: fixed;
  inset: var(--frame);
  border: 2px solid var(--rule);
  pointer-events: none;
  z-index: 1;
}

/* Centred in whatever height is going, rather than offset a fixed 20vh from
   the top. The old padding mixed units — 20vh top against 8rem bottom — so how
   balanced it looked depended entirely on viewport height: fine on a tall
   desktop window, visibly low in a short wide webview. */
main {
  max-width: 36rem;
  /* grows to take up the leftover height, pushing the footer down */
  flex: 1 0 auto;
  display: flex;
  flex-direction: column;
  justify-content: center;
  margin: 0 auto;
  padding: 4rem var(--gutter);
}

h1 {
  font-size: clamp(2.5rem, 8vw, 3.5rem);
  font-weight: 500;
  line-height: 1.05;
  letter-spacing: -0.03em;
  margin: 0 0 1.25rem;
}

/* The name types itself out, backspaces, and retypes in the next face. The
   container is a block with a fixed line box so the paragraphs below never
   move, whatever ascent/descent the current face happens to have — and
   whitespace is preserved so the trailing space survives mid-word. */
.name {
  display: block;
  /* shrink-wrapped so the hover/click target is the name itself, not the
     full width of the heading line */
  width: fit-content;
  height: 1.05em;
  white-space: pre;
}

/* Relative so each face can be nudged onto a common baseline. Where the glyph
   baseline lands inside a fixed line box depends on the font's own ascent and
   descent, so faces with unusual vertical metrics — old-style italics
   especially — sit visibly high or low next to the others. `top` shifts the
   render without touching layout. */
.typed {
  position: relative;
}

/* Block cursor, only shown once the script has taken over — without JS the
   name just sits there statically with no stray caret. */
.cursor {
  display: none;
  width: 0.07em;
  height: 0.78em;
  margin-left: 0.16em;
  background: var(--accent);
  vertical-align: baseline;
}

.name.typing .cursor {
  display: inline-block;
  animation: caret 1.06s steps(1, end) infinite;
}

/* Two deliberate blinks to close on. This needs its own keyframe name rather
   than a lower iteration-count on `caret` — same-named animations keep their
   original start time, so the count would be measured against a clock that
   started at page load and could elapse instantly. */
.name.typing.ending .cursor {
  animation: caret-end 1.06s steps(1, end) 2 forwards;
}

/* Once it has settled, hovering wakes the caret back up and clicking replays
   the whole cycle. Gated on real hover: touch browsers latch :hover onto the
   last-tapped element, which would leave the caret blinking forever after a
   tap-to-replay. Tapping still replays — that runs off the click event. */
.name.done { cursor: pointer; }

@media (hover: hover) {
  .name.done:hover .cursor {
    display: inline-block;
    animation: caret 1.06s steps(1, end) infinite;
  }
}

/* Blinks on `visibility`, not `opacity`. Opacity is compositor-animatable, so
   it promoted the caret to its own layer and the browser expanded that layer
   to the full-width h1 band — which then rasterized on a separate surface with
   subtly different color management, tinting the whole heading strip. Most
   obvious with the word erased, when the caret was all that band contained.
   `visibility` is discrete, animates on the main thread, and creates no layer;
   it also holds the caret's box, so nothing reflows as it flashes. */
@keyframes caret {
  0%, 50%   { visibility: visible; }
  50.01%, 100% { visibility: hidden; }
}

@keyframes caret-end {
  0%, 50%   { visibility: visible; }
  50.01%, 100% { visibility: hidden; }
}

@media (prefers-reduced-motion: reduce) {
  .cursor { display: none; }
}

/* short rule under the name, in the accent */
h1::after {
  content: "";
  display: block;
  width: 2.5rem;
  height: 2px;
  margin-top: 1.5rem;
  background: var(--accent);
}

p {
  margin: 0 0 1.4em;
  text-wrap: pretty;
}

p:last-child { margin-bottom: 0; }

/* Site chrome rather than prose, so it sits outside <main> and off the text
   column: one bar spanning the frame, counter at the left end, webring mark at
   the right. */
.chrome {
  position: fixed;
  left: calc(var(--frame) + 1.4rem);
  right: calc(var(--frame) + 1.4rem);
  bottom: calc(var(--frame) + 0.95rem);
  z-index: 2;
  display: flex;
  align-items: center;
}

.views {
  margin: 0;
  font-size: 0.75rem;
  letter-spacing: 0.03em;
  color: var(--muted);
}

/* Pinned to the frame's top-right corner rather than living in .chrome
   below — the bottom bar has to un-pin on short/narrow viewports because a
   fixed bottom element sits under the mobile browser's own toolbar, but
   nothing on this page ever scrolls the top corner out from under an
   equivalent top bar, so one fixed position covers every size. Boxed at the
   same 1.8rem footprint as .ring so the two read as one family of controls
   despite sitting in different corners. The same extra insets .ring sits at
   in .chrome below (1.4rem horizontal, 0.95rem vertical) are repeated here
   rather than sitting flush against the frame border itself. */
.theme-toggle {
  position: fixed;
  top: calc(var(--frame) + 0.95rem);
  right: calc(var(--frame) + 1.4rem);
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.8rem;
  height: 1.8rem;
  border: 1px solid var(--hair);
  border-radius: 0.35rem;
  background: none;
  padding: 0;
  color: var(--ink);
  opacity: 0.4;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}

.theme-toggle svg { width: 1.05rem; height: 1.05rem; }

/* Only one icon shows at a time: the sun when dark is active (click for
   light), the moon otherwise — the icon signals the click's destination,
   not the current state. Mirrors the token blocks above: a media-gated rule
   for the system default, an attribute-gated rule for the explicit choice. */
.theme-toggle .icon-sun { display: none; }

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

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

/* Same treatment as .ring's hover below: dim until hovered, then to full
   strength in the accent. */
@media (hover: hover) {
  .theme-toggle { opacity: 0.5; }

  .theme-toggle:hover {
    opacity: 1;
    color: var(--accent);
    border-color: var(--accent);
  }
}

/* margin-left:auto rather than space-between, so the mark still sits right
   when the counter is hidden and it's the only child. The padding buys a
   44px touch target and the matching negative margins cancel it out of the
   layout, so the mark still lines up with the frame. */
.ring {
  display: flex;
  align-items: center;
  justify-content: center;
  /* 1.8rem icon + 0.65rem each side ≈ a 49px target, cancelled by the
     matching negative margins */
  margin: -0.65rem -0.65rem -0.65rem auto;
  padding: 0.65rem;
  color: var(--accent);
  opacity: 0.5;
  border-bottom: 0;
  -webkit-tap-highlight-color: transparent;
  transition: opacity 0.15s ease, color 0.15s ease;
}

/* Both dimensions are stated outright (293:211, so 1.8rem → 1.296rem). Safari
   won't derive an SVG's intrinsic ratio from viewBox alone, so `height: auto`
   collapsed to the SVG default of 100% there and the mark filled the bar. */
.ring svg {
  display: block;
  width: 1.8rem;
  height: 1.296rem;
}

/* No hover on touch, so the mark would otherwise sit dim with no way to
   brighten it. Hovering takes it to maize, matching the UMich link. */
@media (hover: hover) {
  .ring { opacity: 0.4; }

  .ring:hover {
    opacity: 1;
    color: var(--maize);
  }
}

/* The slot the digits arrive into. It is opened before the request resolves,
   so the sentence around it never reflows, and it clips whatever is inside so
   the number can start below the line and slide up into view.

   `vertical-align: bottom` is what keeps the figures on the sentence's
   baseline: an overflow-hidden inline-block has no usable baseline of its own,
   so it has to be aligned by an edge instead. Since the box is a single line
   box at the inherited font-size and line-height, its bottom edge and the
   surrounding strut's bottom edge coincide — and so do the two baselines. */
.views-n {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  /* The slot opens at roughly three figures and is then set to the count's
     real width in JS, which the transition below carries. Any fixed reserve
     would be a guess with an expiry date — 999th needs 4.66ch but 1,000th
     needs 6.12ch — so instead of picking a number that eventually goes stale,
     the difference becomes part of the motion: the slot widens as the figures
     rise into it, and "visitor!" glides across instead of snapping. */
  width: 3.1ch;
  /* Same duration and curve as the swoop, so the two read as one movement. */
  transition: width 320ms cubic-bezier(0.1, 0.86, 0.12, 1);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  /* Full strength against the muted sentence, so the number is what the eye
     goes to — and so the thing that slides in is the thing that stands out. */
  color: var(--accent-ink);
}

/* Parked one full line below the slot, i.e. clipped out of sight. */
.views-d {
  display: inline-block;
  transform: translateY(110%);
}

/* Short, and almost entirely front-loaded: the curve spends its first third
   covering most of the distance and the rest arresting, so the number reads as
   having been thrown into place and caught rather than eased in. */
.views.counted .views-d {
  animation: swoop 320ms cubic-bezier(0.1, 0.86, 0.12, 1) both;
}

@keyframes swoop {
  from { transform: translateY(110%); opacity: 0; }
  /* Opaque well before it stops, so the eye reads travel, not a fade. */
  25%  { opacity: 1; }
  to   { transform: translateY(0); opacity: 1; }
}

/* Nothing came back. Close the slot and retire the sentence instead of leaving
   a gap in it. */
.views {
  transition: opacity 0.35s ease;
}

.views.uncounted {
  opacity: 0;
  pointer-events: none;
}

.views.uncounted .views-n { width: 0; }

@media (prefers-reduced-motion: reduce) {
  .views-d { transform: none; }
  .views.counted .views-d { animation: none; }
  .views-n { transition: none; }
}

.views[hidden] { display: none; }

a {
  color: var(--accent);
  text-decoration: none;
  border-bottom: 1px solid var(--hair);
  padding-bottom: 0.05em;
  transition: border-color 0.15s ease, color 0.15s ease;
}

a:hover {
  border-bottom-color: var(--accent);
}

a.umich:hover {
  color: var(--maize);
  border-bottom-color: var(--maize);
}

a.doordash:hover {
  color: var(--dash);
  border-bottom-color: var(--dash);
}

@media (max-width: 34rem) {
  :root { --frame: 0.55rem; --gutter: 2rem; }
  body { font-size: 1.0625rem; }
  /* Symmetric, since main centres its own content now — a viewport-relative
     top offset here would put the imbalance straight back. */
  main { padding: 3rem var(--gutter); }
}

/* Windows High Contrast Mode strips custom colours from decorative
   backgrounds and borders; without this the frame simply vanishes there. */
@media (forced-colors: active) {
  body::before { border-color: CanvasText; }
}

/* Pinning only holds up on a roomy viewport. On phones a fixed bottom element
   sits under the browser's own toolbar, and on a short landscape window it
   lands on the copy — so in both cases the footer returns to the flow, lined
   up with the text column exactly as it was before it was pinned. */
@media (max-width: 34rem), (max-height: 30rem) {
  /* In the flow and carried to the bottom by main's growth, so it only needs
     enough padding to clear the frame rather than the 5rem of standoff it
     used when it sat directly under the copy. */
  .chrome {
    position: static;
    width: 100%;
    max-width: 34rem;
    margin: 0 auto;
    padding: 0 var(--gutter) 1.75rem;
  }

  .views { font-size: 0.8125rem; }
}
