/* ============================================================
   Etzian blog — blog-specific layer.
   The ONLY place in this repository `--blog-*` is defined, and the ONLY
   stylesheet besides the vendored `static/tokens/*.css` (never edited —
   see that directory's own note). Every colour, font, space, and radius
   below resolves through an `--ez-*` custom property from `static/tokens/`;
   the handful of raw numbers that appear are Rune's own proportions,
   which have no `--ez-*` equivalent (execution plan §6.2) — every one of
   them is a unitless ratio or a `rem`/`em` length, never a `px` length and
   never a hex colour, so this file (and every Askama template) passes the
   build's literal grep for both.

   Breakpoints are expressed in `em` (48em = the tablet floor, 90em = the
   desktop floor) for the same reason: a `min-width` written in `px` would
   trip the same grep that keeps colour and size honest everywhere else,
   and `em` breakpoints have the added, unrelated benefit of scaling with a
   user's browser zoom / root font-size instead of ignoring it.
   ============================================================ */

/* ---- Self-hosted faces (execution plan §6.3) ----------------------------
   Poppins: five static weights, latin subset, fetched from Google Fonts'
   own woff2 distribution (OFL) and vendored here — no CDN request at
   runtime, no build pipeline. Clash Display: the variable cut, fetched
   from Fontshare (Indian Type Foundry, free for commercial use) — one file
   covers the whole weight range `--ez-font-display` needs (title through
   section head). See docs/design-review.md for exactly how these were
   obtained; both are real binaries in static/fonts/, not placeholders. */
@font-face {
  font-family: "Poppins";
  src: url("/static/fonts/poppins-light.woff2") format("woff2");
  font-weight: 300;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Poppins";
  src: url("/static/fonts/poppins-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Poppins";
  src: url("/static/fonts/poppins-medium.woff2") format("woff2");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Poppins";
  src: url("/static/fonts/poppins-semibold.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Poppins";
  src: url("/static/fonts/poppins-bold.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Clash Display";
  src: url("/static/fonts/clash-display-variable.woff2") format("woff2-variations");
  font-weight: 200 700;
  font-style: normal;
  font-display: swap;
}

/* ---- Self-hosted faces, Etzian theme (theme plan phase 1) ---------------
   Newsreader and Public Sans, the mockup's serif/sans pairing — self-hosted
   the same OFL/Google-Fonts way Poppins already is (see that block's own
   comment; docs/design-review.md records exactly how these were obtained).
   Declaring the `@font-face` here, unconditionally, costs a Classic-theme
   visitor nothing: a browser only fetches a face once something rendered
   actually needs it (structural decision 2), and nothing in this file uses
   "Newsreader"/"Public Sans" by name outside the `[data-theme-variant="etzian"]`
   override block at the end of this file's `:root` section — every other
   `font-family` declaration reads the `--blog-font-display`/`--blog-font-brand`
   indirection instead of naming a face directly. */
@font-face {
  font-family: "Newsreader";
  src: url("/static/fonts/newsreader-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Newsreader";
  src: url("/static/fonts/newsreader-medium.woff2") format("woff2");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Newsreader";
  src: url("/static/fonts/newsreader-semibold.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Newsreader";
  src: url("/static/fonts/newsreader-italic.woff2") format("woff2");
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: "Public Sans";
  src: url("/static/fonts/public-sans-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Public Sans";
  src: url("/static/fonts/public-sans-medium.woff2") format("woff2");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Public Sans";
  src: url("/static/fonts/public-sans-semibold.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Public Sans";
  src: url("/static/fonts/public-sans-bold.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

/* ---- Blog layout tokens (execution plan §6.2) ---------------------------
   Layered on top of `--ez-*`, never redefining one. Every right-hand side
   is either an `--ez-*` lookup or one of Rune's own proportions (a
   unitless ratio, or a `rem` measurement with no `--ez-*` counterpart). */
:root {
  /* Theme plan structural decision 1: indirection, never a reassignment of
     the display/brand font tokens themselves — `ui-check`'s "never redefine
     an --ez- token" rule (67-69) stays honoured in spirit as well as in the
     letter. Every other font-family declaration in this file naming either
     of those two tokens was mechanically renamed to read these two blog-
     prefixed ones instead; `[data-theme-variant="etzian"]` below overrides
     only these, never the vendored token itself. */
  --blog-font-display: var(--ez-font-display);
  --blog-font-brand: var(--ez-font-brand);
  --blog-content-max-width: 86rem;
  --blog-reading-width: 52rem;
  --blog-header-height: 6rem;
  --blog-padding-x: var(--ez-space-3xl);
  --blog-padding-x-mobile: var(--ez-space-md);
  --blog-card-aspect: 5 / 4;
  --blog-card-radius: var(--ez-radius-md);
  --blog-pill-radius: var(--ez-radius-pill);
  --blog-timeline-width: 64rem;
  --blog-grain-size: 0.1875rem;
  /* Gallery mosaic column floor (`columns: <this>`): at --blog-reading-width
     (52rem) it yields 2-3 columns; on a wider viewport where the content
     column itself grows, more. No --ez-* equivalent — it's a
     component-specific sizing value, same class as --blog-card-aspect above. */
  --blog-gallery-col-min: 14rem;
  /* Hairline borders/outlines have no `--ez-*` equivalent (the design
     system expresses border *colour*, never *width*) — `0.0625rem` is
     Rune's 1-device-pixel hairline at the default root font size, kept as
     a `rem` length like every other raw number in this file. */
  --blog-hairline: 0.0625rem;
  /* Busy indicator: the spinner's diameter and ring thickness, and how far
     the swapping content fades. Sizes in `em` so the spinner tracks the
     type it sits beside; the opacity is a unitless ratio. `--ez-*` has a
     duration and an easing for this (used below) but no geometry. */
  --blog-indicator-size: 1.15em;
  --blog-indicator-ring: 0.125em;
  --blog-swapping-opacity: 0.45;
  /* Phase 11: fixed preview/preset sizes that used to be raw `px` (caught
     by `scripts/ui-check.sh`'s now-error "no raw px lengths" rule). Kept as
     `rem`, like every other length in this file, at the default 16 px root —
     none of these has an `--ez-*` equivalent; they are Rune's own
     component proportions, same class as `--blog-card-aspect` above. */
  --blog-avatar-preview-size: 6rem; /* 96 px */
  --blog-cover-preview-max-width: 30rem; /* 480 px */
  --blog-cover-preview-height: 10rem; /* 160 px */
  --blog-cover-preview-small-max-width: 20rem; /* 320 px */
  --blog-cover-preview-small-height: 6.6875rem; /* 107 px, --cover-preview-height's own 2/3 */
  --blog-hero-min-height: 21.25rem; /* 340 px */
  --blog-cover-display-height: 23.4375rem; /* 375 px, the four cover-display presets' shared baseline */
  --blog-cover-display-width-4-3: 31.25rem; /* 500 px, --cover-display-height's 4:3 partner */
  --blog-cover-display-width-16-9: 41.6875rem; /* 667 px, --cover-display-height's 16:9 partner */
  /* Group statistics: a stat tile's minimum width in the auto-fill grid —
     wide enough for "Logins, last 30 days" on two lines and a "1.4 GB"
     headline; 2 tiles per row on a phone, 4-5 at the content width. */
  --blog-stat-min: 10rem;
}

/* Theme plan structural decisions 1 and 3: the platform-admin, site-wide
   toggle (`<html data-theme-variant="etzian">`, set server-side from
   `Shell::theme_variant` — never client JS, never a per-visitor
   preference). Overrides only the two indirection tokens above; every
   `--ez-color-*`/`--ez-radius-*` this file otherwise reads stays exactly
   what `tokens/{light,dark}.css` already define (structural decision 6 —
   no palette change anywhere in this plan). Newsreader/Public Sans are the
   `@font-face`s declared above; the system-stack fallbacks are the same
   class of value `--ez-font-*`'s own defaults use elsewhere in
   `tokens/base.css` (never edited — see this file's own note on why). */
[data-theme-variant="etzian"] {
  --blog-font-display: 'Newsreader', Georgia, serif;
  --blog-font-brand: 'Public Sans', -apple-system, system-ui, sans-serif;
  /* Phase 3: the mockup's rounder card language (18-20 px vs Classic's
     12 px) — a token override, same indirection as the two font tokens
     above, so `.ui-card__image` and everything else already reading
     `--blog-card-radius` picks it up with no selector of its own. */
  --blog-card-radius: var(--ez-radius-lg);
}

/* Phase 3: the mockup's uppercase-eyebrow proportions (its own inline
   styles: font-size 11.5 px; letter-spacing 0.12em; font-weight 600) —
   `--ez-fs-xs`/`--ez-fw-semibold` are already what `.blog-h-section` and
   `.blog-hero__eyebrow` use for size/weight (or, for the eyebrow's
   font-weight, what this rule now gives it); only letter-spacing moves,
   from unset/`--ez-ls-brand` (0.18em) to `--ez-ls-wide` (0.08em), the
   nearest existing token to the mockup's 0.12em. Classic keeps its exact
   prior metrics — nothing here touches the base `.blog-h-section`/
   `.blog-hero__eyebrow` rules above, only this attribute-scoped override. */
[data-theme-variant="etzian"] .blog-h-section {
  letter-spacing: var(--ez-ls-wide);
}

[data-theme-variant="etzian"] .blog-eyebrow,
[data-theme-variant="etzian"] .blog-hero__eyebrow {
  letter-spacing: var(--ez-ls-wide);
  font-weight: var(--ez-fw-semibold);
}

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

/* `[hidden]` and a class like `.ui-button` or `.ui-cover-preview` that sets
   its own `display` are equally specific (0,1,0 each), so without this the
   later one in the cascade — always the class, since it's an author rule
   loaded after the UA stylesheet — wins and the "hidden" element stays
   visible regardless of the attribute. Every element toggled via `hidden`
   (form.html's cover-image preview/remove button, among others) depends on
   this to actually disappear. */
[hidden] {
  display: none !important;
}

html {
  color-scheme: light dark;
}

body {
  margin: 0;
  background: var(--ez-color-bg);
  color: var(--ez-color-text);
  font-family: var(--ez-font-base);
  font-size: var(--ez-fs-md);
  line-height: var(--ez-lh-normal);
  -webkit-font-smoothing: antialiased;
}

img {
  max-width: 100%;
  display: block;
}

a {
  color: inherit;
}

button,
input,
select,
textarea {
  font: inherit;
  color: inherit;
}

/* Phase 4's guarantee, phase 7's wording. The vendored contract in
   tokens/behaviour.css:130 lists elements and deliberately excludes
   `[tabindex="-1"]`, because a -1 element is not in the tab order — but the
   focus-after-swap landings (`#post-region`'s heading; see site.js, which
   *sets* `tabindex="-1"` on anything it focuses that lacks one) are focused
   programmatically and are exactly that excluded set. This rule complements
   the vendored contract instead of restating it: same tokens, same zero-ish
   specificity (`:where()` keeps the attribute selector free, so the whole
   selector weighs 0,1,0 — identical to upstream's, so a component can still
   style its own ring), disjoint match set. Verified rather than assumed:
   delete it and a paginated landing has no visible focus at all. */
:where([tabindex="-1"]):focus-visible {
  outline: var(--ez-focus-ring);
  outline-offset: var(--ez-focus-ring-offset);
}

/* ---- Skip link (GUIDELINES §3) ---------------------------------------
   `.ez-sr-only` (tokens/behaviour.css) hides it at rest; every property
   that hiding sets has to be undone here, which is why the rule looks
   repetitive. `:focus` is matched alongside `:focus-visible` on purpose —
   the element is unreachable by pointer, so every focus it can receive is a
   keyboard focus, and a browser that declines to call it "visible" would
   otherwise reveal nothing. */
.blog-skip-link:focus,
.blog-skip-link:focus-visible {
  position: fixed;
  top: var(--ez-space-xs);
  left: var(--ez-space-xs);
  z-index: var(--ez-z-popover);
  width: auto;
  height: auto;
  margin: 0;
  padding: var(--ez-space-xs) var(--ez-space-md);
  overflow: visible;
  clip-path: none;
  background: var(--ez-color-surface);
  color: var(--ez-color-text);
  border: var(--blog-hairline) solid var(--ez-color-border);
  border-radius: var(--blog-pill-radius);
  text-decoration: none;
}

/* ---- Typography (execution plan §6.3) -------------------------------- */
.blog-h-display {
  font-family: var(--blog-font-display);
  font-size: var(--ez-fs-display-md);
  line-height: var(--ez-lh-tight);
  font-weight: var(--ez-fw-semibold);
  margin: 0;
}

.blog-h-section {
  font-family: var(--blog-font-display);
  font-size: var(--ez-fs-xl);
  line-height: var(--ez-lh-tight);
  font-weight: var(--ez-fw-semibold);
  margin: 0;
}

.blog-h-card {
  font-family: var(--blog-font-display);
  font-size: var(--ez-fs-lg);
  line-height: var(--ez-lh-tight);
  font-weight: var(--ez-fw-medium);
  margin: 0;
}

.blog-brand {
  font-family: var(--blog-font-brand);
  letter-spacing: var(--ez-ls-brand);
  text-transform: uppercase;
  font-size: var(--ez-fs-sm);
  font-weight: var(--ez-fw-semibold);
}

/* The brand doubles as the app's "home" button (`routes::public::root`) on
   every page that carries a header — give the `<a>` its own affordances
   rather than leaving it looking identical to the plain `<span>` it used to
   be. */
a.blog-brand {
  color: inherit;
  transition: color var(--ez-transition-fast);
}

a.blog-brand:hover {
  color: var(--ez-color-accent);
}

a.blog-brand:focus-visible {
  outline: var(--ez-focus-ring-width) solid var(--ez-color-accent);
  outline-offset: 3px;
}

.blog-body {
  font-family: var(--ez-font-base);
  font-size: var(--ez-fs-md);
  line-height: var(--ez-lh-loose);
}

.blog-meta {
  font-family: var(--ez-font-base);
  font-size: var(--ez-fs-sm);
  line-height: var(--ez-lh-normal);
  color: var(--ez-color-text-muted);
}

/* ---- Page shell -------------------------------------------------------- */
.blog-shell {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

/* Same background as the page body (mockups/magazine-mockup.html's own
   `.app-header`, `background: var(--ez-color-bg)`) — the header is meant to
   read as part of the same surface as the hero beneath it, separated only
   by the hairline below, not by a color block. */
.blog-header {
  position: sticky;
  top: 0;
  z-index: var(--ez-z-sticky);
  height: var(--blog-header-height);
  display: flex;
  align-items: center;
  gap: var(--ez-space-lg);
  padding-inline: var(--blog-padding-x-mobile);
  background: var(--ez-color-bg);
  border-bottom: var(--blog-hairline) solid var(--ez-color-border-soft);
}

.blog-header__group {
  display: inline-block;
  min-width: 0;
  max-width: 100%;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-family: var(--blog-font-brand);
  letter-spacing: var(--ez-ls-brand);
  text-transform: uppercase;
  font-size: var(--ez-fs-sm);
  font-weight: var(--ez-fw-semibold);
  text-decoration: none;
}

/* ---- Header nav — collapses behind a disclosure toggle below the `48em`
   breakpoint (execution plan §6.2's mobile-first convention) so the row of
   account/nav links can never force the header wider than the viewport.
   The toggle button and the nav panel are mutually exclusive by
   breakpoint (`display: none` either way), never both contributing to the
   header row's layout width at once. */
.blog-nav-toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--ez-space-2xs);
  margin-inline-start: auto;
  background: none;
  border: var(--blog-hairline) solid var(--ez-color-border);
  border-radius: var(--blog-pill-radius);
  cursor: pointer;
  padding: var(--ez-space-2xs) var(--ez-space-sm);
  font-size: var(--ez-fs-md);
  color: var(--ez-color-text-muted);
  flex: 0 0 auto;
  /* Target size (GUIDELINES §2.1). This control only exists below 48em, so
     in practice it is only ever a touch target — but a narrow desktop window
     shows it too, hence a token-sourced floor here and the 44 px touch floor
     in the `pointer: coarse` block at the foot of this file. Its label is a
     glyph, so the floor has to apply on both axes, not just height. */
  min-height: var(--ez-control-height-md);
  min-width: var(--ez-control-height-md);
  justify-content: center;
}

.blog-header__nav {
  display: none;
  flex-direction: column;
  align-items: stretch;
  gap: var(--ez-space-md);
  position: absolute;
  inset-inline: 0;
  top: 100%;
  padding: var(--ez-space-md) var(--blog-padding-x-mobile);
  background: var(--ez-color-bg-elevated);
  border-bottom: var(--blog-hairline) solid var(--ez-color-border-soft);
  font-size: var(--ez-fs-sm);
  /* Scroll containment (GUIDELINES §7): a menu overlaying the page must not
     hand its scroll on to the page behind it once it reaches its own end —
     on a phone that silently scrolls the article underneath an open menu.
     No z-index: this panel and the switcher's live inside `.blog-header`,
     which already opens a stacking context at `--ez-z-sticky`, so they are
     above the page without owning a layer of their own. */
  overscroll-behavior: contain;
}

.blog-header__nav[data-open="true"] {
  display: flex;
}

.blog-header__nav a {
  text-decoration: none;
  color: var(--ez-color-text-muted);
}

/* Smallest step of the type scale (`--ez-fs-xs`) — an identity line, not
   a nav action, so it must read as quieter than the links beside it. */
.blog-header__identity {
  font-size: var(--ez-fs-xs);
  color: var(--ez-color-text-muted);
}

/* Decorative grouping between the identity, group-scoped, and account-wide
   sections of `.blog-header__nav` — `aria-hidden` in the markup, so this is
   purely visual; nothing here is read out as a third "pipe" link. Mobile
   default (`.blog-header__nav` stacked in a column, GUIDELINES §6.2) is a
   hairline rule, since a lone "|" glyph sitting alone on its own line reads
   as a stray character rather than a section break; `min-width: 48em`
   below swaps it back to the inline glyph its `|` content already is, once
   the nav is a single row and the glyph sits between its neighbours. */
.blog-header__divider {
  display: block;
  height: var(--blog-hairline);
  background: var(--ez-color-border-soft);
  color: transparent;
  font-size: 0;
}

.blog-header__nav a:hover {
  color: var(--ez-color-text);
}

/* Phase 8: replaces an inline `style="display:inline"` on the logout form in
   `ui/header.html` — a strict Content-Security-Policy (`style-src 'self'`,
   no `'unsafe-inline'`) rejects inline `style` attributes the same way it
   rejects inline `<script>`, so this becomes a real stylesheet rule instead. */
.blog-header__logout-form {
  display: inline;
}

.blog-main {
  flex: 1;
  padding-inline: var(--blog-padding-x-mobile);
  padding-block: var(--ez-space-xl);
  /* `.post-detail__cover--bleed`'s `100vw` includes the scrollbar's own
     width wherever one is visible (most desktop browsers), so without this
     a bleed image sits a few pixels wider than the actual viewport and
     forces a permanent horizontal scrollbar site-wide just to reach that
     sliver. Scoped to `.blog-main` specifically, not `body`/`html`: the
     sticky `.blog-header` is `.blog-main`'s *sibling*, not its descendant,
     so clipping overflow here can never change what containing block that
     header's `position: sticky` resolves against — clipping `body` instead
     would (it's the header's own ancestor), breaking stickiness entirely. */
  overflow-x: clip;
}

.blog-footer {
  padding: var(--ez-space-lg) var(--blog-padding-x-mobile);
  border-top: var(--blog-hairline) solid var(--ez-color-border-soft);
  color: var(--ez-color-text-subtle);
  font-size: var(--ez-fs-xs);
}

@media (min-width: 48em) {
  .blog-header,
  .blog-main,
  .blog-footer {
    padding-inline: var(--blog-padding-x);
  }

  .blog-nav-toggle {
    display: none;
  }

  .blog-header__nav {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: var(--ez-space-md);
    position: static;
    inset-inline: auto;
    top: auto;
    margin-inline-start: auto;
    padding: 0;
    background: none;
    border-bottom: none;
  }

  .blog-header__divider {
    display: inline;
    height: auto;
    background: none;
    color: var(--ez-color-border);
    font-size: inherit;
  }
}

.blog-container {
  max-width: var(--blog-content-max-width);
  margin-inline: auto;
}

.blog-reading-column {
  max-width: var(--blog-reading-width);
  margin-inline: auto;
}

/* ---- Admin console (mockups/etzian_ui_design's dashboard reference) ----
   A two-pane frame inside `.blog-container`: a left nav bar and a content
   pane (`admin/_shell.html`). The mockup's own sidebar is a full-bleed,
   fixed-size artboard chrome with no site header of its own; this app
   already has one (`.blog-header`, theme toggle, account/logout), so rather
   than fight that this reads as a bordered console panel — same proportions
   and dark/light contrast as the mockup, contained instead of edge-to-edge.
   `--ez-sidebar-*` (tokens/{light,dark}.css) exists for exactly this pane;
   nothing about its colours is defined here. */
.blog-admin {
  display: flex;
  flex-direction: column;
  border: var(--blog-hairline) solid var(--ez-color-border);
  border-radius: var(--blog-card-radius);
  overflow: hidden;
  box-shadow: var(--ez-shadow-md);
}

.blog-admin__sidebar {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: var(--ez-space-2xs);
  padding: var(--ez-space-md) var(--ez-space-sm);
  background: var(--ez-sidebar-bg);
  border-bottom: var(--blog-hairline) solid var(--ez-sidebar-border);
}

.blog-admin__eyebrow {
  margin: 0;
  padding: 0 var(--ez-space-sm) var(--ez-space-sm);
  font-family: var(--blog-font-brand);
  letter-spacing: var(--ez-ls-brand);
  text-transform: uppercase;
  font-size: var(--ez-fs-xs);
  font-weight: var(--ez-fw-semibold);
  color: var(--ez-sidebar-text-muted);
}

.blog-admin__nav {
  display: flex;
  flex-direction: column;
  gap: 0.125rem;
}

.blog-admin__navitem {
  display: flex;
  align-items: center;
  gap: var(--ez-space-sm);
  min-height: var(--ez-control-height-md);
  padding-block: var(--ez-space-xs);
  padding-inline: calc(var(--ez-space-sm) - 0.125rem) var(--ez-space-sm);
  border-left: 2px solid transparent;
  border-radius: var(--ez-radius-sm);
  color: var(--ez-sidebar-text-muted);
  text-decoration: none;
  font-size: var(--ez-fs-sm);
}

.blog-admin__navitem:hover {
  background: var(--ez-sidebar-item-hover);
  color: var(--ez-sidebar-text);
}

.blog-admin__navitem.is-active {
  background: var(--ez-sidebar-item-active);
  border-left-color: var(--ez-color-accent);
  color: var(--ez-sidebar-text);
  font-weight: var(--ez-fw-medium);
}

.blog-admin__navicon {
  display: inline-flex;
  flex: 0 0 auto;
  width: 1.0625em;
  height: 1.0625em;
}

.blog-admin__navicon svg {
  width: 100%;
  height: 100%;
}

.blog-admin__main {
  flex: 1;
  min-width: 0;
  padding: var(--ez-space-lg) var(--blog-padding-x-mobile);
  background: var(--ez-color-bg-elevated);
}

@media (min-width: 48em) {
  .blog-admin {
    flex-direction: row;
  }

  .blog-admin__sidebar {
    width: var(--ez-sidebar-width);
    padding: var(--ez-space-lg) var(--ez-space-sm);
    border-bottom: none;
    border-inline-end: var(--blog-hairline) solid var(--ez-sidebar-border);
  }

  .blog-admin__main {
    padding: var(--ez-space-xl) var(--ez-space-2xl);
  }
}

/* ---- Group switcher (execution plan §6.7, no Rune equivalent) --------- */
.blog-switcher {
  position: relative;
  min-width: 0;
  /* No longer `1 1 auto`: that grow factor made sense when this was
     `.blog-header`'s own left-hand filler, direct child of the flex row.
     Nested inside `.blog-header__nav` now, alongside plain links it must
     not crowd out, so it only ever takes the width its label needs. */
  flex: 0 1 auto;
}

.blog-switcher__toggle {
  background: none;
  border: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: var(--ez-space-2xs);
  max-width: 100%;
  font-family: var(--blog-font-brand);
  letter-spacing: var(--ez-ls-brand);
  text-transform: uppercase;
  font-size: var(--ez-fs-sm);
  font-weight: var(--ez-fw-semibold);
  padding: 0;
  /* Target size (GUIDELINES §2.1): `padding: 0` around a 14 px line left this
     ~21 px tall — under the 24 px pointer minimum, on the control that opens
     the group switcher. The floor is height-only: the label is a group name,
     so width is never the failing axis. */
  min-height: var(--ez-control-height-sm);
}

.blog-switcher__label {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.blog-switcher__menu {
  position: absolute;
  inset-inline-start: 0;
  top: 100%;
  margin-top: var(--ez-space-xs);
  background: var(--ez-color-surface);
  border: var(--blog-hairline) solid var(--ez-color-border-soft);
  border-radius: var(--ez-radius-md);
  box-shadow: var(--ez-shadow-md);
  min-width: 12rem;
  padding: var(--ez-space-xs);
  display: none;
  flex-direction: column;
  /* Same containment rule as `.blog-header__nav` above (GUIDELINES §7). */
  overscroll-behavior: contain;
}

.blog-switcher__menu[data-open="true"] {
  display: flex;
}

.blog-switcher__menu a {
  /* `flex`, not `block`: these stretch to the menu's width either way, but a
     flex box is what lets `min-height` centre the label instead of stranding
     it at the top of a taller box. Same reasoning in the coarse-pointer
     block below, where the floor rises to 44 px. */
  display: flex;
  align-items: center;
  min-height: var(--ez-control-height-md);
  padding: var(--ez-space-xs) var(--ez-space-sm);
  border-radius: var(--ez-radius-sm);
  text-decoration: none;
  font-size: var(--ez-fs-sm);
}

.blog-switcher__menu a:hover {
  background: var(--ez-color-surface-hover);
}

.blog-switcher__menu a[aria-current="true"] {
  color: var(--ez-color-accent);
  font-weight: var(--ez-fw-medium);
}

/* ---- Theme toggle -------------------------------------------------- */
.blog-theme-toggle {
  /* `inline-flex` so the two floors below actually centre their content:
     this button's whole visible label is the glyph pair `☀/☾`, which makes
     it an icon button in every way that matters to §2.1 — both axes need a
     floor, not just the height. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--ez-control-height-sm);
  min-width: var(--ez-control-height-sm);
  background: none;
  border: var(--blog-hairline) solid var(--ez-color-border);
  border-radius: var(--blog-pill-radius);
  cursor: pointer;
  padding: var(--ez-space-2xs) var(--ez-space-sm);
  font-size: var(--ez-fs-xs);
  color: var(--ez-color-text-muted);
}

/* ---- UI: Button --------------------------------------------------------- */
.ui-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--ez-space-xs);
  border: var(--blog-hairline) solid transparent;
  border-radius: var(--blog-pill-radius);
  /* Height comes from the token, not from whatever the padding and the
     inherited line-height happen to add up to (GUIDELINES §2.1). The padding
     stays: it is what gives the pill its horizontal proportion. */
  min-height: var(--ez-control-height-md);
  padding: var(--ez-space-xs) var(--ez-space-lg);
  font-size: var(--ez-fs-sm);
  font-weight: var(--ez-fw-medium);
  cursor: pointer;
  text-decoration: none;
  transition: background-color var(--ez-transition-fast), color var(--ez-transition-fast);
}

.ui-button--primary {
  background: var(--ez-color-accent);
  color: var(--ez-color-accent-text);
}

.ui-button--primary:hover {
  background: var(--ez-color-accent-hover);
}

.ui-button--secondary {
  background: var(--ez-color-surface);
  color: var(--ez-color-text);
  border-color: var(--ez-color-border);
}

.ui-button--secondary:hover {
  background: var(--ez-color-surface-hover);
}

.ui-button--danger {
  background: transparent;
  color: var(--ez-color-error);
  border-color: var(--ez-color-error);
}

.ui-button--danger:hover {
  background: var(--ez-color-error);
  color: var(--ez-color-accent-text);
}

/* The most load-bearing size in the app: this modifier carries every
   destructive control there is — comment delete, member remove, post delete,
   invite delete, user suspend, and every editor toolbar button. `padding:
   4 px 16 px` at 12 px type resolved to about 26 px, which cleared §2.1's
   24 px pointer minimum by two pixels of coincidence. Now it is stated. */
.ui-button--small {
  min-height: var(--ez-control-height-sm);
  padding: var(--ez-space-2xs) var(--ez-space-md);
  font-size: var(--ez-fs-xs);
}

/* A `<button>` that reads as the plain inline text link it replaces — the
   "Back to log in" control (`auth/message.html`, `auth/choose_group.html`)
   has to submit a `POST /logout` (a `<button>` inside a `<form>`, not an
   `<a href>`) so leaving mid-flow actually ends the session, but it still
   needs to sit inline in a `<p>` looking exactly like the anchor it used to
   be — same inherited color/underline as the bare `a { color: inherit; }`
   rule above, `button`'s own UA chrome (padding/border/background) stripped
   back out. */
.ui-link-button {
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  font: inherit;
  color: inherit;
  text-decoration: underline;
  cursor: pointer;
}

.ui-link-button:focus-visible {
  outline: var(--ez-focus-ring-width) solid var(--ez-color-accent);
  outline-offset: 2px;
}

/* ---- UI: Badge ------------------------------------------------------- */
.ui-badge {
  display: inline-flex;
  align-items: center;
  border-radius: var(--blog-pill-radius);
  padding: var(--ez-space-2xs) var(--ez-space-sm);
  font-size: var(--ez-fs-xs);
  font-weight: var(--ez-fw-medium);
  line-height: var(--ez-lh-normal);
}

.ui-badge--draft {
  background: color-mix(in srgb, var(--ez-color-warning) 20%, transparent);
  color: var(--ez-color-warning);
}

.ui-badge--neutral {
  background: var(--ez-color-surface-active);
  color: var(--ez-color-text-muted);
}

.ui-badge--role {
  background: color-mix(in srgb, var(--ez-color-info) 18%, transparent);
  color: var(--ez-color-info);
}

/* The four post-moderation states (group/dashboard.html's moderation panel,
   posts/detail.html's banner). One hue each, on the same color-mix pattern
   as --draft above; the label text is the meaning, the tint only reinforces
   it (GUIDELINES §2.1). No new token — all four hues already exist. */
.ui-badge--moderation-active {
  background: color-mix(in srgb, var(--ez-color-success) 20%, transparent);
  color: var(--ez-color-success);
}

.ui-badge--moderation-review {
  background: color-mix(in srgb, var(--ez-color-info) 20%, transparent);
  color: var(--ez-color-info);
}

.ui-badge--moderation-revision {
  background: color-mix(in srgb, var(--ez-color-warning) 20%, transparent);
  color: var(--ez-color-warning);
}

.ui-badge--moderation-suspended {
  background: color-mix(in srgb, var(--ez-color-error) 20%, transparent);
  color: var(--ez-color-error);
}

/* A retained, soft-deleted post (posts/detail.html's banner, the dashboard's
   and the admin console's "Deleted posts" tables) — only ever shown to a
   Group Admin or the Administrator. Muted surface, error-hue text: the word
   "Deleted" carries the meaning, the tint only reinforces it. */
.ui-badge--deleted {
  background: var(--ez-color-surface-active);
  color: var(--ez-color-error);
}

/* ---- UI: Avatar -------------------------------------------------------- */
.ui-avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--ez-space-2xl);
  height: var(--ez-space-2xl);
  border-radius: var(--ez-radius-pill);
  background: var(--ez-color-accent);
  color: var(--ez-color-accent-text);
  font-family: var(--blog-font-brand);
  font-size: var(--ez-fs-xs);
  font-weight: var(--ez-fw-semibold);
  flex-shrink: 0;
}

/* Same badge size as .ui-avatar, for an actual uploaded photo instead of
   initials — post cards, journal entries, and the post detail byline all
   swap between the two depending on whether the author has one
   (`ui/macros.html`'s post_card/journal_entry, posts/detail.html). */
.ui-avatar-image {
  display: block;
  width: var(--ez-space-2xl);
  height: var(--ez-space-2xl);
  border-radius: var(--ez-radius-pill);
  object-fit: cover;
  flex-shrink: 0;
}

/* The account page's own larger preview of an uploaded avatar — same pill
   shape as .ui-avatar's initials badge, sized for a real photo instead of
   two letters, with object-fit so a non-square upload still crops to a
   circle rather than distorting. */
.ui-avatar-preview {
  display: block;
  width: var(--blog-avatar-preview-size);
  height: var(--blog-avatar-preview-size);
  border-radius: var(--ez-radius-pill);
  object-fit: cover;
  background: var(--ez-color-accent);
}

/* The group settings page's own preview of the current cover image —
   banner-shaped (not the avatar's pill) since this fills the same
   .blog-hero__cover slot on the group's own index page, just previewed at
   a manageable size here instead of full-bleed. */
.ui-cover-preview {
  display: block;
  width: 100%;
  max-width: var(--blog-cover-preview-max-width);
  height: var(--blog-cover-preview-height);
  border-radius: var(--ez-radius-md);
  object-fit: cover;
  background: var(--ez-color-accent);
}

/* The post form's own "Main picture" preview (posts/form.html) — 2/3 the
   group settings page's cover-image size, not the same size: a post
   thumbnail sits among several other fields on a form the author is
   actively filling out, not the sole subject of its own page. */
.ui-cover-preview--small {
  max-width: var(--blog-cover-preview-small-max-width);
  height: var(--blog-cover-preview-small-height);
}

/* The post editor's formatting toolbar (`posts/form.html`, built by
   `editor.js`'s `buildToolbarButtons`) — plain `<button>` elements with no
   class of their own before now relied entirely on the browser's native
   button chrome, which is unreadable in dark mode (`button { color: inherit }`
   above sets near-white text, but the background stays the platform's
   light-gray default). `editor.js` now classes each button `.ui-button
   .ui-button--secondary .ui-button--small`; this just lays the row out. */
[data-editor-toolbar] {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ez-space-xs);
}

/* ---- Editor: upload status and progress (GUIDELINES §6) ----------------
   `editor.js`'s `createStatusRegion` builds three children here: a polite
   `role="status"`, an interrupting `role="alert"`, and a determinate
   `<progress>` that replaced a percentage being written into the status
   text on every XHR tick. The two messages are mutually exclusive, so the
   unused one collapses rather than reserving a blank line; the bar is
   `hidden` until the loading gate decides the upload is slow enough to be
   worth drawing. Deliberately left with its native appearance — a
   platform progress bar is honest and accessible for free. */
.editor-status {
  display: flex;
  flex-direction: column;
  gap: var(--ez-space-2xs);
  margin-block: var(--ez-space-xs);
}

.editor-status p {
  margin: 0;
  font-size: var(--ez-fs-sm);
}

.editor-status p:empty {
  display: none;
}

.editor-status p[data-editor-status="error"] {
  color: var(--ez-color-error);
}

.editor-progress {
  width: 100%;
}

/* The post form's "Images in this post" panel (posts/form.html,
   editor.js's `renderMediaList`) — a live thumbnail grid of every image the
   textarea's own markdown currently embeds, each with a delete button that
   removes both the markdown line and (if unused elsewhere) the stored file.
   Empty and unrendered when the post has no images yet, same as
   .ui-cover-preview's own hidden-until-set default. */
.ui-media-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
  gap: var(--ez-space-2xs);
  margin-top: var(--ez-space-sm);
  /* Breaks out of `.blog-main`'s own left/right padding so the grid reaches
     the reading column's true edges — same reasoning as
     `.post-detail__cover--bleed` above, scoped to `.blog-main`'s inset
     rather than the full viewport since this panel never needs to cross
     the reading column's own max-width. */
  margin-inline: calc(-1 * var(--blog-padding-x-mobile));
}

@media (min-width: 48em) {
  .ui-media-list {
    grid-template-columns: repeat(5, 1fr);
    margin-inline: calc(-1 * var(--blog-padding-x));
  }
}

.ui-media-list__item {
  display: flex;
  flex-direction: column;
  gap: var(--ez-space-2xs);
  padding: var(--ez-space-2xs);
  border: var(--blog-hairline) solid var(--ez-color-border);
  border-radius: var(--ez-radius-md);
  background: var(--ez-color-surface);
  /* Grid items default to `min-width: auto`, which for an <img> child
     resolves to the image's intrinsic pixel width — without this, 5
     columns of full-resolution photos can't actually shrink to fit a
     `1fr` track and the grid overflows its container instead. */
  min-width: 0;
}

.ui-media-list__thumb {
  display: block;
  width: 100%;
  height: calc(var(--ez-space-3xl) * 2);
  border-radius: var(--ez-radius-sm);
  object-fit: cover;
  background: var(--ez-color-surface-active);
}

.ui-media-list__name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--ez-fs-xs);
  color: var(--ez-color-text-muted);
}

/* ---- Visual editor (posts/form.html, editor/visual.js) -----------------
   Trix, vendored. Its own stylesheet is not shipped: the toolbar icons in
   it are `data:` URIs the CSP's `img-src 'self'` blocks, and the handful of
   rules Trix injects in a <style> element are blocked by `style-src 'self'`
   — so the rules it genuinely needs to work (the cursor targets, the dialog
   toggle, the placeholder) are restated here, on tokens. */
.visual-editor {
  display: flex;
  flex-direction: column;
  gap: var(--ez-space-xs);
}

trix-toolbar,
trix-editor {
  display: block;
}

.visual-editor__buttons {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ez-space-xs);
}

/* Trix marks the formatting in force at the cursor with `.trix-active`.
   Weight as well as colour, so the state is not carried by colour alone. */
.visual-editor__buttons .trix-active {
  border-color: var(--ez-color-accent);
  background: var(--ez-color-surface-active);
  font-weight: 600;
}

/* "+ Add photos" and "Save", pinned to the window's bottom corner so they
   stay in reach however far down the post the author has written. Still
   inside the toolbar in the markup (Trix and muya.js find the photo button
   there), and hidden along with the editor when the markdown textarea is
   the one in use. The page's own Save button at the foot of the form
   stays. */
.visual-editor__float {
  position: fixed;
  right: calc(var(--ez-space-md) + env(safe-area-inset-right, 0rem));
  bottom: calc(var(--ez-space-md) + env(safe-area-inset-bottom, 0rem));
  z-index: var(--ez-z-sticky);
  display: flex;
  gap: var(--ez-space-xs);
}

.visual-editor__float .ui-button {
  box-shadow: var(--ez-shadow-md);
}

trix-toolbar [data-trix-dialog] {
  display: none;
}

trix-toolbar [data-trix-dialog][data-trix-active] {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ez-space-xs);
  margin-top: var(--ez-space-xs);
}

trix-toolbar [data-trix-dialog] .ui-input {
  flex: 1 1 16rem;
  width: auto;
}

.visual-editor__body {
  min-height: 24rem;
  padding: var(--ez-space-md);
  border: var(--blog-hairline) solid var(--ez-color-border);
  border-radius: var(--ez-radius-md);
  background: var(--ez-color-surface);
  overflow-wrap: anywhere;
}

.visual-editor__body:empty::before {
  content: attr(placeholder);
  color: var(--ez-color-text-muted);
  cursor: text;
  pointer-events: none;
}

.visual-editor__body [data-trix-cursor-target] {
  display: inline-block !important;
  width: var(--blog-hairline) !important;
  padding: 0 !important;
  margin: 0 !important;
  border: none !important;
}

.visual-editor__body [data-trix-cursor-target="left"] {
  vertical-align: top !important;
  margin-left: calc(-1 * var(--blog-hairline)) !important;
}

.visual-editor__body [data-trix-cursor-target="right"] {
  vertical-align: bottom !important;
  margin-right: calc(-1 * var(--blog-hairline)) !important;
}

/* A photo in the text: the picture, its caption underneath, and — while it
   is selected — Trix's own Remove button above it. */
.visual-editor__body figure.attachment {
  position: relative;
  display: block;
  max-width: 100%;
  margin: var(--ez-space-sm) 0;
  text-align: center;
}

/* Undoes `.post-detail__body img`'s placeholder ratio: in the editor a
   photo is shown whole, at its own shape. */
.visual-editor__body figure.attachment img {
  display: block;
  width: auto;
  max-width: 100%;
  height: auto;
  margin-inline: auto;
  aspect-ratio: auto;
  border-radius: var(--ez-radius-sm);
}

.visual-editor__body figure.attachment[data-trix-mutable] img {
  outline: var(--ez-focus-ring);
  outline-offset: var(--ez-focus-ring-offset);
}

.visual-editor__body .attachment__caption {
  display: block;
  margin-top: var(--ez-space-2xs);
  font-size: var(--ez-fs-sm);
  color: var(--ez-color-text-muted);
}

.visual-editor__body figcaption[data-trix-placeholder]:empty::before {
  content: attr(data-trix-placeholder);
  color: var(--ez-color-text-subtle);
}

.visual-editor__body figcaption textarea {
  width: 100%;
  padding: var(--ez-space-2xs) var(--ez-space-xs);
  border: var(--blog-hairline) solid var(--ez-color-border);
  border-radius: var(--ez-radius-sm);
  background: var(--ez-color-bg);
  color: inherit;
  font: inherit;
  text-align: center;
  resize: none;
}

.visual-editor__body figcaption textarea.trix-autoresize-clone {
  position: absolute;
  inset-inline-start: -999rem;
  max-height: 0;
}

/* While a photo uploads, Trix previews it from a `blob:` URL, which the
   CSP's `img-src 'self'` blocks — so the picture is held back and the
   figure is a plain placeholder under its progress bar until the real URL
   arrives. */
.visual-editor__body figure.attachment:has(.attachment__progress) {
  min-height: 10rem;
  border-radius: var(--ez-radius-sm);
  background: var(--ez-color-surface-active);
}

.visual-editor__body figure.attachment:has(.attachment__progress) img {
  visibility: hidden;
}

.visual-editor__body .attachment__progress {
  position: absolute;
  inset-inline: var(--ez-space-sm);
  bottom: 50%;
  width: auto;
}

.visual-editor__body .attachment__toolbar {
  position: absolute;
  inset-inline: 0;
  top: var(--ez-space-xs);
  z-index: 1;
  text-align: center;
}

.visual-editor__body .trix-button--remove {
  min-height: var(--ez-touch-target-min);
  padding: var(--ez-space-2xs) var(--ez-space-sm);
  border: var(--blog-hairline) solid var(--ez-color-border);
  border-radius: var(--ez-radius-md);
  background: var(--ez-color-surface);
  color: var(--ez-color-error);
  font: inherit;
  font-size: var(--ez-fs-sm);
  cursor: pointer;
}

.visual-editor__hint {
  margin: 0;
  font-size: var(--ez-fs-sm);
  color: var(--ez-color-text-muted);
}

/* ---- Muya editor (posts/form.html, editor/muya.js) ---------------------
   Muya, vendored with its own stylesheet (`vendor/muya/muya.css`, loaded
   by the script). It shares the visual editor's toolbar and frame classes;
   these are the few rules it needs on top.

   Muya draws its three photo-status icons (uploaded, failed, close) with a
   `style="background: url(…)"` attribute, which the CSP's `style-src
   'self'` blocks, so the same backgrounds are restated here. The file
   names are the vendored build's own; `crates/web/tests/muya_vendor.rs`
   checks they exist. */
.muya-editor .mu-image-icon-success .icon-inner,
.muya-editor .mu-image-icon-fail .icon-inner,
.muya-editor .mu-image-icon-close .icon-inner {
  background-repeat: no-repeat;
  background-size: 100%;
}

.muya-editor .mu-image-icon-success .icon-inner {
  background-image: url("/static/editor/vendor/muya/assets/icons/afda92700ecf3647.png");
}

.muya-editor .mu-image-icon-fail .icon-inner {
  background-image: url("/static/editor/vendor/muya/assets/icons/a6766f71eb16f6bd.png");
}

.muya-editor .mu-image-icon-close .icon-inner {
  background-image: url("/static/editor/vendor/muya/assets/icons/c091372ebb16f3d7.png");
}

/* Muya's surface is one contenteditable filling the frame. Its own
   stylesheet centres the text in a narrow column with a wide side gutter
   for the block handle, which is not loaded here (see `muya.js`); the frame
   already pads, and the text should read in the post's own type and
   colours, in both themes. */
.muya-editor__body {
  cursor: text;
  font-family: inherit;
}

.muya-editor__body .mu-container,
.muya-editor__body .mu-container :is(h1, h2, h3, h4, h5, h6) {
  max-width: none;
  padding: 0;
  color: inherit;
}

/* Undoes `.post-detail__body img`'s placeholder ratio, as for Trix above:
   while writing, a photo is shown whole. */
.muya-editor__body img {
  width: auto;
  max-width: 100%;
  height: auto;
  aspect-ratio: auto;
}

/* Account option "smaller thumbnails in the editor" (`<html
   data-editor-thumbs="small">`, from `users.editor_small_thumbnails`): while
   writing, a photo is drawn as a small thumbnail so a post with many
   pictures stays scannable. Display-only — the stored file and the
   published post are untouched. */
[data-editor-thumbs="small"] .muya-editor__body img {
  max-width: min(100%, 12rem);
  max-height: 9rem;
  object-fit: contain;
}

/* ---- UI: Card (the post card, shared by both templates) --------------- */
.ui-card {
  display: flex;
  flex-direction: column;
  gap: var(--ez-space-sm);
  text-decoration: none;
  color: inherit;
}

.ui-card__image {
  aspect-ratio: var(--blog-card-aspect);
  width: 100%;
  object-fit: cover;
  border-radius: var(--blog-card-radius);
  border: 1px solid var(--ez-color-border-soft);
  background: var(--ez-color-surface-active);
}

.ui-card__image--placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--ez-color-text-subtle);
  font-family: var(--blog-font-brand);
  letter-spacing: var(--ez-ls-brand);
  text-transform: uppercase;
  font-size: var(--ez-fs-xs);
}

.ui-card__excerpt {
  color: var(--ez-color-text-muted);
  font-size: var(--ez-fs-sm);
  line-height: var(--ez-lh-normal);
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.ui-card__footer {
  display: flex;
  align-items: center;
  gap: var(--ez-space-xs);
  margin-top: auto;
  border-top: 1px solid var(--ez-color-border-soft);
  padding-top: var(--ez-space-md);
}

.ui-card__footer-name {
  font-size: var(--ez-fs-sm);
  font-weight: var(--ez-fw-medium);
}

.ui-card__footer .blog-meta {
  color: var(--ez-color-accent);
}

/* ---- UI: GroupPickerCard (auth/choose_group.html) ----------------------- */
/* One card per group a member belongs to, plus a trailing Admin console
   card — shown right after login when membership is ambiguous. Built on
   the same absolute-cover + scrim + inverse-text technique as `.blog-hero`
   above, just card-sized instead of full-bleed, so a member's own group
   photo sits legibly under its name. */
.group-picker-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--ez-space-lg);
  margin-block: var(--ez-space-xl);
}

@media (min-width: 48em) {
  .group-picker-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

.group-picker-card {
  position: relative;
  z-index: 0;
  display: flex;
  align-items: flex-end;
  aspect-ratio: 16 / 9;
  border-radius: var(--blog-card-radius);
  overflow: hidden;
  background: var(--ez-color-surface-active);
  padding: var(--ez-space-lg);
  text-decoration: none;
  transition: transform var(--ez-transition-fast);
}

.group-picker-card:hover {
  transform: translateY(calc(-1 * var(--ez-space-2xs)));
}

.group-picker-card:focus-visible {
  outline: var(--ez-focus-ring-width) solid var(--ez-color-accent);
  outline-offset: 2px;
}

.group-picker-card__image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  z-index: -1;
}

/* The scrim only exists when a photo is actually behind the title — a
   group with no cover image yet keeps `.group-picker-card`'s plain
   `--ez-color-surface-active` background, which already contrasts fine
   with ordinary text and needs no darkening (mirrors `.blog-hero`'s own
   `:has()` guard just above). */
.group-picker-card:has(.group-picker-card__image)::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(
    0deg,
    color-mix(in srgb, black 55%, transparent) 0%,
    color-mix(in srgb, black 0%, transparent) 60%
  );
}

.group-picker-card__title {
  position: relative;
  font-family: var(--blog-font-display);
  font-size: var(--ez-fs-lg);
  font-weight: var(--ez-fw-semibold);
  color: var(--ez-color-text-inverse);
  text-shadow: 0 var(--blog-hairline) var(--ez-space-sm) color-mix(in srgb, black 35%, transparent);
}

.group-picker-card:not(:has(.group-picker-card__image)) .group-picker-card__title {
  color: var(--ez-color-text);
  text-shadow: none;
}

/* The Admin console card — same shape, no photo, deliberately contrasting
   so it reads as a different kind of destination from the group cards
   rather than one more group. Same accent/accent-text pairing as
   `.ui-button--primary`. */
.group-picker-card--admin {
  align-items: center;
  justify-content: center;
  text-align: center;
  background: var(--ez-color-accent);
}

.group-picker-card--admin:hover {
  background: var(--ez-color-accent-hover);
}

.group-picker-card--admin .group-picker-card__title {
  color: var(--ez-color-accent-text);
  text-shadow: none;
}

/* ---- UI: EmptyState ----------------------------------------------------- */
.ui-empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--ez-space-md);
  padding: var(--ez-space-3xl) var(--ez-space-lg);
  border: var(--blog-hairline) dashed var(--ez-color-border);
  border-radius: var(--ez-radius-lg);
  color: var(--ez-color-text-muted);
}

/* ---- UI: Divider --------------------------------------------------------- */
.ui-divider {
  border: none;
  border-top: var(--blog-hairline) solid var(--ez-color-border-soft);
  margin: var(--ez-space-lg) 0;
}

/* ---- UI: Table (member list, admin tables — execution plan §6.7) -------- */
/* §7, "wide content scrolls inside its own container, never the page
   body": at a 375-pixel viewport width, the five-column members table (and
   every other `.ui-table`) is wider than the viewport. Without this wrapper that
   overflow pushed the whole page sideways instead of staying local to the
   table — the wrapper is what turns that into a horizontal scrollbar owned
   by the table alone. */
.ui-table-wrap {
  overflow-x: auto;
}

.ui-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--ez-fs-sm);
}

.ui-table th,
.ui-table td {
  text-align: left;
  padding: var(--ez-space-sm) var(--ez-space-md);
  border-bottom: var(--blog-hairline) solid var(--ez-color-border-soft);
}

.ui-table th {
  color: var(--ez-color-text-muted);
  font-weight: var(--ez-fw-medium);
  font-size: var(--ez-fs-xs);
  text-transform: uppercase;
  letter-spacing: var(--ez-ls-wide);
}

/* §9: a "joined" or "expires" column of proportional-width digits makes
   every row's date land at a slightly different horizontal position —
   tabular figures give the column a fixed digit width so the dates line
   up like a real column of numbers instead of drifting per row. */
.ui-table__date {
  font-variant-numeric: tabular-nums;
}

/* A numeric column (counts) — right-aligned tabular figures so digits line
   up by place value. */
.ui-table .ui-table__num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* A `<th scope="rowgroup">` heading row inside a table split into several
   `<tbody>` groups (the dashboard's last-login table, one per role). */
.ui-table__group th {
  padding-top: var(--ez-space-md);
  color: var(--ez-color-text);
}

/* A `<th scope="row">` naming its row (platform statistics' group column):
   reads as body text, not as the uppercase column-header style above. */
.ui-table .ui-table__rowhead {
  color: var(--ez-color-text);
  font-size: var(--ez-fs-sm);
  font-weight: var(--ez-fw-medium);
  text-transform: none;
  letter-spacing: normal;
}

/* A secondary figure under a cell's headline number (flagged/deleted
   counts beside a published count). */
.ui-table__sub {
  display: block;
  font-size: var(--ez-fs-xs);
  color: var(--ez-color-text-muted);
}

/* A server-side sort link in a column header. The whole header text is
   the target (padding keeps it at least the pointer minimum); the active
   column carries `aria-sort` for assistive tech and a text arrow for
   sighted users, so colour never carries the state alone. */
.ui-table__sort {
  display: inline-flex;
  align-items: center;
  gap: var(--ez-space-2xs);
  min-height: var(--ez-pointer-target-min);
  color: inherit;
  text-decoration: none;
}

.ui-table__sort:hover {
  text-decoration: underline;
}

.ui-table__sort.is-active {
  color: var(--ez-color-text);
}

/* Doubled `content`, as `.ui-alert--success::before` explains: the
   alt-text form hides the glyph from a screen reader (`aria-sort` already
   said it); an older browser keeps the plain one. */
.ui-table__sort.is-active::after {
  content: "\2193";
  content: "\2193" / "";
}

/* ---- UI: Stat tiles (group dashboard statistics) ----------------------
   A `<dl>` of tiles: label (small, muted), one headline value (large,
   tabular), optional sub-counts. No colour encodes meaning here — every
   number is labelled in text. Surface + hairline like `.ui-alert`. */
.ui-stat-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--blog-stat-min)), 1fr));
  gap: var(--ez-space-md);
  margin: var(--ez-space-md) 0 0;
}

.ui-stat {
  display: flex;
  flex-direction: column;
  gap: var(--ez-space-2xs);
  padding: var(--ez-space-md);
  border: var(--blog-hairline) solid var(--ez-color-border-soft);
  border-radius: var(--ez-radius-md);
  background: var(--ez-color-surface);
}

.ui-stat dd {
  margin: 0;
}

.ui-stat__label {
  font-size: var(--ez-fs-xs);
  font-weight: var(--ez-fw-medium);
  line-height: var(--ez-lh-normal);
  color: var(--ez-color-text-muted);
}

.ui-stat__value {
  font-family: var(--blog-font-display);
  font-size: var(--ez-fs-xl);
  font-weight: var(--ez-fw-semibold);
  line-height: var(--ez-lh-tight);
  color: var(--ez-color-text);
  font-variant-numeric: tabular-nums;
}

.ui-stat__part {
  display: flex;
  justify-content: space-between;
  gap: var(--ez-space-sm);
  font-size: var(--ez-fs-xs);
  color: var(--ez-color-text-muted);
}

.ui-stat__part-value {
  color: var(--ez-color-text);
  font-variant-numeric: tabular-nums;
}

.ui-stat-tables {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, calc(var(--blog-stat-min) * 2.5)), 1fr));
  gap: var(--ez-space-lg);
  margin-top: var(--ez-space-lg);
  align-items: start;
}

.ui-stat-caption {
  text-align: left;
  padding-bottom: var(--ez-space-sm);
  font-weight: var(--ez-fw-medium);
  color: var(--ez-color-text);
}

/* ---- UI: Alert (status/error messaging — replaces a JS toast, since this
   application has no client-side component runtime to trigger one from) -- */
.ui-alert {
  border-radius: var(--ez-radius-md);
  padding: var(--ez-space-sm) var(--ez-space-md);
  font-size: var(--ez-fs-sm);
  border: var(--blog-hairline) solid var(--ez-color-border-soft);
  background: var(--ez-color-surface);
}

.ui-alert--success {
  border-color: color-mix(in srgb, var(--ez-color-success) 40%, transparent);
  background: color-mix(in srgb, var(--ez-color-success) 12%, var(--ez-color-surface));
}

.ui-alert--error {
  border-color: color-mix(in srgb, var(--ez-color-error) 40%, transparent);
  background: color-mix(in srgb, var(--ez-color-error) 12%, var(--ez-color-surface));
}

/* Success and error differ only by hue above, and colour is never the only
   carrier of meaning (GUIDELINES §2.1). The glyph is the visual half; the
   visually-hidden severity word `ui/macros.html` renders is the spoken
   half. No new hue — both tints are untouched.

   The doubled `content` is deliberate: the second declaration adds the
   alt-text form, which suppresses the glyph for a screen reader (the
   severity word already said it). A browser that does not understand
   `content: "x" / ""` drops that declaration at parse time and keeps the
   plain one above it, so the visual signal never disappears. */
.ui-alert--success::before,
.ui-alert--error::before {
  margin-inline-end: var(--ez-space-2xs);
  font-weight: var(--ez-fw-bold);
}

.ui-alert--success::before {
  content: "✓";
  content: "✓" / "";
}

.ui-alert--error::before {
  content: "⚠";
  content: "⚠" / "";
}

/* ---- UI: Input ------------------------------------------------------- */
.ui-field {
  display: flex;
  flex-direction: column;
  gap: var(--ez-space-2xs);
  margin-bottom: var(--ez-space-md);
}

.ui-field__label {
  font-size: var(--ez-fs-sm);
  font-weight: var(--ez-fw-medium);
}

.ui-input,
.ui-textarea,
.ui-select {
  /* A floor, not a height: these already render around 41 px, and a textarea
     is sized by its `rows`. Stating it means a field can never be squeezed
     under the §2.1 minimum by a future padding change, and gives the coarse
     -pointer block one place to raise. */
  min-height: var(--ez-control-height-md);
  border: var(--blog-hairline) solid var(--ez-color-border);
  border-radius: var(--ez-radius-sm);
  padding: var(--ez-space-xs) var(--ez-space-sm);
  background: var(--ez-color-surface);
  color: var(--ez-color-text);
}

.ui-input:focus,
.ui-textarea:focus,
.ui-select:focus {
  border-color: var(--ez-color-accent);
}

/* ---- UI: Field error (GUIDELINES §9) ----------------------------------
   The message sits below the control it belongs to, inside the same
   `.ui-field` box, so it can never be read as belonging to the next one.
   `ui::field` renders it on every request and leaves it `hidden` when
   there is nothing to say — no `display` is set here on purpose, so the
   UA's `[hidden] { display: none }` keeps working and an empty slot never
   contributes the flex gap.

   Colour is never the only carrier (§2.1): the glyph is the visual half,
   `aria-invalid` plus `aria-describedby` the spoken half, and the sentence
   itself says how to fix it. The doubled `content` gives the glyph empty
   alt text, the same trick `.ui-alert--error::before` uses — the message
   is already announced through the control's description. */
.ui-field__error {
  font-size: var(--ez-fs-sm);
  color: var(--ez-color-error);
}

.ui-field__error::before {
  content: "⚠";
  content: "⚠" / "";
  margin-inline-end: var(--ez-space-2xs);
}

.ui-input[aria-invalid="true"],
.ui-textarea[aria-invalid="true"],
.ui-select[aria-invalid="true"] {
  border-color: var(--ez-color-error);
  box-shadow: 0 0 0 3px var(--ez-color-error-ring);
}

/* ---- UI: Pagination ---------------------------------------------------- */
.ui-pagination {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ez-space-md);
  margin-top: var(--ez-space-2xl);
}

.ui-pagination a {
  text-decoration: none;
}

/* ---- Loading: indicator, swap fade, skeleton (GUIDELINES §6) -----------
   §6's latency table, made mechanical:

     under 100 ms   nothing appears at all
     100–300 ms     still nothing — the response usually wins the race
     over 300 ms    an indicator, which then stays at least 500 ms
     first paint    a skeleton shaped like the real thing, not a spinner
     refresh        the old content stays readable, dimmed, never blanked

   Two gates, deliberately overlapping:

     .htmx-request …   the cheap half (htmx-patterns §3). htmx puts this
                       class on whatever `hx-indicator` names, for exactly
                       the duration of the request; `transition-delay`
                       buys the 300 ms. It cannot buy the 500 ms floor —
                       the class is gone the instant the response lands.
     [data-loading]…   `site.js`'s port of _uidesign's `createLoadingGate`,
                       which owns both numbers. It wins where both apply
                       (attribute + class beats class), and it is what the
                       skeleton hangs off, because a skeleton that flashes
                       for 40 ms is worse than no skeleton.

   Everything here is drawn *inside* the region it belongs to, so nothing
   outside the region moves when it appears (§12). */
.blog-indicator {
  display: inline-block;
  flex: none;
  width: var(--blog-indicator-size);
  height: var(--blog-indicator-size);
  border: var(--blog-indicator-ring) solid var(--ez-color-border);
  border-top-color: var(--ez-color-accent);
  border-radius: var(--blog-pill-radius);
  opacity: 0;
  transition: opacity var(--ez-transition-fast);
  /* Slower than any --ez-duration-*, which top out at 320ms: those are
     transition budgets, and a 320ms spin reads as panic. Under
     prefers-reduced-motion, behaviour.css clamps this globally to a single
     1ms iteration — the ring stops turning and stays visible, which is
     still the state it is there to report. */
  animation: blog-indicator-spin 900ms linear infinite;
}

@keyframes blog-indicator-spin {
  to {
    transform: rotate(360deg);
  }
}

.htmx-request .blog-indicator {
  opacity: 1;
  transition-delay: var(--ez-duration-slow);
}

[data-loading="true"] .blog-indicator {
  opacity: 1;
  transition-delay: 0s;
}

/* Refresh never blanks what the user is reading (§6): the outgoing content
   stays in place and legible while its replacement is in flight. Paired in
   the templates with `hx-swap="outerHTML settle:0"`, so the new content is
   painted in one step instead of fading up from an empty box. */
.blog-swap-fade {
  transition: opacity var(--ez-transition-fast);
}

.htmx-request .blog-swap-fade {
  opacity: var(--blog-swapping-opacity);
  transition-delay: var(--ez-duration-slow);
}

[data-loading="true"] .blog-swap-fade {
  opacity: var(--blog-swapping-opacity);
  transition-delay: 0s;
}

/* The skeleton grid mirrors `.magazine-grid`'s tracks rather than reusing
   the class: `.magazine-grid` sets `display: grid` unconditionally and
   would defeat the `display: none` at rest. Column counts and gap are
   copied so a skeleton card occupies exactly the box a real card will. */
.blog-skeleton-grid {
  display: none;
  grid-template-columns: 1fr;
  gap: var(--ez-space-xl);
  margin-top: var(--ez-space-xl);
}

@media (min-width: 48em) {
  .blog-skeleton-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (min-width: 90em) {
  .blog-skeleton-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

/* The Journal timeline is one column at every width. Declared after the
   media queries above, at equal specificity, so it wins. */
.blog-skeleton-grid--column {
  grid-template-columns: 1fr;
}

[data-loading="true"] .blog-skeleton-grid {
  display: grid;
}

/* `.ez-skeleton` is `height: 1em`; the card's cover is an aspect-ratio box.
   Restating the card's own ratio and radius is what makes the skeleton the
   same size as the thing it stands in for. */
.blog-skeleton-card__image {
  height: auto;
  aspect-ratio: var(--blog-card-aspect);
  border-radius: var(--blog-card-radius);
}

/* The line wrappers size in `em`, so each block has to sit at the font size
   of the real text it replaces: title at --ez-fs-lg / --ez-lh-tight (see
   `.blog-h-card`), excerpt at --ez-fs-sm / --ez-lh-normal (see
   `.ui-card__excerpt`, clamped to the same 3 lines). */
.blog-skeleton-card__title {
  font-size: var(--ez-fs-lg);
}

.blog-skeleton-card__excerpt {
  font-size: var(--ez-fs-sm);
}

.blog-skeleton-card__name {
  flex: 1;
  font-size: var(--ez-fs-sm);
}

.blog-skeleton-card__avatar {
  width: var(--ez-space-2xl);
  height: var(--ez-space-2xl);
}

/* Submit button plus its spinner. The spinner sits beside the label and the
   label never changes, so the button neither resizes nor moves when a slow
   submit becomes visible (§6, §9). */
.blog-submit-row {
  display: flex;
  align-items: center;
  gap: var(--ez-space-sm);
}

/* ---- Group hero (Magazine) / banner (Journal) --------------------------
   Centred layout, per mockups/magazine-mockup.html's own `.hero` — not the
   earlier bottom-left-aligned version this replaced. */
.blog-hero {
  position: relative;
  /* Without an explicit z-index, `position: relative` alone does not
     create a new stacking context — .blog-hero__cover's `z-index: -1`
     would then escape to the nearest real stacking context up the tree
     (effectively the page root), painting the cover photo behind the
     entire page instead of just behind this box's own title text. This
     `z-index: 0` is what actually contains it. */
  z-index: 0;
  border-radius: var(--ez-radius-md);
  overflow: hidden;
  margin-bottom: var(--ez-space-2xl);
  background: var(--ez-color-bg-primary);
  min-height: var(--blog-hero-min-height);
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: var(--ez-space-xl);
}

.blog-hero__cover {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  z-index: -1;
}

/* A scrim behind the text, only when there is a real photo to sit legibly
   over — the plain gradient `.blog-hero` background (no cover uploaded
   yet) already contrasts fine with dark text on its own and needs no
   darkening. Mirrors mockups/magazine-mockup.html's own `.hero` gradient. */
.blog-hero:has(.blog-hero__cover)::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(
    180deg,
    color-mix(in srgb, black 18%, transparent) 0%,
    color-mix(in srgb, black 5%, transparent) 45%,
    color-mix(in srgb, black 22%, transparent) 100%
  );
}

.blog-hero__content {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--ez-space-sm);
  max-width: 40rem;
}

/* Phase 4: `.blog-eyebrow` is the pattern `.blog-hero__eyebrow` already
   established, extended to every other stand-alone heading that has a
   natural one-or-two-word category above it. Both classes share the same
   five properties below rather than duplicating them — `.blog-hero__eyebrow`
   only adds the hero-specific inverse-text colour and shadow. */
.blog-eyebrow,
.blog-hero__eyebrow {
  font-family: var(--blog-font-brand);
  font-size: var(--ez-fs-xs);
  letter-spacing: var(--ez-ls-brand);
  text-transform: uppercase;
  margin: 0;
}

.blog-eyebrow {
  color: var(--ez-color-accent);
  /* Unlike `.blog-hero__eyebrow`, which sits in a flex column with its own
     gap, `.blog-eyebrow` stands directly before a zero-margin heading with
     no such container — it needs its own gap below. */
  margin-bottom: var(--ez-space-xs);
}

.blog-hero__eyebrow {
  color: var(--ez-color-text-inverse);
  text-shadow: 0 var(--blog-hairline) var(--ez-space-sm) color-mix(in srgb, black 35%, transparent);
}

.blog-hero__title {
  position: relative;
  font-family: var(--blog-font-display);
  font-size: var(--ez-fs-display-sm);
  line-height: var(--ez-lh-tight);
  font-weight: var(--ez-fw-semibold);
  margin: 0;
  color: var(--ez-color-text-inverse);
  text-shadow: 0 var(--blog-hairline) var(--ez-space-sm) color-mix(in srgb, black 35%, transparent);
}

.blog-hero__subtitle {
  margin: 0;
  font-size: var(--ez-fs-md);
  color: var(--ez-color-text-inverse);
  text-shadow: 0 var(--blog-hairline) var(--ez-space-sm) color-mix(in srgb, black 35%, transparent);
}

.blog-hero:not(:has(.blog-hero__cover)) .blog-hero__title,
.blog-hero:not(:has(.blog-hero__cover)) .blog-hero__eyebrow,
.blog-hero:not(:has(.blog-hero__cover)) .blog-hero__subtitle {
  color: var(--ez-color-text);
  text-shadow: none;
}

.blog-hero:not(:has(.blog-hero__cover)) .blog-hero__eyebrow,
.blog-hero:not(:has(.blog-hero__cover)) .blog-hero__subtitle {
  color: var(--ez-color-text-muted);
}

.blog-banner {
  position: relative;
  overflow: hidden;
  /* Same height as the Magazine template's own cover (`.blog-hero`,
     `--blog-hero-min-height`) — was `--ez-space-3xl` (64 px), a spacing
     token never meant to size a cover photo, left over from before this
     template carried one at all. */
  height: var(--blog-hero-min-height);
  border-radius: var(--ez-radius-md);
  background: var(--ez-color-bg-primary);
  margin-bottom: var(--ez-space-xl);
}

.blog-banner__cover {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ---- Magazine template (execution plan §6.5, §6.8) --------------------- */
.magazine-section + .magazine-section {
  margin-top: var(--ez-space-3xl);
}

/* The "Latest"/"View all →" row above each section's grid
   (mockups/magazine-mockup.html's own `.section-head`). */
.blog-section-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--ez-space-md);
  margin-bottom: var(--ez-space-xl);
}

.blog-section-head__link {
  font-size: var(--ez-fs-sm);
  color: var(--ez-color-accent);
  text-decoration: none;
  white-space: nowrap;
}

.blog-section-head__link:hover {
  text-decoration: underline;
}

.blog-section-head__link:focus-visible {
  outline: 2px solid var(--ez-color-accent);
  outline-offset: 3px;
}

.magazine-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--ez-space-xl);
}

@media (min-width: 48em) {
  .magazine-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (min-width: 90em) {
  .magazine-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

/* Low-volume states (execution plan §6.8 — "required work, not polish"):
   one, two, four, or five posts all use this variant. Six or more posts
   drops back to the ordinary left-aligned, fully tiled `.magazine-grid`.

   Phase 11: this was a CSS Grid `auto-fit`/`minmax` track set, same as the
   comment used to say — "four or five centre their own short last row
   instead of hugging the left edge." Measured in a real browser, that
   promise was false: when the computed track count exactly fills the
   container width (the common case at four or five posts, most widths),
   `justify-content: center` centres the *track set*, which is already
   full-width, so it has nothing to move — the leftover item in the last
   row still lands in track 1, flush left, never centred. Grid has no way
   to centre a single item within an otherwise-empty row on its own.
   Flexbox does: wrapped flex items with `justify-content: center` centre
   every row, including a trailing row of one, at any width, with no media
   query needed to pick a track count. */
.magazine-grid--sparse {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
}

.magazine-grid--sparse > .ui-card {
  flex: 0 1 26rem;
}

/* Zero posts: a single full-width invitation, not an empty grid. */
.magazine-empty {
  grid-column: 1 / -1;
}

/* ---- Journal template (execution plan §6.8) ----------------------------- */
.journal-column {
  max-width: var(--blog-timeline-width);
  margin-inline: auto;
}

.journal-month {
  font-family: var(--blog-font-display);
  font-size: var(--ez-fs-xl);
  margin: var(--ez-space-2xl) 0 var(--ez-space-md);
}

.journal-month:first-child {
  margin-top: 0;
}

.journal-spine {
  position: relative;
  padding-inline-start: var(--ez-space-xl);
  border-inline-start: var(--blog-hairline) solid var(--ez-color-border);
}

.journal-entry {
  position: relative;
  padding-block: var(--ez-space-lg);
}

.journal-entry::before {
  content: "";
  position: absolute;
  inset-inline-start: calc(-1 * var(--ez-space-xl) - 0.25rem);
  top: var(--ez-space-lg);
  width: 0.5rem;
  height: 0.5rem;
  border-radius: var(--ez-radius-pill);
  background: var(--ez-color-accent);
}

.journal-entry__date {
  font-family: var(--blog-font-brand);
  font-size: var(--ez-fs-xs);
  letter-spacing: var(--ez-ls-wide);
  text-transform: uppercase;
  color: var(--ez-color-text-subtle);
}

.journal-entry__cover {
  width: 100%;
  border-radius: var(--blog-card-radius);
  margin-block: var(--ez-space-sm);
  aspect-ratio: 16 / 9;
  object-fit: cover;
  background: var(--ez-color-surface-active);
}

/* ---- Post detail (template-independent — execution plan §6.5) ---------- */

/* The moderation banner: shown only to the post's author or a Group Admin
   (server-gated). A left-border callout on the warning hue — an advisory,
   not an error, and deliberately calmer than `.ui-alert--error`. Left
   aligned against `.post-detail__header`'s centred text so it reads as
   chrome around the post rather than part of it. */
.post-detail__moderation {
  margin-bottom: var(--ez-space-xl);
  padding: var(--ez-space-sm) var(--ez-space-md);
  border: var(--blog-hairline) solid
    color-mix(in srgb, var(--ez-color-warning) 40%, transparent);
  border-inline-start: var(--ez-space-2xs) solid var(--ez-color-warning);
  border-radius: var(--ez-radius-md);
  background: color-mix(in srgb, var(--ez-color-warning) 10%, var(--ez-color-surface));
  font-size: var(--ez-fs-sm);
  text-align: start;
}

.post-detail__moderation-state {
  display: flex;
  align-items: center;
  gap: var(--ez-space-xs);
  margin: 0;
}

.post-detail__moderation-note {
  margin: var(--ez-space-2xs) 0 0;
  color: var(--ez-color-text-muted);
}

.post-detail__moderation-note-label {
  font-weight: var(--ez-fw-medium);
  color: var(--ez-color-text);
}

/* The dashboard moderation control: current-state badge, then the
   state/note/apply form on one wrapping row. `blog-inline`-style flow, but
   its own class because the note input wants to grow and the whole thing
   must wrap cleanly at the narrowest supported viewport inside
   `.ui-table-wrap`. */
.dashboard-moderation {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ez-space-sm);
}

.dashboard-moderation__form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ez-space-xs);
}

.dashboard-moderation__form .ui-input {
  flex: 1 1 12rem;
  min-width: 8rem;
}

/* The platform-admin post view's moderation panel (admin/group_post.html) —
   same control as the dashboard's, laid out as a block section rather than a
   table row. */
.admin-moderation {
  margin-bottom: var(--ez-space-xl);
}

.admin-moderation .dashboard-moderation__form {
  margin-top: var(--ez-space-sm);
}

.post-detail__header {
  text-align: center;
  margin-bottom: var(--ez-space-xl);
}

/* The Draft badge (left) and Edit/Delete (right) share this row, both above
   the title — `margin-inline-start: auto` on `.post-detail__actions` pushes
   it to the far end regardless of whether the badge is present at all, so
   this doesn't need two different layouts for "both" vs "actions only". */
.post-detail__header-top {
  display: flex;
  align-items: center;
  gap: var(--ez-space-sm);
  margin-bottom: var(--ez-space-md);
}

.post-detail__actions {
  display: flex;
  align-items: center;
  gap: var(--ez-space-sm);
  margin-inline-start: auto;
}

.post-detail__title {
  font-family: var(--blog-font-display);
  font-size: var(--ez-fs-display-md);
  line-height: var(--ez-lh-tight);
  font-weight: var(--ez-fw-semibold);
  margin: 0 0 var(--ez-space-md);
}

.post-detail__byline {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--ez-space-sm);
}

/* The edit-post form's "Main picture size" choice (`--bleed` / `--ratio-4-3`
   / `--ratio-16-9` / `--custom`, `posts::CoverDisplay`) — all four pinned to
   a 375 px rendered height. The three presets have their width baked in
   here; `--custom`'s width/height are real per-post data
   (`posts.cover_width`/`cover_height`), so CSS alone can't know them —
   `site.js` sets them via the CSSOM at load time instead of an inline
   `style` attribute, which this app's `style-src 'self'` CSP silently
   drops entirely (see that file's own comment on the same code). */
.post-detail__cover {
  display: block;
  object-fit: cover;
  margin-bottom: var(--ez-space-xl);
}

/* Full-bleed: the classic `calc(50% - 50vw)` technique — the "50%" is
   relative to this element's own containing block (`.blog-reading-column`,
   itself centred in the viewport via `margin-inline: auto`), so this
   reaches the true viewport edges regardless of `.blog-main`'s own padding,
   without needing `position: relative` or a transform. No border-radius:
   a rounded corner sitting flush against the viewport edge would look like
   a bug, not a bleed. */
.post-detail__cover--bleed {
  width: 100vw;
  max-width: 100vw;
  height: var(--blog-cover-display-height);
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
}

/* `width` (not `height`) is the constrained dimension deliberately: with
   `aspect-ratio` set and only one of width/height fixed, the *unfixed* one
   tracks it. Fixing width here means a narrow viewport that clamps it via
   `min(…, 100%)` still yields a true 4:3/16:9 box (height follows
   proportionally) instead of the box silently losing its ratio at small
   sizes — which is what fixing height instead would have done, since
   `max-width` on an auto width has no way to feed back into an
   already-fixed height. 500 px / 667 px are 375 px's own 4:3 / 16:9 partners. */
.post-detail__cover--ratio-4-3 {
  width: min(var(--blog-cover-display-width-4-3), 100%);
  aspect-ratio: 4 / 3;
  margin-inline: auto;
  border-radius: var(--ez-radius-lg);
}

.post-detail__cover--ratio-16-9 {
  width: min(var(--blog-cover-display-width-16-9), 100%);
  aspect-ratio: 16 / 9;
  margin-inline: auto;
  border-radius: var(--ez-radius-lg);
}

/* `width`/`aspect-ratio` come from `site.js` (see that file), reading the
   `data-cover-width`/`data-cover-height` attributes the template renders —
   same `min(…, 100%)` + `aspect-ratio` shape as the two presets above, so
   a custom size shrinks proportionally on a narrow viewport too. */
.post-detail__cover--custom {
  margin-inline: auto;
  border-radius: var(--ez-radius-lg);
}

.post-detail__body {
  font-family: var(--ez-font-base);
  font-size: var(--ez-fs-md);
  line-height: var(--ez-lh-loose);
}

.post-detail__body :is(h1, h2, h3) {
  font-family: var(--blog-font-display);
}

.post-detail__body pre,
.post-detail__body code {
  font-family: var(--ez-font-mono);
  font-size: var(--ez-fs-sm);
}

/* §12, "reserve space for images": a content image comes from an author's
   own markdown (`![alt](src)`), which has no width/height to hand comrak —
   unlike `.post-detail__cover`'s presets and `--custom` box above, there is
   no real aspect ratio to read here, so this is a best-effort placeholder,
   not an exact one. `aspect-ratio` still turns "no box until decode" into
   "a box that resizes once" — one shift at decode time instead of the post
   growing underneath the reader's eye once per image while they scroll
   past text that hasn't moved. `contain-intrinsic-size` backs the same
   reservation for whichever images `loading="lazy"` (added by
   `routes::posts::add_lazy_loading_to_content_images`) hasn't fetched yet.
   Ignored automatically inside the gallery grid below, which already fixes
   both width and height explicitly — `aspect-ratio` only ever resolves the
   dimension left `auto`. */
.post-detail__body img {
  width: 100%;
  height: auto;
  aspect-ratio: 3 / 2;
  contain-intrinsic-size: 100% 30rem;
  background: var(--ez-color-surface);
  border-radius: var(--ez-radius-md);
}

/* Gallery mosaic: when the per-post gallery is on, consecutive image-only
   paragraphs pack into a masonry of natural-aspect tiles instead of
   stacking full-width. CSS multicolumn, not a grid: it nests a short tile
   under a tall one in the same column, so nothing is cropped to a shared
   row height (the grid version forced `height: 100%; object-fit: cover`,
   which cut portrait shots next to landscape ones). Column reading order is
   top-to-bottom then next column; the lightbox still steps through images
   in source order, which is the order the author wrote them.

   `column-span: all` on every child is the multicolumn equivalent of the
   grid version's `grid-column: 1 / -1`: ordinary text paragraphs,
   headings and code blocks break the column flow and run full width, and
   only a paragraph whose sole content is an image — `<p><img></p>` before
   gallery.js wraps it, `<p><a class="glightbox"><img></a></p>` after —
   opts back in with `column-span: none`. Same specificity relationship the
   grid rules relied on. */
.post-detail__body[data-gallery-enabled="true"] {
  columns: var(--blog-gallery-col-min);
  column-gap: var(--ez-space-md);
}

.post-detail__body[data-gallery-enabled="true"] > * {
  column-span: all;
}

.post-detail__body[data-gallery-enabled="true"] p:has(> img:only-child),
.post-detail__body[data-gallery-enabled="true"] p:has(> a.glightbox:only-child) {
  column-span: none;
  break-inside: avoid;
  margin: 0 0 var(--ez-space-md);
}

.post-detail__body[data-gallery-enabled="true"] p:has(> img:only-child) img,
.post-detail__body[data-gallery-enabled="true"] p:has(> a.glightbox:only-child) img {
  width: 100%;
  height: auto;
  /* Undo `.post-detail__body img`'s 3/2 placeholder ratio above — inside
     the mosaic every tile keeps the image's own shape. The trade-off is a
     small reflow as each lazy image decodes; a real width/height on the
     tag (server-side) would remove it, which is a separate change. */
  aspect-ratio: auto;
}

/* Panorama band: `gallery.js` sets `data-gallery-wide` on the tile of any
   image at least twice as wide as tall, and it takes the whole row instead
   of one mosaic column — the full-width strips in a justified gallery. The
   extra attribute outweighs the `column-span: none` rule just above it. */
.post-detail__body[data-gallery-enabled="true"] p[data-gallery-wide]:has(> img:only-child),
.post-detail__body[data-gallery-enabled="true"] p[data-gallery-wide]:has(> a.glightbox:only-child) {
  column-span: all;
}

/* ---- Comments ------------------------------------------------------------ */
.comment-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--ez-space-md);
}

.comment-item {
  padding: var(--ez-space-sm) 0;
  border-bottom: var(--blog-hairline) solid var(--ez-color-border-soft);
}

/* ---- Utility ---------------------------------------------------------- */
.blog-stack {
  display: flex;
  flex-direction: column;
  gap: var(--ez-space-md);
}

.blog-inline {
  display: flex;
  align-items: center;
  gap: var(--ez-space-sm);
}

.blog-row-between {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ez-space-md);
  margin-bottom: var(--ez-space-lg);
}

/* ---- UI: Destructive confirmation (GUIDELINES §1.5, §6) ------------------
   The two-step Delete/Cancel control that replaced the browser's own
   `confirm()` modal
   (`ui/macros.html`'s `confirm_panel`, armed by `site.js`). It reveals in
   place, so it is laid out inline beside whatever it guards rather than
   floating over it — no layer, therefore no `--ez-z-*` and no scrim.

   The tint is the error hue at a lower strength than `.ui-alert--error`'s:
   this is a question, not a failure. Colour is not carrying the meaning
   either way — the question text is. */
.ui-confirm {
  display: inline-flex;
  align-items: center;
  gap: var(--ez-space-sm);
}

.ui-confirm__panel {
  display: inline-flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--ez-space-sm);
  padding: var(--ez-space-2xs) var(--ez-space-sm);
  border: var(--blog-hairline) solid color-mix(in srgb, var(--ez-color-error) 40%, transparent);
  border-radius: var(--ez-radius-md);
  background: color-mix(in srgb, var(--ez-color-error) 8%, var(--ez-color-surface));
}

.ui-confirm__question {
  font-size: var(--ez-fs-sm);
}

/* Both halves of this control are toggled with `hidden`, and both set their
   own `display` above — which would beat the UA stylesheet's
   `[hidden] { display: none }` on the cascade and leave the panel
   permanently visible. The global `[hidden]` rule at the top of this file
   already carries the `!important` that settles it; this note exists so the
   next person to touch that rule knows this component depends on it. */

/* ---- Undo (GUIDELINES §1.5) ----------------------------------------------
   The outcome message and the button that takes it back, side by side, so
   the button reads as belonging to that message rather than to the page.
   `ui::alert_outcome`'s `<p>` already carries its own box; nothing is
   restated here. Wraps on a narrow viewport rather than shrinking the
   button below the §2.1 target floor.

   `.ui-undo__form` sets no `display` of its own, deliberately: `site.js`
   hides it with `hidden` after eight seconds, and the global
   `[hidden] { display: none !important }` at the top of this file is what
   settles that — a `display` here would only be one more thing depending
   on that `!important`. */
.ui-undo {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--ez-space-sm);
}

/* ---- Touch target floor (GUIDELINES §2.1) --------------------------------
   §2.1 asks for two different numbers: 24x24 for any pointer, 44x44 on touch.
   The rules above satisfy the first from `--ez-control-height-*`; this block
   satisfies the second, and only here. Gating on `pointer: coarse` rather
   than on a width breakpoint is the whole point — desktop density stays
   exactly as designed, and a touchscreen laptop still gets the bigger
   targets a narrow-viewport media query would have missed. This is not a
   "make everything bigger" pass.

   Deliberately last in the file: `min-height` here has to win against the
   component's own floor above, and both weigh the same, so document order
   decides. Height only, except for the two glyph-labelled buttons, which are
   the only controls in the app whose width can fail the check. */
@media (pointer: coarse) {
  .ui-button,
  .ui-button--small,
  .blog-nav-toggle,
  .blog-switcher__toggle,
  .blog-switcher__menu a,
  .ui-input,
  .ui-textarea,
  .ui-select {
    min-height: var(--ez-touch-target-min);
  }

  .blog-nav-toggle,
  .blog-theme-toggle {
    min-height: var(--ez-touch-target-min);
    min-width: var(--ez-touch-target-min);
  }
}

/* ---- Gallery lightbox (PHASES.md phase 6) --------------------------------
   `static/gallery/glightbox.min.css` is vendored byte-for-byte and only
   linked on posts with the gallery on (`posts/detail.html`'s `extra_head`
   override), so it always loads *after* this file — its own rules would win
   any tie under plain cascade order. Every declaration below only exists to
   win that tie against a *specific* stock-dark-theme value read straight out
   of the vendored file (never edited itself), so `!important` here is a
   deliberate, narrow override of third-party CSS this project doesn't
   control the load order of — not a general escape hatch. */
.glightbox-clean .gclose,
.glightbox-clean .gnext,
.glightbox-clean .gprev {
  background-color: var(--ez-color-surface) !important;
  box-shadow: var(--ez-shadow-md) !important;
  border-radius: var(--ez-radius-sm) !important;
}

.glightbox-clean .gclose:hover,
.glightbox-clean .gnext:hover,
.glightbox-clean .gprev:hover {
  background-color: var(--ez-color-surface-hover) !important;
}

.glightbox-clean .gclose path,
.glightbox-clean .gnext path,
.glightbox-clean .gprev path {
  fill: var(--ez-color-text) !important;
}

.gclose .garrow,
.gnext .garrow,
.gprev .garrow {
  stroke: var(--ez-color-text) !important;
}

.gbtn.focused {
  outline: var(--ez-focus-ring-width) solid var(--ez-color-accent) !important;
}

.glightbox-clean .gslide-description {
  background: var(--ez-color-surface) !important;
}

.glightbox-clean .gslide-title {
  font-family: var(--blog-font-brand) !important;
  color: var(--ez-color-text) !important;
}

.glightbox-clean .gslide-desc {
  font-family: var(--ez-font-base) !important;
  color: var(--ez-color-text-muted) !important;
}

.gloader {
  border-color: var(--ez-color-accent) !important;
  border-right-color: transparent !important;
}

/* ---- Post editor: link dialog ----------------------------------------
   The toolbar "Link" button's overlay (editor.js `openLinkDialog`). Same
   modal contract as the rest of the app — scrim, centred card, focus
   trapped by `focus.js`, Escape via `layers.js`. The `--ez-z-modal*`
   rungs, not an ad-hoc value. */
.ui-linkdialog__backdrop {
  position: fixed;
  inset: 0;
  z-index: var(--ez-z-modal-backdrop);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--ez-space-lg);
  background: var(--ez-color-scrim);
}

.ui-linkdialog {
  z-index: var(--ez-z-modal);
  display: flex;
  flex-direction: column;
  gap: var(--ez-space-md);
  width: 100%;
  max-width: 30rem;
  padding: var(--ez-space-xl);
  background: var(--ez-color-surface);
  border: var(--blog-hairline) solid var(--ez-color-border);
  border-radius: var(--ez-radius-lg);
  box-shadow: var(--ez-shadow-xl);
}

/* Reserve a line so filling or failing the title lookup doesn't jolt the
   layout (GUIDELINES §12). */
.ui-linkdialog__status {
  min-height: 1.25rem;
  margin: 0;
  font-size: var(--ez-fs-sm);
  color: var(--ez-color-text-muted);
}

.ui-linkdialog__actions {
  display: flex;
  gap: var(--ez-space-sm);
  justify-content: flex-end;
}

/* ---- Confirmation dialog (native <dialog>) ----------------------------
   The delete-post confirmation (`posts/detail.html`, decision 1) and any
   later `[data-confirm-dialog]` form. `showModal()` puts it in the top
   layer, so it needs no `--ez-z-*` rung; `::backdrop` is the scrim. Same
   card as the link dialog. Closed, the UA's `display: none` hides it — the
   no-JS baseline. */
.ui-confirm-dialog {
  width: 100%;
  max-width: 30rem;
  padding: var(--ez-space-xl);
  color: var(--ez-color-text);
  background: var(--ez-color-surface);
  border: var(--blog-hairline) solid var(--ez-color-border);
  border-radius: var(--ez-radius-lg);
  box-shadow: var(--ez-shadow-xl);
}

.ui-confirm-dialog::backdrop {
  background: var(--ez-color-scrim);
}

.ui-confirm-dialog__title {
  margin: 0 0 var(--ez-space-sm);
  font-size: var(--ez-fs-lg);
}

.ui-confirm-dialog__body {
  margin: 0 0 var(--ez-space-lg);
  color: var(--ez-color-text-muted);
}

.ui-confirm-dialog__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ez-space-sm);
  justify-content: flex-end;
}
