/* Layout and page styling for the site app.
 *
 * Every colour, space and size here is an --app-* token. Nothing references a
 * raw Open Props name or a literal hex value, which is what keeps swapping the
 * styling source a one-file change (ADR 0002). tokens.css must load first;
 * ui.css styles the component kit with the same names.
 *
 * Three rules this file follows throughout:
 *
 *   1. Type sizes are fluid — clamp() between two token steps — so there is no
 *      breakpoint at which text abruptly changes size.
 *   2. Every transition and transform is disabled under prefers-reduced-motion,
 *      at the bottom of this file, in one place rather than per rule.
 *   3. Nothing depends on a fixed viewport width. Layouts use auto-fit grids
 *      and intrinsic sizing, so there are only two real breakpoints in here.
 */

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

:root {
  /* Local layout constants, derived from tokens rather than invented. */
  --measure: 68ch;
  --page-width: 72rem;
  --prose-width: 40rem;
  --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
  --speed: 220ms;

  /* A raised surface that is the same stock as the page.
     The neutral ramp tapers chroma to zero at both ends, so the light theme's
     raised role — step 0 — comes out pure grey. On a cream page that reads as a
     colder sheet of paper glued to a warmer one: visible immediately once you
     look for it, and impossible to unsee afterwards.
     Mixing a little of step 2 back in restores the warmth. The fix belongs in
     the generator's chromaTaper, but that is platform code, and platform fixes
     go upstream into the template repo rather than being made here (ADR 0007).
     Dark needs no correction: its raised role is step 7, which is nowhere near
     the end of the ramp and keeps its chroma. Hence the three-state override
     below, mirroring how tokens.css resolves themes. */
  --paper-raised: color-mix(in oklab, var(--app-surface-raised) 76%, var(--app-neutral-2));

  /* The same correction at the other end of the ramp. Light's sunken role is
     step 2, which has chroma and needs nothing; dark's is step 9, which is the
     bottom of the ramp and therefore pure neutral. Left alone it renders a
     sunken well as a cold black hole punched through a warm brown card — most
     obvious on the Pong board, which is a large one. */
  --paper-sunken: var(--app-surface-sunken);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --paper-raised: var(--app-surface-raised);
    --paper-sunken: color-mix(in oklab, var(--app-surface-sunken) 70%, var(--app-neutral-7));
  }
}

:root[data-theme="dark"] {
  --paper-raised: var(--app-surface-raised);
  --paper-sunken: color-mix(in oklab, var(--app-surface-sunken) 70%, var(--app-neutral-7));
}

html {
  /* Anchors and the skip link land below the sticky header instead of under it. */
  scroll-padding-top: 5rem;
}

body {
  margin: 0;
  /* A column so the footer can be pushed to the bottom of a short page.
     Without this the footer sits wherever the content ends, which on a 404 or
     a two-line post leaves it stranded halfway up the screen with a field of
     empty paper beneath it — the page reads as though it failed to finish
     loading.
     dvh after vh, not instead of it: dvh tracks mobile browser chrome as it
     collapses, and the vh line is what browsers without dvh fall back to. */
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  background: var(--app-surface);
  color: var(--app-ink);
  font-family: var(--app-font-sans);
  font-size: clamp(var(--app-text-2), 0.96rem + 0.2vw, var(--app-text-3));
  line-height: 1.6;
  /* Long words in owner-authored HTML must not force the page to scroll
     sideways on a phone. */
  overflow-wrap: break-word;
  -webkit-font-smoothing: antialiased;
}

/* Headings are set in the serif, not the sans.
   The sans is the interface — nav, buttons, labels, dates — and the serif is
   the page. Putting headings in the serif is what makes the site read as
   something printed rather than something administered, and it costs nothing:
   the stack is the same book faces the prose already uses.
   The tighter tracking below was tuned for the sans and is wrong here. Serifs
   at display size need less negative tracking, not more, or the letters start
   colliding at the hero's clamp ceiling. */
h1,
h2,
h3 {
  font-family: var(--app-font-serif);
  line-height: 1.15;
  letter-spacing: -0.01em;
  text-wrap: balance;
  margin: 0;
}

a {
  color: var(--app-brand);
}

/* One focus treatment for everything, defined once. :focus-visible rather than
   :focus so a mouse click does not leave a ring behind. */
:where(a, button, input, textarea, select, [tabindex]):focus-visible {
  outline: 2px solid var(--app-brand);
  outline-offset: 2px;
  border-radius: var(--app-radius-1);
}

/* Visually hidden until focused, rather than removed: a skip link nobody can
   reach is not a skip link. */
.skip {
  position: absolute;
  left: -9999px;
  top: var(--app-space-2);
  z-index: 20;
  padding: var(--app-space-2) var(--app-space-3);
  background: var(--app-brand);
  color: var(--app-brand-on);
  border-radius: var(--app-radius-1);
}

.skip:focus {
  left: var(--app-space-2);
}

/* --------------------------------------------------------------------- */
/* Chrome                                                                 */
/* --------------------------------------------------------------------- */

.site-header {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--app-space-3);
  padding: var(--app-space-3) var(--app-space-4);
  /* Semi-transparent over a blur, so content scrolling underneath stays
     legible without the header becoming an opaque slab. color-mix keeps this
     tied to the surface token instead of a hard-coded rgba(). */
  background: color-mix(in oklab, var(--app-surface) 82%, transparent);
  backdrop-filter: blur(12px) saturate(1.4);
  border-bottom: 1px solid transparent;
  transition: border-color var(--speed) var(--ease);
}

/* The rule only appears once there is something scrolled behind it. */
.site-header[data-scrolled="true"] {
  border-bottom-color: var(--app-line);
}

.site-header__inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--app-space-3);
  width: 100%;
  max-width: var(--page-width);
  margin: 0 auto;
}

.site-mark {
  font-size: var(--app-text-3);
  font-weight: 650;
  letter-spacing: -0.02em;
  color: var(--app-ink);
  text-decoration: none;
}

.site-nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--app-space-1);
}

.site-nav a {
  position: relative;
  padding: var(--app-space-1) var(--app-space-2);
  border-radius: var(--app-radius-1);
  color: var(--app-ink-dim);
  text-decoration: none;
  font-size: var(--app-text-2);
  transition: color var(--speed) var(--ease), background-color var(--speed) var(--ease);
}

.site-nav a:hover {
  color: var(--app-ink);
  background: color-mix(in oklab, var(--app-ink) 7%, transparent);
}

.site-nav a[aria-current="page"] {
  color: var(--app-ink);
}

/* The current-page marker is a pseudo-element rather than a border so it can
   be inset from the padding and animate its width. */
.site-nav a[aria-current="page"]::after {
  content: "";
  position: absolute;
  left: var(--app-space-2);
  right: var(--app-space-2);
  bottom: 2px;
  height: 2px;
  border-radius: 2px;
  background: var(--app-brand);
}

.theme-toggle {
  display: inline-grid;
  place-items: center;
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  border: 1px solid var(--app-line);
  border-radius: var(--app-radius-round, 999px);
  background: transparent;
  color: var(--app-ink-dim);
  cursor: pointer;
  transition: color var(--speed) var(--ease), border-color var(--speed) var(--ease),
    transform var(--speed) var(--ease);
}

.theme-toggle:hover {
  color: var(--app-ink);
  border-color: var(--app-ink-dim);
  transform: rotate(-15deg);
}

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

/* Exactly one of the two icons is shown, chosen by the resolved theme. Both
   are in the DOM so the swap costs no request and cannot flash. */
.theme-toggle__moon {
  display: none;
}

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

:root[data-theme="dark"] .theme-toggle__sun {
  display: none;
}

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

:root[data-theme="light"] .theme-toggle__sun {
  display: block;
}

:root[data-theme="light"] .theme-toggle__moon {
  display: none;
}

.page {
  /* Takes the slack in the body column, which is what holds the footer down.
     width: 100% is required alongside it: a flex item's max-width does not
     make it fill the cross axis, so without this the page collapses to its
     content width and the centring margin has nothing to centre. */
  flex: 1;
  width: 100%;
  max-width: var(--page-width);
  margin: 0 auto;
  padding: var(--app-space-5) var(--app-space-4) var(--app-space-7);
}

.site-footer {
  /* Same as .page: a flex item needs an explicit width before max-width and
     auto margins will centre it. */
  width: 100%;
  max-width: var(--page-width);
  margin: 0 auto;
  padding: var(--app-space-5) var(--app-space-4);
  border-top: 1px solid var(--app-line);
  color: var(--app-ink-dim);
  font-size: var(--app-text-1);
}

.site-footer__mark {
  width: 1.5rem;
  height: 1.5rem;
  margin-bottom: var(--app-space-2);
  color: var(--app-accent);
}

/* Footer links are underlined rather than coloured. The footer is already dim
   ink, and a brand-coloured link in it pulls more attention than the footer
   itself has — an underline says "link" without competing with the page. */
.site-footer a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 0.2em;
  text-decoration-thickness: 1px;
  text-decoration-color: var(--app-line);
  transition: color var(--speed) var(--ease), text-decoration-color var(--speed) var(--ease);
}

.site-footer a:hover {
  color: var(--app-ink);
  text-decoration-color: currentColor;
}

.site-footer p {
  margin: 0;
}

.site-footer p + p {
  margin-top: var(--app-space-2);
}

/* A standfirst under a page heading. Sized between the heading and the body so
   it reads as part of the title block rather than as the first paragraph. */
.lede {
  max-width: 46ch;
  margin: var(--app-space-3) 0 var(--app-space-5);
  color: var(--app-ink-dim);
  font-size: clamp(var(--app-text-3), 1.05rem + 0.4vw, var(--app-text-4));
  text-wrap: balance;
}

/* Quiet by design: it belongs to the few people who already know what it is,
   and should not compete with the writing it points at. */
.feed-link {
  margin-top: var(--app-space-6);
  padding-top: var(--app-space-4);
  border-top: 1px solid var(--app-line);
  font-size: var(--app-text-1);
}

.feed-link a {
  color: var(--app-ink-dim);
  text-decoration: underline;
  text-underline-offset: 0.2em;
  text-decoration-color: var(--app-line);
}

.feed-link a:hover {
  color: var(--app-ink);
}

.narrow {
  max-width: 26rem;
}

.stack > * + * {
  margin-top: var(--app-space-3);
}

.muted {
  color: var(--app-ink-dim);
}

.more {
  display: inline-flex;
  align-items: center;
  gap: var(--app-space-1);
  color: var(--app-brand);
  text-decoration: none;
  font-weight: 550;
}

.more::after {
  content: "→";
  transition: transform var(--speed) var(--ease);
}

.more:hover::after {
  transform: translateX(3px);
}

/* --------------------------------------------------------------------- */
/* Hero                                                                   */
/* --------------------------------------------------------------------- */

.hero {
  position: relative;
  display: grid;
  align-content: center;
  min-height: min(62vh, 30rem);
  margin-bottom: var(--app-space-6);
  padding: var(--app-space-6) 0;
  isolation: isolate;
}

/* The growing tree. Purely decorative — aria-hidden, entirely client-side — so
   it carries no information a reader could miss. */
.hero__canvas {
  position: absolute;
  inset: 0;
  z-index: -1;
  width: 100%;
  height: 100%;
  /* Centred on the canopy rather than on the canvas, and faded out well before
     the left edge so the heading always has quiet ground beneath it.
     This mask replaced one centred at 70% 40% with a soft bottom edge, which
     suited a Pong board floating in the middle of the box but cut the trunk off
     at the ankles — a tree has to be attached to the bottom of its frame to
     read as a tree at all. */
  -webkit-mask-image: radial-gradient(58% 86% at 76% 62%, #000 46%, transparent 88%);
  mask-image: radial-gradient(58% 86% at 76% 62%, #000 46%, transparent 88%);
  opacity: 0.55;
  pointer-events: none;
}

/* On a narrow screen the tagline runs the full width and there is no "beside
   the text" left to put a tree in, so it drops behind the copy and has to get
   out of the way of it. */
@media (max-width: 40rem) {
  .hero__canvas {
    opacity: 0.28;
  }
}

/* 16ch rather than 14, and a little more leading than the global heading rule.
   At 14ch the welcome broke into three lines, and three tightly-led lines of a
   display serif read as squashed: the descenders of "plenty" hang into the caps
   of "of room." beneath. 16ch is the first width at which it settles onto two
   lines of nearly equal length.
   The global 1.15 is right for a heading that occupies one line. A wrapped one
   at ~49px needs enough room for the descenders to clear the line below, which
   is the thing being perceived as squashed rather than the size itself. */
.hero h1 {
  font-size: clamp(var(--app-text-5), 2.2rem + 3.6vw, var(--app-text-7));
  max-width: 16ch;
  line-height: 1.18;
}

/* Differences from .lede: no bottom margin, because the hero spaces its own
   children, and normal wrapping instead of balanced.

   text-wrap: balance is wrong for this one line. Balance equalises line
   lengths, and to do it here it breaks inside the hyphen of "Real-time",
   leaving "Real-" hanging at the end of the first line — at every width tried,
   from 30ch to 42ch, because the break is chosen for symmetry rather than for
   where the text can be divided. Normal wrapping keeps the word whole.

   42ch is then chosen so the remaining break lands after a comma rather than
   mid-phrase. It stays clear of the tree, which is fitted to the right of the
   prose column. */
.hero__tagline {
  max-width: 42ch;
  margin-bottom: 0;
  text-wrap: auto;
}

.hero .prose {
  margin-top: var(--app-space-4);
}

/* --------------------------------------------------------------------- */
/* Sections                                                               */
/* --------------------------------------------------------------------- */

.section {
  margin-top: var(--app-space-6);
}

.section > h2 {
  font-size: clamp(var(--app-text-4), 1.4rem + 0.8vw, var(--app-text-5));
  margin-bottom: var(--app-space-4);
}

/* A hairline that starts at the heading and runs to the edge, which gives the
   page a rhythm without drawing a full-width rule above every section. */
.section > h2::after {
  content: "";
  display: block;
  width: 3rem;
  height: 2px;
  margin-top: var(--app-space-2);
  border-radius: 2px;
  background: linear-gradient(90deg, var(--app-brand), var(--app-accent));
}

.cards {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  /* auto-fit with a minimum: one column on a phone, as many as fit on a
     desktop, and no breakpoints to maintain. */
  grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
  gap: var(--app-space-4);
}

.card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--app-space-2);
  padding: var(--app-space-4);
  background: var(--paper-raised);
  border: 1px solid var(--app-line);
  border-radius: var(--app-radius-3);
  overflow: hidden;
  transition: transform var(--speed) var(--ease), border-color var(--speed) var(--ease),
    box-shadow var(--speed) var(--ease);
}

/* A tint that fades in on hover, drawn behind the content. Using a
   pseudo-element rather than animating background-color keeps the transition
   off the paint-heavy property and lets it be a gradient. */
.card::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  background: linear-gradient(135deg,
      color-mix(in oklab, var(--app-brand) 10%, transparent),
      transparent 60%);
  opacity: 0;
  transition: opacity var(--speed) var(--ease);
}

.card:hover {
  transform: translateY(-3px);
  border-color: color-mix(in oklab, var(--app-brand) 45%, var(--app-line));
  box-shadow: 0 12px 28px -18px color-mix(in oklab, var(--app-ink) 60%, transparent);
}

.card:hover::before {
  opacity: 1;
}

.card > * {
  position: relative;
  z-index: 1;
}

.card__title {
  margin: 0;
  font-size: var(--app-text-3);
}

.card__title a {
  color: var(--app-ink);
  text-decoration: none;
}

/* Stretched link: the whole card is clickable, without nesting interactive
   elements or wrapping the card in an anchor. */
.card__title a::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
}

.card:hover .card__title a {
  color: var(--app-brand);
}

.card__blurb {
  margin: 0;
  color: var(--app-ink-dim);
  font-size: var(--app-text-2);
}

/* The self-playing board inside the Pong card.
   The aspect ratio is fixed here rather than left to the canvas element's
   default 300x150, so the card's height does not change when the script runs —
   or fail to change, and leave a squat box, when the script is blocked. */
.card__board {
  width: 100%;
  aspect-ratio: 16 / 7;
  border-radius: var(--app-radius-2);
  background: var(--paper-sunken);
  border: 1px solid var(--app-line);
  /* Behind the card's hover tint, which is what keeps the board from looking
     like a separate widget dropped into the card. */
  position: relative;
  z-index: 0;
}

.card__action {
  margin: auto 0 0;
  padding-top: var(--app-space-2);
}

/* The button inside a stretched-link card needs to sit above the overlay to
   stay independently clickable. */
.card__action > * {
  position: relative;
  z-index: 3;
}

/* --------------------------------------------------------------------- */
/* Posts                                                                  */
/* --------------------------------------------------------------------- */

.posts {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--app-space-2);
}

.posts__item {
  display: grid;
  gap: var(--app-space-1);
  padding: var(--app-space-3);
  margin-inline: calc(var(--app-space-3) * -1);
  border-radius: var(--app-radius-2);
  transition: background-color var(--speed) var(--ease);
}

.posts__item:hover {
  background: color-mix(in oklab, var(--app-ink) 5%, transparent);
}

/* A post title is a heading in everything but markup — it is an <a> so the
   whole row is one link target — so it is set in the serif like every real
   heading. Left in the sans it was the only title on the site in the interface
   face, and the writing list read as a file listing rather than a contents
   page. */
.posts__link {
  font-family: var(--app-font-serif);
  font-size: var(--app-text-3);
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--app-ink);
  text-decoration: none;
}

.posts__item:hover .posts__link {
  color: var(--app-brand);
}

.posts__date {
  color: var(--app-ink-dim);
  font-size: var(--app-text-1);
  font-variant-numeric: tabular-nums;
}

.posts__summary {
  margin: 0;
  max-width: var(--measure);
  color: var(--app-ink-dim);
}

/* On a wide screen the date moves into a left rail, which turns the list into
   a proper index rather than a stack of blocks. */
@media (min-width: 48rem) {
  .posts__item {
    grid-template-columns: 9rem 1fr;
    align-items: baseline;
    column-gap: var(--app-space-4);
  }

  .posts__date {
    grid-column: 1;
    grid-row: 1;
    text-align: right;
  }

  .posts__link,
  .posts__summary,
  .posts__item > .badge {
    grid-column: 2;
  }
}

.post {
  max-width: var(--prose-width);
  margin: 0 auto;
}

.post__head {
  margin-bottom: var(--app-space-5);
}

.post__head h1 {
  font-size: clamp(var(--app-text-4), 1.6rem + 1.4vw, var(--app-text-6));
  margin-bottom: var(--app-space-2);
}

.post__date {
  color: var(--app-ink-dim);
  font-size: var(--app-text-1);
}

.post__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--app-space-2);
  margin-top: var(--app-space-2);
}

/* Pushed to the far edge by an auto margin rather than by space-between on the
   row, because space-between needs something to sit opposite: an unpublished
   draft has no date, and the button would then park itself at the reading edge
   where the date belongs. */
.post__edit {
  margin-left: auto;
}

/* The way onward from the end of a post. The rule above it is what separates
   the owner's words from the site's, so the link does not read as the last
   line of the writing. */
.post__foot {
  margin-top: var(--app-space-6);
  padding-top: var(--app-space-4);
  border-top: 1px solid var(--app-line);
}

.badge {
  display: inline-block;
  padding: 0 var(--app-space-1);
  border: 1px solid var(--app-line);
  border-radius: var(--app-radius-1);
  color: var(--app-ink-dim);
  font-size: var(--app-text-0);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  white-space: nowrap;
}

.badge--live {
  border-color: color-mix(in oklab, var(--app-accent) 60%, transparent);
  color: var(--app-accent);
}

.badge--block {
  display: block;
  padding: var(--app-space-2) var(--app-space-3);
  text-transform: none;
  letter-spacing: normal;
  font-size: var(--app-text-1);
}

/* --------------------------------------------------------------------- */
/* Prose — owner-authored HTML lands here                                 */
/* --------------------------------------------------------------------- */

.prose {
  max-width: var(--prose-width);
  font-family: var(--app-font-serif);
  font-size: clamp(var(--app-text-3), 1.02rem + 0.25vw, 1.2rem);
  line-height: 1.7;
}

/* Generic element selectors, deliberately: the editor writes plain markup, not
   classes from this file. :where() keeps specificity at zero so a post can
   override anything with an inline style. */
.prose :where(h2) {
  font-family: var(--app-font-sans);
  font-size: var(--app-text-4);
  margin-top: var(--app-space-5);
  margin-bottom: var(--app-space-2);
}

.prose :where(h3) {
  font-family: var(--app-font-sans);
  font-size: var(--app-text-3);
  margin-top: var(--app-space-4);
  margin-bottom: var(--app-space-2);
}

.prose :where(p, ul, ol) {
  margin: var(--app-space-3) 0;
}

/* Stops a paragraph ending with one word alone on the last line.
   `pretty` rather than `balance`: balance equalises every line, which is right
   for a heading of two or three lines and wrong for a paragraph — it reflows
   the whole block to do it. pretty leaves the body of the paragraph alone and
   only adjusts the last lines enough to pull the orphan up.
   Browsers without it wrap exactly as before, which is the current behaviour,
   so there is nothing to fall back to. */
.prose :where(p) {
  text-wrap: pretty;
}

/* Buttons are excluded rather than overridden afterwards.
   .prose :where(a) and .ui-button carry the same specificity — :where()
   contributes nothing, so it is one class against one class — and this file
   loads after ui.css, so prose wins on cascade order alone. A primary button
   in prose therefore got brand-coloured text on a brand-coloured background,
   which is invisible rather than merely wrong.
   Excluding it here beats adding a louder rule to put the colour back: the
   button variants each define their own foreground, and a correction would
   have to name one of them and be wrong for the others. */
.prose :where(a:not(.ui-button)) {
  color: var(--app-brand);
  text-decoration-thickness: 1px;
  text-underline-offset: 0.16em;
}

.prose :where(img, video) {
  display: block;
  max-width: 100%;
  height: auto;
  margin: var(--app-space-4) 0;
  border-radius: var(--app-radius-2);
  border: 1px solid var(--app-line);
}

.prose :where(figure) {
  margin: var(--app-space-5) 0;
}

.prose :where(figcaption) {
  margin-top: var(--app-space-2);
  color: var(--app-ink-dim);
  font-family: var(--app-font-sans);
  font-size: var(--app-text-1);
  text-align: center;
}

.prose :where(pre) {
  background: var(--paper-sunken);
  border: 1px solid var(--app-line);
  border-radius: var(--app-radius-2);
  padding: var(--app-space-3);
  /* A wide code block scrolls inside itself rather than widening the page. */
  overflow-x: auto;
  font-size: var(--app-text-1);
  line-height: 1.5;
}

.prose :where(code) {
  font-family: var(--app-font-mono);
  font-size: 0.92em;
}

.prose :where(:not(pre) > code) {
  padding: 0.1em 0.35em;
  background: var(--paper-sunken);
  border-radius: var(--app-radius-1);
}

.prose :where(blockquote) {
  margin: var(--app-space-4) 0;
  padding-left: var(--app-space-4);
  border-left: 3px solid color-mix(in oklab, var(--app-brand) 50%, var(--app-line));
  color: var(--app-ink-dim);
  font-style: italic;
}

.prose :where(table) {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--app-font-sans);
  font-size: var(--app-text-2);
}

.prose :where(th, td) {
  border-bottom: 1px solid var(--app-line);
  padding: var(--app-space-2);
  text-align: left;
}

.prose :where(hr) {
  margin: var(--app-space-5) 0;
  border: 0;
  border-top: 1px solid var(--app-line);
}

/* --------------------------------------------------------------------- */
/* Scroll reveal                                                          */
/* --------------------------------------------------------------------- */

/* Elements start shifted and fade in when scrolled into view. The initial
   state is applied by script, not by this rule, so that a browser with no
   JavaScript never hides anything — see site.js. */
[data-reveal].is-hidden {
  opacity: 0;
  transform: translateY(14px);
}

[data-reveal] {
  transition: opacity 520ms var(--ease), transform 520ms var(--ease);
}

/* --------------------------------------------------------------------- */
/* Editor                                                                 */
/* --------------------------------------------------------------------- */

.editor__head,
.editor__section-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--app-space-3);
}

.editor__head {
  margin-bottom: var(--app-space-4);
}

.editor__head h1 {
  font-size: var(--app-text-4);
}

.editor__head-actions {
  display: flex;
  gap: var(--app-space-2);
}

.editor__grid {
  display: grid;
  gap: var(--app-space-5);
  margin-top: var(--app-space-4);
}

/* Side by side only when there is room for both to be usable. Below this the
   preview sits under the form rather than being squeezed. */
@media (min-width: 62rem) {
  .editor__grid {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-items: start;
  }

  .editor__preview {
    position: sticky;
    top: 5rem;
  }
}

.editor__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--app-space-2);
}

.editor__preview {
  border: 1px solid var(--app-line);
  border-radius: var(--app-radius-3);
  padding: var(--app-space-4);
  background: var(--paper-raised);
  /* The preview renders arbitrary owner HTML; containing its overflow stops a
     wide element there from stretching the editor layout. */
  overflow: auto;
  max-height: 80vh;
}

.editor__preview-title {
  font-size: var(--app-text-1);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--app-ink-dim);
  margin: 0 0 var(--app-space-3);
}

.preview__title {
  font-size: var(--app-text-4);
  margin-top: 0;
}

.admin-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.admin-list__item {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--app-space-2);
  padding: var(--app-space-3) 0;
  border-bottom: 1px solid var(--app-line);
}

.admin-list__item--stacked {
  display: block;
}

.admin-list__main {
  flex: 1 1 16rem;
  min-width: 0;
}

.admin-list__title {
  display: block;
  color: var(--app-ink);
  text-decoration: none;
  font-weight: 600;
}

.admin-list__title:hover {
  color: var(--app-brand);
}

.admin-list__meta {
  display: block;
  color: var(--app-ink-dim);
  font-size: var(--app-text-1);
}

.project-form__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--app-space-3);
  margin-top: var(--app-space-3);
}

/* --------------------------------------------------------------------- */
/* Pong                                                                   */
/* --------------------------------------------------------------------- */

.pong {
  max-width: 62rem;
  margin: 0 auto;
}

.pong__head h1 {
  font-size: clamp(var(--app-text-4), 1.6rem + 1.2vw, var(--app-text-6));
  margin-bottom: var(--app-space-2);
}

.pong__hint {
  color: var(--app-ink-dim);
  font-size: var(--app-text-2);
  margin: 0 0 var(--app-space-4);
  max-width: var(--measure);
}

.pong__hint kbd {
  font-family: var(--app-font-mono);
  font-size: var(--app-text-0);
  padding: 0.15em 0.4em;
  border: 1px solid var(--app-line);
  border-bottom-width: 2px;
  border-radius: var(--app-radius-1);
  background: var(--paper-sunken);
}

.pong__scoreboard {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--app-space-3);
  margin-bottom: var(--app-space-2);
}

.pong__score {
  font-family: var(--app-font-mono);
  font-size: clamp(var(--app-text-5), 2rem + 2vw, var(--app-text-7));
  line-height: 1;
  /* Tabular figures so the score does not shift the layout as digits change. */
  font-variant-numeric: tabular-nums;
}

.pong__score:last-of-type {
  text-align: right;
}

.pong__status {
  color: var(--app-ink-dim);
  font-size: var(--app-text-1);
  text-align: center;
  min-width: 10rem;
}

.pong__canvas {
  display: block;
  width: 100%;
  height: auto;
  /* Matches the simulation's 1600x900 field, so the canvas reserves its space
     before the first frame arrives and the page does not jump. */
  aspect-ratio: 16 / 9;
  background: var(--paper-sunken);
  border: 1px solid var(--app-line);
  border-radius: var(--app-radius-3);
  touch-action: none;
}

.pong__controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--app-space-3);
  margin-top: var(--app-space-3);
}

.pong__viewers {
  margin: 0;
  color: var(--app-ink-dim);
  font-size: var(--app-text-1);
}

.pong__touch {
  display: none;
  gap: var(--app-space-3);
  margin-top: var(--app-space-3);
}

/* Shown only on a device without a keyboard to hold a key down on, and only
   once seated — the [hidden] state is managed by pong.js. */
@media (pointer: coarse) {
  .pong__touch:not([hidden]) {
    display: flex;
  }
}

.pong__touch-btn {
  flex: 1;
  padding: var(--app-space-4);
  font-size: var(--app-text-4);
  background: var(--paper-raised);
  color: var(--app-ink);
  border: 1px solid var(--app-line);
  border-radius: var(--app-radius-2);
  touch-action: none;
  user-select: none;
}

.pong__touch-btn:active {
  background: var(--app-brand);
  color: var(--app-brand-on);
}

/* --------------------------------------------------------------------- */
/* Motion preferences                                                     */
/* --------------------------------------------------------------------- */

/* One place, rather than a guard on every rule above. Transitions are reduced
   to an imperceptible duration instead of 0s, because some browsers skip
   transitionend entirely at 0s and scripts that wait for it then hang. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  .card:hover {
    transform: none;
  }

  .theme-toggle:hover {
    transform: none;
  }

  /* Revealed content must never be left hidden when the animation that would
     have shown it is suppressed. */
  [data-reveal].is-hidden {
    opacity: 1;
    transform: none;
  }
}

/* --------------------------------------------------------------------- */
/* Image library                                                          */
/* --------------------------------------------------------------------- */

.editor__body-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--app-space-3);
  margin-bottom: calc(var(--app-space-2) * -1);
}

.editor__body-label {
  font-size: var(--app-text-1);
  font-weight: 600;
  color: var(--app-ink-dim);
}

/* The dialog is wider than the default so the thumbnail grid has room for
   more than one column. */
.ui-dialog.dialog--wide {
  max-width: min(52rem, 92vw);
  width: 52rem;
}

.picker__drop {
  margin: var(--app-space-3) 0;
  padding: var(--app-space-4);
  border: 2px dashed var(--app-line);
  border-radius: var(--app-radius-2);
  background: var(--paper-sunken);
  text-align: center;
  transition: border-color var(--speed) var(--ease), background-color var(--speed) var(--ease);
}

.picker__drop[data-dragging="true"] {
  border-color: var(--app-brand);
  background: color-mix(in oklab, var(--app-brand) 8%, var(--paper-sunken));
}

.picker__status {
  margin: var(--app-space-2) 0 0;
  font-size: var(--app-text-1);
  color: var(--app-ink-dim);
  min-height: 1.2em;
}

.picker__status[data-error="true"] {
  color: var(--app-danger);
}

.picker__grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(11rem, 100%), 1fr));
  gap: var(--app-space-3);
  max-height: 46vh;
  overflow-y: auto;
}

.picker__item {
  display: flex;
  flex-direction: column;
  gap: var(--app-space-1);
  padding: var(--app-space-2);
  border: 1px solid var(--app-line);
  border-radius: var(--app-radius-2);
  background: var(--paper-raised);
}

.picker__thumb {
  display: block;
  padding: 0;
  border: 0;
  border-radius: var(--app-radius-1);
  background: var(--paper-sunken);
  cursor: pointer;
  overflow: hidden;
}

.picker__thumb img {
  display: block;
  width: 100%;
  height: 7rem;
  /* contain rather than cover: this is a picker, so seeing the whole image
     matters more than a tidy grid of identical crops. */
  object-fit: contain;
  transition: transform var(--speed) var(--ease);
}

.picker__thumb:hover img {
  transform: scale(1.04);
}

.picker__meta {
  display: flex;
  justify-content: space-between;
  gap: var(--app-space-1);
  font-size: var(--app-text-0);
  color: var(--app-ink-dim);
}

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

.picker__size {
  flex: none;
  font-variant-numeric: tabular-nums;
}

.picker__alt {
  display: grid;
  gap: var(--app-space-1);
}

.picker__alt .ui-field__input {
  font-size: var(--app-text-1);
  padding: var(--app-space-1) var(--app-space-2);
}

.picker__actions {
  display: flex;
  gap: var(--app-space-1);
}

@media (prefers-reduced-motion: reduce) {
  .picker__thumb:hover img {
    transform: none;
  }
}

/* Embedded games.
   
   The frame's shape is set from the embed table via an inline aspect-ratio, so
   the box is the right size before the game exists. Reserving it here rather
   than letting the iframe size itself is what stops thirty-nine megabytes of
   export shoving the rest of the page down when it finally arrives. */
.embed__head {
  margin-block-end: var(--app-space-4);
}

.embed__blurb {
  color: var(--app-ink-dim);
}

.embed__frame {
  display: block;
  inline-size: 100%;
  border: 1px solid var(--app-line);
  border-radius: var(--app-radius-3);
  background: var(--paper-sunken);
}

.embed__status {
  margin-block-start: var(--app-space-3);
  color: var(--app-ink-dim);
}

/* Click-to-load game stage on a card.

   The box exists before the game does, at the aspect ratio the embed table
   declares, so pressing Play swaps a button for a frame of exactly the same
   size and nothing on the page moves. */
.stage {
  display: grid;
  place-items: center;
  inline-size: 100%;
  margin-block: var(--app-space-3);
  border: 1px solid var(--app-line);
  border-radius: var(--app-radius-3);
  background: var(--paper-sunken);
  overflow: hidden;
}

.stage__frame {
  inline-size: 100%;
  block-size: 100%;
  border: 0;
  display: block;
}
