/* Site stylesheet.
   No inline styles anywhere in the templates, so the CSP can keep
   `style-src 'self'` and a player-supplied string has nowhere to turn into
   presentation. The band art and the icon pictures arrive two ways and neither is a template
   making up a URL: pictures the realm names are served by /art/{kind}/{file} from a pack
   enumerated at startup, and the theme that paints the opening band arrives as /theme.css, a
   stylesheet this process writes. `--hero-art` and `--hero-scrim` are set there and default to
   nothing here, which is why a host with no art directory still renders a complete page. */

:root {
  /* The look vocabulary. These are "plain": the stylesheet alone, no art required, and the
     baseline every theme in internal/web/themes.go is measured against. A theme overrides these
     in /theme.css; a property listed here and read nowhere is a bug, and a test says so. */
  --bg: #150f08;
  --bg-deep: #0f0a05;
  --page-glow: #2a1f12;
  --panel: #20160c;
  --panel-edge: #3a2a1a;
  --ink: #e8dcc8;
  --muted: #b09a78;
  --accent: #f5a623;
  --accent-ink: #1a1205;
  --accent-hover: #ffb733;
  /* The accent has two jobs and they pull in opposite directions: a button's fill has to be dark
     enough to hold a light label, and a section heading painted in the accent has to be light
     enough to read on a card. One value cannot do both — the Horde look proved that by failing
     both — so the accent that paints text is its own property. */
  --accent-text: #f5a623;
  --link: #d4b078;
  --line: #3a2a1a;
  --radius: 0.55rem;
  --max: 66rem;
  /* Not part of the look vocabulary: the two state colours, fixed so a look cannot make danger
     look ordinary. */
  --danger: #ef4444;
  --ok: #34d399;
  /* Type. A look picks a face; `inherit` keeps the body face, which is what a page with no
     theme file does. The two bundled display faces are self-hosted below, so a look that names
     one never waits on a third party for a glyph. */
  --heading-font: inherit;
  --nav-font: inherit;
  --nav-transform: none;
  --nav-tracking: normal;
  --nav-size: 0.9rem;
  /* The drawn range under the opening band. A look whose band art is a full picture turns it
     off, because a silhouette over a painting hides the thing the picture was chosen for. */
  --hero-ridge-display: block;
  /* The scrim the headline sits on: opaque on its own (that is the plain look), translucent when
     a look puts a picture under it, so the words keep the contrast they were tuned for either way. */
  --hero-scrim: linear-gradient(178deg, #150f08 0%, #20160c 52%, #2a1f12 100%);
}

/* Two faces, latin subset, self-hosted and embedded in the binary: an engraved serif for the
   Alliance and Onyxia looks, a condensed sans for the Horde one. `font-display: swap` and no
   `preload`: a browser that never uses the face never fetches it, so "plain" costs nothing. */
@font-face {
  font-family: "Cinzel";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/static/fonts/Cinzel-latin.woff2") format("woff2-variations");
}
@font-face {
  font-family: "Oswald";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/static/fonts/Oswald-latin.woff2") format("woff2-variations");
}

* { box-sizing: border-box; }
/* The page itself is the container the opening band measures against; see .hero-art. */
body {
  container-type: inline-size;
  margin: 0;
  /* A quiet glow gives the pages depth without an asset fetch, and a look may put a whole
     picture behind everything: --page-art is the theme's page background, fixed so it stays put
     while the text scrolls over it, `cover` so it never tiles into a seam. With no theme file the
     layer is `none` and the glow alone is the page, as it was before the pack existed. */
  background-image:
    radial-gradient(1100px 340px at 50% -120px, var(--page-glow) 0%, rgba(27, 43, 74, 0) 70%),
    var(--page-art, none);
  background-color: var(--bg);
  background-position: top center, top center;
  background-size: auto, cover;
  background-attachment: scroll, fixed;
  background-repeat: no-repeat;
  color: var(--ink);
  font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  min-height: 100vh;
  /* Sticky footer: the body is the column that every page assembles in, so a page shorter than
     the viewport leaves its dark air below the footer (shots/q/changelogs.1180.png - the foot
     ended at ~60% of the screen with a band of background under it). As a flex column the
     footer's auto top margin eats whatever the content did not. Long pages are untouched:
     auto margins only absorb free space, and a page that fills the viewport has none. */
  display: flex;
  flex-direction: column;
}

/* Headings take the look's face. This one rule covers every heading on the site, the brand, and
   the section titles, because a look that changes the headings of some pages and not others is
   two bugs dressed as a theme. */
h1, h2, h3, .brand, .panel-nav a {
  font-family: var(--heading-font);
}

/* --- pictures: the realm names them, internal/art owns the bytes ------------------
   A frame always draws, with a picture in it or without one, so an item whose realm has no
   display row still holds the shape its row would have held. */
'.row-with-icon { display: inline-flex; align-items: center; gap: 0.55rem; }
/* The name takes the rest of the row and wraps when the rest is two words wide. A flex item's
   default minimum is its longest word, and on the honour board that held the rank column open
   past its 103 px at 768 px of viewport: 14 px of "not recorded" outside the table. */
.row-with-icon .row-name { min-width: 0; }
.icon {
  display: inline-block;
  flex: 0 0 auto;
  width: 28px;
  height: 28px;
  border: 1px solid var(--line);
  border-radius: 0.3rem;
  background: #0c1424;
  overflow: hidden;
}
.icon img { display: block; width: 100%; height: 100%; object-fit: contain; }
.icon.empty { background: repeating-linear-gradient(135deg, #0c1424 0 6px, #101a2e 6px 12px); }
.icon-18 { width: 18px; height: 18px; }
.icon-28 { width: 28px; height: 28px; }
.icon-48 { width: 48px; height: 48px; }
.icon-56 { width: 56px; height: 56px; }
.spellhead { display: flex; align-items: center; gap: 0.8rem; }
.spellhead h1 { margin: 0; }
.itemtip .itemtip-icon { float: left; margin: 0 0.9rem 0.4rem 0; }

a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }

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

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

/* The account strip: one line of small ghost text above the menu bar. It scrolls
   away with the page — the menu row below is what stays sticky, because that is
   the row a visitor reaches for mid-page. */
.util-bar {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 1.1rem;
  padding: 0.35rem 1.5rem;
  background: #0a101e;
  border-bottom: 1px solid var(--line);
  font-size: 0.8rem;
  letter-spacing: 0.04em;
}

.util-bar a {
  color: var(--muted);
  text-decoration: none;
}

.util-bar a:hover { color: var(--ink); }

.util-bar form.inline { margin: 0; display: inline; }

.util-bar button.ghost {
  margin: 0;
  padding: 0.15rem 0.5rem;
  font-size: 0.8rem;
}

.site-head {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.85rem 1.5rem;
  background: rgba(15, 23, 42, 0.92);
  border-bottom: 1px solid var(--line);
  backdrop-filter: blur(6px);
}

.brand {
  white-space: nowrap;
  font-size: 1.05rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
}

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

/* The bar lays out its links on .nav-body, the element the fold keeps whole: the same roster is
   a hover row at a tablet and a tapped-open list on a phone, so the layout belongs to what the
   fold holds, not to the nav that wraps it. */
nav { display: block; }
.nav-body { display: flex; align-items: center; gap: 1.1rem; flex-wrap: wrap; }

.nav-body > a { font-size: var(--nav-size); color: var(--muted); font-family: var(--nav-font); letter-spacing: var(--nav-tracking); text-transform: var(--nav-transform); }
.nav-body > a:hover { color: var(--ink); text-decoration: none; }

/* The fold the bar becomes on a phone. The <details> carries no open attribute, so a page opens
   with the menu shut — one line a thumb can reach instead of the roster's eight — and the
   browser, not a script, owns the state. At a tablet and up the summary is off the page and the
   fold's content is drawn whatever the attribute says, because at that width there is no folding.
   A closed fold's paint lives on the browser's own ::details-content wrapper (Baseline 2025:
   Chrome 131, Safari 18.4, Firefox 143); a browser older than that keeps hiding the closed
   content with a plain display rule, which the flex above out-ranks, and the bar stays the bar
   there too. */
.nav-fold summary { display: none; }
.nav-fold::details-content { content-visibility: visible; }

/* A drawn mark in front of a section name — the same treatment as the state marks (outline,
   `currentColor`, decorative), sized against the bar's own 0.9rem label so shape and word read as
   one unit instead of an icon and a word. The anchor becomes a row so the mark sits on the label's
   middle rather than its text baseline: the bar wraps at a phone width, and a baseline-aligned box
   drifts a pixel or two on every line after the first. */
.nav-body > a, .nav-body .nav-group > a { display: inline-flex; align-items: center; }
.nav-mark {
  width: 0.85rem;
  height: 0.85rem;
  margin-right: 0.32rem;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The links an operator adds. One with links under it is a group: the name itself
   is an ordinary link, and what hangs under it appears beneath it on hover or on
   keyboard focus. There is no script in this, and that is the point — the
   reference's dropdown is UIkit JavaScript, so its menu is empty for any reader
   whose script is blocked, and it needs a second copy of the whole query for the
   mobile panel. Keyboard order is the document order: the children follow their
   parent, so Tab reaches them without a script deciding what is next. */
nav .nav-group { position: relative; display: inline-flex; align-items: center; }
nav .nav-group > a { font-size: var(--nav-size); color: var(--muted); font-family: var(--nav-font); letter-spacing: var(--nav-tracking); text-transform: var(--nav-transform); }
nav .nav-group > a:hover { color: var(--ink); text-decoration: none; }

nav .nav-drop {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  padding-top: 0.5rem;
  z-index: 11;
}

nav .nav-group:hover .nav-drop,
nav .nav-group:focus-within .nav-drop {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  background: #0f172a;
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 0.55rem 0.8rem;
  min-width: 10rem;
}

nav .nav-drop a { font-size: 0.9rem; color: var(--muted); white-space: nowrap; }
nav .nav-drop a:hover { color: var(--ink); text-decoration: none; }

/* A nested row of the panel's roster is indented under its parent, and its own cell names the
   parent under the link's name, so the table says what the markup will draw without a column of
   parent names the eye has to line up across a wide row. */
table.data tr.nested td:first-child { padding-left: 1.15rem; }

/* The roster's "N under it" note goes under the name, not beside it: printed inline it
   reads as the last words of the name, which is how "Community 1" got misread. */
table.data td .hint.block { display: block; margin-top: 0.2rem; }

/* Whether a link is on the bar and the button that changes it live in the same cell, so an
   operator who reads "hidden" finds the way back beside it. The pieces stack as blocks rather
   than by making the cell a flex box: a `display: flex` cell only grows to its own content, and
   a roster row whose state column is three lines tall then leaves the other cells taller than
   it — which is what drew a row separator in mid-row on this page (measured on the harness at
   10:59:39Z: the row 129 px tall, its action cell 57 px, and its border at 350 px where the row
   ended at 422 px). */
table.data td.state-cell > span,
table.data td.state-cell form.inline { display: block; margin: 0 0 0.35rem; }
table.data td.state-cell form.inline:last-child { margin-bottom: 0; }

/* Same reason for the action cell, which this stylesheet already renders as a flex box: the
   row's separator is drawn by the cells that span the whole row, so a shorter cell does not
   draw one of its own part-way down. */
table.data td.row-actions { border-bottom: 0; }

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

main.wrap {
  max-width: var(--max);
  margin: 0 auto;
  padding: 1.5rem 1.5rem 3rem;
}

main.narrow { max-width: 30rem; }

.split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.25rem;
  align-items: start;
}

section,
.card {
  background: var(--panel);
  border: 1px solid var(--panel-edge);
  border-radius: var(--radius);
  padding: 1.15rem 1.35rem;
  margin-bottom: 1.25rem;
}

/* A password this site minted, on the one screen it is ever shown on. It is not body copy
   and it is not a hint: an operator reads it out over Discord or a phone call to the person
   who owns the account, so it is the largest ordinary text on the page, in the monospace
   face that keeps "j" from "k", with the groups legible at a glance. The card takes the
   accent edge because it is the one object on the page that leaves it. */
.card.secret-once { border-color: var(--accent); }
.secret-value { margin: 0.2rem 0 0.6rem; }
.secret-value code {
  font-size: 1.35rem;
  letter-spacing: 0.06em;
  color: var(--ink);
  overflow-wrap: anywhere;
}

section h2,
.card h2 {
  margin: 0 0 0.6rem;
  font-size: 1.05rem;
  color: var(--accent-text);
  letter-spacing: 0.01em;
}

/* --- the 3D character model ------------------------------------------
   The viewer paints a WebGL canvas into .model-canvas. The stage must keep its size from the very
   first frame: the viewer reads the container's dimensions to size its canvas, and a zero-sized
   (display:none) container hands back a null WebGL context and crashes. So it is always a lit,
   vignetted panel; the model draws into it as soon as it is ready, and a page where the model
   fails swaps the panel for the fallback line.
   The section carries the accent edge, the one live object on the page, the way the
   once-shown secret card does; the glow behind the figure is the page glow, so the model
   sits in the light rather than on a flat panel. */
.model3d { border-color: var(--accent); }
/* The viewer sizes its canvas to a square from the container's height, which leaves a wide,
   empty stage on a full-width panel. So the stage is a centred square that the canvas fills
   (mount.js forces the canvas to 100%), and the character owns the frame instead of floating in it. */
.model-canvas {
  position: relative;
  width: min(100%, 480px);
  aspect-ratio: 1 / 1;
  margin: 0 auto 0.5rem;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  overflow: hidden;
  background:
    radial-gradient(120% 90% at 50% 6%, rgba(245, 166, 35, 0.10) 0%, rgba(11, 18, 32, 0) 60%),
    var(--bg-deep);
}
.model-canvas canvas { display: block; width: 100%; height: 100%; }
/* The drag hint shows only when the model is up; the fallback line only when it is not.
   Both stay hidden until the section's class names the state the page is actually in. */
.model-drag, .model-fallback { display: none; }
.model3d.model-ok .model-drag { display: block; }
.model3d.model-failed .model-fallback { display: block; }
.model3d.model-failed .model-canvas { display: none; }
/* The loading line is the default state: it holds the spot while the model builds (or a first
   file reaches the CDN), and is hidden the moment the section names a state, ready or failed. */
.model-loading { display: block; margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--muted); }
.model3d.model-ok .model-loading,
.model3d.model-failed .model-loading { display: none; }
.spinner { display: inline-block; width: 11px; height: 11px; margin-right: 7px;
  border: 2px solid var(--muted); border-top-color: var(--accent); border-radius: 50%;
  vertical-align: -1px; animation: model-spin 0.8s linear infinite; }
@keyframes model-spin { to { transform: rotate(360deg); } }

/* The front-page rail holds three blocks in one column — server status, the realmlist download,
   the chat invite — and only the first is opened by `section h2`. These headings open the blocks
   beneath it: the same colour so a reader sees one column, one step smaller so the section's own
   heading still leads, and air above because they follow other content rather than a card edge.
   Measured against the reference's three-card rail at 1180px (harness 2026-11-02T15:23:29Z), which
   this now matches in information if not in iconography. */
.home-side h3 {
  margin: 1.25rem 0 0.35rem;
  font-size: 0.95rem;
  color: var(--accent-text);
  letter-spacing: 0.01em;
}

.hero {
  padding: 2.25rem 1.6rem;
  margin-bottom: 1.5rem;
  text-align: center;
}

.hero h1 {
  margin: 0 0 0.4rem;
  font-size: clamp(1.7rem, 4vw, 2.4rem);
  line-height: 1.15;
  letter-spacing: -0.01em;
}

.hero .lede { max-width: 46rem; margin: 0 auto 1.1rem; }

/* --- the opening band's artwork ------------------------------------------

   The reference's landing page opens on a slideshow of screenshots from Blizzard's
   press kit. That is the one part of its look this site will not copy: the assets are
   not ours to ship, and a vanilla server has no business distributing them. So the
   opening band is drawn here, in the same language as the banner strip below it —
   gradients and a clip-path, the site's own colour tokens, no file, no fetch, no
   inline style, and `style-src 'self'` stays exactly where it is.

   What it depicts is a horizon: cold sky, a few points of light, a warm leak from
   under a ridge, and two ranges of high ground in front of it. A place, not anybody's
   character, map, or icon.

   This drawing is the band's fallback, the one a page without banners wears. When the
   operator has banners, they are the band: the 2026-11-08 reversal of the "nothing moves"
   call this comment used to make, which stood while the owner said visual work was parked
   and is over because he un-parked it on purpose ("don't stop until it looks impressive")
   and the reference's hero is a full-bleed slideshow. What the old call keeps is the
   discipline, not the stillness: the art is a class from the store's vocabulary, the
   cross-fade is a class flip, the first slide is what a page without JavaScript shows,
   and a page without slides never pulls the driver. */

/* The band reaches the edges of the box the page is laid out in. Container units, not `100vw`:
   a viewport unit counts the scrollbar and a container unit counts the box, so the band cannot hang
   off the right edge in a browser that draws a classic scrollbar — a defect the headless sweep here
   could not see, because headless Chrome overlays its scrollbars. `50%` is half the column the hero
   sits in, `50cqw` is half the page; the difference is the bleed. The padding puts the words back
   inside the reading column while the artwork keeps running to the edges. Older browsers drop the
   two unsupported declarations and keep the card, which is the shape this site had anyway. */
.hero-art {
  position: relative;
  overflow: hidden;
  /* The theme's picture, when the theme has one. With none, this layer is `none` and the
     gradients below are the whole band, exactly as before the art pack existed. */
  background-image: var(--hero-art, none);
  background-size: cover;
  background-position: center 30%;
  margin-inline: calc(50% - 50cqw);
  padding-left: max(1.6rem, calc((100cqw - 62rem) / 2));
  padding-right: max(1.6rem, calc((100cqw - 62rem) / 2));
}

/* Sky, stars, and the light under the horizon. */
.hero-art::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image:
    radial-gradient(1.5px 1.5px at 11% 20%, rgba(230, 237, 246, 0.46) 50%, rgba(0, 0, 0, 0) 51%),
    radial-gradient(1.5px 1.5px at 26% 12%, rgba(230, 237, 246, 0.30) 50%, rgba(0, 0, 0, 0) 51%),
    radial-gradient(1.5px 1.5px at 47% 7%, rgba(230, 237, 246, 0.40) 50%, rgba(0, 0, 0, 0) 51%),
    radial-gradient(1.5px 1.5px at 63% 18%, rgba(230, 237, 246, 0.26) 50%, rgba(0, 0, 0, 0) 51%),
    radial-gradient(1.5px 1.5px at 81% 10%, rgba(230, 237, 246, 0.36) 50%, rgba(0, 0, 0, 0) 51%),
    radial-gradient(1.5px 1.5px at 93% 26%, rgba(230, 237, 246, 0.22) 50%, rgba(0, 0, 0, 0) 51%),
    radial-gradient(120% 64% at 50% 118%, rgba(245, 166, 35, 0.22) 0%, rgba(245, 166, 35, 0) 58%),
    /* The scrim, whose default is this stylesheet's and whose look comes from /theme.css. */
    var(--hero-scrim);
}

/* The far range: paler, lower, and cut by a wider polygon so it reads as distance. */
.hero-ridge {
  display: var(--hero-ridge-display);
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 36%;
  background: linear-gradient(180deg, rgba(29, 44, 76, 0) 0%, #1d2c4c 46%, #243452 100%);
  clip-path: polygon(
    0 62%, 12% 44%, 22% 58%, 34% 38%, 46% 56%, 58% 42%, 70% 60%, 82% 47%, 92% 62%, 100% 52%,
    100% 100%, 0 100%
  );
}

/* The near range: darker than everything behind it, and the tallest thing in the band. */
.hero-art::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 46%;
  background: linear-gradient(180deg, rgba(11, 18, 32, 0.25) 0%, var(--bg-deep) 62%);
  clip-path: polygon(
    0 74%, 9% 52%, 16% 66%, 25% 30%, 33% 56%, 41% 40%, 50% 68%,
    58% 44%, 67% 70%, 75% 50%, 84% 72%, 92% 58%, 100% 74%,
    100% 100%, 0 100%
  );
}

/* The words have to sit above all of that, which is only reliable
   because the text is in the document and the artwork is not. */
.hero-art > h1,
.hero-art > .lede {
  position: relative;
  z-index: 2;
}

/* The heading's rule is the one place the band borrows the site's gold, and it marks
   the sentence rather than the artwork. */
.hero-art > h1 {
  padding-bottom: 0.55rem;
  border-bottom: 1px solid rgba(245, 166, 35, 0.32);
  display: inline-block;
}

/* The front page asks two questions at once on a wide screen. Measured on the fixture with a
   realm attached (three posts, two slides, one guide page) and the same file rendered twice:
   stacked, the first news card starts 953px down at 1440px, nothing at all begins above the
   900px fold, and the document is 1770px; with the status panel in a 19rem column beside the
   news the first card starts at 655px, two cards begin above the fold, and the document is
   1573px. At 420px the two renderings are identical — first card 1264px, document 2400px —
   because the stack is the default and this block is the only place the columns exist. */
@media (min-width: 60rem) {
  /* Exactly one element per cell. Two children placed in the same cell — which is what
     happened when the guide list carried the sidebar class as well — are not two columns,
     they are one cell painted twice, and the second section's heading disappeared under the
     first one's list. */
  .home-grid { display: grid; grid-template-columns: minmax(0, 1fr) 19rem; gap: 1.5rem 2rem; align-items: start; }
  .home-grid > .home-side { grid-column: 2; grid-row: 1; }
  .home-grid > .home-main { grid-column: 1; grid-row: 1; }
  .home-grid > .home-guides { grid-column: 1 / -1; grid-row: 2; }

  /* A news card at column width reads best with the crop on the left and the words on the
     right: five full-bleed photographs stacked is a slideshow, not a list. The image spans the
     card's four text rows, so a card with a short excerpt is as tall as one with a long one.
     Below the fold width the band-on-top layout stands, because a 9rem thumbnail on a 320px
     phone leaves the title with two lines of width. */
  .home-main .entry { display: grid; grid-template-columns: 9rem minmax(0, 1fr); gap: 0.1rem 1.1rem; }
  .home-main .entry .post-image {
    grid-column: 1;
    grid-row: 1 / 5;
    width: 9rem;
    aspect-ratio: 4 / 3;
    max-height: none;
    margin: 0;
  }

  /* The forum keeps the same geometry as the front page: boards at column width, the
     latest-activity rail in the 19rem gutter. One grid, two pages, one idea of where a
     sidebar is. */
  .forum-grid { display: grid; grid-template-columns: minmax(0, 1fr) 19rem; gap: 1.5rem 2rem; align-items: start; }
  .forum-grid .forum-side .activity { margin-top: 0.6rem; }
  .forum-grid .forum-section:first-of-type h2 { margin-top: 0; }
}

/* An item page should look like the item. The name carries the quality the realm recorded, in
   the game's own 1.12 rarity colour -- brightened where the raw value is too dark for this
   site's warm panels -- and no image involved. The colours are set on the block by a class the
   server only emits for a quality it can name, so an item whose quality is missing, or whose
   stored id is outside the enum, keeps the ordinary text colour: no colour is invented for a fact
   the realm did not record. Every value clears 4.5:1 against the warm panel behind the name. The
   rare blue sits deliberately apart from this site's own link colour so a rare item does not
   read as a link. */
.itemtip {
  border: 1px solid var(--line);
  border-left: 3px solid var(--item-quality, var(--line));
  border-radius: 0.3rem;
  padding: 0.9rem 1.1rem;
  margin: 0 0 1rem;
}
.itemtip .item-name {
  margin: 0 0 0.4rem;
  font-size: 1.25rem;
  color: var(--item-quality, inherit);
}
.itemtip .tip-lines {
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: 0.95rem;
}
.itemtip .tip-lines li { margin: 0.15rem 0; }
.itemtip .tip-lines li + li { border-top: 0; }
/* The palette is not scoped to the tooltip block: a quality colour belongs to the item, and
   the same item appears in a search result and in the hand of a character. A row whose realm
   recorded no quality carries none of these classes, and `.item-link` falls back to the
   ordinary link colour rather than to a grey someone invented. */
.q-poor      { --item-quality: #9d9d9d; }
.q-normal    { --item-quality: #ffffff; }
.q-uncommon  { --item-quality: #1eff00; }
.q-rare      { --item-quality: #3a93ff; }
.q-epic      { --item-quality: #c25cff; }
.q-legendary { --item-quality: #ff8000; }
.q-artifact  { --item-quality: #ffd100; }
/* `table.data td a` at the bottom of this file sets the link colour with a specificity of one
   class and three elements, which outranks a lone class selector. Measured, not guessed: with
   only `.item-link` written, every item name in a table computed back to rgb(125, 211, 252) --
   the ordinary link colour -- while carrying a quality class in the markup. The second selector
   names the table so the colour actually lands; a test pins that this rule stays. */
.item-link, table.data td a.item-link { color: var(--item-quality, var(--link)); }

/* Spell school: the game's own 1.12 school colour on the spell name, brightened where the raw
   value is too dark for this site's warm panels. The class is emitted only when the realm
   recorded a school it can name, so a spell with no recorded school keeps the ordinary ink. */
.spellhead h1.school-arcane  { color: #3aa0ff; }
.spellhead h1.school-frost   { color: #40c4ff; }
.spellhead h1.school-fire    { color: #fa972c; }
.spellhead h1.school-shadow  { color: #c870e8; }
.spellhead h1.school-nature  { color: #1eff00; }
.spellhead h1.school-holy    { color: #fff569; }

/* The chat invite sits under the status panel, separated by a rule rather than by another
   heading: it is one link, and a heading would give it the weight of a section the reader has
   to parse. It only appears when an operator has stored an invite, so a realm with no chat
   place shows no empty block. */
.community {
  margin: 0.8rem 0 0;
  padding-top: 0.6rem;
  border-top: 1px solid var(--line);
  font-size: 0.9rem;
}


@media (max-width: 46rem) {
  /* A shorter band at phone width, because the ridges are the art and the words are
     the reason for the band; below this height the far range stops being readable. */
  /* Only the vertical padding changes here; the inline padding above is what keeps the words
     inside the column when the band is bleeding. */
  .hero-art { padding-block: 1.75rem 2.1rem; }
  .hero-ridge { height: 34%; }
  .hero-art::after { height: 46%; }
  /* The slideshow's band is shorter on a phone for the same reason the drawn one is:
     the words are the reason for the band, and a 24rem slide under a 320px viewport
     is a picture with a caption instead of a caption with a picture. */
  .hero-slideshow { min-height: 17rem; }
}

.lede { color: var(--muted); font-size: 1.02rem; }

/* --- the front-page banner strip ----------------------------------------

   Four artworks, all drawn here: a gradient and a pattern, no file, no fetch, and no
   inline style anywhere. That last part is not decoration — the CSP keeps
   `style-src 'self'`, and the reason it can keep it is that the only string a slide
   contributes to the page is one of four class names checked against a list in Go.
   The reference reaches the same element with a filename or an <iframe> src taken from
   a text box labelled "URL or Image Name"; this is the version of that row that can be
   rendered by a browser that trusts the site. */

/* The operator's banners as the opening band. The card strip these rules used to lay
   out is gone: the banners ARE the band now, one at a time. Everything here is a class
   the templates write from the store's vocabulary; the driver (assets/slides.js) flips
   .on and nothing else, so `script-src 'self'` and `style-src 'self'` both stay. */
.hero-slideshow {
  position: relative;
  overflow: hidden;
  /* The bleed is the same calculation the drawn band uses: container units, not 100vw,
     so a classic scrollbar cannot hang the band off the right edge. */
  margin-inline: calc(50% - 50cqw);
  min-height: 24rem;
  margin-bottom: 1.5rem;
}

.hero-slideshow .hero-slides { position: absolute; inset: 0; }

.hero-slide {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  text-align: center;
  padding: 2rem max(1.6rem, calc((100cqw - 62rem) / 2));
  /* A slide with no art rule at all (a pack name this process does not carry) still
     reads: the site's own deep background under the words. */
  background-color: var(--bg-deep);
  opacity: 0;
  transition: opacity 0.7s ease;
}

.hero-slide.on { opacity: 1; }

/* The heading keeps the band's one place of borrowed gold — the rule that marks the
   sentence rather than the artwork — in both levels, because slide one is the h1 and
   the rest are h2 and a reader moving by headings must not hit two levels on one band. */
.hero-slide > h1,
.hero-slide > h2 {
  padding-bottom: 0.55rem;
  border-bottom: 1px solid rgba(245, 166, 35, 0.32);
  display: inline-block;
}

/* A banner that links and a banner that does not must look the same: the difference
   between them is only discoverable where it matters — when the reader points at it. */
.hero-slide h1 a,
.hero-slide h2 a { color: inherit; text-decoration: none; }
.hero-slide h1 a:hover,
.hero-slide h2 a:hover { color: var(--link); }

.hero-slide .lede { margin-bottom: 0; }

/* The dots: one per banner, in the operator's order, at the bottom of the band where
   the reference's are. Buttons, not spans, because a reader who moves by keyboard
   deserves a control that answers to it. */
.hero-dots {
  position: absolute;
  bottom: 1.1rem;
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;
  gap: 0.55rem;
  z-index: 3;
}

.dot {
  width: 0.62rem;
  height: 0.62rem;
  padding: 0;
  border-radius: 50%;
  border: 1px solid rgba(230, 237, 246, 0.55);
  background: rgba(11, 18, 32, 0.4);
  cursor: pointer;
}

.dot.on { background: var(--accent); border-color: var(--accent); }
.dot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* A visitor who asked the system for reduced motion asked for exactly this: the band
   holds on its first picture and the dots stay theirs to press. */
@media (prefers-reduced-motion: reduce) {
  .hero-slide { transition: none; }
}

/* Grey stone: the site's own colours, and the default a new banner gets. */
.slide-art-stone {
  background-image:
    repeating-linear-gradient(135deg, rgba(230, 237, 246, 0.035) 0 1px, rgba(0, 0, 0, 0) 1px 7px),
    linear-gradient(135deg, #1c2c4b 0%, #131f36 55%, #101a2e 100%);
}

/* Forge light: warm at the base of the panel, for a raid call or an event. */
.slide-art-forge {
  background-image:
    radial-gradient(120% 90% at 12% 108%, rgba(245, 166, 35, 0.30) 0%, rgba(245, 166, 35, 0) 62%),
    repeating-linear-gradient(135deg, rgba(245, 166, 35, 0.05) 0 1px, rgba(0, 0, 0, 0) 1px 9px),
    linear-gradient(135deg, #2a1c0d 0%, #1a1409 60%, #131009 100%);
}

/* Night sky: a few drawn points of light, for a maintenance window. */
.slide-art-night {
  background-image:
    radial-gradient(1.5px 1.5px at 18% 24%, rgba(230, 237, 246, 0.55) 50%, rgba(0, 0, 0, 0) 51%),
    radial-gradient(1.5px 1.5px at 44% 12%, rgba(230, 237, 246, 0.40) 50%, rgba(0, 0, 0, 0) 51%),
    radial-gradient(1.5px 1.5px at 71% 31%, rgba(230, 237, 246, 0.50) 50%, rgba(0, 0, 0, 0) 51%),
    radial-gradient(1.5px 1.5px at 88% 16%, rgba(230, 237, 246, 0.35) 50%, rgba(0, 0, 0, 0) 51%),
    linear-gradient(160deg, #17243d 0%, #0d1526 58%, #0b1220 100%);
}

/* Storm: one long streak of light across a cold panel, for something that went wrong. */
.slide-art-storm {
  background-image:
    linear-gradient(118deg, rgba(125, 211, 252, 0) 42%, rgba(125, 211, 252, 0.16) 47%, rgba(125, 211, 252, 0) 52%),
    repeating-linear-gradient(112deg, rgba(148, 163, 184, 0.05) 0 1px, rgba(0, 0, 0, 0) 1px 11px),
    linear-gradient(120deg, #1e2739 0%, #131b2b 55%, #0f172a 100%);
}

/* The banners that wear a picture from the art pack. The picture itself arrives in
   /theme.css — a stylesheet this process writes from names it enumerated at startup —
   layered under a scrim so the headline stays legible over artwork this site did not
   compose. What is here is the fallback for the case where the pack does not carry that
   file, or is absent entirely: the band is drawn, in the same cold range as `storm`, so a
   banner with no picture is still a banner and never a broken image. An operator who picks
   one of these names on a host with no art pack gets this, and the panel says so. */
.slide-art-onyxia-header {
  background-image:
    repeating-linear-gradient(112deg, rgba(148, 163, 184, 0.05) 0 1px, rgba(0, 0, 0, 0) 1px 11px),
    linear-gradient(120deg, #1e2739 0%, #131b2b 55%, #0f172a 100%);
}

/* Alliance's band: the same drawn fallback in Alliance stone — a cold grey-blue with the
   ridge line in it, because the picture this stands in for is a wall of spires. */
.slide-art-alliance-band {
  background-image:
    repeating-linear-gradient(90deg, rgba(148, 163, 184, 0.06) 0 2px, rgba(0, 0, 0, 0) 2px 26px),
    linear-gradient(165deg, #2a3648 0%, #1b2534 48%, #141c28 100%);
}

/* Horde's band: dull brick and iron. Not Alliance's blue, and not the banner red the cards
   use — a fallback should read as the look it belongs to. */
.slide-art-horde-band {
  background-image:
    repeating-linear-gradient(115deg, rgba(180, 83, 9, 0.07) 0 1px, rgba(0, 0, 0, 0) 1px 13px),
    linear-gradient(120deg, #3a211c 0%, #26140f 55%, #1b0f0c 100%);
}

.slides .hint { grid-column: 1 / -1; }

/* --- calls to action ---------------------------------------------------- */

.cta {
  display: inline-block;
  padding: 0.55rem 1.05rem;
  background: var(--accent);
  color: var(--accent-ink);
  border-radius: var(--radius);
  font-size: 0.92rem;
  font-weight: 650;
}

.cta:hover { background: var(--accent-hover); text-decoration: none; }

button {
  margin-top: 0.4rem;
  padding: 0.55rem 1.05rem;
  background: var(--accent);
  color: var(--accent-ink);
  border: 0;
  border-radius: var(--radius);
  font: inherit;
  font-size: 0.92rem;
  font-weight: 650;
  cursor: pointer;
}

button:hover { background: #ffb733; }

button.ghost {
  background: transparent;
  color: var(--muted);
  border: 1px solid var(--line);
  padding: 0.35rem 0.7rem;
  font-weight: 500;
}

button.ghost:hover { background: var(--panel-edge); color: var(--ink); }

form.inline { display: inline; margin: 0; }

/* --- forms -------------------------------------------------------------- */

label {
  display: block;
  margin: 0.8rem 0;
  font-size: 0.875rem;
  color: var(--muted);
}

input {
  display: block;
  width: 100%;
  margin-top: 0.3rem;
  padding: 0.55rem 0.65rem;
  background: var(--bg-deep);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: 0.4rem;
  font: inherit;
}

input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }

.fineprint { color: var(--muted); font-size: 0.8rem; margin-bottom: 0; overflow-wrap: anywhere; }

/* Fine print quotes the daemon: table names, registration lines, `LootMgr.cpp:46`. Those tokens
   have no spaces in them, and the longest one measured 315px inside a 304px column at a 320px
   viewport, pushing the whole area sheet sideways (harness 2026-11-02T14:09:24Z: `p.fineprint`
   scrollWidth 315 against clientWidth 304, document 323 against 320). A word that cannot fit is
   broken instead of allowed to overflow; ordinary prose is untouched, because overflow-wrap only
   fires when a word would not fit the line. */

/* The same failure in the panel's prose, found the same way (dump sweep 2026-11-03T14:52Z): the
   backups page names its directory — `/tmp/TestDumpAdminPagesForVisualReview23…/001`, and on a real
   host a path can be just as long — and the realms card spells out
   `/armory/character/<slug>/<name>`. Neither has a space to break at, so at 320px the `<code>`
   measured 468px inside a 286px card and clipped: the sentence was there and unreadable.
   Prose code spans break; `table.data td.mono`, which is a column of entry numbers and names, does
   not change. */
.fineprint code,
.hint code,
.empty code,
.card-form code { overflow-wrap: anywhere; }

/* The diagnostics sheet prints paths — the store file, the art pack, the media directory — in the
   same kind of description, and a filesystem path is unbreakable by the rules that break words. */
dl.facts dd code { overflow-wrap: anywhere; }

/* Two different spells can carry the same name and the same rank — ranks repeat across
   talents and class trees — and the entry number is the only fact that tells them
   apart. It is printed only when it has to be. */
.data .entry-id { color: var(--muted); font-size: 0.78rem; white-space: nowrap; }

/* `subname` is the realm's own column: the title a creature carries beside its name,
   "Deathbringer" next to Thurmur the Deathbringer. It is stored separately from the name,
   so it is printed separately rather than folded into one string. */
.data .subname { color: var(--muted); font-style: italic; }

/* --- state lines -------------------------------------------------------- */

.error,
.ok-line {
  margin: 0 0 0.9rem;
  padding: 0.55rem 0.75rem;
  border-radius: 0.4rem;
  font-size: 0.9rem;
}

.error {
  border-left: 3px solid var(--danger);
  background: #2a1418;
  color: #fecaca;
}

.ok-line {
  border-left: 3px solid var(--ok);
  background: #10231f;
  color: #bbf7d0;
}

.notice { border-left: 3px solid var(--accent); }

.verify {
  margin: 0.9rem 0;
}

.verify .notice {
  margin: 0 0 0.6rem;
}

.empty {
  color: var(--muted);
  font-size: 0.92rem;
  border: 1px dashed var(--line);
  border-radius: 0.4rem;
  padding: 0.7rem 0.85rem;
  margin: 0;
}

/* --- content ------------------------------------------------------------ */

.entry { padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
.entry:last-child { border-bottom: 0; }

/* A post's picture. The width and height attributes are always on the element — they come from
   the row the store kept, not from the bytes — so the text below does not jump when the image
   arrives. Nothing here fetches an image from another host: img-src is 'self' and the only src
   this site writes is its own /news/image/{id}. */
.post-image {
  display: block;
  max-width: 100%;
  height: auto;
  margin: 0.5rem 0 0.9rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
/* The front-page card prints a band across the card rather than the whole photograph: five
   uncropped 900 px pictures is not a news page, and the page says so above the list. The article
   prints the whole picture. */
.entry .post-image { width: 100%; max-height: 12rem; object-fit: cover; }
/* The panel's preview stays at its natural size, because a picture small enough to be legible at
   300 px should not be stretched to the width of the card. */
.card .post-image { max-height: 14rem; width: auto; }
.entry h3 { margin: 0 0 0.15rem; font-size: 1rem; }
.stamp { margin: 0 0 0.4rem; color: var(--muted); font-size: 0.8rem; }

.entry h3 a { color: var(--ink); text-decoration: none; }
.entry h3 a:hover { color: var(--accent); }
.more { margin: 0.35rem 0 0; font-size: 0.85rem; }

.post h1 { margin: 0 0 0.2rem; font-size: 1.7rem; color: var(--ink); }

/* Operator bodies are plain text. pre-wrap keeps the paragraphing they typed
   without the template ever having to trust markup. */
.post .para { margin: 0 0 0.9rem; white-space: pre-wrap; }
.post .para:last-child { margin-bottom: 1.1rem; }

/* The comment thread under an article. A comment is set apart by a rule and an
   indented block rather than by a card: the article is the thing on the page, and a
   thread that looks like a stack of panels competes with it. The author line is the
   same stamp vocabulary used everywhere else, so a name and a time mean the same
   thing here as they do on the front page. */
.thread { margin-top: 1.6rem; }
.thread h2 { margin: 0 0 0.6rem; font-size: 1.15rem; color: var(--ink); }
.comment-list { list-style: none; margin: 0 0 1rem; padding: 0; }
.comment {
  border-top: 1px solid var(--line);
  padding: 0.7rem 0 0.8rem;
}
.comment:first-child { border-top: 0; }
.comment .stamp { margin: 0 0 0.45rem; }
.comment .stamp strong { color: var(--ink); font-weight: 600; }
.comment .stamp .role { color: var(--accent); }
.comment .para { margin: 0 0 0.6rem; white-space: pre-wrap; }
.comment .para:last-of-type { margin-bottom: 0.5rem; }
.comment form { margin: 0.5rem 0 0; }

/* A number field is the one input that looks absurd at full width. The site's CSP keeps
   style-src 'self' on the strength of a test that no template writes a style attribute
   (hero_test.go), so the width lives here rather than in the markup. */
.field-narrow { max-width: 6rem; }

/* A post staff hid is dimmed rather than removed, so the thread reads as moderated rather than
   truncated: the reader who has the link can see what is not being shown to everyone else. */
.comment.muted { opacity: 0.55; }

/* A forum post carries the same byline vocabulary as a news comment, but the thread is the page
   rather than an appendix to one, so the first post gets the top rule like every other entry. */
.forum-section h2 { margin: 1.4rem 0 0.4rem; font-size: 1.1rem; color: var(--ink); }
.forum-section:first-of-type h2 { margin-top: 0.6rem; }

.activity { margin-top: 1.6rem; }
.activity h2 { margin: 0 0 0.4rem; font-size: 1.1rem; color: var(--ink); }
.activity ul { list-style: none; margin: 0; padding: 0; }
.activity li { padding: 0.45rem 0; border-bottom: 1px solid var(--line); }
.activity li:last-child { border-bottom: 0; }
.activity .muted { color: var(--muted); }

/* A forum list is a list, not a wall of type: a long title stretched to three lines pushed its
   number and date onto lines of their own, the rows came out ragged, and the rail's timestamp
   broke mid-date. The title keeps one line and earns its ellipsis. On the board list the number
   follows the title on the same line; on the index the date follows on the line under it with no
   air between them. A timestamp is one unbreakable token. */
.thread-title { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.thread-row { display: flex; align-items: baseline; gap: 0.45em; min-width: 0; }
.thread-row .hint { flex: 0 0 auto; white-space: nowrap; }
.activity time { white-space: nowrap; }

/* These tables are fixed-layout, so the columns take their widths from the col rows, not from
   the cells that fill them. Left to the default, every column gets an equal share and the story
   column — the one with the ellipsis — shrinks to a fragment. The story column keeps its share;
   the meta columns keep theirs. */
table.forum-boards col:nth-child(1) { width: 40%; }
table.forum-boards col:nth-child(3) { width: 44%; }
table.forum-threads col:nth-child(1) { width: 43%; }
table.forum-threads col:nth-child(4) { width: 29%; }

details { margin: 0.5rem 0 0; }
details summary { cursor: pointer; font-size: 0.85rem; color: var(--accent); }
details .card-form { margin-top: 0.5rem; }

/* The login card wants a 30rem measure; an article wants something a reader can
   actually run their eye down. */
main.wrap.reading { max-width: 42rem; }


.status-card h2 { margin-top: 0; }

/* This card puts one line of explanation directly above a button. `.fineprint` carries
   `margin-bottom: 0` because elsewhere it sits inside table cards and definition lists where a
   gap would break the rhythm, so the air between an explanation and a call to action belongs to
   this card rather than to the shared class. Measured on 2026-11-02T14:43Z on the photographed
   403 page, at 320px and at 1180px: the button's box began exactly at the fine print's bottom
   edge — gap 0.0px — and a button touching a sentence reads as part of it. */
.status-card .fineprint { margin-bottom: 0.75rem; }


/* The front-page page list. A page is a document rather than an announcement, so
   the entry leads with its title and lets the update stamp trail it — the opposite
   of a news entry, which headlines its date. */
.page-list { list-style: none; margin: 0; padding: 0; }
.page-list li {
  display: flex; flex-wrap: wrap; justify-content: space-between;
  gap: 0.2rem 1rem; padding: 0.45rem 0; border-bottom: 1px solid var(--line);
}
.page-list li:last-child { border-bottom: 0; }
.page-list .stamp { margin: 0; }

.facts {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.3rem 1rem;
  margin: 0.4rem 0 0.9rem;
}

.facts dt { color: var(--muted); font-size: 0.85rem; }
.facts dd { margin: 0; }

/* The avatar is a picture this process computed (internal/web/avatar.go), so the only job
   here is to keep a coarse grid coarse: the mark is drawn on 5x5 cells with a one-cell
   margin, and browser smoothing over those cells is what makes an identicon unreadable at
   the sizes the account panel and the chooser use. */
.avatar-line {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin: 0.4rem 0 0.9rem;
}

.avatar-line img {
  border: 1px solid var(--line);
  image-rendering: pixelated;
}

.avatar-choices {
  list-style: none;
  display: grid;
  /* auto-fill rather than "four, and two under 46rem". Measured with
     ops/tools/cms_avatar_grid_probe.mjs against the page as the binary serves it: the fixed pair
     gave two columns inside a 523-pixel grid at 600 CSS pixels — a row holding 194 pixels of card
     and leaving the rest empty — and two again at 460 inside 383. Filling from the available
     width gives four at 600 and three at 460, and still two at 400, where 323 pixels are
     available and a card is 97 wide, so three would not fit. Nothing overlapped or stuck out of
     its card either way; what this changes is how much of each row is used. */
  grid-template-columns: repeat(auto-fill, minmax(96px, max-content));
  justify-content: start;
  gap: 0.6rem;
  margin: 0 0 0.9rem;
  padding: 0;
}

.avatar-choices label {
  display: block;
  border: 1px solid var(--line);
  padding: 0.35rem;
  cursor: pointer;
}

.avatar-choices label.chosen { border-color: var(--accent); }

.avatar-choices img { display: block; image-rendering: pixelated; }

code,
pre {
  font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace;
  font-size: 0.9rem;
}

pre {
  background: var(--bg-deep);
  border: 1px solid var(--line);
  border-radius: 0.4rem;
  padding: 0.7rem 0.85rem;
  overflow-x: auto;
}

table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; font-size: 0.85rem; }

/* --- footer ------------------------------------------------------------- */

.site-foot {
  max-width: var(--max);
  /* Top margin auto: the body is a flex column (sticky footer) and this absorbs the space a
     short page leaves under its content, so the foot sits at the bottom of the viewport.
     Bottom stays 0 - on a long page the foot ends the document and there is nothing below. */
  margin: auto auto 0;
  padding: 1.25rem 1.5rem 2rem;
  border-top: 1px solid var(--line);
  color: var(--muted);
  font-size: 0.85rem;
}

.site-foot p { margin: 0; }

.site-foot .foot-links { margin-top: 0.35rem; font-size: 0.9rem; }
.site-foot .foot-links a { color: var(--ink); }
.site-foot .foot-links a:hover { color: var(--accent); }

/* The reference's last line before the legal copy says which engine runs the site; a visitor who
   right-clicks "view source" on a single-binary Go site should get the same answer in the open. */
.site-foot .powered-by { margin-top: 0.35rem; }
.site-foot .powered-by a { color: var(--muted); text-decoration: underline; }
.site-foot .powered-by a:hover { color: var(--ink); }


/* --- operator panel ------------------------------------------------------ */

.panel-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-bottom: 1.3rem;
  padding-bottom: 0.9rem;
  border-bottom: 1px solid var(--line);
}

.panel-nav a {
  padding: 0.32rem 0.75rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--muted);
  font-size: 0.9rem;
  text-decoration: none;
}

.panel-nav a:hover { color: var(--ink); border-color: var(--muted); }

.panel-nav a.on {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
  font-weight: 600;
}

.panel h1 {
  margin: 0 0 0.9rem;
  font-size: 1.5rem;
  color: var(--ink);
}

.panel section { margin-top: 1.8rem; }

/* A confirmation is one sentence; a full-width band makes it look like a
   banner alert and draws more attention than the change it describes. */
.ok-line,
.error { width: fit-content; max-width: 100%; }

.tiles {
  display: grid;
  /* The overview carries exactly eight cards, so the columns are named instead of fit:
     auto-fit gave five columns at 1180 and the eight cards wrapped 5+3, the second row
     ragged, and the 10.5rem floor let "sign-ins denied, last 24h" wrap to two lines in its
     tile, so that card stood taller than its neighbours (shots/q/admin-overview.1180.png).
     Four columns is the only even split of eight that fits at panel width, 2 is the even
     split for the middle, and 1 for a phone (the 46rem block). */
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 0.75rem;
  margin: 0 0 1.5rem;
  padding: 0;
  list-style: none;
}

@media (max-width: 56rem) {
  .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

.tiles li {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0.8rem 1rem;
  color: var(--muted);
  font-size: 0.85rem;
}

.tiles li span {
  display: block;
  font-size: 1.65rem;
  font-weight: 700;
  color: var(--ink);
}

.tiles li.warn span {
  color: var(--muted);
}

.tiles li.warn span { color: var(--danger); }

table.data {
  /* A results table needs the same air below it as above it: with none, the list
     of links that follows reads as one more row of the table. */
  margin: 0 0 1rem;
  table-layout: fixed;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
}

table.data th {
  color: var(--muted);
  font-size: 0.76rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  /* A column's minimum width is the longest word in its header. The honour board at 768 px
     measured 737 px of content in a 720 px box -- seventeen pixels of clipped table, five of
     which one word ("CONTRIBUTION") was responsible for. Header labels are labels, not data:
     they may break. The cells themselves keep their own rule (see below), because a broken
     address or rank number is worse than a scrolled table. */
  overflow-wrap: anywhere;
}

table.data td { vertical-align: top; }
/* Only the address column is allowed to break a long token; an email address
   that folds mid-word reads like a data error. */
table.data td.mono { overflow-wrap: anywhere; }
.subtle { color: var(--muted); font-size: 0.85rem; }
/* A row's control group — a select and a button, or two buttons — used to be pinned to one
   line, which is fine until the column it sits in is narrower than the controls. Measured in the
   harness on 2026-11-01T17:03Z: the users table's Role cell had 158px at a 768px viewport and its
   form wanted 168, and the slides table overflowed its own box by 2px on a 1440px screen. Letting
   the group wrap costs a second line in a narrow column and saves the control from being cut off
   — a button you cannot see is worse than a button sitting below another. */
table.data form.inline {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
  align-items: center;
}
table.data select { min-width: 5.5rem; }
table.data .row-actions { flex-wrap: wrap; margin: 0; }
table.data td a { color: var(--link); text-decoration: none; }
table.data td a:hover { text-decoration: underline; }
/* A .cta button inside a table row would otherwise lose its label to the rule above — measured
   on the downloads page at 1180 the button drew rgb(125, 211, 252) on rgb(245, 166, 35), a
   0.82:1 label, while the nav's .cta beside it drew the 9.15:1 ink. Same repair the item links
   got when the same collision put every item name back to the link colour. */
table.data td a.cta { color: var(--accent-ink); }
/* A timestamp is `2026-11-01 17:00:48`, and this rule used to keep it on one line. Measured in
   the harness on 2026-11-01T17:01Z the columns could not always pay for that — the audit page's
   When cell wanted 164px and had 154 at a 960px viewport, the queue's wanted 139 and had 107 at
   768 — and `nowrap` does not make a column wider, it makes the text disappear past the cell
   edge. A date and a time on two lines is still one timestamp; letters cut off are not. */
table.data td.num {
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  white-space: normal;
  overflow-wrap: normal;
}
table.data tr:last-child td { border-bottom: none; }

/* --- the operator tables' column widths -----------------------------------

   These widths used to live in `style="width: …"` attributes on the <col> elements, and
   they never reached a page: the response CSP keeps `style-src 'self'`, and `table.data`
   also sets `table-layout: fixed`, so a blocked width means every column simply takes an
   equal share of the table. Measured in the harness on 2026-11-01 against /admin/users:
   as served, all five columns measured 201.4 px each and Chromium logged the refusal out
   loud — "Applying inline style violates the following Content Security Policy directive
   'style-src 'self'' … The action has been blocked." With the CSP header stripped from the
   same DOM, the same table produced 191 / 240 / 208 / 240 / 128 px, which is what the
   attributes had been asking for all along.

   The CSP is right and stays exactly as it is; the widths move here, where a browser is
   allowed to read them. The selectors are positional because the colgroup is what declares
   the order: a table that reorders its columns has to move these with it, and the test in
   admin_table_widths_test.go fails if a table names itself without the stylesheet saying
   what it means, or the stylesheet says something about a table that no longer exists. */

/* Every column now takes a share of the table, and the shares of one table add to 100%.
   They used to mix a percentage with absolute `rem` widths, leaving one column to absorb the
   remainder — and that remainder column is exactly what got destroyed. Measured in the harness
   on 2026-11-01T16:57Z at a 768px viewport: the users table was 817px wide in a 720px content
   box (its four absolute columns alone asked for 816px), so the Account column — the one a
   moderator reads first — collapsed to **0px** while its text still wanted 165px; the queue
   table's Message column, the remainder column there, got **31px** for whatever a player typed.
   A table whose specified widths exceed its container does not shrink to fit; it overflows and
   lets the flexible column pay for it. Percentages cannot exceed the container, so the same
   proportions now hold at 320px and at 1920px. */

table.data-users col:nth-child(1) { width: 25%; }
table.data-users col:nth-child(2) { width: 22%; }
table.data-users col:nth-child(3) { width: 19%; }
table.data-users col:nth-child(4) { width: 22%; }
table.data-users col:nth-child(5) { width: 12%; }

/* The account column is a quarter of the sheet, and the widest thing in it is an email
   address — one unbreakable token, measured 231px inside a 179px cell at the 768px viewport
   (admin dump sweep, 2026-11-03T15:40Z, with the account sheet added to the dump set). The
   phone rules already break it; at a tablet width the sheet is a real table again and the
   same token has the same problem. A split address in a list of accounts is readable, a
   clipped one hides the thing an operator was looking for. */
table.data-users td[data-label="Account"] { overflow-wrap: anywhere; }

/* The realms list spends most of its width on the two columns an operator actually reads
   when a realm is not answering: the address their own tools use, and the variable names
   that have to be set on this host. The variable names are the widest thing on the page —
   `VMCMS_REALM_CHAR_DSN` in a mono face — so it gets the largest share and the name is
   allowed to break. */
table.data-realms col:nth-child(1) { width: 19%; }
table.data-realms col:nth-child(2) { width: 21%; }
table.data-realms col:nth-child(3) { width: 15%; }
table.data-realms col:nth-child(4) { width: 27%; }
table.data-realms col:nth-child(5) { width: 18%; }
/* The section switchboard: the sentence column is the one the operator reads to choose
   from, so it takes most of the row; the address column holds a mono list that breaks on
   its spaces. */
table.data-modules col:nth-child(1) { width: 15%; }
table.data-modules col:nth-child(2) { width: 43%; }
table.data-modules col:nth-child(3) { width: 25%; }
table.data-modules col:nth-child(4) { width: 17%; }

/* Two columns, and the answers are short: the label half needs room for "characters online
   right now", the answer half for one badge and a sentence. */
table.data-realm-check col:nth-child(1) { width: 44%; }
table.data-realm-check col:nth-child(2) { width: 56%; }

/* The file name is the widest thing in the backups table — `vmcms-2026-11-03-T21-14-07.sqlite.gz`
   — and it is the one line an operator reads to tell the panel's own snapshots from the timer's.
   The two numeric columns only ever hold "1.4 MiB" and "3 days". */
table.data-backups col:nth-child(1) { width: 36%; }
table.data-backups col:nth-child(2) { width: 12%; }
table.data-backups col:nth-child(3) { width: 10%; }
table.data-backups col:nth-child(4) { width: 22%; }
table.data-backups col:nth-child(5) { width: 20%; }

/* The When column carries a timestamp, `2026-11-09 17:00:48`, and the 17% it had at 1180
   (171px, ~152 of text) sat ~12px short of the 164px the stamp wants, so the date and the
   time went onto two lines on the widest screen an operator reads this table from
   (shots/q/admin-audit.1180.png). The column takes the two points Target has to spare, and
   the stamp stays unbroken where the column pays for it - see the min-width rule below. The
   general `td.num` nowrap was withdrawn for the same arithmetic at 960px: the column could
   not pay there, and in a fixed-layout table unpaid content is what grows the table past
   its box. */
table.data-audit col:nth-child(1) { width: 19%; }
table.data-audit col:nth-child(2) { width: 14%; }
table.data-audit col:nth-child(3) { width: 18%; }
table.data-audit col:nth-child(4) { width: 35%; }
/* The Outcome column holds a six-letter uppercase header, and a header word cannot be broken.
   At 9% that header wanted 81px in a 64px column at a 768px viewport, and in a fixed-layout table
   a column cannot be narrower than its own heading: the whole table grew 16px past its box. */
table.data-audit col:nth-child(5) { width: 14%; }

/* The stamp is unbroken from 66rem up, where 19% of the table is 191px - 172 of text against
   the 164px the stamp wants - and broken below it, where the column cannot pay for nowrap and
   a date on one line and a time on the next is still one timestamp. */
@media (min-width: 66rem) {
  table.data-audit td[data-label="When (UTC)"] { white-space: nowrap; }
}

table.data-bans col:nth-child(1) { width: 26%; }
table.data-bans col:nth-child(2) { width: 23%; }
table.data-bans col:nth-child(3) { width: 19%; }
table.data-bans col:nth-child(4) { width: 32%; }

table.data-nav col:nth-child(1),
table.data-nav col:nth-child(2) { width: 24%; }
table.data-nav col:nth-child(3) { width: 13%; }
table.data-nav col:nth-child(4) { width: 17%; }
table.data-nav col:nth-child(5) { width: 22%; }

table.data-news col:nth-child(1),
table.data-news col:nth-child(2) { width: 24%; }
table.data-news col:nth-child(3) { width: 13%; }
table.data-news col:nth-child(4) { width: 17%; }
table.data-news col:nth-child(5) { width: 22%; }

table.data-pages col:nth-child(1) { width: 27%; }
table.data-pages col:nth-child(2) { width: 26%; }
table.data-pages col:nth-child(3) { width: 13%; }
table.data-pages col:nth-child(4) { width: 15%; }
table.data-pages col:nth-child(5) { width: 19%; }

table.data-slides col:nth-child(1) { width: 22%; }
table.data-slides col:nth-child(2) { width: 13%; }
table.data-slides col:nth-child(3) { width: 9%; }
table.data-slides col:nth-child(4) { width: 15%; }
table.data-slides col:nth-child(5) { width: 13%; }
table.data-slides col:nth-child(6) { width: 13%; }
table.data-slides col:nth-child(7) { width: 15%; }

table.data-tickets col:nth-child(1) { width: 15%; }
table.data-tickets col:nth-child(2) { width: 18%; }
table.data-tickets col:nth-child(3) { width: 35%; }
table.data-tickets col:nth-child(4) { width: 17%; }
/* The Where column carries the realm's own map names now, and the longest one this
   realm holds is "Eastern Kingdoms"; at 7rem that wrapped into three lines. */
table.data-tickets col:nth-child(5) { width: 15%; }
/* A ticket message is whatever a player typed, including a link typed without spaces. */
table.data td.ticket-message { overflow-wrap: anywhere; }

/* The overview's recent-posts table: the title holds the widest share, so the shares
   still add up to the whole table rather than leaving a column to be crushed. */
table.data-posts col:nth-child(1) { width: 65%; }
table.data-posts col:nth-child(2) { width: 11%; }
table.data-posts col:nth-child(3) { width: 17%; }
table.data-posts col:nth-child(4) { width: 7%; }

/* The patch timeline. A bar compares one patch's new-item (or new-spell) volume with
   the largest new count after the baseline — the first patch, where this realm's
   content arrives, and the one that would swallow every other bar — which is the
   only comparison the page can honestly make: there is no cross-realm scale here
   and no dates to plot against.
   The widths are classes rather than an inline style because the site's CSP is
   style-src 'self' with no 'unsafe-inline' — a style="width:…" attribute would be
   dropped by any browser that honours that header, and bars that silently vanish look
   like a broken page rather than an honest one. */
table.timeline .count { display: block; }
table.data td .bar {
  display: block;
  height: 0.42rem;
  margin-top: 0.35rem;
  border-radius: 0.18rem;
  background: var(--accent);
  opacity: 0.6;
}
/* The held columns are the context, not the story; the new-item and new-spell
   columns are what the eye is comparing patch to patch. */
table.timeline td.held { color: var(--muted); }
table.data td[data-label="New spells"] .bar { background: var(--link); }
table.timeline td { white-space: nowrap; }
table.timeline td[data-label="Patch"] strong { color: var(--ink); }

/* The operator's events, drawn above the patch table. The reference gives each event a
   picture and a date heading; this site drops the picture (a docroot to keep writable)
   and keeps the date as the heading, so the heading is the only new thing a card here
   carries. The body paragraphs reuse the site's own `.entry` paragraph rule. */
.timeline-events { margin-bottom: 2rem; }
.timeline-event {
  background: var(--panel);
  border: 1px solid var(--panel-edge);
  border-radius: var(--radius);
  padding: 1rem 1.25rem;
  margin-bottom: 1rem;
}
.timeline-event h2 {
  margin: 0 0 0.35rem;
  font-size: 1.05rem;
  font-weight: 600;
  color: var(--ink);
}
.timeline-event p { margin: 0.4rem 0; }
table.data td .bar.b0 { width: 0; }
/* The 5% floor is for a patch whose new count is a rounding error against the scale
   (one new item against a scale of 897): five percent of a narrow column is barely
   anything, and six pixels still means "far below the scale" and reads as a bar. */
table.data td .bar:not(.b0) { min-width: 6px; }
table.data td .bar.b5 { width: 5%; }
table.data td .bar.b10 { width: 10%; }
table.data td .bar.b15 { width: 15%; }
table.data td .bar.b20 { width: 20%; }
table.data td .bar.b25 { width: 25%; }
table.data td .bar.b30 { width: 30%; }
table.data td .bar.b35 { width: 35%; }
table.data td .bar.b40 { width: 40%; }
table.data td .bar.b45 { width: 45%; }
table.data td .bar.b50 { width: 50%; }
table.data td .bar.b55 { width: 55%; }
table.data td .bar.b60 { width: 60%; }
table.data td .bar.b65 { width: 65%; }
table.data td .bar.b70 { width: 70%; }
table.data td .bar.b75 { width: 75%; }
table.data td .bar.b80 { width: 80%; }
table.data td .bar.b85 { width: 85%; }
table.data td .bar.b90 { width: 90%; }
table.data td .bar.b95 { width: 95%; }
table.data td .bar.b100 { width: 100%; }

/* Which client a player has to run. A build number is compared digit by digit by the
   client, and read that way by a person checking their own install, so the column is set
   in tabular figures rather than proportional ones. */
table.client-builds td.build { font-variant-numeric: tabular-nums; white-space: nowrap; }
/* The build this realm names to clients is the answer to the question the page exists for,
   so it leads the table and is marked; the rows under it are the wider set the realm will
   tolerate, which is a different sentence and should not look the same. */
table.client-builds tr.advertised td { background: rgba(52, 211, 153, 0.07); }
/* The realmlist.wtf line is one line of text a game client parses, reproduced exactly as
   the site serves it. It wraps rather than scrolls because a wrapped line is still the
   same two words, while a scrolled one hides the value on a phone. */
pre.wtf-line { white-space: pre-wrap; margin-top: 0.4rem; }

/* The panel prints the body of a mail test verbatim, so an operator can see there is no
   free text in the form. A <pre> that does not wrap puts a 76-character line outside a
   320px card — the sentence is in the document and unreadable on the page, which is the
   class of bug the visual sweep exists to find. */
pre.mail-body { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 0.4rem; }

.mono, td.num {
  font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
  font-size: 0.85rem;
}

.badge {
  display: inline-block;
  /* A badge is a short label, not a token to be broken. In a table cell whose address text may
     split anywhere, the inherited overflow-wrap was slicing "OFF-SITE" into "OFF - SIT E" on a
     phone (harness render at 11:02:57Z), so a badge now moves to the next line whole or stays
     where it is. */
  /* The phone render taught that a badge must not be cut mid-word — `overflow-wrap: anywhere`
     inherited from an address column sliced "OFF-SITE" into "OFF - SIT E". What that lesson does
     *not* license is forbidding a break at a space: "on the front page" measured 157px inside a
     132px State column on a 1440px screen, because every word had to stay on one line. A badge
     may now take a second line at a space it already had; a word still never splits. */
  white-space: normal;
  overflow-wrap: normal;
  padding: 0.1rem 0.5rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--muted);
  font-size: 0.72rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

.badge.live { border-color: rgba(52, 211, 153, 0.45); color: var(--ok); }
.badge.warn { border-color: rgba(239, 68, 68, 0.45); color: var(--danger); }

/* The tick and the cross inside a state badge (and beside a realm's "up"/"down"). Both shapes are
   strokes rather than fills, so this one rule paints both, and `currentColor` makes the mark the
   colour of whatever it sits in: a live row's tick is the green of its own border, a locked
   account's cross is the red, and the realm card's mark follows the card's up/down colour — no
   rule per colour, and nothing to re-tint if a badge moves. Sized in rem so it scales with the
   0.72rem badge text instead of staying a fixed pixel blob on a phone. */
.mark {
  width: 0.78rem;
  height: 0.78rem;
  margin-right: 0.28rem;
  vertical-align: -0.06rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
}

/* The links at the foot of a page are words rather than buttons, so the button
   gap between them is the width of a word space. Each one carries its own. */
p.row-actions a { padding: 0 0.6rem; }
p.row-actions a:first-child { padding-left: 0; }

/* The tracker's filter row is nine chips, and nine chips with their counts measured 1084px
   against the 1008px row at 1180 - the row is capped at 1008 at every viewport, so the last
   chip sat alone on a second line at every width (shots/q/bugtracker.1180.png). The count
   lives in the chip's title instead, and the chip itself must stay one piece: a chip that
   breaks across the wrap line is a new orphan with more letters. */
.bug-filters a { white-space: nowrap; }

.row-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.45rem;
  align-items: center;
  margin: 0 0 1rem;
}

.card-form {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 1.1rem;
  margin: 0 0 1.2rem;
}

.card-form.danger { border-color: rgba(239, 68, 68, 0.35); }

.card-form input,
.card-form textarea,
.card-form select { width: 100%; }

/* The only checkbox on the site is the confirmation that a realm ban aimed at the
   operator's own address is deliberate. Block-level, full-width inputs are right for
   text fields and wrong here: a bare square floating above its own sentence is how an
   "I really mean it" control gets ticked by accident. */
label.check {
  display: flex;
  gap: 0.55rem;
  align-items: baseline;
  color: var(--ink);
}

label.check input {
  width: auto;
  flex: none;
  margin: 0;
  padding: 0;
  accent-color: var(--accent);
}

.slug-prefix { display: flex; gap: 0.35rem; align-items: center; }

.slug-prefix > span {
  color: var(--muted);
  font-family: ui-monospace, monospace;
  font-size: 0.9rem;
}

textarea,
select {
  background: var(--bg-deep);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0.5rem 0.6rem;
  font: inherit;
}

textarea:focus,
select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }

button.danger {
  background: transparent;
  color: var(--danger);
  border: 1px solid rgba(239, 68, 68, 0.5);
}

button.danger:hover { background: rgba(239, 68, 68, 0.14); }

button:disabled,
select:disabled { opacity: 0.45; cursor: not-allowed; }

p.hint { color: var(--muted); font-size: 0.82rem; }

/* A settings explanation sits under its field, not inside the label above it.
   A hint paragraph directly after a control is that field's note, so it hugs
   the control instead of taking the paragraph's full margins. */
.card-form input + p.hint,
.card-form textarea + p.hint,
.card-form select + p.hint { margin: 0.3rem 0 0; }

/* --- narrow screens ----------------------------------------------------- */


/* The armory. A search form is the only form on a public page that a stranger
   reaches first, so it wraps as one row on a wide screen and as a stack on a
   phone rather than shrinking the field. */
.armory-search {
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: 0.5rem;
  margin-bottom: 0.4rem;
}

.armory-search label { flex: 0 0 auto; }
.armory-search input { flex: 1 1 14rem; min-width: 8rem; }
.armory-search select { flex: 0 1 auto; }

/* A character sheet is a list of facts, not a table of rows, so it is a dl:
   the label and its value belong to each other and move together. */
dl.sheet {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.35rem 1rem;
  margin: 0 0 1rem;
}

dl.sheet dt { color: var(--muted); font-size: 0.82rem; }
dl.sheet dd { margin: 0; }

/* A fact the realm stored only as an id into data this site was never sent. Printed in
   the same ink as a recorded measurement, 1073741825 reads like a number that means
   something, so the two kinds of figure are drawn differently: the label already says
   which is which, and now the type does too. */
dl.sheet dd.as-stored { color: var(--muted); font-variant-numeric: tabular-nums; }

/* A realm exports close to thirty statistics per character. As one stack that list
   runs half a screen long and separates facts that belong together — powers,
   resistances, chances — so each pair is grouped in a div, which is permitted inside
   a dl, and the wide layout puts two pairs side by side. The meaning of the list does
   not change; only its density does. */
dl.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
  gap: 0.4rem 2rem;
  margin: 0 0 1rem;
}

dl.stats > div {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.15rem 1rem;
  align-items: baseline;
}

dl.stats dt { color: var(--muted); font-size: 0.82rem; }

dl.stats dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

/* The item description is text the realm's own item table stores. It is set apart
   from the facts above it without pretending to be a quotation from a person, which
   is why it has a rule and no quote marks. */
.flavour {
  margin: 0 0 1rem;
  padding: 0.2rem 0 0.2rem 0.8rem;
  border-left: 2px solid var(--line);
  color: var(--muted);
  font-style: italic;
}

/* "Dropped by" is a list of chances. The number is the thing an operator scans for,
   so it is set in tabular figures and held in a column of its own. It is a list and
   not a table because there are two facts per line and the second is a name; a table
   would invite a third column this realm never supplied. */
ul.droplist {
  list-style: none;
  margin: 0 0 0.6rem;
  padding: 0;
}

ul.droplist li {
  display: grid;
  /* 5.25rem is measured, not guessed: at 4.5rem the widest chance this dump renders
     -- 13.04% -- overflows into the creature's name and the two collide. */
  grid-template-columns: 5.25rem 1fr;
  gap: 0.6rem;
  padding: 0.15rem 0;
  border-bottom: 1px solid var(--line);
}

ul.droplist li:last-child { border-bottom: 0; }

/* The name and its loot-group note travel in one element on purpose: the row is a
   two-column grid, and a bare text node after the name would become a third anonymous
   grid item -- which is how "(through loot group 2043)" ended up back under the chance
   column in a rendered page. */
ul.droplist .dropper {
  min-width: 0;
}

/* The realm's own picture for the item, at the row's text size. `vertical-align: -4px` puts
   an 18px frame on the baseline of the 0.95rem name beside it; the name may wrap to a second
   line and the frame stays with the first, which is how the reference sits its icon too. */
ul.droplist .dropper-icon {
  display: inline-block;
  vertical-align: -4px;
  margin-right: 0.4rem;
}

ul.droplist .chance {
  color: var(--accent-text);
  font-variant-numeric: tabular-nums;
}

/* A reference group's rows hang under the row that points at them. The chance column
   keeps the same edge a reader's eye already landed on, the indent says which row the
   group belongs to, and the rules stay unbordered so a nested group does not read as
   more top-level drops. Nothing about a group's own chance and its members' chances is
   combined here: both numbers are the realm's, printed as stored. */
/* `grid-column: 1 / -1` is not decoration. The row is a two-column grid, so a nested
   list placed in it without a span lands in the 5.25rem chance column and its item names
   wrap one letter per line — measured on the harness at 2026-11-03, where "Cape of the
   Brotherhood" arrived as a vertical strip. */
ul.droplist ul.droplist {
  grid-column: 1 / -1;
  margin: 0.2rem 0 0.2rem 1.35rem;
  border-left: 1px solid var(--line);
  padding-left: 0.75rem;
}

ul.droplist ul.droplist li {
  grid-template-columns: 4rem 1fr;
  border-bottom: 0;
  font-size: 0.94rem;
  padding: 0.1rem 0;
}

/* Which of the realm's loot templates a source came from: "creature", "game object",
   "skinning", "fished in". It sits inside .dropper for the same reason the loot-group
   note does -- the row is a two-column grid, and a sibling span would become a third
   anonymous grid item and land under the chance column. A herb's only source used to
   read as a nameless entry; now it reads as the node it comes out of. */
ul.droplist .kind {
  color: var(--muted);
  font-size: 0.82rem;
  margin-right: 0.4rem;
}

/* Server status. The panel is a list of measurements, so the numbers are the
   decoration: state first, count second, faction split third. The two faction
   colours are this site's own — a steel blue and a brick red, chosen for contrast
   against the slate panels — rather than the game's, because a page may be about a
   realm without re-hosting its artwork.
   The bar is a proportion of the people online, never a share of a capacity: there
   is no maximum to divide by, and inventing one would put a made-up number on a page
   whose whole claim is that it measures. Widths are step classes for the same reason
   the timeline uses them: the CSP is style-src 'self', so an inline width would be
   dropped by any browser that honours the header. */
.realm-status {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: 0.75rem;
  list-style: none;
  margin: 0.6rem 0 0;
  padding: 0;
}

.realm-status-card {
  background: var(--panel);
  border: 1px solid var(--panel-edge);
  border-left: 3px solid var(--muted);
  border-radius: var(--radius);
  padding: 0.7rem 0.9rem;
}

.realm-status-card.up { border-left-color: var(--ok); }
.realm-status-card.down { border-left-color: var(--danger); }

.rs-name {
  display: flex;
  justify-content: space-between;
  gap: 0.5rem;
  margin: 0;
  font-weight: 600;
}

.rs-state {
  color: var(--muted);
  font-size: 0.78rem;
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.realm-status-card.up .rs-state { color: var(--ok); }
.realm-status-card.down .rs-state { color: var(--danger); }

.rs-people { margin: 0.45rem 0 0; font-size: 1.55rem; font-weight: 600; }
.rs-people .rs-word { color: var(--muted); font-size: 0.9rem; font-weight: 400; margin-left: 0.3rem; }
.rs-people .rs-unknown { color: var(--muted); font-size: 0.95rem; font-style: italic; font-weight: 400; }

.rsplit {
  display: flex;
  height: 8px;
  margin-top: 0.6rem;
  border-radius: 4px;
  overflow: hidden;
  background: var(--line);
}

.rsplit .seg { display: block; height: 100%; }
.rsplit .alliance { background: #3a8fdd; }
.rsplit .horde { background: #d04a3a; }
.rsplit .w0 { width: 0; }
.rsplit .w10 { width: 10%; }
.rsplit .w20 { width: 20%; }
.rsplit .w30 { width: 30%; }
.rsplit .w40 { width: 40%; }
.rsplit .w50 { width: 50%; }
.rsplit .w60 { width: 60%; }
.rsplit .w70 { width: 70%; }
.rsplit .w80 { width: 80%; }
.rsplit .w90 { width: 90%; }
.rsplit .w100 { width: 100%; }

.rs-factions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin: 0.45rem 0 0;
  font-size: 0.84rem;
}

.rs-factions .tag {
  border: 1px solid var(--panel-edge);
  border-radius: 999px;
  color: var(--muted);
  padding: 0.05rem 0.5rem;
}

.rs-factions .tag.alliance { border-color: #3a6f9f; color: #a8d0ec; }
.rs-factions .tag.horde { border-color: #8a3a30; color: #f0a89f; }

.rs-addr { color: var(--muted); font-size: 0.78rem; margin: 0.55rem 0 0; }
.rs-note { color: var(--muted); font-size: 0.85rem; margin: 0.4rem 0 0; }

.page-head { margin-bottom: 1rem; }
.page-head h1 { margin-bottom: 0.3rem; }

.method { color: var(--muted); font-size: 0.9rem; padding-left: 1.1rem; }
.method li { margin-bottom: 0.45rem; }
.method strong { color: var(--ink); }

@media (max-width: 46rem) {
  .split { grid-template-columns: minmax(0, 1fr); gap: 0; }

  /* A stored email address is one unbreakable token, and a `1fr` track will not shrink below
     the min-content of what it holds: measured on the account page at a 320px viewport, the
     two-column `.facts` grid pushed its section to 317px inside a 288px content box, so the
     page scrolled sideways. The track gets a floor of zero and the value is allowed to break —
     an address split across two lines is readable, a page you have to drag sideways is not. */
  .facts { grid-template-columns: auto minmax(0, 1fr); }
  .facts dd { overflow-wrap: anywhere; }

  /* One step further on the sheets where the label is a sentence: the diagnostics page pairs
     "Last status measurement" with a value that carries a badge whose longest word ("measurement",
     "configured") cannot shrink. A two-column grid spends a third of a 313px card on the label and
     the pair stops fitting — the grid measured 321px inside 313px at a 390px viewport. Below this
     width the label goes over the value and both get the full measure; an email address split
     across two lines and a badge given its own line are both readable, a page dragged sideways
     is not. */
  dl.facts { display: block; }
  dl.facts dt { margin-top: 0.75rem; }
  dl.facts dd { margin: 0.1rem 0 0; }
  main.wrap { padding: 1.1rem 1rem 2.5rem; }
  .hero { padding: 1.5rem 1rem; }

  /* The brand is already a link home, so on a phone the duplicate "Home"
     entry is what pushes this row onto three lines. Dropping it keeps the
     header one line and every control reachable. */
  .nav-home { display: none; }
  .site-head { padding: 0.7rem 1rem; gap: 0.6rem; }
  .util-bar { padding: 0.3rem 1rem; }
  /* The brand is the site's name and is short by construction, but an operator
     can still save a long one in the settings panel. The text may break anywhere,
     and the grid item may shrink. Sideways scroll is the failure; two header
     lines are not. */
  .brand {
    font-size: 0.95rem;
    letter-spacing: 0.04em;
    white-space: normal;
    overflow-wrap: anywhere;
    min-width: 0;
  }
  .nav-body { gap: 0.7rem; }

  /* With three controls the header wrapped into a ragged two columns. A grid
     keeps the brand on its own line and puts the controls below it, left
     aligned, where a thumb can reach them. */
  .site-head { display: grid; grid-template-columns: 1fr auto; row-gap: 0.5rem; }
  .site-head nav { grid-column: 1 / -1; }
  .site-head .nav-body { gap: 0.4rem 0.85rem; }

  /* The fold is the menu on a phone: the Menu line is what a thumb reaches for, and the roster
     is drawn only while the attribute says it is open. The line keeps the browser's own
     disclosure marker — a triangle the element turns for itself — because a drawn state nobody
     maintains beats a maintained state a browser might drop. */
  .nav-fold summary {
    display: list-item;
    width: 100%;
    padding: 0.45rem 0;
    color: var(--ink);
    font-size: var(--nav-size);
    font-family: var(--nav-font);
    letter-spacing: var(--nav-tracking);
    text-transform: var(--nav-transform);
    cursor: pointer;
  }
  .nav-fold:not([open]) > .nav-body { display: none; }
  .nav-fold[open] > .nav-body { display: block; }

  /* A phone has no hover to open a dropdown with, and a tap on the parent's name
     is a wish to go to that page rather than a wish to see a list. So on a narrow
     screen what hangs under a link simply follows it, in the flow, where a thumb
     can reach it. The hover rules are re-stated here with the same specificity so
     a stray focus cannot turn them back into a floating panel. */
  /* The group stays a block so it wraps as one unit: eighteen links in a flow read as
     a wall, the same eighteen arranged as a named line each read as a menu. The
     parent's name carries the weight because it is the label the line belongs to. */
  nav .nav-group { display: block; }
  nav .nav-group > a { color: var(--ink); font-weight: 600; }
  nav .nav-drop { display: inline; position: static; padding: 0; margin-left: 0.45rem; }
  nav .nav-group:hover .nav-drop,
  nav .nav-group:focus-within .nav-drop {
    display: inline;
    position: static;
    padding: 0;
    border: 0;
    background: none;
    min-width: 0;
  }
  nav .nav-drop a { white-space: normal; }

  /* Stacked rows beat six narrow columns on a phone — but only if the column
     headers survive as row labels. A stacked cell that just says "ok" or
     "2026-10-29" is unreadable once the <th> that named it is hidden. */
  table.data th { display: none; }
  table.data, table.data tbody, table.data tr { display: block; width: 100%; }
  table.data tr { border-bottom: 1px solid var(--line); padding: 0.6rem 0; }
  table.data td {
    display: flex;
    flex-wrap: wrap;
    row-gap: 0.15rem;
    gap: 1rem;
    align-items: baseline;
    justify-content: space-between;
    border: none;
    padding: 0.2rem 0.6rem;
  }
  table.data td::before {
    content: attr(data-label);
    color: var(--muted);
    font-size: 0.75rem;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    flex: 0 0 auto;
  }
  table.data td:not([data-label])::before { content: none; }

  /* The row label is uppercase with letterspacing, and it was pinned `0 0 auto`: it could
     neither shrink nor move, so a value like "not recorded" was squeezed into a box narrower
     than its own text and cut mid-word — measured on the honour-standings page at a 320px
     viewport, a cell 286px wide holding text that wanted 299. The page did not scroll, so only
     a per-element check found it. The label may now shrink and the cell may wrap: the value
     drops to a second line inside its own cell instead of losing letters. */
  table.data td::before { flex: 0 1 auto; min-width: 0; overflow-wrap: anywhere; }

  /* The column shares are a laid-out-table thing. A stacked row is the whole card; a half-width
     card is a card that has forgotten what it is. */
  table.forum-boards col,
  table.forum-threads col { width: auto; }

  /* A thread title is one line with an ellipsis while it shares a 44%-wide column with a date.
     Stacked, the title owns the card, and a nowrap flex item is a flex item that decides how
     wide your phone is: measured at a 320px viewport, the longest title on the forum board
     page is 958px unbroken, which pushed the page 736px wide and left a white gutter where the
     background stops painting. The card is the whole row now, so the title wraps like any other
     value and the ellipsis — a table-space solution — is withdrawn with the table it served. */
  .thread-title { white-space: normal; overflow: visible; text-overflow: clip; }

  /* The identity cell carries a name, a badge, and an email; side by side with
     its label there is no room left and the address runs off the card. */
  table.data td[data-label="Account"] {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.15rem;
  }
  table.data td[data-label="Account"] .subtle { overflow-wrap: anywhere; }
  /* Several panel cells carry two facts separated by <br> on the desktop table: the
     support queue's timestamp and ticket number, a name and its assignment, a player's
     message and a staff response, a state and the realm's flags; and the bans page's
     expiry with the date the ban was set. The rule below hides <br>, because a flex row
     does not break on one, so those pairs arrived glued together — "03:48#104" in a 420px
     render of the queue, and "…UTCsince …" on the bans page, which the stacked-cell test
     found in a page that had shipped that way. Same treatment as the identity cell: stack
     the cell, and each part keeps its line — which only works if each part is an element.
     Two bare runs of text in a flex container become a single anonymous item and still
     arrive on one line, so "03:48" and "#104" stayed glued until the number was given a
     span of its own. */
  table.data td[data-label="When (UTC)"],
  table.data td[data-label="Player"],
  table.data td[data-label="Message"],
  table.data td[data-label="Until"],
  table.data td[data-label="State"],
  /* The realms list puts a slug under a name, an order under "advertised online", a
     set-of-variables under a heading, and a badge with two links in the last column; the
     capability sheet puts the realm's own error, or its "no honour ledger" sentence, under
     the badge. All of them separate those parts with <br>. */
  table.data td[data-label="Realm"],
  table.data td[data-label="What the site reads"],
  /* The switchboard's sentence column puts the section's own "no page answers yet" line
     under the sentence for the one section not built yet, the same <br> pair as the rest. */
  table.data td[data-label="What the switch decides"],
  table.data td[data-label="Shown as"],
  table.data td[data-label="Variables it reads"],
  table.data td[data-label="Reading it now"],
  table.data td[data-label="Answer"] {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.15rem;
  }
  /* A DSN variable name is twenty characters of mono with nowhere to break, and a phone
     card is narrower than that once its label is in front of it. */
  table.data td[data-label="Variables it reads"],
  table.data td[data-label="Address"] { overflow-wrap: anywhere; }
  /* A backup's file name is 37 characters of mono with no legal break in it, and it is
     the line the operator reads to tell one snapshot from another. */
  table.data td[data-label="File"] { overflow-wrap: anywhere; }
  /* The template separates the address with <br> for the desktop table; in a
     column that already breaks the line, the <br> becomes an empty row. */
  table.data td br { display: none; }

  /* A row of filter links is the armory's normal shape — six to nine of them on one line.
     At phone width natural-width links wrapped /armory/items into five lines that used
     289, 287, 262, 356 and 199 px of the 388 px available: ragged on both edges and 157 px
     of page spent on navigation. The links grow into the line they are on, so a line ends
     where the content ends, and their padding and size step down once so more of them fit.
     The desktop row is untouched: this rule is inside the phone media block, below the base
     `.row-actions` rule, which is the order the cascade needs. */
  .row-actions {
    gap: 0.3rem;
  }
  .row-actions > a {
    flex: 1 1 auto;
    text-align: center;
    padding: 0.3rem 0.45rem;
    font-size: 0.85rem;
  }
  .row-actions > a:first-child {
    padding-left: 0.45rem;
  }
  .panel h1 { font-size: 1.3rem; }
  /* The character sheet keeps its two columns down to phone width: the labels are short by
     construction (Gender, State, Guild, Played, Money, Last seen, Character id), and the
     stacked layout spent a label line, a value line and a half-rem gap per fact - five facts
     ate ~350px of a 640px screen before the first section started
     (shots/q/character.320.png). The value's track gets the same zero floor and break-anywhere
     rule the .facts grid got: an unbreakable value is the only reason a two-column grid has to
     stack, and the fix for that is a value that breaks, not a grid that stacks. */
  dl.sheet {
    grid-template-columns: auto minmax(0, 1fr);
    gap: 0.3rem 0.75rem;
  }

  dl.sheet dd { overflow-wrap: anywhere; }

  /* The overview's eight cards go one per line on a phone: two 10.5rem floors do not fit a
     320px screen, and a half-width card gives a two-line label no room to sit in. */
  .tiles { grid-template-columns: 1fr; }

  dl.stats { grid-template-columns: 1fr; }

  dl.stats dd { margin-bottom: 0.45rem; }

  .armory-search input,
  .armory-search select { flex: 1 1 100%; }

}
