/* ============================================================
   Swiftpoint Hub — shared UI kit

   The component vocabulary every page reuses: buttons, fields,
   cards, chips, tabs, tables, empty states, toasts.
   Loaded after base.css, before page CSS.

   Everything here is token-driven, so both themes work and no
   page needs its own copy of a button.
   ============================================================ */

/* ------------------------------------------------------------
   Buttons
   ------------------------------------------------------------ */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  padding: 0 var(--sp-4);
  height: 38px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line-med);
  background: var(--surface-2);
  color: var(--text);
  font-size: var(--fs-sm);
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  text-decoration: none;
  cursor: pointer;
  transition:
    background var(--t-fast),
    border-color var(--t-fast),
    color var(--t-fast),
    transform var(--t-fast),
    box-shadow var(--t-fast);
}

.btn:hover {
  background: var(--surface-3);
  border-color: var(--line-strong);
  text-decoration: none;
}

.btn:active {
  transform: translateY(1px);
}

.btn--primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
  font-weight: 600;
}

.btn--primary:hover {
  background: var(--accent-hi);
  border-color: var(--accent-hi);
  box-shadow: 0 0 0 3px var(--accent-dim);
}

.btn--ghost {
  background: transparent;
  border-color: transparent;
  color: var(--text-2);
}

.btn--ghost:hover {
  background: var(--surface-2);
  border-color: var(--line);
  color: var(--text);
}

.btn--outline {
  background: transparent;
  border-color: var(--line-med);
  color: var(--text);
}

.btn--danger {
  background: var(--red-dim);
  border-color: var(--red-dim);
  color: var(--red);
}

.btn--danger:hover {
  background: var(--red);
  border-color: var(--red);
  color: var(--red-ink);
}

.btn--sm {
  height: 30px;
  padding: 0 var(--sp-3);
  font-size: var(--fs-xs);
  border-radius: var(--r-xs);
}

.btn--lg {
  height: 46px;
  padding: 0 var(--sp-5);
  font-size: var(--fs-body);
  border-radius: var(--r-md);
}

.btn--block {
  width: 100%;
}

.btn--icon {
  width: 34px;
  height: 34px;
  padding: 0;
  border-radius: var(--r-sm);
}

/* ------------------------------------------------------------
   Fields
   ------------------------------------------------------------ */
.field {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  min-width: 0;
}

.field-label,
.field > label {
  font-size: var(--fs-xs);
  font-weight: 600;
  color: var(--text-2);
  letter-spacing: 0.02em;
}

.field-hint {
  font-size: var(--fs-2xs);
  color: var(--text-3);
  line-height: 1.5;
}

/* ------------------------------------------------------------
   Form controls.

   Everything here is written for a control that carries a class —
   `.input`, `.select`, `.textarea` — because that is how the rest of
   the app does it and it is the only way to style a control
   consistently across a strip, a card and a dialog.

   The `:not([class])` variants exist because a form in a dialog is
   written by hand, and a hand-written `<input>` that nobody gave a
   class to renders as the browser's default grey box: unstyled, but
   *present*, so it looks deliberate and reads as a bug in the page
   rather than a missing class. `:not([class])` is deliberately the
   narrowest possible match — a control nobody styled at all — so it
   cannot override the toolbar's `.tbar-input` or a page's own
   styling, and it disappears the moment anybody adds a class.

   A checkbox and a radio are excluded from it, and that exclusion is
   not fussiness. This rule is `width: 100%; height: 38px; padding:
   8px 12px` — the shape of a text field. Applied to a checkbox it
   wins on specificity against the `input[type='checkbox']` rule below
   and turns a 15px tick into a 38px tall, full-width, padded box.
   That is what "Pin to the top" looked like, and no amount of
   restyling the label would have fixed it.
   ------------------------------------------------------------ */
.input,
.select,
.textarea,
.field :is(input, select, textarea):not([class]):not([type='checkbox']):not([type='radio']) {
  width: 100%;
  min-width: 0;
  padding: var(--sp-2) var(--sp-3);
  height: 38px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line-med);
  background: var(--bg-raise);
  color: var(--text);
  font-size: var(--fs-sm);
  line-height: 1.4;
  transition:
    border-color var(--t-fast),
    box-shadow var(--t-fast),
    background var(--t-fast);
}

.textarea,
.field :is(input, select, textarea).textarea,
.field textarea:not([class]) {
  height: auto;
  padding: var(--sp-3);
  line-height: var(--lh-body);
}

.input:hover,
.select:hover,
.textarea:hover,
.field :is(input, select, textarea):not([class]):not([type='checkbox']):not([type='radio']):hover {
  border-color: var(--line-strong);
}

.input:focus,
.select:focus,
.textarea:focus,
.field :is(input, select, textarea):not([class]):not([type='checkbox']):not([type='radio']):focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-dim);
}

.select {
  appearance: none;
  padding-right: var(--sp-6);
  background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: calc(100% - 17px) 50%, calc(100% - 12px) 50%;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  cursor: pointer;
}

/* Range sliders, used across the builder and OLED pages */
input[type='range'] {
  appearance: none;
  width: 100%;
  height: 4px;
  border-radius: var(--r-pill);
  background: var(--track);
  cursor: pointer;
}

input[type='range']::-webkit-slider-thumb {
  appearance: none;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  background: var(--accent);
  border: 0;
  box-shadow: 0 0 0 3px var(--accent-dim);
  transition: box-shadow var(--t-fast);
}

input[type='range']::-webkit-slider-thumb:hover {
  box-shadow: 0 0 0 5px var(--accent-dim);
}

input[type='range']::-moz-range-thumb {
  width: 15px;
  height: 15px;
  border-radius: 50%;
  background: var(--accent);
  border: 0;
  box-shadow: 0 0 0 3px var(--accent-dim);
}

input[type='checkbox'],
input[type='radio'] {
  accent-color: var(--accent);
  width: 15px;
  height: 15px;
  cursor: pointer;
}

input[type='color'] {
  width: 38px;
  height: 38px;
  padding: 3px;
  border: 1px solid var(--line-med);
  border-radius: var(--r-sm);
  background: var(--bg-raise);
  cursor: pointer;
}

/* ------------------------------------------------------------
   Surfaces
   ------------------------------------------------------------ */
/* ------------------------------------------------------------
   Surfaces

   `.card` itself is defined further down, with the profile card
   it belongs to — a surface rule here and a full rule there is
   two places to change one thing.
   ------------------------------------------------------------ */
.card-pad {
  padding: var(--sp-5);
}

.card-hd {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-5);
  border-bottom: 1px solid var(--line);
}

.card-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: 600;
  letter-spacing: -0.01em;
}

/* ------------------------------------------------------------
   Filter chip

   The interactive pill used to pick one item out of a set:
   the game filter on the browse page and the layer selector on
   a profile. `.chip` above is the static, read-only tag.
   ------------------------------------------------------------ */
.fchip {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 7px 15px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--surface);
  color: var(--text-2);
  font-size: var(--fs-xs);
  font-weight: 600;
  cursor: pointer;
  transition:
    color var(--t-fast),
    border-color var(--t-fast),
    background var(--t-fast),
    transform var(--t-fast);
}

.fchip:hover {
  color: var(--text);
  border-color: var(--line-strong);
  transform: translateY(-1px);
}

.fchip.active {
  background: var(--accent-dim);
  border-color: var(--accent);
  color: var(--accent);
}

/* Optional mono sub-label inside a filter chip */
.fchip-tag {
  padding: 2px 7px 1px;
  border-radius: var(--r-pill);
  background: var(--bg-raise);
  color: var(--text-3);
  font-family: var(--font-mono);
  font-size: 9.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  transition:
    color var(--t-fast),
    background var(--t-fast);
}

.fchip.active .fchip-tag {
  background: var(--accent-dim);
  color: var(--accent);
}

/* ------------------------------------------------------------
   Profile card

   The one card used by the browse gallery and the user
   dashboard, so both stay in lockstep. `.card` is the surface;
   `.card--link` adds the whole-card click target.

   The order of the card is the order of the questions a reader
   asks: what is it for (the game, which also picks the accent
   hue), what is it called, who made it, what does it claim to
   do, what is it set to, and is it any good. The only part
   allowed to change height is the description.
   ------------------------------------------------------------ */
.card {
  --hue: var(--game-hue, 110);
  /* The game hue is computed per card, so its lightness has to follow the
     theme the way the palette's own accents do: 62 % on a dark card is
     unreadable ink on paper. */
  --hue-ink: 62%;
  --hue-rail: 52%;
  --hue-rail-hi: 60%;
  position: relative;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  transition:
    transform var(--t-med),
    border-color var(--t-med),
    box-shadow var(--t-med);
}
[data-theme='light'] .card {
  --hue-ink: 32%;
  --hue-rail: 42%;
  --hue-rail-hi: 48%;
}

.card--link {
  color: inherit;
  text-decoration: none;
  cursor: pointer;
}

.card--link:hover {
  transform: translateY(-3px);
  border-color: var(--line-strong);
  box-shadow: var(--shadow-md);
}

/* The accent bar. Inset rather than flush with the edge, so the card
   keeps its rounded corners and the focus ring around it is not
   clipped. */
.card-rail {
  position: absolute;
  left: 0;
  top: 14px;
  bottom: 14px;
  width: 3px;
  border-radius: 0 3px 3px 0;
  background: hsl(var(--hue), 62%, var(--hue-rail));
  transition: background var(--t-med), top var(--t-med), bottom var(--t-med);
}
.card--link:hover .card-rail {
  top: 10px;
  bottom: 10px;
  background: hsl(var(--hue), 72%, var(--hue-rail-hi));
}

.card-body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  flex: 1;
  padding: 18px 20px 16px;
}

/* Game on the left, uploader on the right: the one row that says
   what this is before any of the words do. */
.card-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  min-height: 24px;
}

.card-game {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: hsl(var(--hue), 55%, var(--hue-ink));
}

.card-ava {
  flex: none;
  width: 24px;
  height: 24px;
  border-radius: 999px;
  object-fit: cover;
  background: var(--surface-2);
  border: 1px solid var(--line);
}
.card-ava--letter {
  display: grid;
  place-items: center;
  font-family: var(--font-display);
  font-size: 11px;
  font-weight: 700;
  color: var(--text-2);
}

.card-title {
  margin: 0;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  font-family: var(--font-display);
  font-size: 16px;
  line-height: 1.32;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--text);
}

.card--link:hover .card-title { color: var(--accent); }

.card-by {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px 8px;
  margin: 0;
  font-size: var(--fs-xs);
  color: var(--text-3);
}
.card-by b { color: var(--text-2); font-weight: 600; }

.card-desc {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  margin: 2px 0 0;
  font-size: var(--fs-sm);
  line-height: 1.5;
  color: var(--text-2);
}

/* The DPI range, drawn. Log scale, so 400–30000 and 200–400 do not
   claim the same width; the numbers sit next to it, because the bar
   is a summary and the text is the fact. */
.card-dpi {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 2px;
}
.card-dpi-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
}
.card-dpi-k {
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--text-3);
}
.card-dpi-v {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--text-2);
}
.card-dpi-track {
  position: relative;
  height: 4px;
  border-radius: 2px;
  background: var(--track);
}
.card-dpi-fill {
  position: absolute;
  top: 0;
  bottom: 0;
  border-radius: 2px;
  background: linear-gradient(90deg, hsl(var(--hue), 40%, var(--hue-rail)), hsl(var(--hue), 58%, var(--hue-rail-hi)));
}

.card-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.tag-pill {
  padding: 2px 9px 1px;
  border: 1px solid color-mix(in srgb, var(--teal) 25%, transparent);
  border-radius: var(--r-pill);
  background: var(--teal-dim);
  color: var(--teal);
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.04em;
  transition: border-color var(--t-fast);
}

.card--link:hover .tag-pill {
  border-color: color-mix(in srgb, var(--teal) 45%, transparent);
}

/* The card's link target.
   The gallery card is an <article>, not an <a>: it holds vote buttons,
   and a button inside an anchor is not a button. So the link is a
   separate, empty <a> stretched over the whole card — the standard
   "pseudo-content overlay" — which keeps the card a real link for
   middle-click, new-tab, copy-address and crawlers while the votes stay
   clickable on top of it. */
.card-hit {
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
  text-decoration: none;
}

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

/* Anything interactive on the card has to sit above the stretched link,
   or the link eats the click. */
.card-foot,
.card-votes,
.vote-btn,
.vote-score {
  position: relative;
  z-index: 2;
}

/* The three numbers, as one line under a rule. A column of label/value
   pairs is a table; this is a caption, and it reads as one. */
.card-metrics {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 16px;
  margin: 2px 0 0;
  font-size: var(--fs-2xs);
  color: var(--text-3);
}
.card-metrics > div,
.card-metrics > span {
  display: flex;
  align-items: baseline;
  gap: 5px;
}
.card-metrics dt,
.card-metrics i {
  font-style: normal;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-3);
}
.card-metrics dd,
.card-metrics b {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 600;
  color: var(--text-2);
}

.card-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  flex-wrap: wrap;
  padding: 10px 16px;
  margin-top: auto;
  background: var(--bg-raise);
  border-top: 1px solid var(--line);
  border-radius: 0 0 var(--r-lg) var(--r-lg);
}

/* Vote control */
.card-votes {
  display: flex;
  align-items: center;
  gap: 6px;
}

.vote-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 4px 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--bg-raise);
  color: var(--text-3);
  font-family: var(--font-mono);
  font-size: 11.5px;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
  transition:
    color var(--t-fast),
    border-color var(--t-fast),
    background var(--t-fast),
    transform var(--t-fast);
}

.vote-btn:hover {
  color: var(--text);
  border-color: var(--line-strong);
  transform: translateY(-1px);
}

.vote-btn.up.active {
  background: var(--accent-dim);
  border-color: var(--accent);
  color: var(--accent);
}

.vote-btn.down.active {
  background: var(--red-dim);
  border-color: var(--red);
  color: var(--red);
}

.vote-btn.active {
  animation: vote-pop var(--t-med);
}

@keyframes vote-pop {
  45% {
    transform: scale(1.1);
  }
}

.vote-score {
  margin-left: var(--sp-1);
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.vote-score.bump {
  animation: score-bump 0.35s var(--ease);
}

@keyframes score-bump {
  40% {
    transform: scale(1.3);
    color: var(--accent);
  }
}

.card-bd {
  padding: var(--sp-5);
}

.card-ft {
  padding: var(--sp-4) var(--sp-5);
  border-top: 1px solid var(--line);
}

.panel {
  background: var(--bg-raise);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  padding: var(--sp-4);
}

/* ------------------------------------------------------------
   Chips, badges, tags
   ------------------------------------------------------------ */
.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-1);
  padding: 3px var(--sp-2);
  border-radius: var(--r-pill);
  border: 1px solid var(--line-med);
  background: var(--surface-2);
  color: var(--text-2);
  font-size: var(--fs-2xs);
  font-weight: 500;
  line-height: 1.5;
  white-space: nowrap;
  text-decoration: none;
  transition:
    background var(--t-fast),
    border-color var(--t-fast),
    color var(--t-fast);
}

.chip--accent {
  background: var(--accent-dim);
  border-color: transparent;
  color: var(--accent);
}

.chip--muted {
  background: transparent;
  color: var(--text-3);
}

button.chip:hover,
a.chip:hover {
  background: var(--surface-3);
  border-color: var(--line-strong);
  color: var(--text);
  text-decoration: none;
}

/* Accent emphasis, for a tag that marks the active context */
.chip--accent {
  background: var(--accent-dim);
  border-color: color-mix(in srgb, var(--accent) 28%, transparent);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.chip--accent:hover {
  background: color-mix(in srgb, var(--accent) 20%, transparent);
  color: var(--accent);
}

/* ------------------------------------------------------------
   Tabs
   ------------------------------------------------------------ */
.tabs {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
  padding: var(--sp-1);
  border-radius: var(--r-md);
  background: var(--surface-2);
  border: 1px solid var(--line);
  overflow-x: auto;
  scrollbar-width: none;
}

.tabs::-webkit-scrollbar {
  display: none;
}

.tab {
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--r-sm);
  color: var(--text-2);
  font-size: var(--fs-sm);
  font-weight: 500;
  white-space: nowrap;
  text-decoration: none;
  transition:
    background var(--t-fast),
    color var(--t-fast);
}

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

.tab[aria-selected='true'],
.tab.is-active {
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow-sm);
}

/* ------------------------------------------------------------
   Tables
   ------------------------------------------------------------ */
.table-wrap {
  overflow-x: auto;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface);
}

.table {
  min-width: 100%;
  margin: 0;
}

.table thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--surface-2);
  white-space: nowrap;
}

.table tbody tr {
  transition: background var(--t-fast);
}

.table tbody tr:hover {
  background: var(--surface-2);
}

.table tbody tr:last-child td {
  border-bottom: 0;
}

/* ------------------------------------------------------------
   Empty / error states
   ------------------------------------------------------------ */
.empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-8) var(--sp-5);
  text-align: center;
  color: var(--text-2);
}

.empty-icon {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: var(--r-md);
  background: var(--surface-2);
  border: 1px solid var(--line);
  color: var(--text-3);
}

.empty-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  color: var(--text);
}

.empty-text {
  margin: 0;
  max-width: 44ch;
  font-size: var(--fs-sm);
  line-height: var(--lh-body);
}

/* ------------------------------------------------------------
   Toast

   A corner stack of realtime messages. `.toast` alone is the
   shared primitive; the grid gives it a mono type label, the
   message and an optional link.
   ------------------------------------------------------------ */
.toast-stack {
  position: fixed;
  right: var(--sp-5);
  bottom: var(--sp-5);
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  z-index: var(--z-toast);
  max-width: min(360px, calc(100vw - 40px));
}

.toast {
  position: relative;
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: baseline;
  gap: 6px var(--sp-3);
  padding: var(--sp-3) 34px var(--sp-3) var(--sp-4);
  border: 1px solid var(--line-strong);
  border-left: 3px solid var(--accent);
  border-radius: var(--r-md);
  background: var(--surface-3);
  color: var(--text);
  box-shadow: var(--shadow-lg);
  animation: toast-in var(--t-med) both;
}

@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateX(16px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.toast-label {
  grid-row: 1;
  grid-column: 1;
  font-family: var(--font-mono);
  font-size: 9.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--accent);
}

.toast-text {
  grid-row: 1;
  grid-column: 2;
  font-size: var(--fs-sm);
  color: var(--text-2);
  line-height: 1.45;
  min-width: 0;
  overflow-wrap: anywhere;
}

.toast-link {
  grid-row: 1;
  grid-column: 3;
  font-family: var(--font-mono);
  font-size: 11.5px;
  color: var(--accent);
  text-decoration: none;
  white-space: nowrap;
}

.toast-link:hover {
  color: var(--text);
}

.toast-close {
  position: absolute;
  top: 6px;
  right: 8px;
  padding: 0;
  background: none;
  border: 0;
  color: var(--text-3);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
}

.toast-close:hover {
  color: var(--text);
}

/* ------------------------------------------------------------
   Modal
   ------------------------------------------------------------ */
.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: var(--z-pop);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--sp-5);
  background: var(--scrim);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}

.modal {
  width: 100%;
  max-width: 440px;
  max-height: calc(100vh - 2 * var(--sp-5));
  overflow-y: auto;
  padding: var(--sp-5);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  background: var(--surface);
  box-shadow: var(--shadow-lg);
  animation: popIn var(--t-med) both;
}

.modal h2 {
  font-size: var(--fs-h3);
  margin: 0 0 var(--sp-1);
}

/* ------------------------------------------------------------
   Big form modals.

   The behaviour and the size are two different classes, on purpose.
   `--form` is the behaviour — a flex column whose body scrolls and
   whose footer does not move. `--wide`, `--tall` and `--full` are
   sizes.

   They used to be one class each, and that is a trap: a modal that
   wanted both "behaves like a big form" and "is full width" had to
   carry `--tall` and `--full` at once, and whichever came later in
   this file won. The full-width request was silently ignored and the
   modal stayed at 1120px — which looked like the CSS was not being
   applied at all, rather than like two classes were fighting.

   So: one class per concern, and they cannot fight.

   Two things go wrong without any of this, and both look like "the
   modal is wrong" rather than like a CSS mistake:

   1. `.modal` caps itself at 440px, because that is right for a
      confirm. A size that sets only `width` does not beat `max-width`
      — the box stays 440px and a two-column form in it has a 100px
      column. So every size here sets `max-width` too, and a test
      compares the two declarations rather than trusting that the
      modifier was applied.
   2. A form taller than the window scrolls the *whole* modal, so the
      Publish button scrolls away and the button you came for is the
      one you cannot see.

   `min-height: 0` on the scrollable child is load-bearing: a flex
   item with `overflow: auto` will not shrink below its content
   without it, and "scroll the body, keep the footer" silently does
   nothing.
   ------------------------------------------------------------ */
.modal--form {
  display: flex;
  flex-direction: column;
  padding: 0;
  overflow: hidden;          /* the body scrolls, not the box */
  max-height: calc(100vh - 2 * var(--sp-4));
}

.modal--full,
.modal--wide,
.modal--tall {
  /* Belt and braces on the cap: the declaration below is the one
     that matters, this is the one that is obvious. */
  max-width: none;
}

/* Full width of the space the overlay gives it — the body of an
   update is a markdown document, and a column you have to scroll
   sideways to write in is not a column. */
.modal--full {
  width: 100%;
  max-width: 100%;
}

.modal--wide {
  width: min(940px, 94vw);
  max-width: 940px;
}

.modal--tall {
  width: min(1120px, 96vw);
  max-width: 1120px;
}

/* The title row, with a close button, and the hint under it. */
.modal-head {
  flex: 0 0 auto;
  padding: var(--sp-5) var(--sp-5) var(--sp-4);
  border-bottom: 1px solid var(--line);
  background: var(--surface-2);
  border-radius: var(--r-lg) var(--r-lg) 0 0;
}
.modal-head h2 { margin: 0; }
.modal-head .hint { margin: var(--sp-2) 0 0; }
.modal-x {
  position: absolute;
  top: var(--sp-4);
  right: var(--sp-4);
  width: 30px;
  height: 30px;
  border-radius: 50%;
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--text-3);
  font-size: 17px;
  line-height: 1;
  cursor: pointer;
  transition: color var(--t-fast), border-color var(--t-fast);
}
.modal-x:hover { color: var(--text); border-color: var(--line-strong); }

/* The form is the flex column that fills the box; the body is what
   scrolls. */
.modal-form {
  display: flex;
  flex-direction: column;
  min-height: 0;
  flex: 1 1 auto;
}
.modal-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: var(--sp-5);
  overscroll-behavior: contain;
}
.modal-foot {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-3) var(--sp-5);
  border-top: 1px solid var(--line);
  background: var(--surface-2);
  border-radius: 0 0 var(--r-lg) var(--r-lg);
}
.modal-foot .form-error { flex: 1 1 auto; margin-top: 0; }
.modal-foot .modal-actions { margin-top: 0; }
.modal-foot .fr-modal-foot { flex: 1 1 auto; }

@media (max-width: 560px) {
  .modal--wide,
  .modal--tall {
    width: 100%;
    max-width: 100%;
  }
  .modal-body { padding: var(--sp-4); }
}

.modal .hint {
  margin: 0 0 var(--sp-5);
  color: var(--text-2);
  font-size: var(--fs-sm);
}

.modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--sp-2);
  margin-top: var(--sp-5);
}

.form-error {
  min-height: 1em;
  margin-top: var(--sp-2);
  color: var(--red);
  font-size: var(--fs-xs);
}

/* ------------------------------------------------------------
   Notifications dropdown

   Shared by the admin and user pages (both load js/notifications.js).
   ------------------------------------------------------------ */
.notif-wrap {
  position: relative;
}

.notif-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: transparent;
  color: var(--text-2);
  cursor: pointer;
  transition:
    color var(--t-fast),
    border-color var(--t-fast),
    background var(--t-fast);
}

.notif-btn:hover {
  color: var(--text);
  border-color: var(--accent);
  background: var(--accent-dim);
}

.notif-btn.has-unread {
  border-color: var(--accent);
  color: var(--accent);
}

.notif-bell {
  width: 18px;
  height: 18px;
}

.notif-badge {
  position: absolute;
  top: -6px;
  right: -6px;
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: 10.5px;
  font-weight: 700;
  color: var(--on-accent);
  background: var(--accent);
  border: 2px solid var(--bg);
  border-radius: var(--r-pill);
}

.notif-panel {
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  z-index: var(--z-pop);
  width: min(380px, calc(100vw - 32px));
  overflow: hidden;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  background: var(--surface-2);
  box-shadow: var(--shadow-lg);
  transform-origin: top right;
  animation: popIn var(--t-med) both;
}

.notif-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  padding: var(--sp-3) var(--sp-4);
  border-bottom: 1px solid var(--line);
}

.notif-title {
  font-family: var(--font-mono);
  font-size: 11.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text);
}

.notif-mark {
  padding: 0;
  font-family: var(--font-mono);
  font-size: 11.5px;
  color: var(--accent);
  background: none;
  border: 0;
  cursor: pointer;
  transition: color var(--t-fast);
}

.notif-mark:hover {
  color: var(--text);
}

.notif-list {
  max-height: 420px;
  overflow-y: auto;
}

.notif-item {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: baseline;
  gap: 4px var(--sp-3);
  padding: var(--sp-3) var(--sp-4);
  border-bottom: 1px solid var(--line);
  color: var(--text-2);
  text-decoration: none;
  transition: background var(--t-fast);
}

.notif-item:last-child {
  border-bottom: 0;
}

a.notif-item:hover {
  background: var(--surface);
  color: var(--text);
}

.notif-item.unread {
  background: var(--accent-dim);
}

.notif-type {
  grid-row: 1;
  grid-column: 1;
  margin-top: 1px;
  padding: 2px 6px;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-xs);
  font-family: var(--font-mono);
  font-size: 9.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--text-3);
}

.notif-type.t-new_profile,
.notif-type.t-profile_updated {
  color: var(--accent);
  border-color: var(--lime-border);
}

.notif-type.t-new_comment {
  color: var(--green);
  border-color: color-mix(in srgb, var(--green) 45%, transparent);
}

.notif-type.t-new_user {
  color: var(--blue);
  border-color: color-mix(in srgb, var(--blue) 45%, transparent);
}

.notif-text {
  grid-row: 1;
  grid-column: 2;
  font-size: var(--fs-sm);
  line-height: 1.45;
  min-width: 0;
  overflow-wrap: anywhere;
}

.notif-time {
  grid-row: 1;
  grid-column: 3;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text-3);
  white-space: nowrap;
}

.notif-empty {
  margin: 0;
  padding: var(--sp-5) var(--sp-4);
  font-size: var(--fs-sm);
  color: var(--text-3);
  text-align: center;
}

/* ------------------------------------------------------------
   Skeleton loading
   ------------------------------------------------------------ */
.skel {
  border-radius: var(--r-sm);
  background: linear-gradient(90deg, var(--surface-2) 25%, var(--surface-3) 37%, var(--surface-2) 63%);
  background-size: 400% 100%;
  animation: shimmer 1.4s ease-in-out infinite;
}

@keyframes shimmer {
  from {
    background-position: 100% 50%;
  }
  to {
    background-position: 0 50%;
  }
}

/* ------------------------------------------------------------
   The markdown editor.

   A shared control, so it lives here with .btn, .field and .modal —
   not in a page stylesheet. It is used by the new-thread form, the
   reply composer, the post editor and the update form, and it was
   in forum.css, which the updates page does not load. The whole
   editor therefore rendered unstyled there: a bar of unaligned text
   and a textarea with the browser default, which reads as a broken
   page rather than as a missing stylesheet.

   If a control is used by more than one page, its styles belong to
   this file. test/ui-css-coverage.test.js checks that, by resolving
   the classes each page uses against the sheets that page loads.
   ------------------------------------------------------------ */
/* ============ The markdown editor ============ */

.md {
  border: 1px solid var(--line-med);
  border-radius: var(--r-sm);
  background: var(--bg-raise);
  overflow: hidden;
  /* The focus ring is on the frame, not on the textarea: a 3px glow
     around a 2000px-wide text area looks like an error state. */
  transition: border-color var(--t-fast), box-shadow var(--t-fast);
}
.md:focus-within {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-dim);
}

/* The bar is one scrolling row and never wraps.
   It was `flex-wrap: wrap`, and with twenty controls — some of them
   labelled "Changed" and "Security" — it grew to four rows inside a
   form column. The editor below it was a strip, the two looked like
   unrelated things, and the modal felt broken in a way that was very
   hard to point at. Wrapping was the wrong instinct: a toolbar whose
   height depends on the text of its own labels cannot be laid out
   anywhere reliably. Scroll sideways instead, and keep the height
   constant. */
.md-bar {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 8px;
  background: var(--surface-2);
  border-bottom: 1px solid var(--line);
  flex: 0 0 auto;
}
.md-tools,
.md-group {
  display: flex;
  align-items: center;
  gap: 2px;
  flex-wrap: nowrap;
  min-width: 0;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  padding-bottom: 1px;
}
.md-tools::-webkit-scrollbar,
.md-group::-webkit-scrollbar {
  height: 0;
}
/* The tools scroll; the changelog group is short and pinned. */
.md-tools { flex: 1 1 auto; }
.md-group { flex: 0 0 auto; }

/* The block row: everything that inserts a self-contained block.
   A plain strip of buttons that never scrolls on its own — it is short
   by construction, because it holds at most ten. */
.md-blocks {
  display: flex;
  align-items: center;
  gap: 2px;
  flex-wrap: wrap;
  min-width: 0;
}

/* Two rows, where there is a second row worth having.

   A grid with named areas rather than `flex-wrap`, because "wrap and
   hope" is not a layout: whether the Write/Preview switch lands beside
   the tools or drops onto a third line depends on how wide the panel
   happens to be and how long the labels are, and adding a button then
   silently costs a row. Named areas make it two rows at every width,
   which is the thing being asked for.

   `.md-tools` keeps its own overflow, so on a phone the first row still
   scrolls rather than wrapping to six lines and pushing the textarea off
   the screen. */
.md-bar--rows {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas:
    "tools  modes"
    "blocks blocks";
  gap: 5px 8px;
  align-items: center;
}
.md-bar--rows .md-tools {
  grid-area: tools;
  flex: 1 1 auto;
  /* Nothing to scroll in the common case, and the fade is then a lie —
     it dims the last button to look like there is more over there. */
  mask-image: none;
}
.md-bar--rows .md-modes { grid-area: modes; }
.md-bar--rows .md-blocks { grid-area: blocks; }
.md-bar--rows .md-blocks.is-hidden { display: none; }

.md-tools.is-hidden,
.md-blocks.is-hidden { visibility: hidden; }
/* In preview the bar keeps its height so switching back does not move the
   text under the cursor — but a reserved second row of nothing is a gap,
   so the grid drops to one. */
.md-bar--rows:has(.md-blocks.is-hidden) {
  grid-template-areas: "tools modes";
}

/* The right edge fades where there is more to scroll to, so "there is
   more" is visible rather than something you have to discover.
   Only when there is: a permanent fade dims the last button to look
   like there is something past it, on a bar that ends. The class is set
   from the measured overflow, not assumed. */
.md-tools.is-scrollable {
  mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent);
}

/* The changelog blocks, as their own group. They insert content rather
   than change formatting, and a person scanning the bar should not have
   to read the labels to tell which is which. */
.md-group--entries {
  gap: 1px;
  padding: 2px;
  background: var(--surface-3);
  border: 1px solid var(--line);
  border-radius: 7px;
  flex: 0 0 auto;
  scrollbar-width: thin;
}
.md-group--entries .md-btn {
  height: 22px;
  font-family: var(--font-body);
  font-size: 11px;
  letter-spacing: 0.01em;
  color: var(--text-3);
}
.md-group--entries .md-btn:hover {
  color: var(--lime-hi);
  background: var(--surface);
}

.md-btn {
  min-width: 27px;
  height: 26px;
  padding: 0 7px;
  border-radius: 5px;
  border: 1px solid transparent;
  background: transparent;
  color: var(--text-3);
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
  cursor: pointer;
  transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.md-btn:hover {
  color: var(--text);
  background: var(--surface-2);
  border-color: var(--line);
}
.md-btn:active { transform: translateY(0.5px); }
.md-btn--warn:hover { color: var(--orange); }
.md-sep {
  width: 1px;
  height: 16px;
  background: var(--line);
  margin: 0 4px;
}

/* The Write/Preview switch, pinned to the right of the bar so it never
   moves when the tools scroll under it. */
.md-modes {
  display: flex;
  gap: 2px;
  flex: 0 0 auto;
  padding: 2px;
  background: var(--surface-3);
  border: 1px solid var(--line);
  border-radius: 7px;
}
.md-mode {
  height: 22px;
  padding: 0 12px;
  border-radius: 5px;
  border: 1px solid transparent;
  background: transparent;
  color: var(--text-3);
  font-size: 11.5px;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
}
.md-mode:hover { color: var(--text); }
.md-mode[aria-pressed='true'] {
  background: var(--surface);
  border-color: var(--line-med);
  color: var(--text);
  box-shadow: var(--shadow-xs, 0 1px 2px rgb(0 0 0 / 0.2));
}

.md-area {
  display: block;
  width: 100%;
  /* Tall enough to write a paragraph and see it, with the toolbar
     taking one fixed row above it. `field-sizing: content` is not used:
     it is Chromium-only and would make the box jump on every
     keystroke. */
  min-height: clamp(180px, 22vh, 300px);
  padding: 16px 18px;
  border: 0;
  background: transparent;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 13.5px;
  line-height: 1.7;
  tab-size: 2;
  resize: vertical;
}
.md-area:focus { outline: none; }
.md-area::placeholder { color: var(--text-4); }

.md-preview {
  min-height: 180px;
  padding: 4px 16px 12px;
  font-size: 14.5px;
  line-height: 1.7;
  color: var(--text-2);
  overflow-wrap: anywhere;
}
.md-preview > *:first-child { margin-top: 10px; }
.md-preview > *:last-child { margin-bottom: 0; }
.md-preview p { margin: 0 0 0.8em; }
.md-preview h1, .md-preview h2, .md-preview h3, .md-preview h4 {
  font-family: var(--font-display);
  color: var(--text);
  line-height: 1.3;
  margin: 1.2em 0 0.5em;
}
/* The sizes, which the block above does not set. Without them every
   heading in the Preview tab came out the same size as the body text —
   so the one thing a preview is for, checking that a heading reads as a
   heading, was the one thing it could not show. These are the numbers
   .fr-post-body uses, because a preview that disagrees with the post
   about type size is worse than no preview. */
.md-preview h1 { font-size: 1.4em; }
.md-preview h2 { font-size: 1.22em; }
.md-preview h3 { font-size: 1.1em; }
.md-preview h4 {
  font-size: 1em;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-3);
}
.md-preview ul, .md-preview ol { margin: 0 0 0.8em; padding-left: 1.5em; }
.md-preview li { margin: 0.2em 0; }
.md-preview li > ul, .md-preview li > ol { margin-bottom: 0.2em; }
.md-preview blockquote {
  margin: 0 0 0.8em;
  padding: 2px 0 2px 14px;
  border-left: 2px solid var(--line-strong);
  color: var(--text-3);
}
.md-preview blockquote > *:last-child { margin-bottom: 0; }
.md-preview hr {
  border: 0;
  border-top: 1px solid var(--line);
  margin: 1.4em 0;
}
.md-preview a { color: var(--lime-hi); }
.md-preview code {
  font-family: var(--font-mono);
  font-size: 0.88em;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: 4px;
  padding: 0.1em 0.36em;
  color: var(--text);
}
.md-preview pre {
  margin: 0 0 0.8em;
  padding: 12px 14px;
  background: var(--bg-raise);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  overflow-x: auto;
}
.md-preview pre code { background: none; border: 0; padding: 0; font-size: 12.5px; }
/* Tables. markdown renders them, .fr-post-body styles them, and this
   pane did not — so a table in the Preview tab came out as unbordered
   text with the columns running together, and the person writing it had
   no way to find out until it was posted. */
.md-preview table {
  width: 100%;
  border-collapse: collapse;
  margin: 0 0 0.8em;
  font-size: 13.5px;
}
.md-preview th, .md-preview td {
  border: 1px solid var(--line);
  padding: 6px 10px;
  text-align: left;
}
.md-preview th { background: var(--surface-2); color: var(--text); font-weight: 600; }

/* ------------------------------------------------------------
   Syntax highlighting — which was on, and invisible.

   highlight.js runs on the server for every fenced block that names a
   language it knows, and emits the usual `.hljs-keyword` /
   `.hljs-string` / `.hljs-comment` spans. Not one stylesheet on this
   site defined any of them: `grep -r hljs public/css` came back empty.
   So the highlighting was computed on every request and thrown away,
   and every code block in a post, a guide and a preview rendered as one
   flat colour.

   Scoped to `pre code` rather than to a page class so it lands
   everywhere code is rendered — .fr-post-body, .d-prose, .md-preview —
   without each surface having to remember it. Colours are the site's
   own accent tokens, not hex values, so the light theme gets a theme
   that was designed for it rather than a dark-theme palette with the
   brightness turned down.
   ------------------------------------------------------------ */

pre code .hljs-comment,
pre code .hljs-quote { color: var(--text-3); font-style: italic; }
pre code .hljs-doctag,
pre code .hljs-meta { color: var(--text-3); }
pre code .hljs-keyword,
pre code .hljs-selector-tag,
pre code .hljs-literal,
pre code .hljs-type { color: var(--violet); }
pre code .hljs-built_in,
pre code .hljs-class .hljs-title { color: var(--teal); }
pre code .hljs-string,
pre code .hljs-regexp,
pre code .hljs-char.escape_ { color: var(--green); }
pre code .hljs-number,
pre code .hljs-symbol,
pre code .hljs-bullet { color: var(--orange); }
pre code .hljs-title,
pre code .hljs-section,
pre code .hljs-title.class_,
pre code .hljs-title.function_ { color: var(--blue); }
pre code .hljs-attr,
pre code .hljs-attribute,
pre code .hljs-variable,
pre code .hljs-template-variable,
pre code .hljs-params { color: var(--text); }
pre code .hljs-name,
pre code .hljs-tag { color: var(--pink); }
pre code .hljs-meta .hljs-keyword { color: var(--violet); }
pre code .hljs-addition { color: var(--green); }
pre code .hljs-deletion { color: var(--red); }
pre code .hljs-emphasis { font-style: italic; }
pre code .hljs-strong { font-weight: 600; }
/* A rule hljs did not put a class on — the operator and the spaces —
   must stay in the inherited colour, so it is reset explicitly rather
   than left to whatever a token happened to inherit. */
pre code .hljs-operator,
pre code .hljs-punctuation { color: var(--text-2); }
.md-empty, .md-loading {
  margin: 12px 0;
  font-size: 13px;
  color: var(--text-3);
}

.md-foot {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 7px 12px;
  background: var(--surface);
  border-top: 1px solid var(--line);
  font-size: 11.5px;
  color: var(--text-3);
}
.md-help { color: var(--text-3); text-decoration: none; font-weight: 600; }
.md-help:hover { color: var(--lime-hi); text-decoration: underline; }
.md-count { margin-left: auto; font-family: var(--font-mono); }
.md-count.is-near { color: var(--orange); }
.md-count.is-over { color: var(--red); }

.md-cheats {
  border-top: 1px solid var(--line);
  background: var(--surface);
  padding: 10px 12px;
  font-size: 12px;
  color: var(--text-3);
}
.md-cheats > b {
  display: block;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-4);
  margin-bottom: 8px;
}
.md-cheats-g { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
.md-cheats code {
  font-family: var(--font-mono);
  font-size: 11.5px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: 4px;
  padding: 2px 7px;
  color: var(--text-2);
}
.md-cheats-n { margin: 4px 0 0; font-size: 11.5px; }

/* ------------------------------------------------------------
   Two rules the forum and the updates list both use.

   The empty state and the kind badge are written by two different
   page scripts and mean the same thing in both, so they live here.
   They were in updates.css, which the forum does not load — so the
   forum's empty states rendered as unstyled paragraphs. Same bug as
   the markdown editor, one layer over: a shared class in a page sheet.
   ------------------------------------------------------------ */

.up-empty {
  padding: 48px 24px;
  text-align: center;
  border: 1px dashed var(--line-strong);
  border-radius: var(--r-md);
}

.up-kind {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 21px;
  padding: 0 10px;
  border-radius: var(--r-pill);
  border: 1px solid var(--line-med);
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  white-space: nowrap;
}

/* ------------------------------------------------------------
   Two more rules two pages share.

   The "loading…" line and the rendered markdown inside an editor
   preview. Both were in forum.css, which the updates page does not
   load, so the updates list had an unstyled loading line and
   unstyled images in its preview — the same bug as the editor
   itself, in the parts of it that were left behind.
   ------------------------------------------------------------ */

.sp-loading {
  padding: 40px 0;
  text-align: center;
  font-size: 13.5px;
  color: var(--text-3);
}

/* An image in rendered markdown, bounded so a screenshot cannot push
   the rest of the column off the screen.

   This was scoped to `.fr-md`, which is a class nothing outside the
   editor's own preview carries: a post is wrapped in `.fr-post-body`
   (see public/js/thread.js) and an update in `.up-body`. So the rule
   styled images in the Preview tab — where you were about to notice —
   and not in the posts themselves, where a 3000px-wide screenshot of a
   DPI value rendered at its natural width and dragged the whole page
   sideways on a phone. `.fr-md` is kept because it is the preview's
   class; the two posts surfaces are named because they are where the
   images actually are. */
.fr-md .fr-img,
.fr-post-body .fr-img,
.up-body .fr-img {
  display: block;
  max-width: 100%;
  height: auto;
  margin: 0.6em 0;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--bg-raise);
}
/* The zoom affordance belongs to the guides' lightbox, which is the
   only place that opens one — so it is not offered anywhere else,
   rather than being a cursor that lies. */
.fr-md .fr-img,
.fr-post-body .fr-img,
.up-body .fr-img { cursor: default; }

/* ------------------------------------------------------------
   The small inline button.

   Edit / Pin / Delete under an update, Quote under a post. Two
   pages, so it is here rather than named for one of them — which is
   what it was, and the updates list shipped unstyled buttons for a
   while because of it.
   ------------------------------------------------------------ */
.fr-pager .sp-mini { height: 28px; }
.sp-mini {
  height: 24px;
  padding: 0 9px;
  border-radius: var(--r-pill);
  border: 1px solid var(--line);
  background: transparent;
  color: var(--text-3);
  font-size: 11.5px;
  font-weight: 600;
  cursor: pointer;
  transition: color var(--t-fast), border-color var(--t-fast);
}
.sp-mini:hover { color: var(--text); border-color: var(--line-strong); }
.sp-mini.is-danger:hover { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, transparent); }

/* ------------------------------------------------------------
   The OLED animation preview canvas.

   Built by two different pages (the Checker and a profile) and
   sized in both, so it is here. It was only in profile-beta.css,
   which the Checker does not load — so the Checker's preview
   canvas was full-width and smooth-scaled.
   ------------------------------------------------------------ */
.oled-anim-canvas { display: block; width: 150px; image-rendering: pixelated; border-radius: 8px; border: 1px solid var(--line-strong); background: var(--oled-canvas); }

/* ------------------------------------------------------------
   A checkbox with a label.

   "Pin to the top" and "Posting adds you to the watchers". Two
   pages, so it is here; it was in forum.css, which the updates page
   does not load, and the label was not a row at all.
   ------------------------------------------------------------ */
.fr-check {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  font-size: 13.5px;
  color: var(--text-2);
  cursor: pointer;
  user-select: none;
  width: fit-content;
}
.fr-check:hover { color: var(--text); }
.fr-check input[type="checkbox"] {
  width: 16px;
  height: 16px;
  flex: 0 0 auto;
  margin: 0;
  cursor: pointer;
}
.fr-check input[type="checkbox"]:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px var(--accent-dim);
  border-radius: 3px;
}

/* ------------------------------------------------------------
   Rendered markdown: the containers, not the page.

   The editor's Preview tab is a shared control on three pages, and it
   is filled from POST /api/forum/preview — which renders with the `fr-`
   prefix on *every* page. So the updates page shows `fr-entry-*` in its
   preview, and these rules were in forum.css, which the updates page
   does not load. The changelog blocks looked right in a post and looked
   like plain paragraphs in the preview of the page they were written
   for.

   So everything the renderer emits for a container lives here. The
   typography of a post body — `.fr-post-body p`, `pre`, `blockquote` —
   stays in forum.css, because that is a post and only a post.
   ------------------------------------------------------------ */
.fr-img-bad {
  display: inline-block;
  font-size: 12.5px;
  color: var(--text-3);
  border: 1px dashed var(--line-strong);
  border-radius: var(--r-sm);
  padding: 2px 8px;
}

/* ::: note / info / tip / warning */
.fr-note {
  margin: 0 0 0.9em;
  padding: 12px 14px;
  border: 1px solid var(--line);
  border-left-width: 3px;
  border-radius: var(--r-sm);
  background: var(--surface-2);
}
.fr-note-t {
  margin: 0 0 6px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-3);
}
.fr-note-b > *:last-child { margin-bottom: 0; }
.fr-note-note { border-left-color: var(--blue); }
.fr-note-info { border-left-color: var(--teal); }
.fr-note-tip { border-left-color: var(--lime); }
.fr-note-warning { border-left-color: var(--orange); }

/* ::: video — a YouTube player.
   16/9 with `aspect-ratio` rather than a padding hack, because the
   height then also adapts when the box is narrower, and there is one
   property instead of two that have to stay in step. */
.fr-video {
  margin: 0 0 1em;
  max-width: 100%;
  
}
.fr-video-frame {
  position: relative;
  aspect-ratio: 16 / 9;
  width: var(--video-w, 640px);
  max-width: 100%;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--bg-raise);
}
.fr-video-frame > iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}
.fr-video-cap {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 10px;
  align-items: baseline;
  margin-top: 6px;
  font-size: 12.5px;
  color: var(--text-3);
}
/* Always offered, never the only way out: if the frame is blocked by a
   browser extension, by the reader's network, or by a YouTube outage,
   the caption is what they are left with and it has to be enough to find
   the video. */
/* When the embed plays only part of the video. The player is told to
   stop from the page — an embed url carries no end the player honours —
   so without this the video halts for no visible reason and reads as a
   broken embed rather than as a clip somebody chose. */
.fr-video-range {
  font-family: var(--font-mono);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-4);
  border: 1px dashed var(--line-strong);
  border-radius: var(--r-pill);
  padding: 1px 8px;
  white-space: nowrap;
}
.fr-video-link {
  margin-left: auto;
  font-size: 11.5px;
  color: var(--text-3);
  text-decoration: none;
  white-space: nowrap;
}
.fr-video-link:hover { color: var(--lime-hi); text-decoration: underline; }
.fr-video-bad {
  margin: 0 0 0.9em;
  padding: 10px 12px;
  border: 1px dashed var(--orange);
  border-radius: var(--r-sm);
  color: var(--orange);
}

/* --- video: size and chapters -------------------------------------

   The player is capped rather than filling the column. A 16:9 frame at
   the width of a guide page is taller than the fold, which made a
   `::: video` read as "the whole page is this video" — the width is the
   one thing about an embed that is not content. `--video-w` is the single
   knob: a `size=480` in the markdown sets it inline on the figure, and
   the keywords below set it in css. */

.fr-video--small { --video-w: 360px; }
.fr-video--medium { --video-w: 640px; }
.fr-video--large { --video-w: 100%; }

/* The chapter row. Each chip is a real link to the watch page at that
   second, so with scripting off — or before it has loaded — they are
   still the useful thing they were meant to be. js/video-player.js only
   takes over the click. */
.fr-video-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 8px;
}
.fr-video-chip {
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
  padding: 3px 9px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--bg-raise);
  font-size: 12px;
  line-height: 1.5;
  color: var(--text-2);
  text-decoration: none;
}
.fr-video-chip:hover {
  border-color: var(--lime-hi);
  color: var(--lime-hi);
  text-decoration: none;
}
.fr-video-chip:focus-visible {
  outline: 2px solid var(--lime-hi);
  outline-offset: 2px;
}
.fr-video-chip-t {
  font-variant-numeric: tabular-nums;
  color: var(--lime-hi);
  font-weight: 600;
}
.fr-video-chip-l { color: var(--text-3); }


/* ::: spoiler — a native <details>, closed.
   The marker is drawn rather than left to the platform so the row reads
   the same in every browser, and the summary gets the site's button
   hover instead of a bare system triangle. */
.fr-spoiler {
  margin: 0 0 0.9em;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface-2);
  overflow: hidden;
}
.fr-spoiler > .fr-spoiler-t {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 9px 12px;
  font-family: var(--font-mono);
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: 0.04em;
  color: var(--text-2);
  cursor: pointer;
  list-style: none;
  user-select: none;
}
.fr-spoiler > .fr-spoiler-t::-webkit-details-marker { display: none; }
/* A caret on the left, where a disclosure triangle is expected and where
   the click target is, and a word on the right that says which state it
   is in. The caret alone was the whole affordance: a 9px triangle in the
   faintest grey at the far edge of a full-width row, so "Full changelog"
   looked like a heading and nothing said there was text under it. */
.fr-spoiler > .fr-spoiler-t::before {
  content: '▸';
  flex: none;
  font-size: 11px;
  color: var(--text-3);
  transition: transform var(--t-fast);
}
.fr-spoiler[open] > .fr-spoiler-t::before { transform: rotate(90deg); }
.fr-spoiler > .fr-spoiler-t::after {
  content: 'Show';
  margin-left: auto;
  padding: 2px 9px;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  background: var(--surface-3);
  color: var(--text-3);
  font-size: 10px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.fr-spoiler[open] > .fr-spoiler-t::after {
  content: 'Hide';
  color: var(--lime-hi);
  border-color: color-mix(in srgb, var(--lime) 35%, transparent);
  background: var(--lime-dim);
}
.fr-spoiler > .fr-spoiler-t:hover {
  background: var(--surface-3);
  color: var(--lime-hi);
}
.fr-spoiler-b {
  padding: 0 12px 12px;
  border-top: 1px solid var(--line);
  padding-top: 10px;
}
.fr-spoiler-b > *:last-child { margin-bottom: 0; }

/* ::: steps — a walkthrough embedded from the recordings page.
   The player (js/steps-player.js) fills this in and styles its inside
   with the shared d-steps* classes from css/docs.css, which the thread
   page loads. Only the container is ours. */
.fr-steps {
  margin: 0 0 1em;
  padding: 0;
  border: 0;
  background: none;
}
.fr-steps-missing {
  margin: 0 0 0.9em;
  padding: 10px 12px;
  border: 1px dashed var(--orange);
  border-radius: var(--r-sm);
  color: var(--orange);
  font-size: 13px;
}

/* The changelog blocks in the same `fr-` prefix the renderer emits,
   for the same reason as everything above: the editor preview shows
   them on every page that has the editor, and only ui.css is loaded
   by all of them. */
/* --- ::: added / changed / fixed / removed / security, in a post ----
   The same blocks as the updates list, rendered inside a forum post. A
   support answer that says "here is what changed in 2.1" should look
   like a changelog to the person reading it, and it should use the same
   colours as everywhere else — a reader who has learned that lime
   means a fix does not have to learn it twice. */
.fr-entry {
  --entry-colour: var(--text-3);
  --entry-tint: var(--surface-2);
  position: relative;
  margin: 0 0 12px;
  padding: 14px 16px 14px 18px;
  border: 1px solid color-mix(in srgb, var(--entry-colour) 34%, transparent);
  border-left: 4px solid var(--entry-colour);
  border-radius: var(--r-sm);
  background: var(--entry-tint);
  box-shadow: 0 1px 0 rgb(0 0 0 / 0.12);
}
.fr-entry-t {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  margin: 0 0 8px;
}
.fr-entry-badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  line-height: 1.5;
  /* The page background as the text colour: inverted, so the badge is
     the brightest thing in the block. In the light theme the kind
     colours are dark and the background is near white, so the same
     relationship holds. */
  color: var(--bg);
  background: var(--entry-colour);
  border-radius: var(--r-pill);
  padding: 3px 11px;
  white-space: nowrap;
}
.fr-entry-badge::before {
  content: var(--entry-glyph, '');
  font-size: 12px;
  line-height: 1;
  opacity: 0.85;
}
.fr-entry-title { font-size: 14.5px; font-weight: 700; line-height: 1.3; color: var(--text); }
.fr-entry-b { font-size: 14.5px; line-height: 1.68; color: var(--text-2); }
.fr-entry-b > *:last-child { margin-bottom: 0; }
.fr-entry-added { --entry-colour: var(--teal); --entry-tint: var(--teal-dim); --entry-glyph: '+'; }
.fr-entry-changed { --entry-colour: var(--blue); --entry-tint: var(--blue-dim); --entry-glyph: '~'; }
.fr-entry-fixed { --entry-colour: var(--lime); --entry-tint: var(--lime-dim); --entry-glyph: '✓'; }
.fr-entry-removed { --entry-colour: var(--orange); --entry-tint: var(--orange-dim); --entry-glyph: '✕'; }
.fr-entry-security { --entry-colour: var(--red); --entry-tint: var(--red-dim); --entry-glyph: '!'; }
