/* global.css — sitewide base styles for OSTA 2026 */

/* ---- SELF-HOSTED FONTS: INTER (no third-party requests) ---- */
@font-face {
  font-family: 'Inter';
  src: url('../fonts/inter-400.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Inter';
  src: url('../fonts/inter-600.woff2') format('woff2');
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Inter';
  src: url('../fonts/inter-700.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

/* ==========================================================================
   1. RESET
   ========================================================================== */

*,
*::before,
*::after {
  box-sizing: border-box;
}

body,
h1, h2, h3, h4, h5, h6,
p,
ul, ol, dl,
figure {
  margin: 0;
}

img, picture, svg, video, canvas {
  max-width: 100%;
  display: block;
  height: auto;
}

/* ==========================================================================
   2. BASE
   ========================================================================== */

html {
  scroll-behavior: smooth;
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
}

body {
  font-family: var(--font-body);
  font-size: var(--text-body);
  line-height: var(--leading-body);
  color: var(--color-body);
  background: var(--color-surface);
  -webkit-font-smoothing: antialiased;
}

/* ==========================================================================
   3. TYPOGRAPHY
   ========================================================================== */

h1, h2, h3, h4,
.h1, .h2, .h3, .h4 {
  font-family: var(--font-display);
  font-weight: var(--weight-bold);
  line-height: var(--leading-tight);
  color: var(--color-ink);
}

h1, .h1 { font-size: var(--text-h1); }
h2, .h2 { font-size: var(--text-h2); }
h3, .h3 { font-size: var(--text-h3); }
h4, .h4 { font-size: var(--text-h4); }

h1, h2, h3, h4 {
  margin-top: var(--space-6);
  margin-bottom: var(--space-2);
}

h1:first-child, h2:first-child, h3:first-child, h4:first-child {
  margin-top: 0;
}

p, li {
  line-height: var(--leading-body);
}

p {
  margin-bottom: var(--space-3);
}

/* Readable measure — opt in only, never applied globally. */
.measure {
  max-width: var(--measure-max);
}

strong {
  font-weight: var(--weight-semibold);
}

/* ==========================================================================
   4. LINKS
   ========================================================================== */

a {
  color: var(--color-link);
  text-decoration: underline;
  text-decoration-thickness: 0.12em;
  text-underline-offset: 0.2em;
}

a:hover {
  color: var(--color-link-hover);
  text-decoration-thickness: 0.16em;
}

/* Never --color-accent for a text link. On navy, default link blue
   fails contrast — a light blue plus the same underline keeps the cue. */
.on-dark a {
  color: var(--color-link-on-dark);
}

.on-dark a:hover {
  color: var(--color-surface);
}

/* ==========================================================================
   5. FOCUS
   ========================================================================== */

:focus {
  outline: none;
}

:focus-visible {
  box-shadow: var(--focus-ring);
  border-radius: inherit;
}

.on-dark :focus-visible {
  box-shadow: var(--focus-ring-inverse);
}

.skip-link {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
  background: var(--color-surface);
}

.skip-link:focus-visible {
  position: fixed;
  top: var(--space-1);
  left: var(--space-1);
  width: auto;
  height: auto;
  padding: var(--space-2) var(--space-3);
  margin: 0;
  overflow: visible;
  clip: auto;
  white-space: normal;
}

/* ==========================================================================
   6. CONTAINER
   ========================================================================== */

.container {
  width: 100%;
  max-width: var(--container-max);
  margin-inline: auto;
  padding-inline: var(--space-3);
}

.container--narrow {
  width: 100%;
  max-width: var(--measure-max);
  margin-inline: auto;
  padding-inline: var(--space-3);
}

@media (max-width: 767px) {
  .container,
  .container--narrow {
    padding-inline: var(--space-2);
  }
}

/* ==========================================================================
   7. BUTTONS
   ========================================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--tap-min);
  min-width: var(--tap-min);
  padding: var(--space-2) var(--space-4);
  /* Transparent 3px border so solid and outline CTAs share the same
     outer height (outline used to read ~5px taller). */
  border: 3px solid transparent;
  border-radius: var(--radius-button);
  font-weight: var(--weight-semibold);
  font-size: var(--text-body);
  text-decoration: none;
  cursor: pointer;
  transition: background-color 150ms, border-color 150ms, color 150ms;
}

@media (prefers-reduced-motion: reduce) {
  .btn {
    transition: none;
  }
}

.btn--primary {
  background: var(--color-brand);
  color: var(--color-surface);
}

/* PASS 60: color repeated explicitly on every interactive state, not
   just background. Root cause of the "label disappears on hover" bug:
   a:hover (global.css, LINKS section) is `color: var(--color-brand-
   hover)`, specificity (0,1,1). .btn--primary's own base rule is
   (0,1,0) — lower — so on an <a class="btn btn--primary">, a:hover's
   color WON on hover even though .btn--primary:hover's higher-
   specificity (0,2,0) background rule correctly applied. Both ended up
   var(--color-brand-hover) — text and background the same colour,
   label invisible. Confirmed live before fixing, not assumed: this hit
   every <a class="btn btn--primary"> sitewide (header/hero Join OSTA,
   all three homepage pricing cards, "Go to My OSTA", etc.) — a <button>
   version never had this problem, since a:hover can't match a button.
   Repeating color here gives this rule (0,2,0) the specificity AND the
   declaration needed to beat a:hover's (0,1,1) for good, on every state
   a member can land on, not just :hover. */
.btn--primary:hover,
.btn--primary:focus-visible,
.btn--primary:active {
  background: var(--color-brand-hover);
  color: var(--color-surface);
}

/* The join/donate button. */
.btn--accent {
  background: var(--color-accent);
  color: var(--color-accent-on);
}

/* PASS 60: same fix as .btn--primary above, for the same reason — this
   one was never fully invisible (--color-accent-on and a:hover's
   --color-brand-hover are different navy shades, not an exact
   collision like primary's), but it was still silently swapping the
   button's own intended text colour for the link colour on every <a
   class="btn btn--accent">, an unstable, unintended result either way. */
.btn--accent:hover,
.btn--accent:focus-visible,
.btn--accent:active {
  background: var(--color-accent-hover);
  color: var(--color-accent-on);
}

/* Outline button: light surface = navy border/text (brand CTA, not
   prose-link blue); dark surface = white border/text. box-sizing is
   already border-box on *, so the 3px border stays inside --tap-min. */
.btn--outline {
  background: transparent;
  border: 3px solid var(--color-brand);
  color: var(--color-brand);
}

.btn--outline:hover,
.btn--outline:focus-visible,
.btn--outline:active {
  background: var(--color-brand-wash);
  color: var(--color-brand);
  border-color: var(--color-brand);
}

/* For use inside .on-dark sections. */
.btn--on-dark {
  background: var(--color-surface);
  color: var(--color-brand);
}

/*
 * PASS "Block theme conversion, Group 1" — a real, pre-existing contrast
 * failure, found while converting Park Maintenance and fixed at source
 * rather than per-page.
 *
 * .btn--on-dark exists to be used INSIDE an .on-dark section, but this
 * file's own `.on-dark a { color: var(--color-brand-on-dark) }` (0,1,1)
 * outranks the button's own (0,1,0) rule immediately above. Every
 * .btn--on-dark therefore rendered its label in the light periwinkle
 * meant for plain links on navy — on the button's WHITE background.
 * Measured on Park Maintenance's "Contact Your Chapter" CTA: roughly
 * 2.06:1, well under the 4.5:1 AA floor, on a site whose audience is
 * explicitly older and low-vision. With this rule it is navy on white,
 * roughly 11.6:1.
 *
 * Deliberately NOT scoped to .entry-content. The Phase 4 pass added
 * `.entry-content .btn--on-dark` for a different specificity fight
 * (`.entry-content a`), which had the side effect of masking this one on
 * converted pages only — so the bug is currently invisible on About and
 * this group's four pages, and still live on every page that has not
 * converted yet, plus inside the block editor canvas where the load
 * order differs. One rule at (0,2,0) beats `.on-dark a` everywhere and
 * fixes all three cases at once. The .entry-content rule stays: it
 * answers a genuinely different selector and is not redundant.
 */
.on-dark .btn--on-dark {
  color: var(--color-brand);
}

.btn[disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Shared hero CTA row — used on the front-page hero, ORS library, and
   any other page that reuses .hero__actions. Lives in global.css (not
   homepage.css) so non-front templates still get equal-height CTAs and
   mobile stacking. Homepage-only measure/layout stays in homepage.css;
   page-with-sidebar hubs keep their own action rows in inner-pages.css. */
.hero__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: var(--space-2);
}

.hero__actions .btn {
  flex: 0 1 auto;
  text-align: center;
  white-space: nowrap;
  font-weight: var(--weight-bold);
  min-height: 52px;
  padding: 0.875rem 1.75rem;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28);
}

@media (max-width: 767px) {
  .hero__actions {
    flex-direction: column;
    align-items: stretch;
  }

  .hero__actions .btn {
    width: 100%;
    white-space: normal;
  }
}

.btn--block,
.btn-block,
.btnu002du002dblock {
  width: 100%;
}

/*
 * Sitewide button paint: Gutenberg comments cannot contain "--", so
 * btn--accent is often saved/printed as btnu002du002daccent. Alias those
 * tokens. Also paint core Button blocks even when they are not inside
 * .entry-content and do not carry .btn.
 */
:is(.btn-primary, .btnu002du002dprimary) {
  background: var(--color-brand);
  color: var(--color-surface);
}

:is(.btn-primary, .btnu002du002dprimary):hover,
:is(.btn-primary, .btnu002du002dprimary):focus-visible,
:is(.btn-primary, .btnu002du002dprimary):active {
  background: var(--color-brand-hover);
  color: var(--color-surface);
}

:is(.btn-accent, .btnu002du002daccent) {
  background: var(--color-accent);
  color: var(--color-accent-on);
}

:is(.btn-accent, .btnu002du002daccent):hover,
:is(.btn-accent, .btnu002du002daccent):focus-visible,
:is(.btn-accent, .btnu002du002daccent):active {
  background: var(--color-accent-hover);
  color: var(--color-accent-on);
}

:is(.btn-outline, .btnu002du002doutline) {
  background: transparent;
  border: 3px solid var(--color-brand);
  color: var(--color-brand);
}

:is(.btn-outline, .btnu002du002doutline):hover,
:is(.btn-outline, .btnu002du002doutline):focus-visible,
:is(.btn-outline, .btnu002du002doutline):active {
  background: var(--color-brand-wash);
  color: var(--color-brand);
  border-color: var(--color-brand);
}

:is(.btn-on-dark, .btnu002du002don-dark) {
  background: var(--color-surface);
  color: var(--color-brand);
}

.on-dark :is(.btn-on-dark, .btnu002du002don-dark),
.is-style-osta-cta-navy :is(.btn-on-dark, .btnu002du002don-dark) {
  color: var(--color-brand);
}

.wp-block-button__link,
.wp-block-button .wp-element-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--tap-min);
  min-width: var(--tap-min);
  padding: var(--space-2) var(--space-4);
  border: 3px solid transparent;
  border-radius: var(--radius-button);
  background: var(--color-brand);
  color: var(--color-surface);
  font-weight: var(--weight-semibold);
  font-size: var(--text-body);
  text-decoration: none;
  cursor: pointer;
}

.wp-block-button__link:hover,
.wp-block-button__link:focus-visible,
.wp-block-button__link:active,
.wp-block-button .wp-element-button:hover,
.wp-block-button .wp-element-button:focus-visible,
.wp-block-button .wp-element-button:active {
  background: var(--color-brand-hover);
  color: var(--color-surface);
}

.wp-block-button.is-style-outline .wp-block-button__link,
.wp-block-button.is-style-outline .wp-element-button {
  background: transparent;
  border-color: var(--color-brand);
  color: var(--color-brand);
}

.wp-block-button.is-style-outline .wp-block-button__link:hover,
.wp-block-button.is-style-outline .wp-block-button__link:focus-visible,
.wp-block-button.is-style-outline .wp-block-button__link:active,
.wp-block-button.is-style-outline .wp-element-button:hover,
.wp-block-button.is-style-outline .wp-element-button:focus-visible,
.wp-block-button.is-style-outline .wp-element-button:active {
  background: var(--color-brand-wash);
  color: var(--color-brand);
}

.on-dark .wp-block-button__link,
.is-style-osta-cta-navy .wp-block-button__link {
  background: var(--color-surface);
  color: var(--color-brand);
}

/* Pass 33, Step 6 — a vertical stack of full-width .btn--outline rows,
   for a list of "go to another page" actions that used to be plain text
   links (see /my-osta/'s Account card). Same --space-2 rhythm as every
   other tightly-related group of controls in this build. */
.action-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

/* ==========================================================================
   8. FORMS
   ========================================================================== */

label {
  display: block;
  font-weight: var(--weight-semibold);
  /* PASS "Fix the accessibility failures found by the component
     inventory", item 2 — every form label, was --text-small (16px).
     Matches .pmpro .pmpro_form_label, already fixed the same way by
     "Restyle the checkout page" — that was the precedent for this
     sitewide change. */
  font-size: var(--text-body);
  margin-bottom: var(--space-1);
  color: var(--color-ink);
}

input,
select,
textarea {
  width: 100%;
  min-height: var(--tap-min);
  padding: var(--space-2);
  font-size: var(--text-body);
  /* PASS "Fix the accessibility failures found by the component
     inventory", item 4 — --color-border (#CDD1DC) on white measures
     1.53:1, under the 3:1 WCAG 1.4.11 floor for UI component
     boundaries. --color-muted clears it. Copied from .contact-form
     input/textarea (inner-pages.css), applied globally this time —
     including <select>, which that earlier fix missed. */
  border: 1px solid var(--color-muted);
  border-radius: var(--radius-sm);
  background: var(--color-surface);
  color: var(--color-ink);
}

input:focus-visible,
select:focus-visible,
textarea:focus-visible {
  border-color: var(--color-brand);
  /* PASS "Fix the accessibility failures found by the component
     inventory", item 5 — a 1px border-colour swap alone is not a
     reliably visible focus indicator. --focus-ring was previously
     reserved for error states only; every focusable control now gets
     the same visible ring keyboard users rely on elsewhere on the
     site. */
  box-shadow: var(--focus-ring);
}

/* Native select arrow is left in place — never removed without a
   replacement. */

input[type="checkbox"],
input[type="radio"] {
  width: 24px;
  height: 24px;
  min-height: 24px;
}

/* PASS "Fix the accessibility failures found by the component
   inventory", item 3 — the 24px control itself is under --tap-min, but
   every single checkbox/radio in this theme (verified: grepped every
   type="checkbox"/type="radio" in every template) uses this nested
   <label><input>text</label> pattern, never the for=/id= sibling form —
   so this :has() rule already fires for 100% of real usages, and the
   whole label (checkbox + gap + text, min-height 48px, all of it
   clickable since it's a real <label>) is the actual tap target, not
   just the 24px glyph. Chose "ensure the wrapper" over "raise the
   control" — a 48px checkbox glyph would look oversized and out of
   place for no compliance benefit once the wrapper already clears the
   floor. */
label:has(input[type="checkbox"]),
label:has(input[type="radio"]) {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  min-height: var(--tap-min);
}

.form-hint {
  /* PASS "Fix the accessibility failures found by the component
     inventory", item 2 — was --text-small (16px). */
  font-size: var(--text-body);
  color: var(--color-muted);
}

.form-error {
  color: var(--color-alert);
}

/* ---- Field wrapper: label above input, hint/error tied to the input
   via aria-describedby (added in markup, not CSS — this only styles
   the relationship). One field per .form-field; stack with normal
   margin, no flex/grid needed. ---- */
.form-field {
  margin-bottom: var(--space-3);
}

.form-field:last-child {
  margin-bottom: 0;
}

.osta-chapter-listing-request {
  margin-top: var(--space-3);
  padding: var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
}

.osta-chapter-listing-request[hidden] {
  display: none;
}

.osta-chapter-listing-request__check {
  display: flex;
  gap: 0.5em;
  align-items: flex-start;
  font-weight: var(--weight-semibold);
}

.osta-chapter-listing-request__name {
  margin-top: var(--space-3);
}

.osta-chapter-edit-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.osta-chapter-edit-list li {
  margin-bottom: var(--space-2);
}

.form-required {
  color: var(--color-alert);
  margin-left: 0.2em;
}

.form-field--error input,
.form-field--error select,
.form-field--error textarea {
  border-color: var(--color-alert);
}

.form-field--error input:focus-visible,
.form-field--error select:focus-visible,
.form-field--error textarea:focus-visible {
  border-color: var(--color-alert);
  box-shadow: var(--focus-ring);
}

/* ---- Grouping fields (Step 2's checkout requirement: "group fields,
   label clearly"). A real <fieldset>/<legend> pair, not a styled div —
   keeps the semantic grouping screen readers already announce. ---- */
.form-fieldset {
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--space-4);
  margin: 0 0 var(--space-4);
  min-width: 0;
}

.form-fieldset legend {
  font-family: var(--font-display);
  font-weight: var(--weight-bold);
  font-size: var(--text-h4);
  color: var(--color-ink);
  padding-inline: var(--space-1);
}

/* ==========================================================================
   9. SECTIONS AND SURFACES
   ========================================================================== */

.section {
  padding-block: var(--space-10);
}

@media (max-width: 767px) {
  .section {
    padding-block: var(--space-6);
  }
}

.section--soft {
  background: var(--color-surface-soft);
}

.section--alt {
  background: var(--color-surface-alt);
}

.section--dark,
.on-dark {
  background: var(--color-surface-dark);
  color: var(--color-surface);
}

.section--dark h1,
.section--dark h2,
.section--dark h3,
.section--dark h4,
.on-dark h1,
.on-dark h2,
.on-dark h3,
.on-dark h4 {
  color: var(--color-surface);
}

/* Link color for .on-dark is defined in LINKS above. */

/* ==========================================================================
   10. LOCKED CONTENT
   ========================================================================== */

.locked {
  border: 1px solid var(--color-border);
  background: var(--color-surface-soft);
  padding: var(--space-4);
  border-radius: var(--radius-md);
}

.locked__label {
  font-size: var(--text-eyebrow);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--color-locked);
  font-weight: var(--weight-semibold);
}

.locked__notice {
  font-size: var(--text-body);
  color: var(--color-body);
}

/* ==========================================================================
   11. NOTICE (info / success / warning / error)
   Base .alert defaults to the error variant — the only variant this file
   had before Pass 21. .alert--info/--success/--warning are additive, so
   any earlier .alert markup keeps rendering exactly as before.
   Every notice needs its icon (.alert__icon, an inline <svg> the caller
   provides — shape carries the signal, not just the color) AND a title
   that names the type in words ("Error", "Success", ...). Color is the
   third signal, never the only one — required by the audience brief.
   ========================================================================== */

.alert {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  border-left: 4px solid var(--color-alert);
  background: var(--color-alert-wash);
  padding: var(--space-3);
  border-radius: var(--radius-sm);
}

.alert__icon {
  flex-shrink: 0;
  width: 24px;
  height: 24px;
  margin-top: 0.1em;
  color: var(--color-alert);
}

.alert__content > *:last-child {
  margin-bottom: 0;
}

.alert__title {
  color: var(--color-alert);
  font-weight: var(--weight-bold);
  margin: 0 0 var(--space-1);
}

.alert__body {
  margin: 0;
  color: var(--color-body);
}

/* PASS "Fix the accessibility failures found by the component
   inventory", item 3 — My OSTA's leadership links stacked with <br>
   inside .alert__body had no sizing and no gap between adjacent
   targets. CSS-only fix, no markup change: each <a> becomes its own
   48px-tall flex box (the <br> already puts one per line), with a
   little vertical margin so two stacked targets never touch edge to
   edge. */
.alert__body a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
  margin-block: 2px;
}

.alert--info {
  border-left-color: var(--color-brand);
  background: var(--color-brand-wash);
}

.alert--info .alert__icon,
.alert--info .alert__title {
  color: var(--color-brand);
}

.alert--success {
  border-left-color: var(--color-accent-ink);
  background: var(--color-success-wash);
}

.alert--success .alert__icon,
.alert--success .alert__title {
  color: var(--color-accent-ink);
}

.alert--warning {
  border-left-color: var(--color-warning);
  background: var(--color-warning-wash);
}

.alert--warning .alert__icon,
.alert--warning .alert__title {
  color: var(--color-warning);
}

/* ==========================================================================
   12. UTILITIES
   ========================================================================== */

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.text-center {
  text-align: center;
}

.list-reset {
  list-style: none;
  margin: 0;
  padding: 0;
}

.stack > * + * {
  margin-block-start: var(--space-3);
}

/* ==========================================================================
   COMPONENT LAYER (Pass 21)
   Everything portals, account screens and gated pages are built from.
   Primitives only — none of these set their own max-width; wrap in
   .container/.container--narrow for page layout, same as everywhere else.
     Page header  — .page-header      — title bar for every inner page
     Card         — .card             — the portal workhorse
     Data list    — .data-list        — label/value pairs (account, chapter facts)
     Data table   — .data-table       — member/chapter lists, cards on mobile
     Empty state  — .empty-state      — "nothing here yet" placeholder
   (Notice is above, section 11; buttons/forms/.locked already existed.)
   ========================================================================== */

/* ---- 13. PAGE HEADER ---- */

/* PASS 60: padding-block evened out to a single value (was --space-6
   top / --space-4 bottom, a 48px/32px split) — part of the same "uneven
   space above and below the heading" complaint this pass's Step 2 named,
   confirmed by measuring the rendered gap, not assumed from the values
   alone. The border-bottom below still gives the heading zone a clear
   visual stop before whatever page content follows; margin-bottom here
   is a separate section-to-content gap, not "heading padding," so it's
   untouched. */
.page-header {
  padding-block: var(--space-6);
  border-bottom: 1px solid var(--color-border-soft);
  margin-bottom: var(--space-6);
}

.page-header__row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-3);
}

/* PASS 60: font-size added — this class previously reset only margin,
   so every page using it (all Part B/portal templates, plus every
   PMPro core page: checkout, levels, account, login, billing, cancel,
   confirmation, member_profile_edit — all of them route through this
   theme's generic page.php, none of PMPro's own or this theme's PMPro
   overrides print a competing <h1>, confirmed by checking) silently
   inherited the BARE h1 tag's own --text-h1 (44px) — sized for a
   genuine hero heading, not a plain utility-page title sitting flush
   under the header with no other visual weight around it. Dropped one
   scale rung to --text-h2 (34px): still comfortably large for this
   audience (nearly double the 18px body-text floor), just no longer
   competing with the public marketing pages' own actual hero headings
   (--text-display, 60px, used only by hero-style page templates like
   About/Contact/Get Answers — untouched by this change, they don't use
   .page-header__title at all). */
.page-header__title {
  font-size: var(--text-h2);
  margin: 0;
}

.page-header__description {
  font-size: var(--text-lead);
  color: var(--color-body);
  max-width: var(--measure-max);
  margin-top: var(--space-2);
  margin-bottom: 0;
}

.page-header__action {
  flex-shrink: 0;
}

/* ---- 14. CARD ---- */

.card {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-card);
  padding: var(--space-4);
}

.card__heading {
  margin: 0 0 var(--space-2);
}

.card__body {
  color: var(--color-body);
  overflow-wrap: break-word;
}

.card__body > *:last-child {
  margin-bottom: 0;
}

.card__footer {
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--color-border-soft);
}

/* A "mark as read" form sitting next to a plain link inside a
   .card__footer (see /notifications/) — scoped to this one class rather
   than changing .card__footer itself, so every other .card__footer
   already in the site (Pass 22's level cards, one action each) keeps
   its existing block layout untouched. */
.osta-notification-mark-read-form {
  display: inline-block;
  margin: 0;
}

.osta-notifications-clear-all {
  margin: 0 0 var(--space-4);
}

/* A row of cards — auto-fits down to one column, no breakpoint math
   needed since minmax() handles it. */
.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: var(--space-4);
}

/* ---- 15. DATA LIST ----
   <dl class="data-list"><div class="data-list__row"><dt>…</dt><dd>…</dd>
   </div>…</dl> — a div wrapper per dt/dd pair is valid HTML5 and is what
   makes each pair its own grid row. */

.data-list {
  margin: 0;
}

.data-list__row {
  display: grid;
  grid-template-columns: minmax(140px, 220px) 1fr;
  gap: var(--space-1) var(--space-3);
  padding-block: var(--space-2);
  border-bottom: 1px solid var(--color-border-soft);
}

.data-list__row:first-child {
  padding-top: 0;
}

.data-list__row:last-child {
  border-bottom: none;
  padding-bottom: 0;
}

.data-list__label {
  font-weight: var(--weight-semibold);
  color: var(--color-muted);
  margin: 0;
}

.data-list__value {
  margin: 0;
  color: var(--color-ink);
}

@media (max-width: 767px) {
  .data-list__row {
    grid-template-columns: 1fr;
    gap: var(--space-1);
  }
}

/* ---- 16. DATA TABLE ----
   A real <table>, restyled into a stack of cards below the mobile
   breakpoint instead of scrolling sideways. Changing display on
   table/tr/td can strip their implicit table semantics in some screen
   readers, so the explicit role="table"/"rowgroup"/"row"/"cell" /
   "columnheader" pairing below is required in the markup, not optional —
   see the style guide for the full pattern. Each <td> also needs a
   data-label attribute matching its column header; that's what
   ::before/attr() prints as the card's row label. */

.data-table {
  width: 100%;
  border-collapse: collapse;
  background: var(--color-surface);
}

.data-table__caption {
  text-align: left;
  caption-side: top;
  font-size: var(--text-small);
  color: var(--color-muted);
  margin-bottom: var(--space-2);
}

.data-table th,
.data-table td {
  text-align: left;
  padding: var(--space-2) var(--space-3);
  /* PASS "Fix the accessibility failures found by the component
     inventory", item 5 — row separator, was --color-border-soft, well
     under the 3:1 non-text-contrast floor. */
  border-bottom: 1px solid var(--color-muted);
  font-size: var(--text-body);
}

.data-table th {
  font-weight: var(--weight-semibold);
  color: var(--color-ink);
  /* Item 5 — was --color-border (1.53:1). */
  border-bottom: 2px solid var(--color-muted);
}

/* PASS "Fix the accessibility failures found by the component
   inventory", item 3 — every link inside a table cell had no sizing at
   all, computing to ~31px. */
.data-table td a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
}

.data-table tbody tr:hover {
  background: var(--color-surface-soft);
  /* Item 5 — the background tint alone (--color-surface-soft on white)
     measures close to 1:1, nowhere near the 3:1 floor for a UI
     component's state indicator. A solid navy edge accent (already
     proven 11.57:1 on white elsewhere in this theme) gives the hover
     state an indicator that actually clears it, while the soft tint
     stays as a secondary cue for anyone who can perceive it. */
  box-shadow: inset 3px 0 0 var(--color-brand);
}

@media (max-width: 767px) {
  .data-table thead {
    /* Headers reappear per-row via data-label — hidden, not removed,
       so a browser without CSS support (or print) still shows them. */
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
  }

  /* Without this, <th> is the one cell type nothing below tells to
     stop being display: table-cell — .data-table tr already turns
     block (so thead's row no longer offers a real table-row context),
     and an orphaned table-cell in that situation gets wrapped in a
     browser-generated anonymous table for layout purposes, sized to
     the header text's own full intrinsic width, ignoring the 1px/
     overflow: hidden box its thead ancestor is clipped to. That
     anonymous table was the actual source of the horizontal-scroll
     bug Pass 36's own scrollWidth check caught on the board portal's
     Audit Log view — table-scroll-cap's sticky-th rule (scoped to
     desktop just above) was a red herring, not the cause. */
  .data-table thead th {
    display: block;
  }

  /* Same anonymous-table issue as thead th above, for the same reason:
     <caption> is display: table-caption by default and nothing else
     in this block touches it, so once its table siblings are
     blockified it becomes an orphaned table-caption needing its own
     anonymous table wrapper — the visible symptom here was the
     caption text wrapping one word per line in a squeezed column
     while still (together with the th fix's own residual) leaving
     the page a few pixels wider than the viewport. */
  .data-table__caption {
    display: block;
  }

  .data-table,
  .data-table tbody,
  .data-table tr,
  .data-table td {
    display: block;
    width: 100%;
  }

  .data-table tr {
    margin-bottom: var(--space-3);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    padding: var(--space-2) var(--space-3);
    box-shadow: var(--shadow-card);
  }

  .data-table tr:last-child {
    margin-bottom: 0;
  }

  .data-table td {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: var(--space-2);
    text-align: right;
    padding: var(--space-1) 0;
    border-bottom: 1px solid var(--color-border-soft);
  }

  .data-table td:last-child {
    border-bottom: none;
  }

  .data-table td::before {
    content: attr(data-label);
    font-weight: var(--weight-semibold);
    color: var(--color-muted);
    text-align: left;
    margin-right: var(--space-2);
  }
}

/* Pass 33, Step 3: a long .data-table (the board portal's audit log can
   run to 50 rows) reading at full length outweighs shorter, more
   important content above it on the same page. This wraps the table in
   a capped, scrollable region — same rows, same markup, same mobile
   card-collapse behaviour inside it — so its SCREEN FOOTPRINT stays
   proportionate to its importance without touching the data itself.
   Optional utility; most .data-table instances on this site are short
   enough never to need it. */
.table-scroll-cap {
  max-height: 480px;
  overflow-y: auto;
  border: 1px solid var(--color-border-soft);
  border-radius: var(--radius-md);
}

.table-scroll-cap .data-table {
  border: none;
}

/* Desktop only — a sticky header only means something while the table
   scrolls as a real table (table-scroll-cap's own vertical scroll,
   above). At mobile the same table becomes stacked cards with no
   scrolling header row to stick at all, and the .data-table thead
   rule already visually hides <thead> there; leaving this unscoped
   made every <th> keep an explicit position: sticky the mobile-hidden
   thead's own position:absolute/clip never reaches (position is not
   an inherited property), which was overflowing the header row's
   full-width text past the clipped 1px box and widening the whole
   page — this is the fix for the horizontal-scroll bug the Pass 36
   self-check's scrollWidth check caught on the board portal's Audit
   Log view. */
@media (min-width: 768px) {
  .table-scroll-cap .data-table thead th {
    position: sticky;
    top: 0;
    background: var(--color-surface);
    z-index: 1;
  }
}

@media (max-width: 767px) {
  /* The mobile card-collapse layout already reads as a stack of short
     cards — a fixed scroll cap fights that instead of helping it, so
     this becomes a plain block below the breakpoint. */
  .table-scroll-cap {
    max-height: none;
    overflow-y: visible;
    border: none;
    border-radius: 0;
  }
}

/* ---- 17. EMPTY STATE ---- */

.empty-state {
  text-align: center;
  padding: var(--space-8) var(--space-4);
  /* PASS "Fix the accessibility failures found by the component
     inventory", item 5 — was --color-border (1.53:1), under the 3:1
     floor. */
  border: 1px dashed var(--color-muted);
  border-radius: var(--radius-md);
  background: var(--color-surface-soft);
}

.empty-state__heading {
  margin: 0 0 var(--space-2);
}

.empty-state__body {
  color: var(--color-body);
  max-width: var(--measure-max);
  margin: 0 auto var(--space-3);
}

.empty-state__body:last-child {
  margin-bottom: 0;
}

/* ---- 18. EDITOR CONTENT (Pass 61) ----
   Wherever a page template renders the page's own the_content() — every
   template this pass touched, plus single.php's blog posts, which
   already used this exact class name (blog.css keeps its own small
   supplementary rules; nothing here conflicts, this file just loads
   first and covers every OTHER template blog.css was never enqueued
   on). One shared ruleset so a client's typed paragraph, heading, list,
   quote or synced pattern reads the same wherever she uses it, rather
   than each template growing its own slightly-different prose styling.

   The sitewide margin: 0 reset (top of this file) strips spacing from
   every heading/paragraph/list — correct for hand-built section markup
   that sets its own rhythm, wrong for arbitrary block content, which
   has no other way to get vertical spacing between elements. The
   lobotomized-owl selector below (* + *) restores it, scoped to this
   class only. */

/*
 * PASS "Block theme conversion, Group 2 (Tenant Rights)" — `word-break:
 * break-word` was dropped from this rule.
 *
 * It is a non-standard legacy alias, and its real effect is not the one the
 * pairing above suggests: unlike `overflow-wrap: break-word`, which only
 * breaks a word that would otherwise overflow and leaves intrinsic sizing
 * alone, `word-break: break-word` behaves like `overflow-wrap: anywhere` and
 * DOES shrink an element's min-content width. It also inherits, so on a
 * converted page it reached every descendant of the whole page, not just the
 * prose it was written for.
 *
 * What that costs, measured on Tenant Rights at 360px: every accordion
 * heading is a shrink-to-fit flex item, so the smaller min-content width made
 * each one narrower than its own text needed — "Eviction & Lease Termination"
 * came out 182px instead of 193px and wrapped onto a fourth line. Across
 * eight accordions the page grew 142px taller than its classic render.
 *
 * Park Maintenance hit this same bug in Group 1 and got a page-scoped
 * `.park-maint-page { word-break: normal; overflow-wrap: break-word }`
 * workaround, which is now redundant and has been removed with it. Two pages
 * independently is the signal it belongs here.
 *
 * overflow-wrap: break-word stays: long unbroken strings (a pasted URL) still
 * break instead of overflowing, which is the behaviour this rule wanted.
 */
.entry-content {
  overflow-wrap: break-word;
}

.entry-content > * + * {
  margin-top: var(--space-3);
}

.entry-content > :first-child {
  margin-top: 0;
}

/*
 * PASS "Block theme conversion, Phase 4 (About)" — a fourth instance of
 * the same category of bug as the .list-reset/.btn fixes above, and the
 * one with the widest blast radius: EVERY converted page's own section
 * headings (.about-hero__heading, .about-board__heading, etc.) already
 * carry their own margin rule, but nearly all of them are a single class
 * selector (0,1,0) — lower specificity than this plain-prose rule
 * (0,1,1) once that heading sits inside .entry-content too. Unlike the
 * li/button fixes, margin-top happened to survive on some headings only
 * by accident (a same-specificity tie against the sitewide `h1:first-
 * child` reset, which some headings win and others don't depending on
 * unrelated DOM shape) — not a fix to build on. Rather than hardcode
 * every section's own correct margin value here, :not(.osta-frame *)
 * makes this rule simply not match a heading with an .osta-frame
 * ancestor at all, so it falls back to that section's own authored
 * margin exactly as it rendered outside .entry-content (old PHP path).
 * A real prose heading she types in the editor never sits inside an
 * .osta-frame element, so this rule still reaches it unchanged.
 */
.entry-content h1:not(.osta-frame *),
.entry-content h2:not(.osta-frame *),
.entry-content h3:not(.osta-frame *),
.entry-content h4:not(.osta-frame *),
.entry-content h5:not(.osta-frame *),
.entry-content h6:not(.osta-frame *) {
  margin-top: var(--space-6);
  margin-bottom: var(--space-2);
}

.entry-content h5:not(.osta-frame *),
.entry-content h6:not(.osta-frame *) {
  font-family: var(--font-display);
  font-weight: var(--weight-bold);
  line-height: var(--leading-tight);
  color: var(--color-ink);
  font-size: var(--text-body);
}

.entry-content > :first-child.wp-block-heading {
  margin-top: 0;
}

/* --------------------------------------------------------------------------
 * PROSE LISTS INSIDE .entry-content
 *
 * PASS "Block theme conversion, Group 2 (Find Resources)" — this whole
 * block was rewritten. It used to be three blanket rules (`.entry-content
 * ul, ol { padding-left }`, `.entry-content li + li { margin-top }`,
 * `.entry-content li > ul, li > ol { margin-top }`) plus two Phase 4
 * patches that zeroed all three back out again for `.list-reset`.
 *
 * Why the patches were the wrong shape: they could only say "this
 * structural list wants NOTHING", because zero was the only value that was
 * safe to hard-code for every list at once. That covered About's card
 * grids and it covered every nav list, so it looked like a general fix for
 * two whole groups of pages. It is not general — it breaks the moment a
 * component wants its own NON-zero value, and Find Resources has one of
 * each, both measured live against this page's own classic render:
 *
 *   .resources-grid__list        padding-left: 1.2em  (21.6px)
 *                                rendered at 32px, narrowing every card's
 *                                text column by ~10px and reflowing three
 *                                cards onto an extra line.
 *   .resources-guides__list      li + li margin-top: 8px  (this list DOES
 *                                carry .list-reset)
 *                                rendered at 0px, collapsing 24px out of
 *                                the sidebar — the Recent Guides items
 *                                after the first each sat 8px too high.
 *
 * The second one is the important one: .list-reset was doing active harm,
 * not just failing to help. A per-component override would have fixed this
 * page and left the same trap set for every page still to convert.
 *
 * The distinction that actually holds is about WHO AUTHORED the list, and
 * it is legible straight off the element:
 *
 *   CLIENT-authored — a list she typed into the editor. Either classless
 *   (a plain <ul> in a classic editor field) or core's own .wp-block-list
 *   and nothing else. It has no styling of its own and genuinely needs
 *   these defaults.
 *
 *   THEME-authored — a component list. Either it carries its own component
 *   class and nothing core (.resources-grid__list), or, when a section was
 *   built out of a core/list block so the client can still edit its items,
 *   it carries .wp-block-list AND this codebase's own .list-reset marker
 *   for "structural, not prose" (.disability-rights-toc__list). Either
 *   way it brings its own padding and margins.
 *
 * The .list-reset half of that is not optional and was learned the hard
 * way: the first version of this rewrite keyed only on .wp-block-list and
 * regressed Disability Rights by 8px per TOC row (~32px down the page),
 * because .disability-rights-toc__list is a genuine core/list block —
 * deliberately, so its links stay editable — that is nonetheless part of a
 * fixed design. "It is a core block" says who can EDIT it, never who
 * STYLED it.
 *
 * So the prose defaults match client-authored lists only. Nothing has to
 * be zeroed back out afterwards, and a component that wants 1.2em, or 8px,
 * or nothing at all, simply gets what it asked for — .list-reset's own
 * `margin: 0; padding: 0` included, which is why the two Phase 4 patches
 * that used to restate it here are gone.
 * ----------------------------------------------------------------------- */
/*
 * The :where() wrappers below are load-bearing, not decoration. Narrowing
 * a selector with a bare :not()/.class ADDS specificity, and that is a
 * second, independent way to break a page: the sibling fix to this one
 * spelled the link rule `.entry-content a:not([class])`, which lifted it
 * from (0,1,1) to (0,2,1) and took Park Maintenance's own breadcrumb
 * colour with it — `.park-maint-breadcrumb a` (0,1,1) had been winning
 * that tie purely on source order (inner-pages.css loads after this file),
 * and silently stopped. Measured: muted grey #5E6475 turned brand navy on
 * a page that had been at 0.0000% against its own classic render.
 *
 * :where() contributes zero specificity, so each rule below keeps EXACTLY
 * the weight its blanket predecessor had — (0,1,1) for the padding,
 * (0,1,2) for both margins — and narrowing what they match cannot change
 * which other rule wins anywhere else on the site.
 */
.entry-content ul:where(:not([class]), .wp-block-list:not(.list-reset)),
.entry-content ol:where(:not([class]), .wp-block-list:not(.list-reset)) {
  padding-left: var(--space-4);
}

.entry-content :where(ul, ol):where(:not([class]), .wp-block-list:not(.list-reset)) > li + li {
  margin-top: var(--space-1);
}

.entry-content li > ul:where(:not([class]), .wp-block-list:not(.list-reset)),
.entry-content li > ol:where(:not([class]), .wp-block-list:not(.list-reset)) {
  margin-top: var(--space-1);
}

/*
 * PASS "Block theme conversion, Group 2 (Find Resources)" — narrowed from
 * a blanket `.entry-content a`, same category as the ul/ol fix above and
 * found the same way. A prose link should be brand-coloured and
 * underlined; a THEME-authored component link already decides both for
 * itself, and at (0,1,1) this rule beat every one of them (0,1,0).
 *
 * Measured live on Find Resources: .resource-sidebar-siblings__link sets
 * `color: var(--color-body)` and `text-decoration: none` (inner-pages.css)
 * and was rendering brand navy AND underlined instead. Re-checked the
 * already-accepted Group 1 pages afterwards and Disability Rights was
 * shipping the identical defect on the identical component — small enough
 * (a 1px underline on six links) to hide inside that page's accepted
 * 0.55% diff, which is exactly why it needed measuring rather than
 * eyeballing. Fixing it here repairs that page too.
 *
 * The discriminator: a link the CLIENT inserts through the editor carries
 * no class at all (core's link format emits a bare <a href>), while every
 * theme component link carries its own class — .btn, .resource-sidebar-
 * siblings__link, .resources-guides__link, the card links. Deliberately
 * NOT :not(.osta-frame *): her editable paragraphs genuinely do sit inside
 * .osta-frame sections, and a link she types in one must still look like a
 * link.
 *
 * The .entry-content .btn* overrides below are now redundant for a .btn
 * that sits inside a converted page, but they are kept: they still carry a
 * .btn placed in ordinary prose, and they cost nothing since each one only
 * repeats its own variant's base colour.
 *
 * The :where() is load-bearing — see the note above the list rules for the
 * Park Maintenance breadcrumb this rule broke when it was spelled with a
 * bare :not(). A classless link is either matched here or it is not; the
 * exclusion does the work, and the weight must stay exactly (0,1,1) so
 * that a component rule that was already winning this tie on source order
 * keeps winning it.
 */
.entry-content a:where(:not([class])) {
  color: var(--color-link);
  text-decoration: underline;
  text-decoration-thickness: 0.12em;
  text-underline-offset: 0.2em;
}

/*
 * PASS "Block theme conversion, Phase 4 (About)" — same category of bug
 * this file's own PASS 60 comment already documents for .btn--primary/
 * --accent/--outline's HOVER states (a:hover beating them on specificity):
 * this is the identical problem for the BASE (non-hover) color, now that
 * core/post-content's own wrapper is genuinely .entry-content around an
 * entire page's real content, not just its narrow "additional text" area.
 * .entry-content a {color:...} is one class + one tag (0,1,1); a plain
 * .btn--primary/--accent/--outline base rule is one class (0,1,0) —
 * lower — so a .btn's own base text colour (and, separately,
 * .entry-content a's text-decoration:underline) would otherwise be
 * silently overridden. For .btn--primary specifically this is a real
 * legibility failure, not just an inconsistency: navy text
 * (--color-brand) on its own navy background — found on the About
 * page's own "Become a Member" (primary) button, invisible until this
 * fix. Repeats each variant's own base color at this higher specificity,
 * exactly the "declaration needed to beat a:hover for good" pattern the
 * existing hover-state fixes above already use for the same reason.
 */
.entry-content .btn {
  text-decoration: none;
}

.entry-content .btn--primary {
  color: var(--color-surface);
}

.entry-content .btn--accent {
  color: var(--color-accent-on);
}

.entry-content .btn--outline {
  color: var(--color-brand);
}

.entry-content .btn--on-dark {
  color: var(--color-brand);
}

/* Same scope as the base rule above, for the same reason. */
.entry-content a:where(:not([class])):hover {
  color: var(--color-link-hover);
}

.entry-content a:where(:not([class])):visited {
  color: var(--color-link-visited);
}
}

/*
 * PASS "Block theme conversion, Group 1" — the same :not(.osta-frame *)
 * exclusion this file already applies to .entry-content's heading
 * margins, for the same reason and found the same way: three of the four
 * pages converted this round (Contact, Evictions, Disability Rights)
 * independently hit it and each reached for an identical page-scoped
 * workaround, which is the signal that it belongs here instead.
 *
 * This rule is for a client's own typed prose, where a <strong> should
 * read as genuinely bold against surrounding body copy. It was never
 * meant for a SECTION's own hand-built markup, where <strong> is part of
 * a fixed design that already sets its own weight — the sitewide
 * `strong { font-weight: var(--weight-semibold) }` (600) at the top of
 * this file. At (0,1,1) this rule beat that (0,0,1) the moment a page's
 * section markup moved into post_content, silently pushing 600 to 700 on
 * copy that was never designed for it — and because a heavier word is
 * wider, it re-broke lines: measured live on Evictions, the callout
 * paragraph's line break moved a full word earlier.
 *
 * Structural section markup is already marked .osta-frame (never client
 * content, by the convention this file establishes above), so excluding
 * its descendants restores the section's own intended 600 while leaving
 * a paragraph she types in the editor completely unaffected.
 */
.entry-content strong:not(.osta-frame *) {
  font-weight: var(--weight-bold);
}

.entry-content blockquote {
  border-left: 4px solid var(--color-brand);
  background: var(--color-surface-soft);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-sm);
  color: var(--color-body);
  font-size: var(--text-lead);
  line-height: var(--leading-body);
}

.entry-content blockquote cite {
  display: block;
  margin-top: var(--space-2);
  font-size: var(--text-small);
  font-style: normal;
  color: var(--color-muted);
}

.entry-content hr {
  border: none;
  border-top: 1px solid var(--color-border-soft);
  margin: 0;
}

.entry-content code,
.entry-content kbd,
.entry-content samp {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.9em;
  background: var(--color-surface-soft);
  border: 1px solid var(--color-border-soft);
  border-radius: 4px;
  padding: 0.1em 0.35em;
}

.entry-content pre {
  background: var(--color-surface-soft);
  border: 1px solid var(--color-border-soft);
  border-radius: var(--radius-sm);
  padding: var(--space-3);
  overflow-x: auto;
}

.entry-content pre code {
  background: none;
  border: none;
  padding: 0;
}

.entry-content figure {
  max-width: 100%;
}

.entry-content figcaption {
  font-size: var(--text-small);
  color: var(--color-muted);
  margin-top: var(--space-1);
}

.entry-content iframe,
.entry-content embed,
.entry-content video {
  max-width: 100%;
}

/* ---- WP core blocks a client is likely to actually use ---- */

.entry-content .wp-block-button__link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--tap-min);
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-button);
  background: var(--color-brand);
  color: var(--color-surface);
  font-weight: var(--weight-semibold);
  font-size: var(--text-body);
  text-decoration: none;
}

.entry-content .wp-block-button__link:hover,
.entry-content .wp-block-button__link:focus-visible,
.entry-content .wp-block-button__link:active {
  background: var(--color-brand-hover);
  color: var(--color-surface);
}

.entry-content .is-style-outline .wp-block-button__link {
  background: transparent;
  border: 3px solid var(--color-brand);
  color: var(--color-brand);
}

.entry-content .is-style-outline .wp-block-button__link:hover,
.entry-content .is-style-outline .wp-block-button__link:focus-visible,
.entry-content .is-style-outline .wp-block-button__link:active {
  background: var(--color-brand-wash);
  color: var(--color-brand);
}

.entry-content .wp-block-table table {
  width: 100%;
  border-collapse: collapse;
}

.entry-content .wp-block-table th,
.entry-content .wp-block-table td {
  padding: var(--space-2);
  border: 1px solid var(--color-border);
  text-align: left;
}

.entry-content .wp-block-table th {
  font-weight: var(--weight-semibold);
  background: var(--color-surface-soft);
}

.entry-content .wp-block-columns {
  gap: var(--space-4);
}

/* Client-chosen "Small" text preset (WP core writes this as an inline
   style="font-size:..." on the element, which beats any class-based
   selector on specificity alone — !important is the only way to hold
   the 18px body-text floor against that, and this is the one place in
   the theme it's justified: a genuine accessibility minimum for this
   audience, not a convenience override. Large/bigger presets are left
   alone; only a size below the floor is corrected. */
.entry-content .has-small-font-size,
.entry-content .has-small-font-size * {
  font-size: var(--text-small) !important;
}

/* ==========================================================================
   WORKSPACE MODE (Pass 33, Step 2)
   "Public pages and portals should not look identical." The device: one
   class, body.workspace, added server-side (inc/design.php) to every
   portal/dashboard template — nothing about markup, gates, or data
   changes, only this. Same palette, same type scale, same components as
   the rest of the site — the difference is carried entirely by spacing,
   surface, and page-header treatment:
     - the page background shifts from plain white to --color-surface-
       workspace, a cooler, denser neutral;
     - the page header loses its generous public padding and shrinks to
       something closer to a toolbar;
     - the gap between stacked cards tightens from the public default.
   A public page (Home, About, Chapters, …) never carries this class and
   is completely unaffected by every rule in this block.
   ========================================================================== */

body.workspace {
  background: var(--color-surface-workspace);
}

body.workspace .page-header {
  padding-block: var(--space-3) var(--space-2);
  margin-bottom: var(--space-4);
}

body.workspace .page-header__description {
  font-size: var(--text-body);
}

body.workspace .stack > * + * {
  margin-block-start: var(--space-2);
}

/* ==========================================================================
   CARD HIERARCHY (Pass 33, Step 3)
   Three levels, applied everywhere a page has more than one card:
     .card            SECONDARY (the unmodified default) — supporting
                       context. Most cards on most pages.
     .card--primary    the thing the page exists for — one per page,
                       almost always the first card after the page header.
     .card--tertiary   empty states and deferred features. Recedes: no
                       shadow, a dashed border, a smaller/quieter heading.
                       Roughly a third of what's on screen today is one of
                       these — they should never carry the same weight as
                       real data again.
   ========================================================================== */

.card--primary {
  border-top: 4px solid var(--color-brand);
  box-shadow: var(--shadow-primary);
}

.card--primary .card__heading {
  font-size: var(--text-h3);
}

.card--tertiary {
  box-shadow: none;
  border-style: dashed;
  /* PASS "Fix the accessibility failures found by the component
     inventory", item 5 — inherited --color-border (1.53:1) from .card;
     a dashed border with no shadow needs its own contrast since it has
     nothing else to lean on. Scoped to this modifier only — .card's own
     base (solid, backed by --shadow-card) is untouched. */
  border-color: var(--color-muted);
  background: var(--color-surface-soft);
}

.card--tertiary .card__heading {
  font-size: var(--text-h4);
  font-weight: var(--weight-semibold);
  color: var(--color-muted);
}

/* A .empty-state nested inside a .card--tertiary would otherwise be a
   dashed, soft-background box inside an identically dashed, soft-
   background box — the outer card already carries that signal, so the
   inner one drops its own border/background and keeps only its spacing. */
.card--tertiary .empty-state {
  border: none;
  background: transparent;
  padding: var(--space-4) var(--space-2);
}

/* ==========================================================================
   IDENTITY STATEMENT (Pass 33, Step 4 — "the signature")
   The one element this site is remembered by. Carries the single most
   meaningful sentence in the product — "You're part of Lakewood Vista, in
   Lane County, District: Lane County" on /my-osta/, and an adapted,
   shorter form at the top of the chapter and district portals ("Lakewood
   Vista · Lane County · Lane County District"). Real information first:
   every word here is still plain-language, readable text — nothing is a
   graphic standing in for a fact.
   Why this shape: the org's own visual mark is a roofline (see the OSTA
   logo) over the words "manufactured & floating home communities" — a
   home someone owns, on ground and water they don't. The roofline
   silhouette along the card's own top edge is that same idea carried into
   one component: a place to belong to, not a stat to report. No numbered
   sequence, no gradient, no big-number-small-label — those default
   patterns say "here is a metric"; this says "here is where you live."
   ========================================================================== */

.identity-card {
  position: relative;
  overflow: hidden;
  background: var(--color-surface-dark);
  color: var(--color-surface);
  border-radius: var(--radius-lg);
  padding: var(--space-5) var(--space-4) var(--space-4);
}

/* The roofline: a single flat-topped triangle, drawn in border tricks so
   no image request or inline SVG markup is needed anywhere this
   component is used. Purely decorative (aria-hidden in the markup) —
   every fact it sits above is ordinary readable text underneath. */
.identity-card::before {
  content: "";
  position: absolute;
  top: 0;
  left: var(--space-4);
  width: 64px;
  height: 28px;
  background: var(--color-accent);
  clip-path: polygon(0% 100%, 50% 0%, 100% 100%);
}

.identity-card__eyebrow {
  font-size: var(--text-eyebrow);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: var(--weight-semibold);
  color: var(--color-brand-on-dark);
  margin: var(--space-3) 0 var(--space-1);
}

.identity-card__place {
  font-family: var(--font-display);
  font-weight: var(--weight-bold);
  font-size: var(--text-h2);
  line-height: var(--leading-tight);
  color: var(--color-surface);
  margin: 0 0 var(--space-2);
}

.identity-card__place a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 0.12em;
}

.identity-card__place a:hover,
.identity-card__place a:focus-visible {
  color: inherit;
}

.identity-card__context {
  font-size: var(--text-lead);
  color: var(--color-surface);
  margin: 0;
}

.identity-card__meta {
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid rgba(255, 255, 255, 0.2);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-4);
  font-size: var(--text-small);
  color: var(--color-brand-on-dark);
}

/* Compact variant — the "adapted, shorter form" for the top of the
   chapter/district portals, where the identity statement is context for
   a workspace, not the page's whole reason to exist the way it is on
   /my-osta/. Smaller type, tighter padding, no meta row. */
.identity-card--compact {
  padding: var(--space-3) var(--space-4);
}

.identity-card--compact::before {
  width: 44px;
  height: 20px;
  top: 0;
}

.identity-card--compact .identity-card__place {
  font-size: var(--text-h4);
  margin-bottom: 0;
}

.identity-card--compact .identity-card__eyebrow {
  margin-top: var(--space-2);
}

.identity-card--compact .identity-card__context {
  font-size: var(--text-small);
}

@media (max-width: 767px) {
  .identity-card__place {
    font-size: var(--text-h3);
  }
}

/* PASS — Portal cleanup, "My OSTA card". Scoped to the FULL card only
   (:not(--compact)) — the only place this shape ever renders is /my-osta/'s
   own "You're part of" card (template-my-osta.php); the compact variant
   (the portal sidebar's own identity strip, every other portal's header)
   is untouched, still navy, not mentioned by this pass. White surface,
   navy/body text — every pair below re-verified against white, not
   assumed carried over from the dark version:
     --color-brand (#263279) on white:      11.57:1
     --color-body  (#363B4A) on white:      11.16:1
     --color-muted (#5E6475) on white:       5.91:1
   All clear of the 4.5:1 AA floor with wide margin. Border + shadow match
   this site's own plain .card convention (global.css, ~line 673) — this
   card now sits nested inside a white .card wrapper on the page and needs
   its own edge to read as a distinct region rather than disappearing into
   its parent.

   THE ROOFLINE ARROW: removed rather than "flipped" — its own design
   rationale (this section's comment above) was built around a navy card
   echoing the logo's navy roofline; floating a green triangle over a
   plain white card has no equivalent visual anchor and reads as stray
   regardless of which way it points. Per this pass's own "if unsure,
   remove it" guidance. */
.identity-card:not(.identity-card--compact) {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  box-shadow: var(--shadow-card);
  padding: var(--space-6) var(--space-5) var(--space-5);
}

.identity-card:not(.identity-card--compact)::before {
  content: none;
}

.identity-card:not(.identity-card--compact) .identity-card__eyebrow {
  color: var(--color-muted);
  /* No roofline above to clear anymore — reads as a normal card eyebrow. */
  margin-top: 0;
}

.identity-card:not(.identity-card--compact) .identity-card__place {
  color: var(--color-brand);
}

.identity-card:not(.identity-card--compact) .identity-card__context {
  color: var(--color-body);
}

.identity-card:not(.identity-card--compact) .identity-card__meta {
  border-top-color: var(--color-border-soft);
  color: var(--color-muted);
}

/* ==========================================================================
   PORTAL SIDEBAR (Pass 36)
   "One navigation, every logged-in user." A left rail like wp-admin's own
   — menu items on the left, one view in the main panel. Every item is a
   real URL (?view=…), built from the registry in inc/portal-nav.php and
   rendered by template-parts/portal-sidebar.php; this block is its
   appearance only.

   Navy rail (--color-surface-dark — the SAME navy the identity card
   already uses, on purpose: nesting a compact identity card at the top
   of this rail means it sits flush, no visible seam, reading as one
   navy surface rather than a box inside a box). White text throughout.
   Active item: --color-brand (a visibly LIGHTER navy than the rail — the
   two are different tokens, not an opacity trick) plus a green
   (--color-accent) left edge bar. Green marks POSITION here — a border,
   a surface — never text, per the locked palette rule. Hover gets its
   own third state (a faint white wash) so it is never mistaken for
   active while tabbing/pointing around.
   ========================================================================== */

.portal-layout {
  display: flex;
  align-items: flex-start;
  gap: var(--space-4);
  /* PASS — Portal cleanup: guarantees a clear gap between this whole
     region (sidebar included) and whatever comes next (osta_content_
     slot('below_dashboard')/get_footer()) regardless of which column —
     sidebar or main — ends up taller on a given portal/view. Applied
     here, at the shared two-column container every one of the four
     portal templates (my-osta, chapter, district, board) already uses,
     not per-template, so it can never be missed on one of the four. */
  margin-bottom: var(--space-8);
}

.portal-main {
  flex: 1 1 auto;
  min-width: 0; /* a wide table inside must not stretch this flex item past the sidebar's own share of the row */
}

@media (min-width: 768px) {
  /* PASS — Portal cleanup, CORRECTION (direct user report, third time):
     sticky (below) locks the rail only for as much scroll distance as
     .portal-main is TALLER than the sidebar itself — that's how sticky's
     release point is computed, not a bug in the values chosen. On a
     short view (My Account: 435px of content vs. a 629px sidebar; My
     Courses: 358px vs. 629px) main is actually SHORTER than the
     sidebar, so there is zero room to lock — the rail moves with the
     page from the very first pixel of scroll, exactly "scrolls with the
     page like before." Confirmed empirically (scroll-position sweep) on
     both of those, plus a near-miss on Events (656px vs. 629px — locks
     for about 100px of scroll, then releases; practically
     imperceptible). Longer views (Announcements, Notifications,
     District, …) were never affected — main content is already well
     taller than the sidebar there.
     Fix: give .portal-main a height floor of one screenful below the
     header, regardless of how little real content a given view has.
     This guarantees the sidebar always has close to a full viewport's
     worth of genuine "stuck" room to lock through, on every view, not
     just the long ones — short views just gain some breathing room
     below their content before the footer, same light background, not
     a visible seam. min-height only ever raises .portal-main's box on
     views shorter than one screen; it's a no-op everywhere else
     (District's 3614px of real content is already far past this
     floor). */
  .portal-main {
    min-height: calc(100vh - var(--header-height) - var(--space-3));
  }
}

.portal-sidebar {
  flex: 0 0 260px;
  background: var(--color-surface-dark);
  border-radius: var(--radius-lg);
  overflow-x: hidden; /* keeps the active item's border-radius/left-bar clean at the rail's own rounded corners */
  overflow-y: hidden; /* auto only once sticky is in play — see the desktop media query below */
  /* PASS — Portal cleanup: breathing room above whatever the rail's own
     first child is (the compact identity card on most portals, or
     straight into the nav on one with none) — previously flush against
     the rail's own top edge. */
  padding-top: var(--space-3);
}

/* Desktop: no disclosure triangle, no toggle — always open. See the
   mobile block at the bottom of this section for the <details> element
   this overrides; identical markup, two very different jobs. */
.portal-sidebar__summary {
  display: none;
}

.portal-sidebar__nav {
  /* PASS — Portal cleanup: bottom raised from --space-2 (16px) to
     --space-4 (32px) — the last item used to sit almost flush against
     the rail's own bottom edge. Top stays --space-2; when an identity
     card sits above this <nav>, .portal-sidebar's own new padding-top
     (above) is what gives that top block room, not this. */
  padding-block: var(--space-2) var(--space-4);
}

.portal-sidebar__list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.portal-sidebar__divider {
  border: none;
  border-top: 1px solid rgba(255, 255, 255, 0.18);
  margin: var(--space-2) var(--space-3) 0;
}

.portal-sidebar__group-label {
  margin: var(--space-2) var(--space-3) var(--space-1);
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: var(--weight-semibold);
  color: var(--color-brand-on-dark);
}

.portal-sidebar__item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--tap-min);
  padding: var(--space-2) var(--space-3);
  color: var(--color-surface);
  font-size: var(--text-body);
  text-decoration: none;
  border-left: 4px solid transparent;
}

.portal-sidebar__item:hover,
.portal-sidebar__item:focus-visible {
  background: rgba(255, 255, 255, 0.08);
  color: var(--color-surface);
  text-decoration: none;
}

/* Dark-surface focus ring (--focus-ring-inverse) — the default blue ring
   is built for light backgrounds and is low-contrast on this navy. */
.portal-sidebar__item:focus-visible {
  box-shadow: var(--focus-ring-inverse);
  outline: none;
}

.portal-sidebar__item--active {
  background: var(--color-brand);
  border-left-color: var(--color-accent);
  font-weight: var(--weight-semibold);
}

/* Active stays visually active under hover/focus too — a lighter wash
   on TOP of --color-brand would drift toward the same tone as plain
   hover on the rail, undoing the one thing "active" needs to say. */
.portal-sidebar__item--active:hover,
.portal-sidebar__item--active:focus-visible {
  background: var(--color-brand);
}

.portal-sidebar__icon {
  flex-shrink: 0;
  opacity: 0.85;
}

.portal-sidebar__identity .identity-card--compact {
  border-radius: 0; /* the rail's own border-radius already rounds this corner; a second radius here would double up oddly */
}

@media (max-width: 767px) {
  .portal-layout {
    flex-direction: column;
  }

  .portal-sidebar {
    flex-basis: auto;
    width: 100%;
    /* PASS — Portal cleanup: sticky is a desktop-only behaviour (see the
       @media (min-width: 768px) block below) — explicit position: static
       here so a narrow screen never inherits it, and no max-height/
       overflow-y:auto trap either: closed by default via the <details>
       below, and even open, plain document flow scrolls the whole page
       normally rather than the rail fighting the page for its own
       scrollbar on a screen with no room to spare. */
    position: static;
    max-height: none;
    overflow-y: visible;
  }

  /* .portal-sidebar gets width: 100% above; .portal-main needs the
     same explicit width once .portal-layout switches to a column —
     flex's cross-axis stretch alone still let a wide-enough card
     inside grow .portal-main past its own container's edge (min-width:
     0 only overrides the MAIN-axis automatic minimum size; it does not
     stop cross-axis content from doing this). Confirmed live: without
     this, the board portal's Audit Log view measured .portal-main at
     420px inside a 358px-wide .portal-layout at 390px viewport width,
     the actual source of the horizontal-scroll bug Pass 36's own
     scrollWidth check exists to catch. */
  .portal-main {
    width: 100%;
    min-width: 0;
  }

  /* Native <details>/<summary> disclosure — closed by default (no open
     attribute in the markup), zero JavaScript, keyboard- and screen-
     reader-native. Labelled with the current view, not a bare "Menu". */
  .portal-sidebar__summary {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);
    min-height: var(--tap-min);
    padding: var(--space-3);
    color: var(--color-surface);
    font-weight: var(--weight-semibold);
    font-size: var(--text-body);
    cursor: pointer;
    list-style: none;
  }

  .portal-sidebar__summary::-webkit-details-marker {
    display: none;
  }

  .portal-sidebar__summary::after {
    content: "";
    flex-shrink: 0;
    width: 10px;
    height: 10px;
    border-right: 2px solid currentColor;
    border-bottom: 2px solid currentColor;
    transform: rotate(45deg);
  }

  .portal-sidebar[open] .portal-sidebar__summary::after {
    transform: rotate(-135deg);
  }
}

@media (min-width: 768px) {
  /* Forces the <details> content open regardless of the (absent-by-
     default) open attribute — desktop never shows a closed rail.
     display:block alone is NOT enough: a closed <details> element's
     non-summary content is forced hidden per the HTML spec via an
     internal content-visibility: hidden, a rendering-level skip that
     sits underneath (and is invisible to) the display property —
     getComputedStyle can report display:block here while the box
     still generates zero layout height. content-visibility: visible
     is the one declaration that actually reaches and cancels that
     forced state; display:block is kept alongside it only because
     content-visibility's own spec says its behaviour is undefined on
     an element whose display is inline. Verified live: without the
     content-visibility override the rail computed a real 260px width
     but a 0px height and never painted. */
  details.portal-sidebar > *:not(summary) {
    content-visibility: visible;
    display: block;
  }

  /* Chromium (and now other engines) render a closed <details>'s
     non-summary content inside an internal ::details-content box, not
     as a plain child — setting content-visibility on the light-DOM
     children above does not reach it, since the pseudo-element wraps
     and independently governs them. This is the declaration that
     actually restores box height; without it the rule above still
     computes content-visibility: visible on the children yet the rail
     renders at 0px height. */
  details.portal-sidebar::details-content {
    content-visibility: visible;
    block-size: auto;
  }

  /* PASS — Portal cleanup: sidebar, desktop only. Offset clears the
     fixed glass header (--header-height) plus a little breathing room.
     CORRECTION (direct user report, same pass's own follow-up): this
     used to also cap the rail at max-height: calc(100vh - …) with
     overflow-y: auto, so an unusually long item list (a leadership
     portal with every member-section row plus its own section's rows)
     scrolled WITHIN the rail rather than running past the viewport. By
     request, that internal scrollbar is gone — every item always
     renders at its own natural height, nothing nested to scroll, so the
     full menu always "shows" as one plain list. Height is auto (no
     max-height, so .portal-sidebar's own overflow-y: hidden above never
     actually clips anything — there is nothing taller than the box to
     clip once the box is free to grow).
     CORRECTION (direct user report, second time): tried position: fixed
     here instead of sticky, on the theory that sticky's brief catch-up
     phase near the top of the page (moving in-flow until it reaches
     `top`, then locking) was what read as "still scrolls with the
     page." Fixed does stop that — it never moves, period — but it
     introduced two new problems the user caught immediately: (1) fixed
     has no release mechanism, so on any page long enough it visually
     persisted over the footer past the end of the real content, which
     read as "weird" even with matching navy tones; (2) directly
     contradicts the follow-up ask — on a page as long as the
     Notifications list can get, the sidebar SHOULD travel with the page
     once you're past its own natural height, exactly what fixed cannot
     do by definition.

     Reverted to position: sticky, which turns out to be the correct
     tool for both asks made together: it holds at `top` (feels "locked")
     for as long as .portal-layout (sidebar + main content, whichever is
     taller) has room below the sidebar's own height, then releases and
     scrolls with the page for the remainder — no footer overlap is even
     possible, because release always happens before .portal-layout's own
     bottom edge, well above the footer. Checked empirically against
     every real menu length before reverting (My OSTA: sidebar 629px vs.
     848px of main content; Notifications: 478px vs. 1479px; District,
     the longest menu: 1169px vs. 3614px) — main content is taller than
     the sidebar on every one of them, so there is real room to stick,
     not just a token few pixels. Still pure CSS; still works identically
     with JavaScript disabled. */
  .portal-sidebar {
    position: sticky;
    top: calc(var(--header-height) + var(--space-3));
  }
}

/* ---- Fixed, frosted glass bar ----
   Reskinned to match a glass/frosted header pattern the client supplied
   from another build: always position: fixed, translucent + blurred
   rather than solid, sitting above every page including the front
   page's photo hero (which used to get its own separate --overlay
   variant — retired below, one consistent header treatment everywhere
   now). Colors, type, and spacing are still 100% this project's own
   locked tokens (navy/green/Inter/18px scale/48px taps) — only the
   glass mechanic itself (background, blur, fixed position, scroll
   state) is new, and even that is built from --header-glass-* tokens
   added to tokens.css for exactly this, not new raw values. */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  height: var(--header-height);
  background: var(--header-glass-bg);
  backdrop-filter: var(--header-glass-blur);
  -webkit-backdrop-filter: var(--header-glass-blur);
  border-bottom: 1px solid var(--color-border-soft);
  transition: background-color 200ms ease, box-shadow 200ms ease;
}

/* Pass 37 — without JS there is no toggle to hide the mobile menu, so
   .primary-nav (below) stays permanently visible and, at full list
   height, permanently taller than --header-height. A fixed header
   with a capped height can't grow to fit that: the box stays 56/64px
   tall while the nav's real content renders past it, floating over
   whatever the page puts directly below. Fixed positioning is only a
   problem in the no-JS case — with JS, the menu starts collapsed and
   the fixed bar never needs to hold more than one row. So this is the
   one part of the header that genuinely depends on JS being available,
   and is the only header rule in this file gated behind .js/.no-js:
   fixed + capped height when JS can manage what's inside it, static +
   auto height (a normal in-flow block that simply grows and pushes
   the rest of the page down) when it can't. Every other declaration
   above — background, blur, border, transition — applies identically
   either way, so the bar looks the same; it just no longer floats. */
.no-js .site-header {
  position: static;
  height: auto;
}

/* Reduced motion respected — same per-component pattern .btn already
   uses just above (search "prefers-reduced-motion"), not a global rule,
   so this one needs its own entry rather than inheriting one. */
@media (prefers-reduced-motion: reduce) {
  .site-header {
    transition: none;
  }
}

/* backdrop-filter has no fallback of its own — without this, a browser
   that doesn't support it renders --header-glass-bg's 92%-opacity white
   over whatever has scrolled underneath, unblurred, which reads as
   washed-out rather than frosted. The stronger, near-opaque tone reads
   fine as a plain solid bar instead. */
@supports not ( backdrop-filter: blur( 1px ) ) {
  .site-header {
    background: var(--header-glass-bg-strong);
  }
}

/* A small, deliberately subtle cue that the page has scrolled — a
   slightly denser frost plus a soft shadow, toggled by main.js at
   scrollY > 10. Never an opaque swap; still reads as the same glass
   bar, just a hair more solid against busier content underneath it. */
.site-header.is-scrolled {
  background: var(--header-glass-bg-strong);
  box-shadow: var(--shadow-hover);
}

/* Logged-in users get a WordPress admin bar the header's own fixed
   positioning otherwise ignores, clipping the logo behind it. 32px /
   46px are WordPress core's own admin bar heights (core's fixed
   values, not this theme's design values, so raw px is correct here —
   matching core's own 782px breakpoint for the switch). */
body.admin-bar .site-header {
  top: 32px;
}

@media (max-width: 782px) {
  body.admin-bar .site-header {
    top: 46px;
  }
}

.site-header__inner {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  height: 100%;
}

/* Without this, flex's default shrink-everything-equally behavior lets
   the nav's own width get squeezed even when the three items together
   fit the row fine (measured: brand + nav + account + gaps = ~1152px
   inside a 1200px container when logged in with a long "N unread
   notifications" string) — the row itself doesn't wrap, but individual
   nav links do (e.g. "Get Answers" breaking to "Get" / "Answers"),
   which reads as broken even though nothing is actually overflowing. */
.site-header__brand,
.primary-nav,
.site-header__account {
  flex-shrink: 0;
}

/* PASS 57 — the real cause of the logo's uneven vertical gap: this div
   was plain display:block, so its only child (.site-header__logo-link,
   an inline-flex box) was positioned through ordinary INLINE layout —
   subject to the browser's default vertical-align:baseline for the <img>
   inside it. That leaves room for a font "descender" strut below the
   baseline that has nothing above it to match, so the parent .site-
   header__brand div rendered TALLER than its own 48px-tall child, with
   every bit of that extra height added below, never above. Measured
   before this fix: image top edge sat 7.8px below the header's own top
   edge, but 15.0px above its bottom edge — a ~7.2px imbalance, not a
   rounding error. (The nav links right next to it never had this
   problem: .primary-nav__list is already display:flex, so its <a> tags
   are real flex items, not inline content subject to baseline rules —
   confirmed near-symmetric, ~7.5px/8.5px, at measurement time.)
   Making .site-header__brand a flex container too removes the inline
   line-box mechanism entirely rather than compensating for it — no
   vertical-align hack, no margin nudge, just the same layout method
   already used correctly one flex item over. Re-measured after: the
   48px-tall logo box now sits at the exact same top/bottom offsets
   (7.5px / 8.5px, the ordinary 64-in-48 centering split every nav link
   already had) as every nav link beside it — image-to-box centering
   itself is a symmetric 3.4px/3.4px inside that box. The old ~7.2px
   imbalance is gone; what's left is the same sub-pixel rounding already
   present everywhere else in the row, not a logo-specific defect. */
.site-header__brand {
  display: flex;
  align-items: center;
}

.site-header__title a,
.site-header__logo-link {
  text-decoration: none;
  color: var(--color-ink);
}

/* Pass 33, Step 6: the logo LINK's clickable box, not the logo image
   itself, must meet the 48px minimum — the artwork (assets/images/osta-
   logo.png, a wide horizontal lockup) renders at 41px tall at its own
   natural proportions under the existing max-height/max-width caps
   below, which is correct for its appearance and not something this fix
   changes. inline-flex + align-items centers that same image inside a
   box that is guaranteed at least --tap-min tall, so the tappable area
   grows without the artwork being stretched or cropped. */
.site-header__logo-link {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
}

/* The header is the same frosted-glass bar on every page now, including
   over the front-page photo hero — its own light background is what
   carries contrast for the logo/nav text sitting on it, not a text-
   color swap the way the old fully-transparent overlay variant needed.
   --color-ink is correct everywhere; no --site-header--overlay text
   override exists to reintroduce. */

/* The logo is a 1:1 circular seal now, not the 5:1 horizontal lockup
   the previous --space-8 cap was written for. Fixed height, auto
   width, with a max-width as a backstop against an unexpectedly wide
   asset. Scoped to .site-header__brand only — the footer logo (a
   different wrapper, .footer-col--identity / .osta-logo) keeps its own
   sizing below. */
/* Shape-agnostic on purpose: the logo file has changed aspect ratio
   more than once during this build (circular seal, horizontal
   lockup...), each time needing a different single-dimension cap
   (height for a tall mark, width for a wide one) to avoid either
   distorting the artwork or overflowing the header row. Constraining
   both max-height and max-width at once, with width/height left auto,
   makes the browser scale to whichever bound is tighter and keeps the
   aspect ratio correct either way, without needing to know the shape
   in advance. */
.site-header__brand img {
  max-height: var(--space-10);
  max-width: 200px;
  width: auto;
  height: auto;
}

.site-header__account {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}

/* PASS 57: scoped down from .btn's own default sizing (--space-4
   horizontal / --space-2 vertical padding, --text-body/18px font) —
   correct for a page's own call-to-action button, but oversized sitting
   inline next to the header's plain-text nav links. The button's real
   problem wasn't min-height itself (that's the shared 48px tap-min
   floor, unchanged and still required) — it was that 16px vertical
   padding plus an 18px line box pushed the button's OWN rendered height
   to ~59px, well past the 48px every other header element actually
   measures, which is what read as "too large" against a 64px bar. Padding
   and font-size are cut here instead of touching min-height, exactly as
   instructed: with the smaller padding/font, natural content height
   drops under 48px, so min-height becomes the binding constraint again
   and the button lands at exactly 48px — measured after this change, not
   assumed. Scoped to this one class, not .btn itself, so no other button
   anywhere else on the site is affected. */
.site-header__join {
  flex-shrink: 0;
  min-height: var(--tap-min);
  padding-block: var(--space-1);
  padding-inline: var(--space-3);
  /* PASS "Fix the accessibility failures found by the component
     inventory", item 2 — the sitewide Join button, was --text-small
     (16px). min-height stays fixed via --tap-min regardless of the
     larger font, so this doesn't change the button's height. */
  font-size: var(--text-body);
}

/* Pass 26 — sized to the same minimum tap target as every other
   interactive element in the header. PASS — Portal cleanup: the
   notifications link moved to the portal sidebar (see header.php's own
   docblock on osta_header_account_links()) — this rule used to also
   cover .site-header__notifications, dropped since that class is no
   longer output by this file. "My OSTA" was briefly dropped from here
   too and is back per that same docblock's own correction — restored to
   this shared rule rather than left as a one-off. */
.site-header__my-osta,
.site-header__logout {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
  flex-shrink: 0;
  font-weight: var(--weight-semibold);
  white-space: nowrap;
}

/* ---- Mobile "Menu" toggle ----
   Hidden without JS: a button that does nothing is worse than no button
   — with .no-js the primary nav is simply always visible instead.
   Hidden above the mobile breakpoint: the primary nav runs inline there. */
.nav-toggle {
  display: none;
  align-items: center;
  justify-content: center;
  min-height: var(--tap-min);
  min-width: var(--tap-min);
  background: transparent;
  border: none;
  color: var(--color-ink);
  cursor: pointer;
}

.nav-toggle__icon-close {
  display: none;
}

.nav-toggle[aria-expanded="true"] .nav-toggle__icon-open {
  display: none;
}

.nav-toggle[aria-expanded="true"] .nav-toggle__icon-close {
  display: block;
}

.primary-nav__list {
  display: flex;
  align-items: center;
  /* Restored to --space-4: the --space-3 reduction was only needed to
     stop the header wrapping under the old ~310px-wide horizontal
     logo. Since then the logo became a much narrower (~62px) circular
     seal, so the row now has plenty of room on one line at the wider
     gap again — verified at 1440px and 1280px. */
  gap: var(--space-4);
  list-style: none;
  margin: 0;
  padding: 0;
}

.primary-nav__list a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
  font-size: var(--text-body);
  font-weight: var(--weight-semibold);
  color: var(--color-ink);
  text-decoration: none;
}

/* Underline as a hover/focus affordance, not a permanent mark on every
   link — back to Pass 9's original treatment (a later pass had turned
   this on at rest; reverted per client feedback that it read as
   cluttered across a six-item row). The CURRENT-page indicator further
   down (.current-menu-item, a colored border) is a separate, always-on
   signal and is untouched by this. */
.primary-nav__list a:hover,
.primary-nav__list a:focus-visible {
  text-decoration: underline;
  text-underline-offset: 0.15em;
}

/* Mobile-only duplicate of .site-header__account, living inside
   .primary-nav so the same toggle/panel controls both — see
   header.php's osta_header_account_links(). Hidden above the mobile
   breakpoint (the real .site-header__account is what desktop shows). */
.primary-nav__account {
  display: none;
}

/* ---- Submenu toggle buttons ----
   Hidden without JS — the submenu list itself stays visible by default
   so every link stays reachable. Applies at every breakpoint: submenus
   are click-to-open everywhere, never hover-only — this audience
   includes touch devices with no hover state at all, so hover-to-open
   was never on the table here even though the reskin this section is
   based on used it. */
.submenu-toggle {
  display: none;
  align-items: center;
  justify-content: center;
  min-height: var(--tap-min);
  min-width: var(--tap-min);
  background: transparent;
  border: none;
  color: inherit;
  cursor: pointer;
}

.js .submenu-toggle {
  display: inline-flex;
}

.sub-menu {
  list-style: none;
  margin: 0;
  padding: 0;
}

.js .sub-menu {
  display: none;
}

.js .submenu-toggle[aria-expanded="true"] + .sub-menu {
  display: block;
}

/* PASS 57: raised from the site's usual 767px mobile breakpoint to
   1199px, for THIS block only. .site-header__brand/.primary-nav/.site-
   header__account are all flex-shrink: 0 (deliberately, see that rule's
   own comment above) — none of them compress, so the desktop row's
   content width is fixed regardless of viewport. Adding "Home" and
   "Contact" to the menu (this same pass, Step 2) grew that fixed width
   past what fits below ~1120px — measured directly: the row stopped
   overflowing its container only at 1120px and wider, tested in 5-30px
   steps from 768px up. 767px, the OLD threshold, left a badly broken
   ~350px overflow with no wrap and no visible Join button, confirmed
   live before this fix. 1199px (one below --container-max: 1200px)
   keeps a real margin above that measured 1120px floor rather than
   sitting right at the edge. Every OTHER 767px breakpoint in this file
   is untouched — this is the nav's own switch-to-hamburger point only,
   not the site's general mobile breakpoint. */
@media (max-width: 1199px) {
  .js .nav-toggle {
    display: inline-flex;
  }

  /* The header is fixed with JS, so the mobile panel can no longer just
     push the row taller the way it could when the header sat in normal
     document flow — it has to float above whatever is underneath it
     instead, exactly like the front-page hero already floats behind
     the header. Capped and scrollable so a very long menu never runs
     off the bottom of a short phone screen.

     Positioning applies unconditionally here (not gated to .js) — .no-
     js needs this same panel too, just permanently visible instead of
     toggle-controlled, since there is no toggle button without JS to
     reveal it (.nav-toggle's own default is display: none, only shown
     under .js below). Only the HIDDEN starting state is gated to .js —
     the same split the pre-reskin version of this rule used, just
     applied to a floating panel instead of an in-flow block. Without
     that split a no-JS visitor would never see the nav at all: .js
     .nav-toggle would never exist to be clicked, and .primary-nav
     would default to display: none forever.

     Pass 37: the ABSOLUTE part of this positioning is exactly what let
     the always-visible no-JS panel float over the page instead of
     pushing it down — .site-header going static above only fixes the
     header's OWN box; .primary-nav has its own position:absolute here,
     independent of its ancestor's position, and needed its own no-js
     override to actually join normal flow. See .no-js .primary-nav
     just below. */
  .primary-nav {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    max-height: calc(100vh - var(--header-height));
    overflow-y: auto;
    background: var(--color-surface);
    border-top: 1px solid var(--color-border-soft);
    box-shadow: var(--shadow-hover);
    padding: var(--space-3);
  }

  /* Pass 37 — join normal document flow: a static, auto-height block
     participates in layout like anything else, so its full expanded
     list pushes #main-content down instead of floating over it. The
     max-height/overflow-y cap above existed only to keep an
     absolutely-positioned overlay panel from running off the bottom of
     the screen; a normal in-flow block has no such risk (the page
     itself just scrolls), so both are lifted along with position. */
  .no-js .primary-nav {
    position: static;
    max-height: none;
    overflow-y: visible;
    width: 100%;
  }

  /* .site-header__inner is still the same row flexbox (logo, toggle,
     nav, account) it always is — taking .primary-nav out of absolute
     positioning only stops it floating OVER the page, it doesn't stop
     it sitting NEXT TO the logo as a flex item in that row, squeezed
     into whatever width is left rather than stacking below at full
     width. Wrapping the row is what actually puts it on its own line:
     .site-header__brand (and the two hidden items) fill the first
     line, then .primary-nav — already width: 100% above — has nothing
     left to share it with and wraps to a full line of its own. */
  .no-js .site-header__inner {
    flex-wrap: wrap;
  }

  .js .primary-nav {
    display: none;
  }

  .primary-nav__list {
    flex-direction: column;
    align-items: stretch;
    gap: 0;
  }

  .primary-nav__list a {
    width: 100%;
  }

  .js .nav-toggle[aria-expanded="true"] + .primary-nav {
    display: block;
  }

  /* Notifications / My OSTA / Log Out (or Join OSTA) move into the
     open panel on mobile — the desktop-only copy inline in the bar is
     hidden here instead, so the account block appears exactly once,
     never both hidden-and-shown at the same time. */
  .site-header__account {
    display: none;
  }

  .primary-nav__account {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-2);
    margin-top: var(--space-3);
    padding-top: var(--space-3);
    border-top: 1px solid var(--color-border-soft);
  }

  .primary-nav__account .site-header__join {
    width: 100%;
  }
}

/* ---- Current page indicator ---- */
.primary-nav .current-menu-item > a,
.primary-nav .current-menu-ancestor > a {
  color: var(--color-brand);
  border-bottom: 3px solid var(--color-brand);
}

/* The header is fixed again — content needs top clearance for it.
   Pages with a full-bleed hero (body.has-hero) handle that inside the
   hero's own top padding instead, so the photo can extend full-bleed
   behind the glass header; every other page (body.no-hero) adds it
   here, to the actual content wrapper, once. See inc/design.php for
   the has-hero/no-hero body_class filter. */
body.no-hero #main-content {
  padding-top: var(--header-height);
}

body.admin-bar.no-hero #main-content {
  padding-top: calc(var(--header-height) + 32px);
}

@media (max-width: 782px) {
  body.admin-bar.no-hero #main-content {
    padding-top: calc(var(--header-height) + 46px);
  }
}

/* Pass 37 — this whole block exists to reserve space for a header
   that has been taken OUT of the page's normal flow by position:
   fixed. .no-js .site-header (above) puts it back INTO flow instead,
   so with no JS the header already occupies real, counted space of
   its own — reserving var(--header-height) again on top of that would
   just push content down twice (a visible empty gap the exact height
   of the header, admin bar or not). Every width and admin-bar variant
   of the reservation gets zeroed the same way, at higher specificity
   than each one it cancels so it wins regardless of source order. */
.no-js body.no-hero #main-content {
  padding-top: 0;
}

.no-js body.admin-bar.no-hero #main-content {
  padding-top: 0;
}

main {
  scroll-margin-top: var(--header-height);
}

/* Same reasoning as the padding-top zeroing above: scroll-margin-top
   exists to keep a same-page anchor jump from landing underneath a
   fixed header. With no JS the header isn't fixed, so an anchor target
   scrolled to the very top of the viewport is already fully visible —
   reserving margin for a header that's no longer floating would only
   leave an unnecessary gap above the target. */
.no-js main {
  scroll-margin-top: 0;
}

/* ==========================================================================
   FOOTER
   ========================================================================== */

/* ---- .site-footer carries .on-dark in the markup, inheriting the dark
   surface, light text and --color-brand-on-dark link colour already
   defined above. Not redeclared here.

   One extension to those existing .on-dark rules: .on-dark a's link
   colour has higher specificity than any single .btn--* class (two
   selectors beat one), so without this, a .btn placed inside .on-dark
   — as the footer's "Join OSTA" button now is — would have its own
   deliberately-chosen text colour silently overridden by the on-dark
   link colour. .btn--accent is the one actually used in the footer, but
   all four variants get the same treatment for consistency, since any
   of them could end up on a dark surface later. ---- */
.on-dark .btn--primary {
  color: var(--color-surface);
}

.on-dark .btn--accent {
  color: var(--color-accent-on);
}

/* Outline on dark: white border/text (Stitch CTA pair). Navy outline
   on navy is invisible — same failure mode as the .btn--accent fix. */
.on-dark .btn--outline,
.section--dark .btn--outline,
.wp-block-cover.is-dark .btn--outline,
.wp-block-group.is-style-osta-cta-navy .btn--outline,
.has-navy-background-color .btn--outline,
.osta-content-slot--bg-navy .btn--outline {
  background: transparent;
  border-color: var(--color-surface);
  color: var(--color-surface);
}

.on-dark .btn--outline:hover,
.on-dark .btn--outline:focus-visible,
.on-dark .btn--outline:active,
.section--dark .btn--outline:hover,
.section--dark .btn--outline:focus-visible,
.section--dark .btn--outline:active,
.wp-block-cover.is-dark .btn--outline:hover,
.wp-block-cover.is-dark .btn--outline:focus-visible,
.wp-block-cover.is-dark .btn--outline:active,
.wp-block-group.is-style-osta-cta-navy .btn--outline:hover,
.wp-block-group.is-style-osta-cta-navy .btn--outline:focus-visible,
.wp-block-group.is-style-osta-cta-navy .btn--outline:active,
.has-navy-background-color .btn--outline:hover,
.has-navy-background-color .btn--outline:focus-visible,
.has-navy-background-color .btn--outline:active,
.osta-content-slot--bg-navy .btn--outline:hover,
.osta-content-slot--bg-navy .btn--outline:focus-visible,
.osta-content-slot--bg-navy .btn--outline:active {
  background: rgba(255, 255, 255, 0.14);
  color: var(--color-surface);
  border-color: var(--color-surface);
}

/* Beat .entry-content .btn--outline (same 0,2,0 weight earlier in this
   file) when the button sits on a dark band inside post content. */
.entry-content .on-dark .btn--outline,
.entry-content .section--dark .btn--outline,
.entry-content .wp-block-cover.is-dark .btn--outline,
.entry-content .wp-block-group.is-style-osta-cta-navy .btn--outline,
.entry-content .has-navy-background-color .btn--outline {
  color: var(--color-surface);
  border-color: var(--color-surface);
}

.entry-content .on-dark .btn--outline:hover,
.entry-content .on-dark .btn--outline:focus-visible,
.entry-content .on-dark .btn--outline:active,
.entry-content .section--dark .btn--outline:hover,
.entry-content .section--dark .btn--outline:focus-visible,
.entry-content .section--dark .btn--outline:active,
.entry-content .wp-block-cover.is-dark .btn--outline:hover,
.entry-content .wp-block-cover.is-dark .btn--outline:focus-visible,
.entry-content .wp-block-cover.is-dark .btn--outline:active,
.entry-content .wp-block-group.is-style-osta-cta-navy .btn--outline:hover,
.entry-content .wp-block-group.is-style-osta-cta-navy .btn--outline:focus-visible,
.entry-content .wp-block-group.is-style-osta-cta-navy .btn--outline:active,
.entry-content .has-navy-background-color .btn--outline:hover,
.entry-content .has-navy-background-color .btn--outline:focus-visible,
.entry-content .has-navy-background-color .btn--outline:active {
  color: var(--color-surface);
  border-color: var(--color-surface);
  background: rgba(255, 255, 255, 0.14);
}

/*
 * In-copy links on navy stay text (white underline), not pills.
 * Classless CTAs become outline buttons only in action rows.
 */
.wp-block-group.is-style-osta-cta-navy a:not(.btn):not(.wp-block-button__link) {
  color: var(--color-surface);
  text-decoration: underline;
  text-decoration-thickness: 0.12em;
  text-underline-offset: 0.2em;
}

.wp-block-group.is-style-osta-cta-navy a:not(.btn):not(.wp-block-button__link):hover,
.wp-block-group.is-style-osta-cta-navy a:not(.btn):not(.wp-block-button__link):focus-visible {
  color: var(--color-surface);
}

.wp-block-group.is-style-osta-cta-navy .resident-assoc-actions a:not(.btn):not(.wp-block-button__link),
.entry-content .wp-block-group.is-style-osta-cta-navy .resident-assoc-actions a:not(.btn):not(.wp-block-button__link),
.on-dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link),
.section--dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link),
.wp-block-cover.is-dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link),
.has-navy-background-color .resident-assoc-actions a:not(.btn):not(.wp-block-button__link),
.osta-content-slot--bg-navy .resident-assoc-actions a:not(.btn):not(.wp-block-button__link),
.entry-content .on-dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link),
.entry-content .wp-block-cover.is-dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link),
.entry-content .has-navy-background-color .resident-assoc-actions a:not(.btn):not(.wp-block-button__link) {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--tap-min);
  min-width: var(--tap-min);
  padding: var(--space-2) var(--space-4);
  border: 3px solid var(--color-surface);
  border-radius: var(--radius-button);
  background: transparent;
  color: var(--color-surface);
  font-weight: var(--weight-semibold);
  font-size: var(--text-body);
  text-decoration: none;
}

.wp-block-group.is-style-osta-cta-navy .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):hover,
.wp-block-group.is-style-osta-cta-navy .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):focus-visible,
.wp-block-group.is-style-osta-cta-navy .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):active,
.on-dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):hover,
.on-dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):focus-visible,
.on-dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):active,
.section--dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):hover,
.section--dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):focus-visible,
.section--dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):active,
.wp-block-cover.is-dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):hover,
.wp-block-cover.is-dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):focus-visible,
.wp-block-cover.is-dark .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):active,
.has-navy-background-color .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):hover,
.has-navy-background-color .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):focus-visible,
.has-navy-background-color .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):active,
.osta-content-slot--bg-navy .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):hover,
.osta-content-slot--bg-navy .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):focus-visible,
.osta-content-slot--bg-navy .resident-assoc-actions a:not(.btn):not(.wp-block-button__link):active {
  background: rgba(255, 255, 255, 0.14);
  color: var(--color-surface);
  border-color: var(--color-surface);
  text-decoration: none;
}

/* .btn--on-dark's own base rule (background --color-surface, text
   --color-brand) already IS the dark-surface variant — no .on-dark
   override needed; one only ever restated it. */

/* No padding on .site-footer itself — each band below (.footer-stats,
   .footer-columns, .footer-legal) manages its own padding-block, so the
   green rules between them sit at the true edge of each band. */

/* ---- Stats band ---- */

.footer-stats {
  padding-block: var(--space-6);
  border-bottom: 1px solid var(--color-accent);
}

/* PASS 58: auto-fit, not a hardcoded repeat(4, 1fr) — the bar is now
   ACF-driven and ships 3 stats by default, with a 4th entirely optional
   (group_osta_home_sections.json's "Footer Stats" tab). A fixed 4-column grid would leave a
   lopsided empty gap with only 3 items; auto-fit instead spaces however
   many actually render evenly across the row, unchanged for either
   count. minmax(140px, 1fr) is generous enough that both 3 and 4 items
   stay on one row at the desktop widths this rule applies at (the
   max-width: 991px override below still forces 2 columns at tablet/
   mobile regardless of count, unchanged from before). */
.footer-stats__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: var(--space-6);
  text-align: center;
}

.footer-stats__value {
  font-size: var(--text-h1);
  font-weight: var(--weight-bold);
  color: var(--color-surface);
  margin: 0;
}

.footer-stats__label {
  font-size: var(--text-small);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: var(--weight-semibold);
  color: var(--color-surface);
  margin: 0;
}

/* Two columns at tablet AND mobile — the design wants the same count
   at both, so one breakpoint covers it. */
@media (max-width: 991px) {
  .footer-stats__grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* ---- Columns ---- */

.footer-columns {
  padding-block: var(--space-8);
  border-bottom: 1px solid var(--color-accent);
}

.footer-columns__grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--space-6);
}

.osta-logo,
.osta-logo-text {
  margin-bottom: var(--space-3);
}

/* Footer logo sizing — .osta-logo is a footer-only construct (header.php
   has its own separate markup, see osta_logo_html()'s docblock), so
   this is safely scoped without needing an extra class. Capped by
   width, not height: the invert-filter stopgap that used to live here
   was removed (see osta_logo_html()) — the circular seal has a white
   disc, so brightness(0) invert(1) was turning the whole mark into a
   solid white circle instead of just recolouring the artwork. */
.osta-logo img {
  max-width: 160px;
  height: auto;
}

/* Pass 33, Step 6: the logo artwork is navy-on-transparent (see
   assets/images/osta-logo.png) — on this footer's navy surface it reads
   as barely-there. No reversed (white/light) asset exists to swap in. A
   FILTER was deliberately rejected here, same as it already was for the
   header (see the comment above, and osta_logo_html()'s own docblock) —
   inverting this specific artwork does not reliably produce a clean
   white mark for every logo shape this build has already been through.
   The fix is a TREATMENT instead: a small light card behind the mark,
   the same surface the logo already reads correctly on everywhere else
   on the site. It reads as a deliberate plaque, not a bug, and needs no
   new asset. */
.osta-logo {
  display: inline-block;
  background: var(--color-surface);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
}

/* Column headings: small, uppercase, spaced out — not --text-h4 (Pass
   8's original size) any more. Colour not redeclared: .on-dark h1-h4
   (SECTIONS, above) already sets --color-surface on every heading in
   this footer. */
.footer-col__title {
  font-size: var(--text-small);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: var(--weight-semibold);
}

.footer-nav__list li + li {
  margin-block-start: var(--space-1);
}

/* Scoped to the links that are actually standalone controls — contact,
   membership, resources, social, back to top, legal — not a blanket
   .site-footer a, which would also catch any inline link inside a
   paragraph and turn it into a 48px-tall flex box, breaking the line. */
.footer-col__link,
.footer-nav__list a,
.footer-social__link,
.back-to-top,
.footer-legal__right a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
}

/* Pass 33, Step 6: the group rule above only guaranteed HEIGHT — every
   other footer link's own text ("Membership Levels", "Find Resources", …)
   is already wider than 48px, but a single-letter platform name ("X")
   is not, and was rendering a tap target as narrow as its own two
   characters. min-width alone, scoped to just this one link type rather
   than the whole group above, so a long social label (e.g. "LinkedIn")
   is untouched and only the genuinely narrow ones grow. */
.footer-social__link {
  min-width: var(--tap-min);
}

/* Single stacked column, not a wrapping horizontal row — five labels in
   a narrow column was wrapping awkwardly (2 then 3). */
.footer-social__list {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

/* Two columns at tablet widths, one below 767px (existing mobile
   breakpoint). No tablet-range token exists in tokens.css, so this
   introduces one more literal breakpoint value, same category as the
   767px breakpoint already used elsewhere in this file. */
@media (max-width: 991px) {
  .footer-columns__grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (max-width: 767px) {
  .footer-columns__grid {
    grid-template-columns: 1fr;
  }
}

/* ---- Back to top: sits just above the legal bar, right-aligned. ---- */
.footer-back-to-top {
  display: flex;
  justify-content: flex-end;
  padding-block: var(--space-4);
}

/* ---- Legal bar: copyright left, Privacy/Terms right, stacks on
   mobile. ---- */
.footer-legal {
  padding-block: var(--space-4);
}

.footer-legal__inner {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: center;
  gap: var(--space-3);
}

.footer-legal__right {
  display: flex;
  gap: var(--space-3);
}

@media (max-width: 767px) {
  .footer-legal__inner {
    flex-direction: column;
    align-items: flex-start;
  }
}

/*
 * PASS "Block theme conversion" — .osta-frame: the fix for a systematic
 * spacing bug found while verifying templates/single.html against the
 * live single.php it replaced (a ~51px height drift, not visible on
 * templates/page.html only because that page's own Group wrapper never
 * happened to have more than one visible child).
 *
 * CAUSE: theme.json declares a sitewide default of 24px "block gap" for
 * every core/group block (styles.blocks.core/group.spacing.blockGap),
 * generated as a global, zero-specificity rule —
 *   :root :where(.wp-block-group-is-layout-constrained) > * {
 *     margin-block-start: 24px;
 *   }
 * — intended for a CLIENT typing several paragraphs into a Group block
 * she inserted herself, where 24px between them is the right default.
 * Every block template this conversion builds also uses core/group as
 * pure STRUCTURAL scaffolding (the container/post-header/entry-content/
 * post-meta/page-header wrappers, etc.) — those already have their own,
 * often different, spacing rules from the site's original CSS (this
 * file, inner-pages.css, blog.css...). The 24px default silently added
 * ON TOP of that existing spacing, once per Group, compounding with every
 * nested structural Group a page happens to use — worse on richer pages,
 * invisible on simple ones (exactly the "systematic, not single.html-
 * specific" pattern flagged before this fix).
 *
 * FIX, ONCE: every purely-structural Group block this theme's own
 * templates/*.html and parts/*.html files author (never a Group a CLIENT
 * inserts through the editor) carries this one additional class,
 * alongside its real semantic className. It has zero effect on any
 * Group block a client adds herself — those never carry this class,
 * and keep the normal 24px default exactly as intended. Since :where()
 * carries zero specificity, a single ordinary class selector is enough
 * to override it — no !important, no per-block inline style needed.
 */
/*
 * PASS "Block theme conversion, Phase 4 (About)" — :not(.keep-margin)
 * added to both selectors below. Found on About's hero: .about-hero__
 * actions (inner-pages.css) sets its own real margin-top (space-4,
 * spacing the button away from the paragraph above it) — a single-
 * class rule, same (0,1,0) specificity as .about-hero__heading and
 * every other section-level rule this fix category already covers, but
 * THIS selector is a 3-class compound (0,3,0), so it was winning
 * regardless of load order, forcing the button flush against the
 * paragraph above it. .keep-margin is the opt-out: a Group block that
 * is itself a child of an osta-frame flow container AND carries its own
 * deliberate margin-top (not relying on gap/blockGap for its spacing)
 * adds this class so this rule stops matching it — the same "structural
 * marker, not client content" convention as .list-reset, just spelled
 * the other direction (that class says "not prose", this one says
 * "don't zero me"). Every other osta-frame child with no margin-top of
 * its own (the overwhelming majority — card grids, button rows that
 * rely on gap, etc.) is completely unaffected: :not() only changes
 * whether THIS rule matches, never adds a match. Known live use so
 * far: about_content.html's hero actions Group. Find Resources'
 * .resources-hero__actions (inner-pages.css) is the identical pattern
 * and will need the same class when that page converts.
 */
/*
 * KNOWN LIMIT OF .keep-margin — read before reaching for it on a FIRST
 * child. This rule is only one of TWO that zero an element's top margin
 * inside a converted page. The other is WordPress core's own
 *   :root :where(.is-layout-flow) > :first-child { margin-block-start: 0 }
 * at (0,2,0) (:root and :first-child each count; :where() is free),
 * printed inline by the block-layout support. .keep-margin does nothing
 * about that one — it only opts out of THIS rule — so an element that is
 * BOTH the first child of a flow container AND carries its own
 * margin-top still loses it, because the component's own rule is
 * typically a single class (0,1,0).
 *
 * This deliberately has no generic fix here, and that is not an
 * oversight: core's rule sets the property to 0 outright, so anything
 * that outranks it must supply a concrete replacement VALUE — and only
 * the component knows what its own margin should be. A value-agnostic
 * marker class cannot express that (the same reason .keep-margin-bottom
 * below has to name --space-3 explicitly rather than "restore whatever
 * was there"). `revert` does not help either: every rule in play is
 * author-origin with no cascade layers, so it reverts past all of them.
 *
 * The correct shape is therefore per-component, in that component's own
 * stylesheet: re-state the real value at (0,2,0) or higher, scoped to
 * the page. Live example, measured rather than guessed —
 * inner-pages.css's `.evictions-page .evictions-callout__icon,
 * .evictions-page .evictions-safeharbor__check { margin-top: 2px }`,
 * added when both icons rendered 2px too high against the classic
 * render. Expect roughly one of these per converted page that opens a
 * section with a self-spacing element; it is a two-line fix once
 * recognised, and this note exists so the next conversion recognises it
 * from the symptom instead of re-deriving the specificity chain.
 */
.osta-frame.wp-block-group.is-layout-flow > *:not(.keep-margin),
.osta-frame.wp-block-group.is-layout-constrained > *:not(.keep-margin) {
  margin-block-start: 0;
}
.osta-frame.wp-block-group.is-layout-flex,
.osta-frame.wp-block-group.is-layout-grid {
  gap: 0;
}

/*
 * PASS "Block theme conversion, Phase 4 (About)" — the margin-block-END
 * companion to .keep-margin above, and a DIFFERENT shape of the same
 * underlying problem: WordPress emits its own
 *   :root :where(.is-layout-flow) > :last-child { margin-block-end: 0; }
 * at (0,2,0) specificity (:root and :last-child each count) — high
 * enough to beat this file's own sitewide `p { margin-bottom: var(
 * --space-3); }` rule (0,0,1), which every card/section paragraph
 * sitewide has always relied on for its own trailing rhythm, PHP-
 * rendered pages included. Found on About's card grid: .card__body (a
 * plain <p>, global.css's own .card component) is the last element in
 * every "why we exist" card, so once that card is an osta-frame Group,
 * WP's rule silently zeroed a bottom margin that rendered live in the
 * original PHP output.
 *
 * Deliberately an OPT-IN marker (.keep-margin-bottom), not a blanket
 * "every last <p> in an osta-frame container" rule like the margin-
 * START fix above — tried that first and it collided twice: .about-who-
 * we-are__body p:last-child { margin-bottom: 0 } and .about-membership-
 * benefits__panel-quote { margin: 0 } are both existing, deliberate
 * "sits flush, no trailing space" rules on a last-child paragraph, and
 * a blanket restore at high-enough specificity to beat WP's (0,2,0)
 * necessarily outranks those too. An opt-in class only ever touches a
 * paragraph this pass has actually verified needs its sitewide default
 * back — every other last-child <p> (there are more of those than there
 * are cards) is completely unaffected. Same (0,2,0) specificity as WP's
 * rule; global.css still loads after the inline global-styles block
 * that emits it, so the tie resolves here on load order, same proof as
 * .keep-margin's own comment above.
 */
/*
 * PASS "Block theme conversion, Group 2 (Tenant Rights)" — the value became a
 * custom property. The sitewide paragraph default (--space-3) is still what
 * you get for free, but this marker was hard-coding it, so an element whose
 * own design uses a different trailing margin got the wrong one restored:
 * .tenant-rights-helpline__hours wants --space-4, and came back 8px short,
 * which shrank the whole helpline card by 8px against its classic render.
 *
 * A custom property is the right tool because it sidesteps the specificity
 * fight entirely. Beating WordPress's (0,2,0) :last-child rule is what forces
 * this rule's own weight up; the VALUE it restores does not need to win any
 * cascade at all, since nothing else sets --osta-keep-margin-bottom. A
 * component declares its own value wherever it already declares its margins,
 * at whatever specificity it likes, and this rule picks it up.
 */
.entry-content .keep-margin-bottom {
  margin-bottom: var(--osta-keep-margin-bottom, var(--space-3));
}

/*
 * PASS "Block theme conversion, Phase 4 (About)" — same margin-block-END
 * problem, this time on a WordPress CORE block rather than a theme
 * component: block-library's own CSS gives .wp-block-columns a default
 * margin-bottom: 1.75em (for the stacked-on-mobile gap between columns
 * and whatever follows). About's preserved "Browse by Topic Cards"
 * pattern — the page's own real, client-editable trailing content,
 * copied verbatim into the new post_content — ends in exactly one
 * wp:columns block. Once its wrapper (.entry-content, this page's own
 * "article" Group, NOT core/post-content's outer one) is a real
 * is-layout-flow Group, that block becomes :last-child of it and loses
 * its own 1.75em to the same WP (0,2,0) reset every other fix on this
 * page has been working around. Not scoped with .keep-margin-bottom
 * (that class always restores this THEME's own --space-3; this is a
 * different, core-defined value) — a plain, general rule instead, since
 * any future page's own trailing content can end in a columns block the
 * same way and none of them should have to remember a marker class for
 * a WordPress-core default. (0,2,0) via :last-child ties WP's rule on
 * specificity; global.css still loads after the inline block emitting
 * it, so the tie resolves here on load order, same proof as the other
 * margin-block-end fixes above.
 */
.entry-content .wp-block-columns:last-child {
  margin-bottom: 1.75em;
}

/*
 * PASS "Block theme conversion, Phase 4 (About)" — a second, deeper case
 * of the exact same bug the two rules above already fix, found while
 * verifying the About page: those rules zero the 24px default BETWEEN an
 * osta-frame element's OWN children. They do nothing when the osta-frame
 * element is itself sitting as a top-level child of core/post-content's
 * own wrapper (<div class="entry-content wp-block-post-content is-
 * layout-flow">, generated automatically by that block, never written in
 * any template file this theme owns) — every one of a page's own
 * section-level blocks (osta-hero, about-who-we-are, …) is a direct
 * child of THAT wrapper once real page content lives in post_content, so
 * each one still picked up an unwanted 24px gap from outside itself.
 * A plain, low-specificity class selector is enough to win against the
 * zero-specificity :where() rule feeding that gap in, regardless of
 * which container is the source — deliberately unconditional (not
 * scoped to a specific parent), since every osta-frame element is
 * already vetted, structural, and never wants blockGap spacing either
 * way. Client-typed content in post_content is never marked osta-frame,
 * so her own paragraphs/images keep the normal 24px rhythm between them,
 * completely unaffected.
 */
.osta-frame {
  margin-block-start: 0;
}

/*
 * PASS "Block theme conversion, Phase 4 (About)" — .about-editor-content
 * is NOT marked osta-frame, on purpose, even though it sits directly in
 * core/post-content's own wrapper the exact same way every section
 * above it does. Every osta-frame-scoped rule in this file (including
 * the :not(.osta-frame *) heading-margin exclusion higher up) treats
 * "has an osta-frame ancestor" as "purely structural, never client
 * content" — but this wrapper's whole job is to reproduce the original
 * PHP template's <div class="container about-editor-content"><article
 * class="entry-content"> shell around the page's own the_content()
 * trailing region, which On the classic PHP path always was, and here
 * still is, the CLIENT's real, editable prose (confirmed live: the
 * "Browse by Topic Cards" pattern already sitting in this page's
 * post_content before this pass touched anything). Marking these two
 * wrapper Groups osta-frame silently opted every heading and paragraph
 * inside them OUT of the normal .entry-content prose treatment instead
 * — caught by the exact height-parity check this fix category was
 * built for. This narrower rule gives the outer wrapper the one thing
 * it actually needs (immunity from blockGap's margin-block-start on
 * itself, so it still sits flush against the section above it, same as
 * the PHP render) without opting its own children out of anything.
 */
.entry-content > .about-editor-content {
  margin-block-start: 0;
}

/*
 * PASS "Block theme conversion" — second systemic spacing source found
 * while chasing the same single.html drift: WordPress's own block-canvas
 * (wp-includes/template-canvas.php, used for every block-template page)
 * wraps the WHOLE template in <div class="wp-site-blocks">, and core
 * itself emits a zero-specificity default —
 *   :where(.wp-site-blocks) > * { margin-block-start: 24px; }
 *   :where(.wp-site-blocks) > :first-child { margin-block-start: 0; }
 * — meant for a client who's built a template visually as several
 * stacked top-level sections. On this theme every block-template page's
 * top level is always exactly header part → osta/page-frame (<main>) →
 * footer part, never client-arranged sections, so the 24px gap this adds
 * before <main> AND before the footer part (48px total) has no
 * equivalent in the original design at all — header, main and footer
 * always sit flush. Confirmed present on Leadership (templates/page.html)
 * as well as templates/single.html — genuinely sitewide, not single.html-
 * specific, and NOT caught by Phase 2's own before/after screenshots,
 * since both pages chosen for that test (home, the chapter portal) reach
 * header.php/footer.php through their own PHP Template Name files and
 * never actually render via a block template — flagging that gap
 * honestly rather than letting the earlier "byte-identical" claim stand
 * unqualified.
 *
 * No marker class needed here, unlike .osta-frame — the top level of
 * .wp-site-blocks is never client content on this theme (page content
 * lives inside <main>, not as sibling top-level blocks), so this is safe
 * to override unconditionally, sitewide, once.
 */
.wp-site-blocks > * {
  margin-block-start: 0;
}
