/* ==========================================================================
   PKOUG Design System — Components
   --------------------------------------------------------------------------
   Requires pkoug-tokens.css to be loaded first.

   Every class is namespaced `pk-` so this file cannot collide with the
   the legacy style.css / home.css / pages.css rules while both were in play.
   Those three are gone now; nothing here needs to defer to them any more
   during the phased rollout.

   Contents
     1.  Base & reset
     2.  Layout
     3.  Typography
     4.  Links & focus
     5.  Buttons
     6.  Forms
     7.  Cards
     8.  Alerts
     9.  Badges & status pills
     10. Tables
     11. Modal dialog
     12. Site header & navigation
     13. Breadcrumbs
     14. Pagination
     15. Empty states
     16. Loading states
     17. Avatars
     18. Member / profile cards
     19. Event cards
     20. Sponsor cards
     21. Footer
     22. Utilities
   ========================================================================== */

/* ==========================================================================
   1. BASE & RESET
   ========================================================================== */

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

.pk-scope,
body.pk-body {
  margin: 0;
  background: var(--pk-bg);
  color: var(--pk-ink);
  font-family: var(--pk-font-sans);
  font-size: var(--pk-text-base);
  line-height: var(--pk-leading-normal);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* Give in-page anchors room to clear the sticky header. */
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}
:target { scroll-margin-top: 6rem; }

img,
svg,
video { max-width: 100%; height: auto; }

/* Skip link — offscreen until focused.
   Positioned with `top` rather than a transform: a transformed element can
   still be scrolled to and, more importantly, the offset is animated, which
   left it stranded offscreen on focus in testing. Changing `top` is the
   long-established pattern and behaves identically in every browser. */
.pk-skip-link {
  position: fixed;
  top: -100px;
  left: var(--pk-space-4);
  z-index: var(--pk-z-toast);
  background: var(--pk-surface);
  color: var(--pk-link);
  border: 2px solid var(--pk-focus-ring);
  border-radius: var(--pk-radius-md);
  padding: var(--pk-space-3) var(--pk-space-4);
  font-weight: var(--pk-weight-semibold);
  text-decoration: none;
  box-shadow: var(--pk-shadow-lg);
}
.pk-skip-link:focus,
.pk-skip-link:focus-visible { top: var(--pk-space-4); }

/* Screen-reader-only text that is still focusable when needed. */
.pk-visually-hidden {
  position: absolute !important;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ==========================================================================
   2. LAYOUT
   ========================================================================== */

.pk-container {
  width: 100%;
  max-width: var(--pk-container);
  margin-inline: auto;
  padding-inline: var(--pk-container-pad);
}
.pk-container--narrow { max-width: 780px; }
.pk-container--wide   { max-width: 1400px; }

.pk-section { padding-block: var(--pk-section-y); }
.pk-section--tight { padding-block: var(--pk-space-10); }
.pk-section--sunk  { background: var(--pk-surface-sunk); }

.pk-stack > * + * { margin-top: var(--pk-space-4); }
.pk-stack--sm > * + * { margin-top: var(--pk-space-2); }
.pk-stack--lg > * + * { margin-top: var(--pk-space-8); }

.pk-cluster {
  display: flex;
  flex-wrap: wrap;
  gap: var(--pk-space-3);
  align-items: center;
}

/* Auto-fitting grid: the min column width is the only thing to tune. */
.pk-grid {
  display: grid;
  gap: var(--pk-space-6);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
}
.pk-grid--2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); }
/* --3 was used by events.php, index.php and admin/event_edit.php without ever
   being defined, so all three silently fell back to the base 280px auto-fit.
   That happened to look right at desktop width and wrong between 840 and
   1120px, where the base rule fits four tracks and the markup expects three. */
.pk-grid--3 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); }
.pk-grid--4 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); }

/* ==========================================================================
   3. TYPOGRAPHY
   ========================================================================== */

.pk-h1, .pk-h2, .pk-h3, .pk-h4 {
  margin: 0;
  color: var(--pk-ink);
  font-weight: var(--pk-weight-heavy);
  line-height: var(--pk-leading-tight);
  letter-spacing: var(--pk-tracking-tight);
  text-wrap: balance;
}
.pk-h1 { font-size: var(--pk-text-4xl); }
.pk-h2 { font-size: var(--pk-text-3xl); }
.pk-h3 { font-size: var(--pk-text-2xl); font-weight: var(--pk-weight-bold); }
.pk-h4 { font-size: var(--pk-text-xl);  font-weight: var(--pk-weight-bold); }

.pk-display {
  margin: 0;
  font-size: var(--pk-text-5xl);
  font-weight: var(--pk-weight-heavy);
  line-height: var(--pk-leading-tight);
  letter-spacing: var(--pk-tracking-tight);
  text-wrap: balance;
}

.pk-eyebrow {
  display: inline-block;
  margin: 0 0 var(--pk-space-3);
  color: var(--pk-link);
  font-size: var(--pk-text-sm);
  font-weight: var(--pk-weight-bold);
  letter-spacing: var(--pk-tracking-wide);
  text-transform: uppercase;
}

/* The place name in an eyebrow is a brand mark, and takes the same green as
   the highlighted `Pakistan` in the hero heading below it. Recolouring one
   word inside an otherwise uniform red line reads as a mistake, so the
   separators are muted as well: that turns the line into three distinct
   tokens, and the green one is plainly the first token rather than a word
   that lost its colour. `--pk-ink-muted`, not `--pk-ink-faint` — these are
   real characters in the text, not decoration. */
.pk-eyebrow__mark { color: var(--pk-brand-green-ink); }
.pk-eyebrow__sep  { color: var(--pk-ink-muted); font-weight: var(--pk-weight-semibold); }

.pk-lead {
  margin: 0;
  max-width: var(--pk-measure);
  color: var(--pk-ink-soft);
  font-size: var(--pk-text-lg);
  line-height: var(--pk-leading-relaxed);
  text-wrap: pretty;
}

.pk-prose { max-width: var(--pk-measure); color: var(--pk-ink-soft); }
.pk-prose > * + * { margin-top: var(--pk-space-4); }
.pk-prose p { margin: 0; text-wrap: pretty; }

.pk-muted { color: var(--pk-ink-muted); }
.pk-faint { color: var(--pk-ink-faint); font-size: var(--pk-text-sm); }

/* Brand wordmark: "Pk" green, "OUG" red. */
.pk-wordmark { font-weight: var(--pk-weight-heavy); letter-spacing: var(--pk-tracking-tight); }
.pk-wordmark .pk-wordmark__pk  { color: var(--pk-brand-green); }
.pk-wordmark .pk-wordmark__oug { color: var(--pk-brand-red-ink); }

/* ==========================================================================
   4. LINKS & FOCUS
   ========================================================================== */

/* The blanket link colour must not capture anchors that are styled as
   buttons, chips or nav items — those carry their own colour, and a bare
   `a` selector here outranks the single-class `.pk-btn` rule.

   THE NAV EXCLUSION MATCHES `nav__`, NOT `pk-nav__`, AND THAT IS THE FIX.
   It read `[class*="pk-nav__"]`, which excludes the public site's
   `pk-nav__link` and does NOT exclude `pk-admin-nav__link` — that string
   contains `admin-nav__`, and `pk-nav__` never appears in it. So every link
   in the admin section bar was captured here at specificity (0,5,1), beating
   its own `.pk-admin-nav__link` rule at (0,1,0): ten links in brand red, with
   the current section distinguishable only by a faint background.

   Nothing reported it. The class had a rule, the rule was in a sheet the page
   linked, and the token it named was defined — so tools/test_admin_styles.php
   was green throughout. That suite asks whether a rule EXISTS; this was a rule
   that existed and lost. The two are different questions and this is the first
   time the second one has bitten.

   Widened rather than given a fifth `:not()` on each of four selectors: the
   comment above already says "nav items", and `nav__` is what that means in
   this codebase's naming. Specificity is unchanged, so nothing else moves. */
.pk-scope a:not([class*="pk-btn"]):not([class*="pk-chip"]):not([class*="nav__"]):not([class*="pk-pagination__"]),
.pk-body a:not([class*="pk-btn"]):not([class*="pk-chip"]):not([class*="nav__"]):not([class*="pk-pagination__"]) {
  color: var(--pk-link);
  text-underline-offset: 0.2em;
}
.pk-scope a:not([class*="pk-btn"]):not([class*="pk-chip"]):not([class*="nav__"]):not([class*="pk-pagination__"]):hover,
.pk-body a:not([class*="pk-btn"]):not([class*="pk-chip"]):not([class*="nav__"]):not([class*="pk-pagination__"]):hover {
  color: var(--pk-link-hover);
}

/* One focus treatment for everything interactive. Uses :focus-visible so a
   mouse click does not leave a ring behind, but keyboard focus always does.
   Never paired with `outline: none` and no replacement. */
.pk-scope :focus-visible,
.pk-body :focus-visible,
.pk-btn:focus-visible,
.pk-input:focus-visible,
.pk-select:focus-visible,
.pk-textarea:focus-visible {
  outline: var(--pk-focus-width) solid var(--pk-focus-ring);
  outline-offset: var(--pk-focus-offset);
  border-radius: var(--pk-radius-sm);
}

/* ==========================================================================
   5. BUTTONS
   ========================================================================== */

.pk-btn {
  --_bg: transparent;
  --_fg: var(--pk-ink);
  --_bd: var(--pk-border-strong);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--pk-space-2);
  /* min-height 44px keeps every button above the WCAG 2.2 target size. */
  min-height: 44px;
  padding: var(--pk-space-3) var(--pk-space-5);
  border: 1px solid var(--_bd);
  border-radius: var(--pk-radius-md);
  background: var(--_bg);
  color: var(--_fg);
  font-family: inherit;
  font-size: var(--pk-text-base);
  font-weight: var(--pk-weight-semibold);
  line-height: 1.2;
  text-decoration: none;
  cursor: pointer;
  transition: background-color var(--pk-dur) var(--pk-ease),
              border-color var(--pk-dur) var(--pk-ease),
              color var(--pk-dur) var(--pk-ease),
              transform var(--pk-dur-fast) var(--pk-ease);
}
.pk-btn:hover { transform: translateY(-1px); }
.pk-btn:active { transform: translateY(0); }
.pk-btn[disabled],
.pk-btn[aria-disabled="true"] {
  opacity: 0.55;
  cursor: not-allowed;
  transform: none;
}

/* Primary — brand-red fill, white label. Verified 4.54:1 in both themes. */
.pk-btn--primary {
  --_bg: var(--pk-brand-red);
  --_fg: var(--pk-on-brand);
  --_bd: var(--pk-brand-red);
}
.pk-btn--primary:hover:not([disabled]) {
  --_bg: var(--pk-brand-red-hover);
  --_bd: var(--pk-brand-red-hover);
  color: var(--pk-on-brand);
}

.pk-btn--secondary {
  --_bg: var(--pk-surface);
  --_fg: var(--pk-ink);
  --_bd: var(--pk-border-strong);
}
.pk-btn--secondary:hover:not([disabled]) {
  --_bd: var(--pk-brand-red-ink);
  --_fg: var(--pk-link);
}

.pk-btn--ghost { --_bg: transparent; --_fg: var(--pk-link); --_bd: transparent; }
.pk-btn--ghost:hover:not([disabled]) { --_bg: var(--pk-brand-red-tint); }

.pk-btn--danger {
  --_bg: var(--pk-danger);
  --_fg: #FFFFFF;
  --_bd: var(--pk-danger);
}

.pk-btn--sm { min-height: 36px; padding: var(--pk-space-2) var(--pk-space-3); font-size: var(--pk-text-sm); }
.pk-btn--lg { min-height: 52px; padding: var(--pk-space-4) var(--pk-space-8); font-size: var(--pk-text-lg); }
.pk-btn--block { display: flex; width: 100%; }

/* Icon-only button — keeps a 44px hit area regardless of glyph size. */
.pk-btn-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 1px solid var(--pk-border-strong);
  border-radius: var(--pk-radius-md);
  background: var(--pk-surface);
  color: var(--pk-ink-soft);
  cursor: pointer;
  transition: background-color var(--pk-dur) var(--pk-ease),
              color var(--pk-dur) var(--pk-ease);
}
.pk-btn-icon:hover { background: var(--pk-surface-sunk); color: var(--pk-link); }

/* Busy state, toggled by pkoug.js */
.pk-btn[data-busy="true"] { pointer-events: none; opacity: 0.7; }
.pk-btn[data-busy="true"]::before {
  content: "";
  width: 1em; height: 1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: pk-spin 0.6s linear infinite;
}

/* ==========================================================================
   6. FORMS
   ========================================================================== */

.pk-field { display: block; margin-bottom: var(--pk-space-5); }

.pk-label {
  display: block;
  margin-bottom: var(--pk-space-2);
  color: var(--pk-ink);
  font-size: var(--pk-text-sm);
  font-weight: var(--pk-weight-semibold);
}

/* Required marker carries text for screen readers, not just an asterisk. */
.pk-required { color: var(--pk-danger); font-weight: var(--pk-weight-bold); }

.pk-hint {
  margin: var(--pk-space-2) 0 0;
  color: var(--pk-ink-muted);
  font-size: var(--pk-text-sm);
}

/*
 * F3. WebKit sizes a <select>'s scrollable content from its longest OPTION,
 * not from its rendered box. On admin/admins.php at 360px the Role select
 * rendered at 278px — comfortably inside its container — while still pushing
 * document scrollWidth out by 63px, so the PAGE scrolled sideways in WebKit
 * and Safari while Chromium was clean.
 *
 * width:100% and min-width:0 do not help, because the box was never the
 * problem. Clipping the select's own overflow is what stops it contributing;
 * the rendered width is identical either way, so nothing moves visually and
 * the control still opens its full-width popup natively.
 *
 * THIS RULE IS FOR .pk-select ALONE. `.pk-input` was once part of its selector
 * list — a stray `.pk-input,` sat above this comment — which silently took
 * every text input on the site out of the base rule below and left it with
 * nothing but `overflow: hidden`. See the note on that rule.
 */
.pk-select {
  overflow: hidden;
  text-overflow: ellipsis;
}

/*
 * Every text control on the site. `.pk-input` MUST stay in this list.
 *
 * It was missing from it between f93336c and this commit, and the damage was
 * total rather than cosmetic: inputs fell back to the user-agent stylesheet at
 * 21px tall with a 2px inset border and a 13.3px font. That silently undid two
 * deliberate decisions recorded right here — the 44px minimum target size, and
 * the 16px font floor that stops iOS Safari zooming the page on focus. The
 * admin sign-in, the ACE guidance form and every search box were affected.
 *
 * Nothing caught it: the suites assert markup, and Q8 asserted labels,
 * contrast and focus rings — none of them measured a control's geometry. Q8
 * now does.
 */
.pk-input,
.pk-select,
.pk-textarea {
  display: block;
  width: 100%;
  min-height: 44px;
  padding: var(--pk-space-3) var(--pk-space-4);
  border: 1px solid var(--pk-border-strong);
  border-radius: var(--pk-radius-md);
  background: var(--pk-surface);
  color: var(--pk-ink);
  font-family: inherit;
  /* 16px minimum prevents iOS Safari from zooming on focus. */
  font-size: var(--pk-text-base);
  line-height: 1.4;
  transition: border-color var(--pk-dur) var(--pk-ease),
              box-shadow var(--pk-dur) var(--pk-ease);
}

/* Reset the UA appearance so `type="search"` and `type="date"` cannot bring
   their own box back. The search cancel button goes with it — it is
   inconsistent between engines and the field already sits beside a Search
   button. */
.pk-input {
  -webkit-appearance: none;
  appearance: none;
}
.pk-input::-webkit-search-cancel-button,
.pk-input::-webkit-search-decoration { -webkit-appearance: none; }
.pk-input::placeholder,
.pk-textarea::placeholder { color: var(--pk-ink-faint); }

.pk-input:hover,
.pk-select:hover,
.pk-textarea:hover { border-color: var(--pk-ink-faint); }

.pk-input:focus,
.pk-select:focus,
.pk-textarea:focus {
  border-color: var(--pk-focus-ring);
  box-shadow: 0 0 0 3px var(--pk-focus-halo);
}

.pk-textarea { min-height: 120px; resize: vertical; }

.pk-select {
  appearance: none;
  padding-right: var(--pk-space-10);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666F7C'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--pk-space-3) center;
  background-size: 20px 20px;
}

/* Invalid state — driven by aria-invalid so the styling and the accessible
   state can never drift apart. */
.pk-input[aria-invalid="true"],
.pk-select[aria-invalid="true"],
.pk-textarea[aria-invalid="true"] {
  border-color: var(--pk-danger);
  background: var(--pk-danger-tint);
}
.pk-input[aria-invalid="true"]:focus,
.pk-textarea[aria-invalid="true"]:focus { box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.22); }

.pk-error {
  display: flex;
  align-items: flex-start;
  gap: var(--pk-space-2);
  margin: var(--pk-space-2) 0 0;
  color: var(--pk-danger);
  font-size: var(--pk-text-sm);
  font-weight: var(--pk-weight-medium);
}
.pk-error:empty { display: none; }

/* Checkbox / radio — 24px control with a comfortable clickable label. */
.pk-check {
  display: flex;
  align-items: flex-start;
  gap: var(--pk-space-3);
  min-height: 44px;
  padding: var(--pk-space-2) 0;
  cursor: pointer;
}
.pk-check input[type="checkbox"],
.pk-check input[type="radio"] {
  flex: none;
  width: 24px; height: 24px;
  margin: 0;
  accent-color: var(--pk-brand-red);
  cursor: pointer;
}
.pk-check__text { font-size: var(--pk-text-base); line-height: 1.4; }

/* Password field with visibility toggle. The toggle is inert without JS,
   so pkoug.js injects it rather than it being hard-coded in markup. */
.pk-password { position: relative; }
.pk-password .pk-input { padding-right: 3rem; }
.pk-password__toggle {
  position: absolute;
  top: 50%;
  right: var(--pk-space-2);
  transform: translateY(-50%);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px; height: 40px;
  border: 0;
  border-radius: var(--pk-radius-sm);
  background: transparent;
  color: var(--pk-ink-muted);
  cursor: pointer;
}
.pk-password__toggle:hover { color: var(--pk-link); background: var(--pk-surface-sunk); }

.pk-fieldset {
  margin: 0 0 var(--pk-space-6);
  padding: var(--pk-space-5);
  border: 1px solid var(--pk-border);
  border-radius: var(--pk-radius-lg);
}
.pk-fieldset > legend {
  padding-inline: var(--pk-space-2);
  font-size: var(--pk-text-sm);
  font-weight: var(--pk-weight-bold);
  letter-spacing: var(--pk-tracking-wide);
  text-transform: uppercase;
  color: var(--pk-ink-muted);
}

.pk-form-row {
  display: grid;
  gap: var(--pk-space-4);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
}

/* ==========================================================================
   7. CARDS
   ========================================================================== */

.pk-card {
  display: flex;
  flex-direction: column;
  padding: var(--pk-space-6);
  border: 1px solid var(--pk-border);
  border-radius: var(--pk-radius-lg);
  background: var(--pk-surface);
  box-shadow: var(--pk-shadow-sm);
}
.pk-card--flat  { box-shadow: none; }
.pk-card--sunk  { background: var(--pk-surface-sunk); box-shadow: none; }
.pk-card--pad-lg { padding: var(--pk-space-8); }

/* Interactive card: the whole card is clickable via the stretched-link
   pattern, but only the inner <a> is a real tab stop. */
.pk-card--link { position: relative; transition: box-shadow var(--pk-dur) var(--pk-ease), transform var(--pk-dur) var(--pk-ease); }
.pk-card--link:hover { box-shadow: var(--pk-shadow-lg); transform: translateY(-2px); }
.pk-card--link:focus-within { box-shadow: var(--pk-shadow-lg); }
.pk-card__link::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }

.pk-card__title { margin: 0 0 var(--pk-space-2); font-size: var(--pk-text-xl); font-weight: var(--pk-weight-bold); line-height: var(--pk-leading-snug); }
.pk-card__body  { margin: 0; color: var(--pk-ink-soft); }
.pk-card__footer {
  display: flex;
  flex-wrap: wrap;
  gap: var(--pk-space-3);
  align-items: center;
  margin-top: auto;
  padding-top: var(--pk-space-5);
}

/* ==========================================================================
   8. ALERTS
   ========================================================================== */

.pk-alert {
  --_tint: var(--pk-info-tint);
  --_edge: var(--pk-info-edge);
  --_ink: var(--pk-info);
  display: flex;
  gap: var(--pk-space-3);
  padding: var(--pk-space-4) var(--pk-space-5);
  border: 1px solid var(--_edge);
  border-left-width: 4px;
  border-radius: var(--pk-radius-md);
  background: var(--_tint);
  color: var(--pk-ink);
}
.pk-alert__icon  { flex: none; color: var(--_ink); line-height: 1; }
.pk-alert__title { margin: 0 0 var(--pk-space-1); font-weight: var(--pk-weight-bold); color: var(--_ink); }
.pk-alert__body  { margin: 0; color: var(--pk-ink-soft); font-size: var(--pk-text-sm); }

.pk-alert--success { --_tint: var(--pk-success-tint); --_edge: var(--pk-success-edge); --_ink: var(--pk-success); }
.pk-alert--warning { --_tint: var(--pk-warning-tint); --_edge: var(--pk-warning-edge); --_ink: var(--pk-warning); }
.pk-alert--error   { --_tint: var(--pk-danger-tint);  --_edge: var(--pk-danger-edge);  --_ink: var(--pk-danger); }

/* An outcome the reader did not have a moment ago — a success, a refusal, an
   empty result — arrives rather than being found already there. It is a CSS
   animation, not a JS-applied class, so the state is on the page for a reader
   with no JavaScript exactly as it is for everyone else; the animation is the
   only thing they miss.

   NOT in the admin area. Admin screens are tools, and a flash on every save
   confirmation becomes noise to someone working through a queue. `.pk-admin`
   is on <body> there, so this scopes itself off without each screen opting
   out. */
.pk-body:not(.pk-admin) .pk-alert,
.pk-body:not(.pk-admin) .pk-empty {
  animation: pk-state-in var(--pk-dur-slow) var(--pk-ease-out);
}

@keyframes pk-state-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}

/* ==========================================================================
   9. BADGES & STATUS PILLS
   ========================================================================== */

.pk-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--pk-space-1);
  padding: 0.25em 0.7em;
  border: 1px solid var(--pk-border-strong);
  border-radius: var(--pk-radius-full);
  background: var(--pk-surface-sunk);
  color: var(--pk-ink-soft);
  font-size: var(--pk-text-xs);
  font-weight: var(--pk-weight-bold);
  letter-spacing: var(--pk-tracking-wide);
  text-transform: uppercase;
  white-space: nowrap;
}
.pk-badge--brand   { background: var(--pk-brand-red-tint);  border-color: var(--pk-brand-red-edge); color: var(--pk-brand-red-ink); }
.pk-badge--success { background: var(--pk-success-tint);    border-color: var(--pk-success-edge);   color: var(--pk-success); }
.pk-badge--warning { background: var(--pk-warning-tint);    border-color: var(--pk-warning-edge);   color: var(--pk-warning); }
.pk-badge--danger  { background: var(--pk-danger-tint);     border-color: var(--pk-danger-edge);    color: var(--pk-danger); }
.pk-badge--info    { background: var(--pk-info-tint);       border-color: var(--pk-info-edge);      color: var(--pk-info); }

/* Verified marker. The tick is decorative; the word carries the meaning so
   the status is never colour-or-icon-only. */
.pk-verified {
  display: inline-flex;
  align-items: center;
  gap: var(--pk-space-1);
  color: var(--pk-success);
  font-size: var(--pk-text-xs);
  font-weight: var(--pk-weight-bold);
}
.pk-verified svg { flex: none; }

/* Skill / tag chips */
.pk-chip {
  display: inline-flex;
  align-items: center;
  padding: 0.3em 0.75em;
  border: 1px solid var(--pk-border);
  border-radius: var(--pk-radius-full);
  background: var(--pk-surface-sunk);
  color: var(--pk-ink-soft);
  font-size: var(--pk-text-xs);
  font-weight: var(--pk-weight-medium);
  white-space: nowrap;
}
a.pk-chip:hover { border-color: var(--pk-brand-red-ink); color: var(--pk-link); }

/* ==========================================================================
   10. TABLES
   ========================================================================== */

/* Wrapper owns the horizontal scroll so the page body never does.
   tabindex="0" is set by pkoug.js when the table actually overflows, so
   keyboard users can scroll it. */
.pk-table-wrap {
  overflow-x: auto;
  border: 1px solid var(--pk-border);
  border-radius: var(--pk-radius-lg);
  background: var(--pk-surface);
  -webkit-overflow-scrolling: touch;
}
.pk-table-wrap:focus-visible { outline: var(--pk-focus-width) solid var(--pk-focus-ring); outline-offset: 2px; }

.pk-table { width: 100%; border-collapse: collapse; font-size: var(--pk-text-sm); }
.pk-table th,
.pk-table td { padding: var(--pk-space-3) var(--pk-space-4); text-align: left; vertical-align: middle; }
.pk-table thead th {
  position: sticky;
  top: 0;
  background: var(--pk-surface-sunk);
  color: var(--pk-ink-muted);
  font-size: var(--pk-text-xs);
  font-weight: var(--pk-weight-bold);
  letter-spacing: var(--pk-tracking-wide);
  text-transform: uppercase;
  white-space: nowrap;
  border-bottom: 1px solid var(--pk-border);
}
.pk-table tbody tr + tr td { border-top: 1px solid var(--pk-divider); }
.pk-table tbody tr:hover td { background: var(--pk-surface-sunk); }
.pk-table__num { text-align: right; font-variant-numeric: tabular-nums; }

/* Below 768px each row becomes a stacked block. The column name comes from
   data-label on the cell, so no duplicated markup is needed. */
@media (max-width: 767px) {
  /* The whole table drops out of table layout. Leaving `display: table` on
     the element while the rows are blocks makes the browser synthesise
     anonymous table boxes, which keeps the intrinsic column widths and
     forces the container to scroll sideways. */
  .pk-table-wrap:has(.pk-table--stack) { overflow-x: visible; }
  .pk-table--stack,
  .pk-table--stack tbody { display: block; width: 100%; }
  .pk-table--stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); }
  .pk-table--stack tbody tr {
    display: block;
    padding: var(--pk-space-3) var(--pk-space-4);
    border-bottom: 1px solid var(--pk-border);
  }
  .pk-table--stack tbody tr + tr td { border-top: 0; }
  .pk-table--stack td {
    display: grid;
    grid-template-columns: 40% 1fr;
    gap: var(--pk-space-3);
    padding: var(--pk-space-2) 0;
  }
  .pk-table--stack td::before {
    content: attr(data-label);
    color: var(--pk-ink-muted);
    font-size: var(--pk-text-xs);
    font-weight: var(--pk-weight-bold);
    letter-spacing: var(--pk-tracking-wide);
    text-transform: uppercase;
  }
  .pk-table--stack td:empty { display: none; }
}

/* ==========================================================================
   11. MODAL DIALOG
   Built on <dialog>, so Esc-to-close, focus trapping and inertness of the
   background come from the platform rather than from script.
   ========================================================================== */

.pk-modal {
  width: min(560px, calc(100vw - 2rem));
  padding: 0;
  border: 1px solid var(--pk-border);
  border-radius: var(--pk-radius-lg);
  background: var(--pk-surface);
  color: var(--pk-ink);
  box-shadow: var(--pk-shadow-lg);
}
.pk-modal::backdrop { background: var(--pk-overlay); }

.pk-modal__header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--pk-space-4);
  padding: var(--pk-space-5) var(--pk-space-6);
  border-bottom: 1px solid var(--pk-border);
}
.pk-modal__title { margin: 0; font-size: var(--pk-text-xl); font-weight: var(--pk-weight-bold); }
.pk-modal__body  { padding: var(--pk-space-6); color: var(--pk-ink-soft); }
.pk-modal__footer {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: var(--pk-space-3);
  padding: var(--pk-space-5) var(--pk-space-6);
  border-top: 1px solid var(--pk-border);
  background: var(--pk-surface-sunk);
  border-radius: 0 0 var(--pk-radius-lg) var(--pk-radius-lg);
}

/* ==========================================================================
   12. SITE HEADER & NAVIGATION
   ========================================================================== */

.pk-header {
  position: sticky;
  top: 0;
  z-index: var(--pk-z-header);
  border-bottom: 1px solid var(--pk-border);
}

/* ==========================================================================
   THE GLASS IS ON A PSEUDO-ELEMENT, AND THAT IS NOT A STYLE CHOICE
   ==========================================================================

   `backdrop-filter` makes an element a CONTAINING BLOCK for its own
   `position: fixed` descendants. It was on `.pk-header`, and the mobile
   navigation panel is a `position: fixed` descendant of `.pk-header`.

   So `inset: 72px 0 0` on the panel — written to mean "the viewport, below
   the header" — resolved against the 73px header box instead. The menu opened
   into a 64px strip with the page showing underneath, on every phone, while
   reading as a rendering glitch rather than as a layout bug. Nothing warned:
   the markup was right, the JavaScript was right, `position: fixed` was
   honoured exactly as specified, and the specification is that the header had
   become the viewport as far as that panel was concerned.

   `view-transition-name` on the header has the same power and was measured as
   NOT contributing here — with the blur removed the panel was full height
   whether the name was set or not — so it stays where §24 put it.

   Moving the blur one level down fixes it with no visual change at all: the
   pseudo-element fills the header, sits behind its content, and blurs exactly
   the same backdrop. The header itself is left with no property that creates
   a containing block, so a future `position: fixed` child cannot be caught by
   this again.

   `.pk-progress` is unaffected: it is `position: absolute` and takes its
   containing block from `position: sticky`, which is still here.
   ========================================================================== */
.pk-header::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;              /* behind the header's content, above the page */
  pointer-events: none;
  background: color-mix(in srgb, var(--pk-surface) 92%, transparent);
  backdrop-filter: blur(10px);
}
@supports not (backdrop-filter: blur(10px)) {
  .pk-header::before { background: var(--pk-surface); }
}

.pk-header__inner {
  display: flex;
  align-items: center;
  gap: var(--pk-space-4);
  min-height: 72px;
}

.pk-brand {
  display: inline-flex;
  align-items: center;
  gap: var(--pk-space-3);
  text-decoration: none;
  color: inherit;
  margin-right: auto;
}
.pk-brand__logo { width: 44px; height: 44px; object-fit: contain; flex: none; }
.pk-brand__name { display: block; font-size: var(--pk-text-lg); line-height: 1.1; }
.pk-brand__sub  { display: block; color: var(--pk-ink-muted); font-size: var(--pk-text-xs); font-weight: var(--pk-weight-medium); }

.pk-nav__toggle { display: inline-flex; }

/* At desktop the nav is not a disclosure at all. This override makes the
   layout correct even if the script's `hidden` bookkeeping is stale — the
   nav can never end up invisible on a wide screen. */
@media (min-width: 1024px) {
  .pk-nav__toggle { display: none; }
  .pk-nav,
  .pk-nav[hidden] { display: flex; align-items: center; gap: var(--pk-space-4); margin-left: auto; }
}

.pk-nav__list {
  display: flex;
  align-items: center;
  gap: var(--pk-space-1);
  margin: 0;
  padding: 0;
  list-style: none;
}

.pk-nav__link {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--pk-space-1);
  min-height: 44px;
  padding: var(--pk-space-2) var(--pk-space-3);
  border: 0;
  border-radius: var(--pk-radius-md);
  background: transparent;
  color: var(--pk-ink-soft);
  font-family: inherit;
  font-size: var(--pk-text-sm);
  font-weight: var(--pk-weight-semibold);
  text-decoration: none;
  cursor: pointer;
  white-space: nowrap;
  /* Colour only. A nav link must not move on hover: these sit in a row and a
     translate on one of them nudges the eye along the whole bar. */
  transition: background-color var(--pk-dur-fast) var(--pk-ease),
              color var(--pk-dur-fast) var(--pk-ease);
}
.pk-nav__link:hover { background: var(--pk-surface-sunk); color: var(--pk-link); }

/* Current page marked with a bar, not colour alone. */
.pk-nav__link[aria-current="page"] { color: var(--pk-link); background: var(--pk-brand-red-tint); }
.pk-nav__link[aria-current="page"]::before {
  content: "";
  position: absolute;
  bottom: 0; left: var(--pk-space-3); right: var(--pk-space-3);
  height: 2px;
  background: var(--pk-brand-red-ink);
  border-radius: var(--pk-radius-full);
}
.pk-nav__item { position: relative; }

/* Dropdown. Hidden with [hidden] so it is genuinely removed from the a11y
   tree when closed, rather than merely visually hidden. */
.pk-dropdown {
  position: absolute;
  top: calc(100% + var(--pk-space-2));
  left: 0;
  z-index: var(--pk-z-dropdown);
  min-width: 260px;
  padding: var(--pk-space-2);
  border: 1px solid var(--pk-border);
  border-radius: var(--pk-radius-lg);
  background: var(--pk-surface);
  box-shadow: var(--pk-shadow-lg);
}
.pk-dropdown[hidden] { display: none; }
.pk-dropdown__item {
  display: block;
  padding: var(--pk-space-3);
  border-radius: var(--pk-radius-sm);
  color: var(--pk-ink-soft);
  font-size: var(--pk-text-sm);
  font-weight: var(--pk-weight-medium);
  text-decoration: none;
}
.pk-dropdown__item:hover { background: var(--pk-surface-sunk); color: var(--pk-link); }
.pk-dropdown__note { display: block; color: var(--pk-ink-faint); font-size: var(--pk-text-xs); font-weight: var(--pk-weight-normal); }

.pk-nav__actions { display: flex; align-items: center; gap: var(--pk-space-2); }

/* --- Mobile navigation ------------------------------------------------- */
@media (max-width: 1023px) {
  .pk-nav {
    position: fixed;
    inset: 72px 0 0;
    z-index: var(--pk-z-overlay);
    padding: var(--pk-space-4) var(--pk-container-pad) var(--pk-space-12);
    background: var(--pk-surface);
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  .pk-nav[hidden] { display: none; }
  .pk-nav__list { flex-direction: column; align-items: stretch; gap: 0; }
  .pk-nav__link { width: 100%; justify-content: space-between; padding: var(--pk-space-4) var(--pk-space-2); font-size: var(--pk-text-base); border-bottom: 1px solid var(--pk-divider); border-radius: 0; }
  .pk-nav__link[aria-current="page"]::before { display: none; }
  .pk-nav__link[aria-current="page"] { border-left: 3px solid var(--pk-brand-red-ink); padding-left: var(--pk-space-4); }
  /* Dropdowns become inline accordions rather than floating panels. */
  .pk-dropdown {
    position: static;
    min-width: 0;
    margin: 0 0 var(--pk-space-2);
    border: 0;
    border-radius: 0;
    box-shadow: none;
    background: var(--pk-surface-sunk);
  }
  .pk-dropdown__item { padding: var(--pk-space-3) var(--pk-space-5); }
  .pk-nav__actions { margin-top: var(--pk-space-6); }
  .pk-nav__actions .pk-btn { flex: 1; }
}

/* Locks background scroll while the mobile nav is open. */
body.pk-nav-open { overflow: hidden; }

/* ==========================================================================
   13. BREADCRUMBS
   ========================================================================== */

.pk-breadcrumb { margin-block: var(--pk-space-5); font-size: var(--pk-text-sm); }
.pk-breadcrumb__list { display: flex; flex-wrap: wrap; align-items: center; gap: var(--pk-space-2); margin: 0; padding: 0; list-style: none; }
.pk-breadcrumb__item { display: inline-flex; align-items: center; gap: var(--pk-space-2); color: var(--pk-ink-muted); }
.pk-breadcrumb__item:not(:last-child)::after { content: "/"; color: var(--pk-ink-faint); }
/* min-height 24px meets WCAG 2.2 SC 2.5.8 without visually enlarging the
   crumb, which reads as body text. */
.pk-breadcrumb__item a {
  display: inline-flex;
  align-items: center;
  min-height: 24px;
  color: var(--pk-ink-muted);
  text-decoration: none;
}
.pk-breadcrumb__item a:hover { color: var(--pk-link); text-decoration: underline; }
.pk-breadcrumb__item[aria-current="page"] { color: var(--pk-ink); font-weight: var(--pk-weight-semibold); }

/* ==========================================================================
   14. PAGINATION
   ========================================================================== */

.pk-pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: var(--pk-space-2); margin-block: var(--pk-space-8); }
.pk-pagination__link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  padding: 0 var(--pk-space-3);
  border: 1px solid var(--pk-border-strong);
  border-radius: var(--pk-radius-md);
  background: var(--pk-surface);
  color: var(--pk-ink-soft);
  font-size: var(--pk-text-sm);
  font-weight: var(--pk-weight-semibold);
  text-decoration: none;
}
.pk-pagination__link:hover { border-color: var(--pk-brand-red-ink); color: var(--pk-link); }
.pk-pagination__link[aria-current="page"] {
  background: var(--pk-brand-red);
  border-color: var(--pk-brand-red);
  color: var(--pk-on-brand);
}
.pk-pagination__link[aria-disabled="true"] { opacity: 0.45; pointer-events: none; }
.pk-pagination__gap { padding-inline: var(--pk-space-1); color: var(--pk-ink-faint); }

/* ==========================================================================
   15. EMPTY STATES
   ========================================================================== */

.pk-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--pk-space-4);
  padding: var(--pk-space-16) var(--pk-space-6);
  border: 1px dashed var(--pk-border-strong);
  border-radius: var(--pk-radius-lg);
  background: var(--pk-surface);
  text-align: center;
}
.pk-empty__icon  { color: var(--pk-ink-faint); }
.pk-empty__title { margin: 0; font-size: var(--pk-text-xl); font-weight: var(--pk-weight-bold); }
.pk-empty__body  { margin: 0; max-width: 46ch; color: var(--pk-ink-muted); }

/* ==========================================================================
   16. LOADING STATES
   ========================================================================== */

@keyframes pk-spin { to { transform: rotate(360deg); } }
@keyframes pk-pulse { 50% { opacity: 0.45; } }

.pk-spinner {
  display: inline-block;
  width: 1.25rem; height: 1.25rem;
  border: 2px solid var(--pk-border-strong);
  border-top-color: var(--pk-brand-red-ink);
  border-radius: 50%;
  animation: pk-spin 0.7s linear infinite;
}

.pk-skeleton {
  border-radius: var(--pk-radius-sm);
  background: var(--pk-surface-sunk);
  animation: pk-pulse 1.4s var(--pk-ease) infinite;
}
.pk-skeleton--text   { height: 0.85em; margin-block: 0.3em; }
.pk-skeleton--title  { height: 1.4em; width: 60%; }
.pk-skeleton--avatar { width: 64px; height: 64px; border-radius: 50%; }

/* With reduced motion the spinner would freeze mid-rotation and read as
   broken, so it is replaced by a gentle opacity pulse instead. */
@media (prefers-reduced-motion: reduce) {
  .pk-spinner,
  .pk-btn[data-busy="true"]::before {
    animation: pk-pulse 1.4s ease-in-out infinite;
    border-top-color: var(--pk-border-strong);
  }
  .pk-skeleton { animation: none; opacity: 0.7; }
}

/* ==========================================================================
   17. AVATARS
   ========================================================================== */

.pk-avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 64px; height: 64px;
  border: 1px solid var(--pk-border);
  border-radius: 50%;
  background: var(--pk-surface-sunk);
  overflow: hidden;
  color: var(--pk-ink-muted);
  font-size: var(--pk-text-lg);
  font-weight: var(--pk-weight-bold);
  text-transform: uppercase;
  letter-spacing: 0;
}
.pk-avatar img { width: 100%; height: 100%; object-fit: cover; }
.pk-avatar--sm { width: 40px; height: 40px; font-size: var(--pk-text-sm); }
.pk-avatar--lg { width: 96px; height: 96px; font-size: var(--pk-text-2xl); }
.pk-avatar--xl { width: 128px; height: 128px; font-size: var(--pk-text-3xl); }

/* ==========================================================================
   18. MEMBER / PROFILE CARDS
   ========================================================================== */

.pk-member-card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--pk-space-4);
  padding: var(--pk-space-6);
  border: 1px solid var(--pk-border);
  border-radius: var(--pk-radius-lg);
  background: var(--pk-surface);
  box-shadow: var(--pk-shadow-sm);
  transition: box-shadow var(--pk-dur) var(--pk-ease), transform var(--pk-dur) var(--pk-ease);
}
.pk-member-card:hover { box-shadow: var(--pk-shadow-lg); transform: translateY(-2px); }
.pk-member-card:focus-within { box-shadow: var(--pk-shadow-lg); }

.pk-member-card__head { display: flex; align-items: flex-start; gap: var(--pk-space-4); }
.pk-member-card__ident { min-width: 0; }
.pk-member-card__name {
  margin: 0 0 var(--pk-space-1);
  font-size: var(--pk-text-lg);
  font-weight: var(--pk-weight-bold);
  line-height: var(--pk-leading-snug);
}
.pk-member-card__name a { color: inherit; text-decoration: none; }
.pk-member-card__name a::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }
.pk-member-card__name a:hover { color: var(--pk-link); }

.pk-member-card__title { margin: 0; color: var(--pk-ink-soft); font-size: var(--pk-text-sm); font-weight: var(--pk-weight-medium); }
.pk-member-card__org   { margin: 0; color: var(--pk-ink-muted); font-size: var(--pk-text-sm); }

.pk-member-card__meta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--pk-space-2) var(--pk-space-4);
  color: var(--pk-ink-muted);
  font-size: var(--pk-text-sm);
}
.pk-member-card__meta span { display: inline-flex; align-items: center; gap: var(--pk-space-1); }

.pk-member-card__tags { display: flex; flex-wrap: wrap; gap: var(--pk-space-2); }
.pk-member-card__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--pk-space-3);
  margin-top: auto;
  padding-top: var(--pk-space-4);
  border-top: 1px solid var(--pk-divider);
}

/* Profile header on the full member page */
.pk-profile-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--pk-space-6);
  padding: var(--pk-space-8);
  border: 1px solid var(--pk-border);
  border-radius: var(--pk-radius-xl);
  background: var(--pk-surface);
  box-shadow: var(--pk-shadow-sm);
}
.pk-profile-head__ident { min-width: 0; flex: 1 1 320px; }

/* ==========================================================================
   19. EVENT CARDS
   ========================================================================== */

.pk-event-card {
  position: relative;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--pk-border);
  border-radius: var(--pk-radius-lg);
  background: var(--pk-surface);
  box-shadow: var(--pk-shadow-sm);
  overflow: hidden;
  transition: box-shadow var(--pk-dur) var(--pk-ease), transform var(--pk-dur) var(--pk-ease);
}
.pk-event-card:hover { box-shadow: var(--pk-shadow-lg); transform: translateY(-2px); }
.pk-event-card:focus-within { box-shadow: var(--pk-shadow-lg); }

/* aspect-ratio reserves the space before the image loads, so the card does
   not shift when it arrives. */
.pk-event-card__media { aspect-ratio: 16 / 9; background: var(--pk-surface-sunk); }
.pk-event-card__media img { width: 100%; height: 100%; object-fit: cover; }

.pk-event-card__body { display: flex; flex-direction: column; gap: var(--pk-space-3); padding: var(--pk-space-5); flex: 1; }

/* Date block — the calendar-tile pattern */
.pk-event-date {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 62px;
  padding: var(--pk-space-2);
  border: 1px solid var(--pk-brand-red-edge);
  border-radius: var(--pk-radius-md);
  background: var(--pk-brand-red-tint);
  line-height: 1.1;
}
.pk-event-date__month { color: var(--pk-brand-red-ink); font-size: var(--pk-text-xs); font-weight: var(--pk-weight-heavy); letter-spacing: var(--pk-tracking-wide); text-transform: uppercase; }
.pk-event-date__day   { color: var(--pk-ink); font-size: var(--pk-text-xl); font-weight: var(--pk-weight-heavy); }
.pk-event-date__year  { color: var(--pk-ink-muted); font-size: var(--pk-text-xs); }

.pk-event-card__title { margin: 0; font-size: var(--pk-text-lg); font-weight: var(--pk-weight-bold); line-height: var(--pk-leading-snug); }
.pk-event-card__title a { color: inherit; text-decoration: none; }
.pk-event-card__title a::after { content: ""; position: absolute; inset: 0; }
.pk-event-card__title a:hover { color: var(--pk-link); }

.pk-event-card__meta { display: flex; flex-wrap: wrap; gap: var(--pk-space-2) var(--pk-space-4); color: var(--pk-ink-muted); font-size: var(--pk-text-sm); }
.pk-event-card__meta span { display: inline-flex; align-items: center; gap: var(--pk-space-1); }
.pk-event-card__foot { display: flex; flex-wrap: wrap; gap: var(--pk-space-2); margin-top: auto; padding-top: var(--pk-space-3); }

.pk-event-card--past { opacity: 0.82; }
.pk-event-card--past .pk-event-date { background: var(--pk-surface-sunk); border-color: var(--pk-border); }
.pk-event-card--past .pk-event-date__month { color: var(--pk-ink-muted); }

/* ==========================================================================
   20. SPONSOR CARDS
   ========================================================================== */

.pk-sponsor-card {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--pk-space-4);
  padding: var(--pk-space-6);
  border: 1px solid var(--pk-border);
  border-top: 3px solid var(--_tier, var(--pk-border-strong));
  border-radius: var(--pk-radius-lg);
  background: var(--pk-surface);
  box-shadow: var(--pk-shadow-sm);
  text-align: center;
  transition: box-shadow var(--pk-dur) var(--pk-ease), transform var(--pk-dur) var(--pk-ease);
}
.pk-sponsor-card:hover { box-shadow: var(--pk-shadow-lg); transform: translateY(-2px); }

/* Tier is signalled by the top rule AND the badge text, never colour alone. */
.pk-sponsor-card--platinum { --_tier: #6E7B8B; }
.pk-sponsor-card--gold     { --_tier: #B7791F; }
.pk-sponsor-card--silver   { --_tier: #8A94A6; }

.pk-sponsor-card__logo {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 96px;
  padding: var(--pk-space-3);
  background: var(--pk-surface-sunk);
  border-radius: var(--pk-radius-md);
}
.pk-sponsor-card__logo img { max-height: 100%; width: auto; object-fit: contain; }
.pk-sponsor-card__name { margin: 0; font-size: var(--pk-text-lg); font-weight: var(--pk-weight-bold); }
.pk-sponsor-card__name a { color: inherit; text-decoration: none; }
.pk-sponsor-card__name a::after { content: ""; position: absolute; inset: 0; }

/* ==========================================================================
   21. FOOTER
   ========================================================================== */

.pk-footer {
  margin-top: var(--pk-space-20);
  padding-top: var(--pk-space-16);
  border-top: 1px solid var(--pk-border);
  background: var(--pk-surface-sunk);
  color: var(--pk-ink-soft);
}
.pk-footer__grid {
  display: grid;
  gap: var(--pk-space-8);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
  padding-bottom: var(--pk-space-12);
}
.pk-footer__brandcol { grid-column: span 1; max-width: 42ch; }
.pk-footer__head {
  margin: 0 0 var(--pk-space-4);
  color: var(--pk-ink);
  font-size: var(--pk-text-sm);
  font-weight: var(--pk-weight-bold);
  letter-spacing: var(--pk-tracking-wide);
  text-transform: uppercase;
}
.pk-footer__list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: var(--pk-space-1); }
.pk-footer__list a {
  display: inline-flex;
  align-items: center;
  min-height: 32px;   /* discrete nav target, not inline body text */
  color: var(--pk-ink-soft);
  font-size: var(--pk-text-sm);
  text-decoration: none;
}
.pk-footer__list a:hover { color: var(--pk-link); text-decoration: underline; }

.pk-footer__social { display: flex; gap: var(--pk-space-2); margin-top: var(--pk-space-5); }
.pk-footer__social a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px; height: 44px;
  border: 1px solid var(--pk-border);
  border-radius: var(--pk-radius-md);
  background: var(--pk-surface);
  color: var(--pk-ink-muted);
}
.pk-footer__social a:hover { color: var(--pk-link); border-color: var(--pk-brand-red-ink); }

.pk-footer__bottom {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--pk-space-4);
  padding-block: var(--pk-space-5);
  border-top: 1px solid var(--pk-border);
  color: var(--pk-ink-muted);
  font-size: var(--pk-text-sm);
}

/* ==========================================================================
   20b. TABS
   The tablist ships with [hidden] and is revealed by script; with JS off
   every panel stays open, so the content reads as a plain sequence of
   sections rather than becoming unreachable.
   ========================================================================== */

.pk-tabs {
  display: flex;
  flex-wrap: nowrap;
  gap: var(--pk-space-2);
  overflow-x: auto;
  padding-bottom: var(--pk-space-2);
  border-bottom: 1px solid var(--pk-border);
  -webkit-overflow-scrolling: touch;
  /* The strip scrolls horizontally on narrow screens; scroll-snap keeps a
     tab from being left half-visible. */
  scroll-snap-type: x proximity;
}
.pk-tabs[hidden] { display: none; }

.pk-tab {
  flex: none;
  scroll-snap-align: start;
  min-height: 44px;
  padding: 0 var(--pk-space-4);
  border: 1px solid transparent;
  border-radius: var(--pk-radius-md) var(--pk-radius-md) 0 0;
  background: transparent;
  color: var(--pk-ink-muted);
  font-family: inherit;
  font-size: var(--pk-text-sm);
  font-weight: var(--pk-weight-semibold);
  white-space: nowrap;
  cursor: pointer;
  transition: color var(--pk-dur) var(--pk-ease),
              background-color var(--pk-dur) var(--pk-ease);
}
.pk-tab:hover { color: var(--pk-link); background: var(--pk-surface-sunk); }

/* Selected tab carries a weight change and an underline bar, not colour
   alone, and is exposed through aria-selected. */
.pk-tab[aria-selected="true"] {
  position: relative;
  color: var(--pk-link);
  background: var(--pk-surface);
  border-color: var(--pk-border);
  border-bottom-color: transparent;
}
.pk-tab[aria-selected="true"]::after {
  content: "";
  position: absolute;
  left: var(--pk-space-3);
  right: var(--pk-space-3);
  bottom: -1px;
  height: 2px;
  background: var(--pk-brand-red);
  border-radius: var(--pk-radius-full);
}

/* Grows from the centre so the indicator reads as arriving on this tab rather
   than appearing. scaleX only — the bar reserves its full width throughout,
   so the tab strip never reflows.

   Public only, like the state entrance below. `admin/pricing_history.php`
   renders no tabs while its audit table is empty, so this leaks into the
   admin area only once real history exists — which is exactly the kind of
   motion that appears months later and nobody connects to this commit. */
.pk-body:not(.pk-admin) .pk-tab[aria-selected="true"]::after {
  transform-origin: 50% 50%;
  animation: pk-tab-underline var(--pk-dur) var(--pk-ease-out);
}

@keyframes pk-tab-underline {
  from { transform: scaleX(0); opacity: 0; }
  to   { transform: scaleX(1); opacity: 1; }
}

.pk-tabpanel[hidden] { display: none; }

/* The newly shown panel fades up. Applied to the panel that is NOT hidden,
   so with JS off — where every panel is visible and stacked — it plays once
   on load and then never again. Public only, for the reason above. */
.pk-body:not(.pk-admin) .pk-tabpanel:not([hidden]) {
  animation: pk-panel-in var(--pk-dur) var(--pk-ease-out);
}

@keyframes pk-panel-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: none; }
}
/* Without JS the panels all show, so they need separating from each other. */
.pk-tabpanel + .pk-tabpanel {
  margin-top: var(--pk-space-8);
  padding-top: var(--pk-space-8);
  border-top: 1px solid var(--pk-divider);
}

/* ==========================================================================
   20c. PROGRESS BAR
   Decorative reporting of a value shown in adjacent text, so it is marked
   aria-hidden at the usage site rather than given a progressbar role.
   ========================================================================== */

.pk-progress__track {
  height: 8px;
  margin-top: var(--pk-space-3);
  border-radius: var(--pk-radius-full);
  background: var(--pk-surface-sunk);
  border: 1px solid var(--pk-border);
  overflow: hidden;
}
.pk-progress__bar {
  display: block;
  width: 0;
  height: 100%;
  border-radius: var(--pk-radius-full);
  background: var(--pk-brand-red);
  transition: width var(--pk-dur-slow) var(--pk-ease-out);
}

/* ==========================================================================
   20d. HONEYPOT
   Moved off-screen rather than display:none. Bots commonly skip fields that
   are display:none or type=hidden, but will fill a visible-to-the-DOM text
   input. Real users never reach it: it is aria-hidden and tabindex="-1".
   ========================================================================== */

.pk-honeypot {
  position: absolute !important;
  left: -9999px !important;
  top: auto;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* ==========================================================================
   21a. FILTER BUTTONS
   Toggle chips for filtering a list. Real <button> elements with
   aria-pressed, so keyboard operation comes from the platform.
   ========================================================================== */

.pk-filter-bar { gap: var(--pk-space-2); }

.pk-filter {
  display: inline-flex;
  align-items: center;
  /* 36px keeps these above the WCAG 2.2 24px minimum while staying visually
     light enough to read as chips rather than buttons. */
  min-height: 36px;
  padding: 0 var(--pk-space-4);
  border: 1px solid var(--pk-border-strong);
  border-radius: var(--pk-radius-full);
  background: var(--pk-surface);
  color: var(--pk-ink-soft);
  font-family: inherit;
  font-size: var(--pk-text-sm);
  font-weight: var(--pk-weight-semibold);
  cursor: pointer;
  transition: background-color var(--pk-dur) var(--pk-ease),
              border-color var(--pk-dur) var(--pk-ease),
              color var(--pk-dur) var(--pk-ease);
}
.pk-filter:hover { border-color: var(--pk-brand-red-ink); color: var(--pk-link); }

/* Selected state carries a fill, a border and a leading dot — not colour
   alone — and is exposed through aria-pressed. */
.pk-filter[aria-pressed="true"] {
  background: var(--pk-brand-red);
  border-color: var(--pk-brand-red);
  color: var(--pk-on-brand);
}
.pk-filter[aria-pressed="true"]::before {
  content: "";
  width: 6px;
  height: 6px;
  margin-right: var(--pk-space-2);
  border-radius: 50%;
  background: currentColor;
}

/* Monospace field, used for the copyable SIG proposal template. */
.pk-mono {
  font-family: var(--pk-font-mono);
  font-size: var(--pk-text-sm);
  line-height: var(--pk-leading-relaxed);
  white-space: pre;
  overflow-wrap: normal;
  overflow-x: auto;
}

/* ==========================================================================
   21b. LISTS, CHECKLISTS & FAQ DISCLOSURES
   ========================================================================== */

.pk-list {
  margin: 0;
  padding-left: var(--pk-space-5);
  color: var(--pk-ink-soft);
}
.pk-list li + li { margin-top: var(--pk-space-2); }

/* Ticked list. The marker is decorative — the text carries the meaning, and
   list semantics still announce the item count. */
.pk-checklist { margin: 0; padding: 0; list-style: none; }
.pk-checklist li {
  position: relative;
  padding-left: var(--pk-space-8);
  color: var(--pk-ink-soft);
  font-size: var(--pk-text-sm);
}
.pk-checklist li + li { margin-top: var(--pk-space-3); }
.pk-checklist li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.15em;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--pk-success-tint);
  border: 1px solid var(--pk-success-edge);
  /* Tick drawn with a border, so it needs no icon font or extra request. */
  background-image: linear-gradient(45deg, transparent 45%, var(--pk-success) 45%, var(--pk-success) 55%, transparent 55%),
                    linear-gradient(-45deg, transparent 60%, var(--pk-success) 60%, var(--pk-success) 70%, transparent 70%);
  background-size: 10px 2px, 6px 2px;
  background-position: 6px 10px, 4px 8px;
  background-repeat: no-repeat;
}

/* Native <details> so the disclosure works with JavaScript disabled. */
.pk-faq {
  border: 1px solid var(--pk-border);
  border-radius: var(--pk-radius-lg);
  background: var(--pk-surface);
  overflow: hidden;
}
.pk-faq + .pk-faq { margin-top: var(--pk-space-3); }

.pk-faq__q {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--pk-space-4);
  min-height: 56px;
  padding: var(--pk-space-4) var(--pk-space-5);
  color: var(--pk-ink);
  font-weight: var(--pk-weight-semibold);
  cursor: pointer;
  list-style: none;
}
.pk-faq__q::-webkit-details-marker { display: none; }
.pk-faq__q::after {
  content: "";
  flex: none;
  width: 10px;
  height: 10px;
  border-right: 2px solid var(--pk-ink-muted);
  border-bottom: 2px solid var(--pk-ink-muted);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform var(--pk-dur) var(--pk-ease);
}
.pk-faq[open] .pk-faq__q::after { transform: rotate(-135deg) translate(-2px, -2px); }
.pk-faq__q {
  transition: background-color var(--pk-dur-fast) var(--pk-ease),
              color var(--pk-dur-fast) var(--pk-ease);
}
.pk-faq__q:hover { background: var(--pk-surface-sunk); color: var(--pk-link); }
.pk-faq__q:focus-visible {
  outline: var(--pk-focus-width) solid var(--pk-focus-ring);
  outline-offset: -2px;
}
.pk-faq__a {
  padding: 0 var(--pk-space-5) var(--pk-space-5);
  color: var(--pk-ink-soft);
  font-size: var(--pk-text-sm);
  line-height: var(--pk-leading-relaxed);
}

/* The answer fades up as the disclosure opens.
   <details> reflows when it opens — that is the control working, not a shift
   this introduces — so the animation deliberately does not touch height. It
   animates opacity and a 4px translate only, and is over well before the
   reader has finished the first line. */
.pk-faq[open] .pk-faq__a {
  animation: pk-faq-open var(--pk-dur) var(--pk-ease-out);
}

@keyframes pk-faq-open {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: none; }
}

/* ==========================================================================
   22. SCROLL REVEAL
   Content is styled visible here. pkoug.js sets `data-pk-reveal="pending"`
   only after confirming IntersectionObserver exists, then flips it to
   "shown" as each element scrolls in. So with JavaScript off, blocked or
   unsupported, nothing is ever hidden.

   Only opacity and transform animate — neither triggers layout, so this
   cannot cause a shift. The element reserves its full space throughout.
   ========================================================================== */

[data-pk-reveal="pending"] {
  opacity: 0;
  transform: translateY(12px);
}

[data-pk-reveal="shown"] {
  opacity: 1;
  transform: none;
  transition: opacity var(--pk-dur-slow) var(--pk-ease-out),
              transform var(--pk-dur-slow) var(--pk-ease-out);
  transition-delay: var(--pk-reveal-delay, 0ms);
}

/* Reduced motion: never hide anything, never move anything. The attribute
   still gets set, but it has no visual effect. */
@media (prefers-reduced-motion: reduce) {
  [data-pk-reveal="pending"],
  [data-pk-reveal="shown"] {
    opacity: 1;
    transform: none;
    transition: none;
    transition-delay: 0ms;
  }
}

/* ==========================================================================
   23. UTILITIES
   ========================================================================== */

.pk-hide { display: none !important; }
@media (max-width: 767px)  { .pk-hide-mobile  { display: none !important; } }
@media (min-width: 768px)  { .pk-hide-desktop { display: none !important; } }

.pk-text-center { text-align: center; }
.pk-mt-0 { margin-top: 0 !important; }
.pk-mb-0 { margin-bottom: 0 !important; }

/* Honour the OS setting for anything that slipped through the token layer. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  .pk-spinner,
  .pk-skeleton,
  .pk-btn[data-busy="true"]::before {
    animation-duration: 1.4s !important;
    animation-iteration-count: infinite !important;
  }
}

/* ==========================================================================
   24. REFINEMENT PASS
   Phase 2. Every rule here earns its place by fixing something measured in a
   browser, not by being current. Opacity and transform only; nothing here
   changes layout, and everything degrades to the previous appearance when a
   feature is unsupported.
   ========================================================================== */

/* --------------------------------------------------------------- RHYTHM ---
   Two adjacent <section class="pk-section"> each carry padding-block, so the
   space between them was the SUM of both — 192px at desktop on blog.php,
   between the search field and its own results. The preceding section's
   padding-bottom is already the separation; the following one's padding-top
   was pure dead space.

   Sunk sections keep theirs: their padding is the band, not the gap. */
.pk-section + .pk-section:not(.pk-section--sunk) { padding-top: 0; }

/* A page-head section sits closer to what it introduces than to an unrelated
   band below it. */
.pk-blog-head { padding-bottom: var(--pk-space-10); }

/* ---------------------------------------------------------- STICKY NAV ---
   The header is already sticky with a backdrop blur. What it lacked was any
   acknowledgement that the page had moved: over a long article it floated
   with the same flat edge it has at the top, which reads as an artefact
   rather than as a layer.

   Driven by scroll position, NOT by a scroll listener — `animation-timeline:
   scroll()` runs on the compositor, so this costs no JavaScript at all and
   honours the project's no-per-frame-JS rule by construction. Where it is
   unsupported the header keeps exactly its current appearance, which is why
   the whole thing sits behind @supports. */
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .pk-header {
      animation: pk-header-lift linear both;
      animation-timeline: scroll(root block);
      /* Complete the change over the first 120px of travel. */
      animation-range: 0 120px;
    }
    /* The translucency lives on the pseudo-element now, so the half of the
       lift that changes it has to be animated there. Same timeline and same
       range: two declarations describing one movement, not two movements. */
    .pk-header::before {
      animation: pk-header-lift-glass linear both;
      animation-timeline: scroll(root block);
      animation-range: 0 120px;
    }
    @keyframes pk-header-lift {
      to {
        box-shadow: var(--pk-shadow-md);
        border-bottom-color: transparent;
      }
    }
    @keyframes pk-header-lift-glass {
      to { background: color-mix(in srgb, var(--pk-surface) 82%, transparent); }
    }
  }
}

/* ------------------------------------------------------------- SURFACES ---
   A hard 1px line between bands is the cheapest possible transition and looks
   it. A hairline that fades out at both ends reads as the surface ending
   rather than as a drawn rule. Decorative, so it is a pseudo-element and
   never announced. */
.pk-section--sunk { position: relative; }
.pk-section--sunk::before,
.pk-section--sunk::after {
  content: "";
  position: absolute;
  left: 0; right: 0;
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--pk-border) 18%, var(--pk-border) 82%, transparent);
  pointer-events: none;
}
.pk-section--sunk::before { top: 0; }
.pk-section--sunk::after  { bottom: 0; }

/* ---------------------------------------------------------------- CARDS ---
   The lift existed; the edge did not move with it, so a hovered card grew a
   shadow while keeping the same flat border as its neighbours. Moving the
   border toward the brand tint is what makes it read as "this one", and it
   costs no layout because the border width never changes.

   `@media (hover: hover)` matters: on a touch screen :hover sticks after a
   tap, so a card stayed lifted until something else was tapped. */
@media (hover: hover) {
  .pk-card--link:hover { border-color: var(--pk-brand-red-edge); }
}

/* focus-within gets the SAME treatment as hover, deliberately. A keyboard
   user tabbing into a card should see what a pointer user sees, and this was
   previously a weaker state — shadow only, no border. */
.pk-card--link:focus-within {
  border-color: var(--pk-brand-red-edge);
  transform: translateY(-2px);
}

/* Blog and sponsor cards are the same object with different content, and were
   drifting apart: only .pk-card--link had any interaction feedback at all. */
.pk-blog-card,
.pk-sponsor-card {
  transition: box-shadow var(--pk-dur) var(--pk-ease),
              transform var(--pk-dur) var(--pk-ease),
              border-color var(--pk-dur) var(--pk-ease);
}
@media (hover: hover) {
  .pk-blog-card:hover,
  .pk-sponsor-card:hover {
    box-shadow: var(--pk-shadow-lg);
    transform: translateY(-2px);
    border-color: var(--pk-brand-red-edge);
  }
}
.pk-blog-card:focus-within,
.pk-sponsor-card:focus-within {
  box-shadow: var(--pk-shadow-lg);
  transform: translateY(-2px);
  border-color: var(--pk-brand-red-edge);
}

/* The image inside a blog card scales very slightly on hover. The media box
   already clips, so this is contained — and it is on the IMAGE, not the card,
   so the card's own geometry is untouched. */
.pk-blog-card__media { overflow: hidden; }
.pk-blog-card__media img { transition: transform var(--pk-dur-slow) var(--pk-ease-out); }
@media (hover: hover) {
  .pk-blog-card:hover .pk-blog-card__media img { transform: scale(1.03); }
}

/* --------------------------------------------------------------- STATES ---
   An empty state was a flat panel with an icon. Giving it a faint tinted wash
   makes it read as a deliberate answer — "there is nothing here yet" — rather
   than as a region that failed to load. */
.pk-empty {
  background:
    radial-gradient(120% 100% at 50% 0%, var(--pk-brand-red-tint), transparent 70%),
    var(--pk-surface);
}

/* A disabled control should look unavailable, not broken. */
.pk-btn[disabled],
.pk-btn[aria-disabled="true"] { filter: saturate(0.65); }

/* ------------------------------------------------- BUTTON PRESS FEEDBACK ---
   Buttons lifted on hover and returned on :active, which is right, but the
   press had no weight to it. A very slight inset scale reads as the control
   accepting the press. Transform only. */
.pk-btn:active { transform: translateY(0) scale(0.985); }

/* ------------------------------------------------------ VIEW TRANSITIONS ---
   Cross-document view transitions: a same-origin navigation cross-fades
   instead of blanking to white. This is progressive in the strictest sense —
   an engine without support navigates exactly as it does today, and there is
   no script, no polyfill and no fallback path to maintain.

   Deliberately NOT enabled under reduced motion: a cross-fade is still
   motion, and the rule is that it is removed rather than shortened. The
   @media wrapper is what does that, because `navigation: auto` cannot be
   overridden per-element. */
@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto; }
}

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: var(--pk-dur);
  animation-timing-function: var(--pk-ease-out);
}

/* The header is the one element that must NOT cross-fade: it is in the same
   place on both pages, so fading it out and in makes a fixed element flicker
   on every navigation. Naming it gives it its own transition group, and
   assigning no animation to that group leaves it alone. */
.pk-header { view-transition-name: pk-header; }
::view-transition-group(pk-header) { animation: none; }

/* ==========================================================================
   25. MOTION HIERARCHY
   Five tiers, strongest first. The point of a hierarchy is that things are
   NOT animated the same way: if every element arrives with equal weight the
   page reads as busy and nothing is emphasised.

     1. hero content ......... one orchestrated entrance, the largest travel
     2. major sections ....... restrained reveal, larger travel than a card
     3. cards ................ short staggered entrance, small travel
     4. buttons and controls . interaction feedback only, no entrance
     5. admin ................ essential transitions only, nothing decorative

   Everything is opacity and transform. Nothing here changes layout.
   ========================================================================== */

/* --- TIER 2. Sections ----------------------------------------------------
   A section header carries more travel than a card, so arriving at a new
   section registers as a section rather than as more cards. */
[data-pk-reveal="pending"]:not([data-pk-reveal-group] > *) {
  transform: translateY(20px);
}
[data-pk-reveal="shown"]:not([data-pk-reveal-group] > *) {
  transition-duration: 520ms;
}

/* --- TIER 3. Cards -------------------------------------------------------
   Deliberately SHORTER and smaller than a section. A card is one item in a
   set; it should feel like part of a sequence, not an announcement. The
   stagger does the work, so each individual card needs very little travel. */
[data-pk-reveal-group] > [data-pk-reveal="pending"] {
  transform: translateY(9px);
}
[data-pk-reveal-group] > [data-pk-reveal="shown"] {
  transition-duration: 380ms;
}

/* --- TIER 4. Controls ----------------------------------------------------
   Feedback only. No control has an entrance animation: a button that arrives
   is a button you cannot press yet. */
.pk-btn--primary { box-shadow: 0 1px 2px rgba(11,15,20,.10); }
@media (hover: hover) {
  .pk-btn--primary:hover:not([disabled]) {
    box-shadow: 0 6px 16px color-mix(in srgb, var(--pk-brand-red) 28%, transparent);
  }
}
.pk-btn { transition-property: background-color, border-color, color, transform, box-shadow; }

/* The focus ring arrives instantly — never transitioned. A ring that fades in
   is a ring that is not there yet for someone tabbing quickly. */
.pk-btn:focus-visible,
.pk-input:focus-visible,
.pk-select:focus-visible,
.pk-textarea:focus-visible { transition: none; }

/* Chips and tags: the smallest interactive thing on the page, so the
   smallest possible response. Colour only. */
.pk-chip {
  transition: background-color var(--pk-dur-fast) var(--pk-ease),
              border-color var(--pk-dur-fast) var(--pk-ease),
              color var(--pk-dur-fast) var(--pk-ease);
}

/* Tab and FAQ hit areas already transition colour; give the FAQ marker the
   same easing as the panel it opens so the two read as one gesture. */
.pk-faq__q::after { transition-timing-function: var(--pk-ease-out); }

/* --- DEPTH ---------------------------------------------------------------
   A flat tint reads as a block of colour; a very shallow vertical gradient
   reads as a surface. Two stops, a few percent apart — enough to catch the
   eye as depth, not enough to be seen as a gradient. */
.pk-section--sunk {
  background:
    linear-gradient(180deg,
      color-mix(in srgb, var(--pk-surface-sunk) 60%, var(--pk-bg)) 0%,
      var(--pk-surface-sunk) 18%,
      var(--pk-surface-sunk) 82%,
      color-mix(in srgb, var(--pk-surface-sunk) 60%, var(--pk-bg)) 100%);
}

/* Cards sat flat against the page. A resting shadow this shallow is not
   visible as a shadow — it is what stops the card looking painted on. */
.pk-card,
.pk-blog-card,
.pk-sponsor-card { box-shadow: 0 1px 2px rgba(11,15,20,.045); }
:root[data-theme="dark"] .pk-card,
:root[data-theme="dark"] .pk-blog-card,
:root[data-theme="dark"] .pk-sponsor-card { box-shadow: 0 1px 2px rgba(0,0,0,.4); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pk-card,
  :root:not([data-theme="light"]) .pk-blog-card,
  :root:not([data-theme="light"]) .pk-sponsor-card { box-shadow: 0 1px 2px rgba(0,0,0,.4); }
}

/* --- TYPOGRAPHY ----------------------------------------------------------
   Large display type needs negative tracking or it reads loose; body text at
   the same tracking would read cramped. This is why it is set per size
   rather than globally. */
.pk-display { letter-spacing: -0.03em; }
.pk-h1 { letter-spacing: -0.022em; }
.pk-h2 { letter-spacing: -0.016em; }

/* A lead is the bridge between a heading and body text and should read as
   neither. Slightly longer line-height, slightly softer ink. */
.pk-lead { line-height: 1.62; color: var(--pk-ink-soft); }

/* An eyebrow is a label, not a sentence: wide tracking, small, and it should
   sit tight to the heading it introduces rather than floating. */
.pk-eyebrow { margin-bottom: var(--pk-space-2); }

/* --- MOBILE --------------------------------------------------------------
   Designed for the width, not compressed from desktop. Below 560px the hero
   is the whole first screen, so it gets its own proportions: less vertical
   padding (the viewport is short), tighter display tracking (long words wrap
   badly at large sizes), and full-width buttons that are genuinely thumb
   targets rather than desktop buttons that happen to fit. */
@media (max-width: 560px) {
  .pk-hero { padding-block: var(--pk-space-10) var(--pk-space-12); }
  .pk-display { letter-spacing: -0.035em; line-height: 1.06; }
  .pk-hero__cta { gap: var(--pk-space-3); }
  .pk-hero__cta .pk-btn { flex: 1 1 100%; }
  .pk-lead { line-height: 1.58; }

  /* Cards carry their own separation on a narrow screen, where they stack
     and the eye has no column to follow. */
  .pk-grid { gap: var(--pk-space-5); }

  /* A section boundary is the only cue that content has changed subject once
     everything is one column. */
  .pk-section { padding-block: var(--pk-space-10); }
}

/* ==========================================================================
   26. EVENT BANNERS
   Uploads are accepted as supplied — no conversion, no thumbnails — so the
   ratio is enforced in CSS instead. Every <img> carries intrinsic width and
   height in the markup, which is what reserves the box before the bytes
   arrive; without those a banner is the largest layout shift on the page.
   ========================================================================== */

.pk-event-banner {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: var(--pk-radius-lg);
  background: var(--pk-surface-sunk);
}
.pk-event-banner img {
  display: block;
  width: 100%;
  height: 100%;
  /* An image that is not 16:9 is cropped rather than distorted or letterboxed:
     the recommendation is 1600x900, and anything else still has to sit in the
     same slot without changing the layout around it. */
  object-fit: cover;
  object-position: center;
}

/* The designed fallback. An event with no banner must not leave a hole, and it
   must not look like an image that failed to load — so this is deliberately a
   panel with the brand wash rather than a grey box or a broken-image icon. */
.pk-event-banner--none {
  display: grid;
  place-items: center;
  background:
    radial-gradient(120% 140% at 20% 0%, var(--pk-brand-red-tint), transparent 60%),
    radial-gradient(120% 140% at 85% 100%, var(--pk-brand-navy-tint), transparent 62%),
    var(--pk-surface-sunk);
  border: 1px solid var(--pk-border);
}
.pk-event-banner--none span {
  color: var(--pk-ink-faint);
  font-size: var(--pk-text-sm);
  font-weight: var(--pk-weight-semibold);
  letter-spacing: var(--pk-tracking-wide);
  text-transform: uppercase;
}

/* On a card the banner is the top edge, so it loses its lower corners. */
.pk-card .pk-event-banner { border-radius: var(--pk-radius-md); margin-bottom: var(--pk-space-4); }

/* ==========================================================================
   27. TESTIMONIALS / COMMUNITY VOICES
   A quotation with an attribution, so the markup is figure > blockquote +
   figcaption rather than a paragraph wearing quotation marks. The styles below
   only dress that structure; none of them are load-bearing for meaning.

   Photos are accepted as supplied — no conversion, no thumbnails, same
   decision as the event banners — so the circle and the crop are enforced
   here, and every <img> carries intrinsic width and height in the markup to
   reserve its box before the bytes arrive.
   ========================================================================== */

.pk-quote {
  display: flex;
  flex-direction: column;
  gap: var(--pk-space-5);
}

.pk-quote__body {
  margin: 0;
  /* The decorative mark is a pseudo-element, not a character in the text: a
     screen reader must not announce it, and it must not end up in a copied
     selection of what the person actually said. */
  position: relative;
  padding-top: var(--pk-space-6);
}
.pk-quote__body::before {
  content: "\201C";
  position: absolute;
  top: calc(var(--pk-space-2) * -1);
  left: 0;
  font-size: 3.5rem;
  line-height: 1;
  color: var(--pk-brand-red-edge);
  pointer-events: none;
}
.pk-quote__body p {
  margin: 0;
  font-size: var(--pk-text-lg);
  line-height: var(--pk-leading-relaxed);
  color: var(--pk-ink);
}

.pk-quote__by {
  display: flex;
  align-items: center;
  gap: var(--pk-space-3);
  /* Pushed to the bottom so attributions line up across a row of cards whose
     quotes are different lengths. */
  margin-top: auto;
}

.pk-quote__photo {
  flex: 0 0 auto;
  width: 3rem;
  height: 3rem;
  border-radius: 50%;
  object-fit: cover;
  object-position: center;
  border: 1px solid var(--pk-border);
  background: var(--pk-surface-sunk);
}

.pk-quote__name {
  display: block;
  font-weight: var(--pk-weight-semibold);
  color: var(--pk-ink);
}
.pk-quote__role {
  display: block;
  font-size: var(--pk-text-sm);
  line-height: var(--pk-leading-snug);
}

/* ==========================================================================
   28. STATUS VOCABULARY — shared between the admin area and the member portal
   ==========================================================================

   These rules lived in pkoug-admin.css, which no member page loads. The member
   dashboard and security centre were already emitting `.pk-status`,
   `.pk-billing--*` and `.pk-cred--*`, so every one of those pills has been
   rendering as plain inherited text — the same silent failure §25 recorded,
   one level up: the class HAD a rule, just not in a stylesheet the page asked
   for. tools/test_admin_styles.php could not see it, because it checked that a
   rule existed SOMEWHERE rather than that it existed in a sheet the page links.
   It now checks per page, so this cannot recur.

   They are here rather than duplicated because there is one vocabulary. An
   account that is `locked` means the same thing to the administrator holding
   the record and to the member reading their own security centre, and two
   copies of a colour scale is how the two come to disagree.
   ========================================================================== */

/* --------------------------------------------------------------- status --
   Semantic colour, separate from the brand accent: these say good, waiting
   and refused, and must not be confused with "this is a PKOUG thing". */

.pk-status {
  display: inline-block;
  font-size: var(--pk-text-xs, .75rem);
  font-weight: var(--pk-weight-heavy);
  padding: .18em .6em;
  border-radius: 999px;
  border: 1px solid currentColor;
  white-space: nowrap;
}

/* Through the semantic tokens rather than literal hex. These were four
   hardcoded colours plus two duplicated dark-theme override blocks, which is
   how `--rejected` ended up pointing at an undefined property while the other
   three quietly kept working. The tokens are already defined for every theme
   state, so the overrides are no longer needed at all.

   Measured, light on surface / on sunk, then dark:
     approved  5.02 / 4.56 / 10.06     verified  5.02 / 4.56 / 10.56
     rejected  6.47 / 5.88 /  6.98     pending   7.56 / 6.87 /  7.80  */
.pk-status--approved             { color: var(--pk-success); }
.pk-status--verified             { color: var(--pk-warning); }
.pk-status--rejected             { color: var(--pk-danger); }
.pk-status--pending_verification { color: var(--pk-text-muted); }

/* Billing state pills — payments list, record view, and the member dashboard.
   Separate from .pk-status--* because billing state and submission status
   are different axes: a record can be `verified` and `expired` at once. */
.pk-billing--active  { color: var(--pk-success); }
.pk-billing--expired { color: var(--pk-danger); }
.pk-billing--pending { color: var(--pk-warning); }
.pk-billing--other   { color: var(--pk-text-muted); }

/* --------------------------------------------------- publication state --
   admin/events.php and admin/testimonials.php both interpolate a row's status
   into the class name, so EVERY value either table can hold needs a rule here
   or the badge renders unstyled. Events was doing exactly that: `published`,
   `draft` and `cancelled` were emitted and none of the three existed.
   tools/test_admin_styles.php now fails when that recurs.

   Publication state is its own axis, distinct from billing and from
   submission status — an event can be `published` and in the past. */
.pk-billing--published { color: var(--pk-success); }
.pk-billing--draft     { color: var(--pk-text-muted); }
.pk-billing--cancelled { color: var(--pk-danger); }

/* Member submission states, added with migration 016. `pending` already exists
   above on the billing axis and carries the same meaning here — something is
   waiting on a person — so it is reused rather than duplicated.

   `rejected` is muted rather than red. A moderator declining to publish a
   testimonial is an ordinary editorial outcome, not a fault or an alarm, and
   the member can revise and resubmit; colouring it like an error would misread
   the state to whoever is triaging the queue, and would tell the member their
   own words were a mistake. `--pk-danger` stays for states that are genuinely
   irreversible or wrong. */
.pk-billing--rejected  { color: var(--pk-text-muted); }

/* Withdrawn consent, on admin/testimonials.php and on the member's own
   testimonial screen. Its own modifier rather than reusing `--cancelled`: an
   administrator cancelling an event and a person revoking permission to be
   quoted are different acts, and the second is not reversible. Heavier weight
   because it is the one state on either screen that can never be undone. */
.pk-billing--withdrawn {
  color: var(--pk-danger);
  font-weight: var(--pk-weight-heavy);
}

/* ==========================================================================
   28a. CREDENTIAL STATE — migration 017
   ==========================================================================

   Its own prefix rather than more `.pk-billing--*` values. Billing state,
   publication state and CREDENTIAL state are three different axes: a member
   can be billed `active` and locked out, or `expired` and perfectly able to
   sign in. Overloading one prefix is how `pk-billing--published` came to sit
   beside `pk-billing--expired` meaning nothing like the same thing.

   admin/view.php and member/security.php both interpolate the state into the
   class name, so every value pkoug_member_credential_state() can return needs
   a rule here or the badge renders unstyled and silent — the defect class §25
   recorded. The registry in tools/test_admin_styles.php reads
   PKOUG_CRED_STATES, so adding a state to the constant fails that suite until
   it is styled.                                                              */

.pk-cred--active    { color: var(--pk-success); }

/* Waiting on the member, and actionable by an admin — reissue the invitation.
   Warning rather than muted because it is a state somebody should chase. */
.pk-cred--invited   { color: var(--pk-warning); }

/* Nothing is wrong: they simply have not been invited yet. Muted, for the
   same reason `--rejected` is muted on the testimonial axis — an ordinary
   outcome must not be coloured like a fault. */
.pk-cred--unclaimed { color: var(--pk-text-muted); }
.pk-cred--none      { color: var(--pk-text-muted); }

/* A restriction an administrator applied. Red is right here: unlike a declined
   testimonial, this is somebody being kept out, and whoever is triaging the
   record — or the member reading their own security centre — needs to see it
   before they act on anything else. */
.pk-cred--locked    { color: var(--pk-danger); }

/* The heaviest state on this axis and the only one an ordinary admin cannot
   reverse, so it carries weight as well as colour — the same treatment
   `--withdrawn` gets, for the same reason. */
.pk-cred--disabled  {
  color: var(--pk-danger);
  font-weight: var(--pk-weight-heavy);
}

/* Not a state — a flag that rides alongside one. Warning, because it is a
   pending obligation on the member rather than a restriction on them. */
.pk-cred--change    { color: var(--pk-warning); }

/* ==========================================================================
   28b. THREE PLAIN CLASSES THAT WERE EMITTED WITH NO RULE
   ==========================================================================

   `.pk-h5` was used six times in admin/view.php and was never written, so every
   sub-heading on that screen rendered at the browser's default h3 size with the
   project's type scale ignored. `.pk-table__caption` belongs with it:
   admin/payments.php hides its caption visually, and a caption that is meant to
   be SEEN needs a rule that makes it look deliberate rather than like stray
   text above a table. The member security centre captions its activity table
   the same way.

   Both are plain classes rather than modifiers, which is why the modifier
   sweep in tools/test_admin_styles.php never caught the first one.

   `.pk-field--check` is the third instance, found by extending that sweep to
   member/: member/profile.php and member/testimonial.php have been using it
   around consent and privacy checkboxes since Phase 2, and it had no rule at
   all. It exists because `.pk-field` carries a bottom margin sized for a
   labelled input, which is too much between stacked checkboxes.              */
.pk-h5 {
  font-family: var(--pk-font-display, inherit);
  font-size: var(--pk-text-lg);
  font-weight: var(--pk-weight-bold);
  line-height: var(--pk-leading-tight);
  color: var(--pk-ink);
}

.pk-table__caption {
  caption-side: top;
  text-align: left;
  padding: 0 0 var(--pk-space-3);
  font-size: var(--pk-text-sm);
  color: var(--pk-text-muted);
}

.pk-field--check { margin-bottom: var(--pk-space-2); }

/* ==========================================================================
   29. WAYFINDING — scroll progress and section rail
   --------------------------------------------------------------------------
   Two related indicators for long pages. The homepage runs to roughly
   16 screens, and until now it offered no answer to "where am I and how
   much is left" — the sticky header looks identical on screen 2 and
   screen 14.

     .pk-progress  a 2px fill under the header. Both viewports.
     .pk-rail      a dotted section index in the right gutter. Desktop only.

   Both are OPT-IN. pk-header.php emits the progress bar only when a page
   sets $pkProgress, and the rail only exists on pages whose sections carry
   [data-pk-rail]. Nothing here changes a page that does not ask for it.
   ========================================================================== */

/* ------------------------------------------------------------- PROGRESS ---
   Driven by scroll position, not a scroll listener: `animation-timeline:
   scroll(root block)` runs on the compositor, which keeps this inside the
   project's no-per-frame-JavaScript rule — the same approach already used by
   .pk-header above.

   The fallback is deliberately "nothing". Where scroll timelines are
   unsupported the fill keeps its scaleX(0) start value and the bar is simply
   invisible: a 2px strip of empty track. Polyfilling it with a scroll handler
   would buy a cosmetic indicator at the cost of per-frame JavaScript on every
   page that uses it, which is the wrong trade.

   Sits INSIDE .pk-header, which is position:sticky and therefore a containing
   block for it. That is what guarantees it can never obscure content: it
   travels with the header and occupies the header's own bottom border line.  */
.pk-progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px;          /* sits on the header's 1px bottom border */
  height: 2px;
  overflow: hidden;
  pointer-events: none;
}

.pk-progress__fill {
  display: block;
  height: 100%;
  background: var(--pk-brand-red);
  transform: scaleX(0);
  transform-origin: 0 50%;
}

@supports (animation-timeline: scroll()) {
  .pk-progress__fill {
    animation: pk-progress-fill linear both;
    animation-timeline: scroll(root block);
  }
}

@keyframes pk-progress-fill {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

/* NOT disabled under prefers-reduced-motion, and that is deliberate. The bar
   has no autonomous motion of its own — it moves only in direct response to
   the visitor's own scrolling, at exactly the rate they scroll, and it
   carries information rather than decoration. Reduced motion suppresses
   movement the visitor did not ask for; this is the opposite of that. The
   rail's active-dot transition below IS damped, because that one animates on
   its own schedule. */

/* ----------------------------------------------------------------- RAIL ---
   A fixed dot index in the right-hand gutter. Hidden below 1280px, which is
   the narrowest width at which the dots clear the 1200px container entirely
   (28px of clearance from painted content at 1280px, more above it).

   Right rather than left: the left edge is where the brand mark and the
   reading eye already live, and a second vertical index there competes with
   the primary nav. z-index deliberately sits at --pk-z-sticky (200), BELOW
   --pk-z-header (300), so an open mobile menu or dropdown always wins.       */
.pk-rail {
  display: none;
}

@media (min-width: 1280px) {
  .pk-rail {
    display: block;
    position: fixed;
    top: 50%;
    right: var(--pk-space-5);
    transform: translateY(-50%);
    z-index: var(--pk-z-sticky);
  }
}

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

.pk-rail__link {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  /* A 28px row gives each dot a comfortably clickable target while keeping
     the whole rail short enough for 15 sections on a 800px viewport. */
  min-height: 28px;
  padding: 0 var(--pk-space-1);
  text-decoration: none;
}

.pk-rail__dot {
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: var(--pk-radius-full);
  border: 1.5px solid var(--pk-border-strong);
  background: transparent;
  transition: background-color var(--pk-dur) var(--pk-ease),
              border-color var(--pk-dur) var(--pk-ease),
              transform var(--pk-dur) var(--pk-ease);
}

.pk-rail__link:hover .pk-rail__dot {
  border-color: var(--pk-ink-muted);
}

/* Current section. aria-current is set by pkoug.js, so the visual state and
   the accessible state can never drift apart — there is no separate class. */
.pk-rail__link[aria-current="true"] .pk-rail__dot {
  background: var(--pk-brand-red);
  border-color: var(--pk-brand-red);
  transform: scale(1.3);
}

/* The label is always in the accessibility tree — opacity, not visibility or
   display, so a screen reader announces "Membership" rather than a bare
   link. It is revealed visually on hover AND on keyboard focus, which is what
   makes the rail usable by tabbing rather than pointer-only. */
.pk-rail__label {
  position: absolute;
  right: calc(100% + var(--pk-space-2));
  white-space: nowrap;
  font-size: var(--pk-text-xs);
  font-weight: var(--pk-weight-medium);
  line-height: 1;
  color: var(--pk-ink);
  background: var(--pk-surface-raised);
  border: 1px solid var(--pk-border);
  border-radius: var(--pk-radius-sm);
  box-shadow: var(--pk-shadow-sm);
  padding: var(--pk-space-2) var(--pk-space-3);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--pk-dur) var(--pk-ease);
}

.pk-rail__link:hover .pk-rail__label,
.pk-rail__link:focus-visible .pk-rail__label,
.pk-rail__link[aria-current="true"]:hover .pk-rail__label {
  opacity: 1;
}

/* The rail is a supplementary index, not the primary nav, so its focus ring
   goes on the dot rather than the full-width row — a 28px-tall outline
   spanning an empty row reads as a rendering fault. */
.pk-rail__link:focus-visible {
  outline: none;
}
.pk-rail__link:focus-visible .pk-rail__dot {
  outline: var(--pk-focus-width) solid var(--pk-focus-ring);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .pk-rail__dot,
  .pk-rail__label { transition: none; }
}

/* Above 1560px the gutter is 180px per side, which is finally enough to set
   the label INLINE beside its dot: always visible, never overlaying anything,
   ~49px clear of the content column. That is the rail at its best, and it is
   worth the extra breakpoint — a permanently readable index is a different
   tool from a row of dots you have to point at to identify.

   Below this width the label stays a hover/focus tooltip. It is
   pointer-events:none and appears only on deliberate hover or keyboard focus,
   but it does overlay the content edge while shown, which is why it is not
   the default treatment wherever there is room to avoid it. */
@media (min-width: 1560px) {
  .pk-rail__label {
    position: static;
    opacity: 1;
    padding: 0;
    margin-right: var(--pk-space-2);
    background: none;
    border: 0;
    box-shadow: none;
    color: var(--pk-ink-faint);
  }
  .pk-rail__link:hover .pk-rail__label {
    color: var(--pk-ink-soft);
  }
  .pk-rail__link[aria-current="true"] .pk-rail__label {
    color: var(--pk-ink);
    font-weight: var(--pk-weight-semibold);
  }
}

/* ==========================================================================
   26. VIDEO BACKGROUND
   --------------------------------------------------------------------------
   A looping background film behind a page's opening band, with one cut per
   theme. Used by the homepage hero and by the topic heads on about, events,
   sig, ace and blog.

   The <video> element is CREATED BY JAVASCRIPT (initVideoBg in pkoug.js), not
   authored in markup. That is what guarantees a visitor who never qualifies —
   reduced motion, save-data, a slow connection, a narrow screen, or no
   JavaScript at all — never downloads a byte of it. A <video src> in the HTML
   would start a ~700KB fetch for all of them before any script could stop it.

   The band's existing background is never replaced. On the hero that is the
   drifting gradient wash, which is a finished design in its own right, so the
   page is complete at first paint and the film fades in on top of it rather
   than filling a hole.

   LAYERING. The mount sits at z-index 0, immediately after the wash in DOM
   order, which puts it above the wash and below any positioned content
   without needing a z-index on the host. That matters: events.php carries an
   inline z-index:1 on its container, and a video layer that outranked it
   would paint straight over the headline.
   ========================================================================== */
.pk-videobg {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  opacity: 0;
  transition: opacity 900ms var(--pk-ease-out);
}

.pk-videobg__video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Settles from a slight overscan as it fades in — the same "arrive and
     settle" language as pk-hero-rise and pk-accent-settle, rather than a
     second, different idea about how a page introduces itself. */
  transform: scale(1.04);
  transition: transform 1400ms var(--pk-ease-out);
  filter: var(--pk-videobg-filter);
}

/* Scrim strength and film treatment.
   --------------------------------------------------------------------------
   These live here rather than in pkoug-tokens.css because they are this
   component's business, not the palette's — but they still need the project's
   three-block theming pattern, so they are declared on bare :root and
   redefined twice, once for the system preference and once for the toggle.

   THE FLOOR IS THE POINT. The first version of this scrim faded to
   transparent at 66%, which was correct for the homepage hero: its copy sits
   in a left column with an opaque stats panel occupying the right. No other
   band shares that layout. On about.php the headline runs to 97% of the band
   and the ghost button sits at 63-80% — straight into the part of the frame
   the old scrim had stopped covering, measured at 36% of pixels failing AA in
   light and 54% in dark. So the gradient keeps its leftward bias, because
   that is what lets the composition breathe on the right, but it now never
   reaches zero.

   BRIGHTNESS, ONLY IN DARK. No scrim alone can rescue light text over a
   near-white pixel: holding 4.5:1 against #E9ECF1 would need ~86% coverage,
   which is not a background any more, it is a flat panel. Taming the film's
   own highlights first costs one GPU colour matrix per frame — far cheaper
   than a blur, which would repaint the whole layer — and leaves the accents
   visible instead of erasing them. Light mode needs none of this: dark text
   over a light film only risks the shadows, and the floor already covers it. */
:root {
  --pk-videobg-near:   62%;   /* scrim at the reading edge */
  --pk-videobg-mid:    45%;
  --pk-videobg-far:    35%;   /* the floor — never 0 */
  --pk-videobg-filter: none;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --pk-videobg-near:   72%;
    --pk-videobg-mid:    58%;
    --pk-videobg-far:    48%;
    --pk-videobg-filter: brightness(0.45);
  }
}
:root[data-theme="dark"] {
  --pk-videobg-near:   72%;
  --pk-videobg-mid:    58%;
  --pk-videobg-far:    48%;
  --pk-videobg-filter: brightness(0.45);
}

.pk-videobg::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg,
    color-mix(in srgb, var(--pk-bg) var(--pk-videobg-near), transparent) 0%,
    color-mix(in srgb, var(--pk-bg) var(--pk-videobg-mid),  transparent) 40%,
    color-mix(in srgb, var(--pk-bg) var(--pk-videobg-far),  transparent) 100%);
}

[data-pk-videobg-state="playing"] > .pk-videobg { opacity: 1; }
[data-pk-videobg-state="playing"] .pk-videobg__video { transform: scale(1); }

/* The hero's green focal highlight existed to give a flat wash a centre.
   Moving footage has its own, and the two together read as muddy. */
.pk-hero::before { transition: opacity 900ms var(--pk-ease-out); }
.pk-hero[data-pk-videobg-state="playing"]::before { opacity: 0; }

/* Hosts that are not already a positioned, clipping band. .pk-hero and
   .pk-blog-head both need to clip the film to their own box; the hero
   already does, the blog head did not. */
.pk-blog-head {
  position: relative;
  overflow: hidden;
}
.pk-blog-head > .pk-container { position: relative; z-index: 1; }

/* Belt and braces. initVideoBg() refuses to build the element at all under
   reduced motion, so this only matters if the preference is turned on
   mid-session: the film stops moving toward anything and the band returns to
   its own background. */
@media (prefers-reduced-motion: reduce) {
  .pk-videobg,
  .pk-videobg__video { transition: none; }
  .pk-videobg__video { transform: none; }
}

/* The ghost button is the one control that sits on the film with nothing of
   its own behind it — primary and secondary both paint solid fills, and the
   hero's stats panel is opaque. Its ink is a mid-tone red by design (it is
   the low-emphasis action), and a mid-tone over moving footage is the one
   combination no scrim fixes cheaply: after the floor and the brightness
   pass it still measured ~1-2% of pixels under 4.5:1, worst case 2.97.

   So it gets a backdrop, but only where one is needed — a band with a film
   actually playing. Everywhere else the ghost button is untouched. The fill
   is deliberately partial: enough to carry the text (measured 7.2:1 dark,
   13.3:1 light), not so much that it stops reading as the quiet third
   option next to two solid buttons. */
[data-pk-videobg-state="playing"] .pk-btn--ghost {
  background: color-mix(in srgb, var(--pk-bg) 72%, transparent);
}
[data-pk-videobg-state="playing"] .pk-btn--ghost:hover:not([disabled]) {
  background: color-mix(in srgb, var(--pk-brand-red-tint) 88%, transparent);
}

/* Muted text is the other thing a film breaks, for a subtler reason than the
   ghost button. --pk-ink-faint is tuned to sit just above AA on a flat page
   background — that is what makes it read as secondary — which leaves it no
   headroom at all once anything is moving behind it. Measured on ace.php in
   light mode: 22.5% of the note's pixels under 4.5:1, worst case 3.04.

   The fix is not a heavier scrim. Holding faint grey at 4.5:1 would need the
   composite above 0.85 luminance, which is a white panel with a video hidden
   behind it. Instead the note steps one rung up the ink ramp for as long as
   a film is playing: still visibly secondary — smaller and lighter than the
   lead above it — but carried by its own colour rather than by the hope that
   a bright frame is underneath. Measures 5.89:1 after the change. */
[data-pk-videobg-state="playing"] .pk-faint,
[data-pk-videobg-state="playing"] .pk-hero__note,
[data-pk-videobg-state="playing"] .pk-hint {
  color: var(--pk-ink-soft);
}

/* ==========================================================================
   27. GLASS
   --------------------------------------------------------------------------
   Frosted surfaces, applied only where there is something behind them worth
   refracting: the sticky header, the nav dropdowns, the hero stats panel and
   the ghost button when a film is playing under it. Glass over a flat page
   background is just a lighter panel with a performance cost — the effect
   only means anything when the layer beneath has content.

   Deliberately NOT applied to .pk-card. There are 101 card instances on the
   public pages, and admin/ loads this same stylesheet across 35 dense data
   screens where translucency measurably hurts scanning. A hundred blurred
   layers is also a real GPU bill on a fifteen-section page.

   These live here rather than in pkoug-tokens.css for the same reason the
   video-background values do — they are this treatment's business, not the
   palette's — but they follow the project's three-block theming pattern:
   declared on bare :root, redefined once for the system preference and once
   for the toggle, so no value is ever defined only inside a media query.

   EVERY rule below sits inside @supports. Where backdrop-filter is missing
   the surfaces keep exactly the solid backgrounds they have today, which is
   why the fallback needs no separate design.
   ========================================================================== */
:root {
  /* Chrome is more opaque than panels: it sits over arbitrary scrolling
     content, so it cannot rely on knowing what is behind it. */
  --pk-glass-chrome:  color-mix(in srgb, var(--pk-surface) 92%, transparent);
  --pk-glass-surface: color-mix(in srgb, var(--pk-surface) 88%, transparent);
  --pk-glass-blur:    blur(12px) saturate(1.25);
  --pk-glass-border:  color-mix(in srgb, var(--pk-white) 55%, var(--pk-border));
  --pk-glass-sheen:   color-mix(in srgb, var(--pk-white) 45%, transparent);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    /* Dark mode is not symmetric with light and cannot use its numbers.
       Light mode puts dark ink on a bright surface, so anything bleeding
       through only ever darkens a backdrop that was already far from the
       text — measured worst case 5.37:1 against pure white behind.
       Dark mode puts mid-tone ink (nav links L=0.61, panel titles L=0.42)
       on a near-black surface, so bright content behind lifts the
       composite straight toward the text. At 90/86 the header measured
       4.17:1 against a white backdrop, under AA. These are the opacities
       that hold the bound with a bright image scrolling underneath. */
    --pk-glass-chrome:  color-mix(in srgb, var(--pk-surface) 94%, transparent);
    --pk-glass-surface: color-mix(in srgb, var(--pk-surface) 92%, transparent);
    --pk-glass-border:  color-mix(in srgb, var(--pk-white) 16%, var(--pk-border));
    --pk-glass-sheen:   color-mix(in srgb, var(--pk-white) 12%, transparent);
  }
}
:root[data-theme="dark"] {
  /* Same numbers as the media-query block above, for the same reason. */
  --pk-glass-chrome:  color-mix(in srgb, var(--pk-surface) 94%, transparent);
  --pk-glass-surface: color-mix(in srgb, var(--pk-surface) 92%, transparent);
  --pk-glass-border:  color-mix(in srgb, var(--pk-white) 16%, var(--pk-border));
  --pk-glass-sheen:   color-mix(in srgb, var(--pk-white) 12%, transparent);
}

@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  /* On the pseudo-element, for the containing-block reason set out beside the
     base rule in §12. The header itself must stay free of backdrop-filter. */
  .pk-header::before {
    background: var(--pk-glass-chrome);
    -webkit-backdrop-filter: var(--pk-glass-blur);
    backdrop-filter: var(--pk-glass-blur);
  }

  /* .pk-hero__panel is authored in pkoug-home.css, which loads after this
     file at equal specificity and would otherwise win — the blur applied
     while the translucency did not, which is a worse look than either
     surface on its own. Qualifying with the hero settles it by weight
     rather than by load order. */
  .pk-hero .pk-hero__panel {
    background: var(--pk-glass-surface);
    -webkit-backdrop-filter: var(--pk-glass-blur);
    backdrop-filter: var(--pk-glass-blur);
    border-color: var(--pk-glass-border);
  }

  /* The dropdown is only glass where it is actually a floating panel.
     Below 1024px it is an inline accordion inside the mobile nav —
     position:static, flat, no border or shadow — and an unscoped rule
     here overrode all of that by sitting later in the file: the
     accordion picked up a translucent background and a blur it was
     never designed for, over a nav that is already opaque, and the
     sheen (absolute) anchored to the fixed nav rather than to the
     static accordion. Blurring on a phone to no visual end is also
     exactly the cost this component was scoped to avoid. */
  @media (min-width: 1024px) {
    .pk-dropdown {
      background: var(--pk-glass-surface);
      -webkit-backdrop-filter: var(--pk-glass-blur);
      backdrop-filter: var(--pk-glass-blur);
      border-color: var(--pk-glass-border);
    }
    .pk-dropdown::before {
      content: "";
      position: absolute;
      inset: 0 0 auto 0;
      height: 1px;
      border-radius: inherit;
      background: linear-gradient(90deg, transparent, var(--pk-glass-sheen) 50%, transparent);
      pointer-events: none;
    }
  }

  /* The lit top edge. This is the detail that makes a translucent rectangle
     read as glass rather than as a panel someone forgot to finish: real glass
     catches light where it turns. One pixel, fading out at both ends. */
  /* ONLY the panel. .pk-dropdown is already position:absolute (see its base
     rule above), which is just as good a containing block for ::before —
     and re-declaring it relative here, later in the file at equal
     specificity, quietly won and dropped the menu back into normal flow.
     It then occupied ~204px inside the header: the bar grew, the nav
     wrapped, the wordmark compressed and the hero was pushed down, every
     time a menu opened. A positioned element does not need help being
     positioned. */
  .pk-hero .pk-hero__panel { position: relative; }
  .pk-hero .pk-hero__panel::before {
    content: "";
    position: absolute;
    inset: 0 0 auto 0;
    height: 1px;
    border-radius: inherit;
    background: linear-gradient(90deg, transparent, var(--pk-glass-sheen) 50%, transparent);
    pointer-events: none;
  }

  /* The ghost button already earns a backdrop over film for contrast. Given
     it has one, it may as well be the same material as everything else. */
  [data-pk-videobg-state="playing"] .pk-btn--ghost {
    -webkit-backdrop-filter: var(--pk-glass-blur);
    backdrop-filter: var(--pk-glass-blur);
  }
}

/* --- Denser footage -------------------------------------------------------
   The chapter films are brighter and busier than the topic loops, and the
   standard scrim does not hold every element over them: measured on
   chapters.php, the eyebrow fell to 4.33:1 in dark (11.6% of its pixels under
   AA) and 4.55:1 in light, which is passing by too little to be worth
   defending. The eyebrow is brand red — #FF6E73 in dark, luminance 0.34 — and
   a mid-tone over moving footage is the same problem the ghost button has.

   This is a MODIFIER rather than a change to the shared values on purpose.
   Raising the global scrim would have fixed it, and every page already has
   margin to absorb it, but it would also have altered the look of six pages
   that are mid-review. A film that needs more cover should say so itself.

   Custom properties inherit, so redefining them on the mount re-tunes the
   scrim and the brightness pass for that band alone. Themed in three blocks,
   like everything else here, so no value exists only inside a media query. */
.pk-videobg--dense {
  /* 78/62/52 rather than 72/56/46: at the lower set the Faisalabad film
     left the eyebrow link at 4.43:1, and the eyebrow is brand red
     (#C4141A, luminance 0.12) — mid-tone again, and marginal is not a
     position worth defending on a page nobody will re-measure. */
  --pk-videobg-near: 78%;
  --pk-videobg-mid:  62%;
  --pk-videobg-far:  52%;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pk-videobg--dense {
    --pk-videobg-near:   80%;
    --pk-videobg-mid:    66%;
    --pk-videobg-far:    56%;
    --pk-videobg-filter: brightness(0.38);
  }
}
:root[data-theme="dark"] .pk-videobg--dense {
  --pk-videobg-near:   80%;
  --pk-videobg-mid:    66%;
  --pk-videobg-far:    56%;
  --pk-videobg-filter: brightness(0.38);
}

/* --- A band tall enough to hold its film ----------------------------------
   The hero's height comes from its content, which is correct for a band whose
   background is a gradient — a gradient has no composition to lose. A film
   does. chapters.php sets three short lines of copy, so the band came out
   355px tall against 1425px wide: a ratio of 4.01 against the footage's 1.78,
   and object-fit:cover therefore threw away 55.7% of the frame height. The
   clock tower in the all-chapters cut lost both its top and its base.

   min() rather than a fixed height, and 56.25vw because that IS 16:9 of the
   viewport width — so on a wide screen the band asks for the whole frame and
   crops nothing. The 74vh cap is what stops that being a hero the height of
   the window: at 1440x900 it settles around 666px, a ratio of 2.14, which
   crops ~17% instead of 56%. A short laptop gives up a little more, which is
   the right trade — it is the visitor with the least room to spare.

   min-height, not height: the band still grows for a chapter with a long
   summary. It never shrinks below what the film needs, and never fixes a
   height that content could overflow. */
.pk-hero--film {
  min-height: min(56.25vw, 74vh);
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* Below the desktop gate no film is ever requested, so the extra height would
   be reserved for a gradient — and on a phone that is just a screenful of
   nothing before the first heading. */
@media (max-width: 1023px) {
  .pk-hero--film { min-height: 0; display: block; }
}

/* --- Type over a film -----------------------------------------------------
   The chapter headings sit on moving footage, and type tuned for a flat page
   does not hold there. Two things change, and only while a film is actually
   playing behind the words: the copy steps up in weight and ink, and it gets
   a halo.

   THE HALO IS NOT ONLY DECORATION. Each rule below is a tight shadow first
   and a wide glow second. The tight one is the legibility half — it puts a
   band of the page's own ground immediately around every glyph, so a bright
   frame passing behind a letter cannot close the gap at its edge. The wide
   one is the glow. Written the other way round it would read as a smudge.

   It is NOT counted as contrast. The scrim and the brightness pass still
   carry the measured ratios on their own; WCAG is computed against the
   background, not against a shadow, and treating a halo as the fix would be
   arguing with the metric rather than passing it. This is on top.

   Theme-specific because the two modes need opposite things. In light the
   film is bright and the ink is near-black, so the halo is white: it lifts
   the letters off a busy frame. In dark the ink is near-white and the footage
   carries red light trails, so the halo is dark first — the part that does
   the work — then brand red, which is the part that looks like the video it
   is sitting on.

   Scoped to .pk-hero--film. The other six film bands are mid visual review
   and are deliberately left alone. */
:root {
  --pk-film-glow-display: 0 1px 0 rgba(255, 255, 255, 0.9),
                          0 0 24px rgba(255, 255, 255, 0.85),
                          0 0 58px rgba(255, 255, 255, 0.45);
  --pk-film-glow-eyebrow: 0 1px 0 rgba(255, 255, 255, 0.85),
                          0 0 16px rgba(255, 255, 255, 0.9),
                          0 0 34px rgba(232, 28, 35, 0.20);
  --pk-film-glow-lead:    0 1px 0 rgba(255, 255, 255, 0.8),
                          0 0 18px rgba(255, 255, 255, 0.75);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --pk-film-glow-display: 0 2px 6px rgba(0, 0, 0, 0.70),
                            0 0 28px rgba(255, 110, 115, 0.30),
                            0 0 68px rgba(232, 28, 35, 0.18);
    --pk-film-glow-eyebrow: 0 1px 3px rgba(0, 0, 0, 0.70),
                            0 0 18px rgba(255, 110, 115, 0.45),
                            0 0 40px rgba(232, 28, 35, 0.22);
    /* Dark halo first, for legibility; then the brand glow, which is the
       half that was missing — the lead had a shadow and no colour, so it
       read as merely outlined rather than lit. */
    --pk-film-glow-lead:    0 1px 3px rgba(0, 0, 0, 0.70),
                            0 0 20px rgba(255, 110, 115, 0.26),
                            0 0 44px rgba(232, 28, 35, 0.15);
  }
}
:root[data-theme="dark"] {
  --pk-film-glow-display: 0 2px 6px rgba(0, 0, 0, 0.70),
                          0 0 28px rgba(255, 110, 115, 0.30),
                          0 0 68px rgba(232, 28, 35, 0.18);
  --pk-film-glow-eyebrow: 0 1px 3px rgba(0, 0, 0, 0.70),
                          0 0 18px rgba(255, 110, 115, 0.45),
                          0 0 40px rgba(232, 28, 35, 0.22);
  --pk-film-glow-lead:    0 1px 3px rgba(0, 0, 0, 0.70),
                          0 0 20px rgba(255, 110, 115, 0.26),
                          0 0 44px rgba(232, 28, 35, 0.15);
}

.pk-hero--film[data-pk-videobg-state="playing"] .pk-display,
.pk-hero--film[data-pk-videobg-state="playing"] .pk-h1 {
  text-shadow: var(--pk-film-glow-display);
}

.pk-hero--film[data-pk-videobg-state="playing"] .pk-eyebrow,
.pk-hero--film[data-pk-videobg-state="playing"] .pk-eyebrow a {
  text-shadow: var(--pk-film-glow-eyebrow);
}

/* The lead is --pk-ink-soft by design: it is the bridge between a heading and
   body text and should read as neither. That softness is right on a flat page
   and wrong on a film, where it is the first thing to dissolve. Full ink and
   one step of weight while a film is playing — still lighter than the
   heading, no longer competing with the footage. */
.pk-hero--film[data-pk-videobg-state="playing"] .pk-lead {
  color: var(--pk-ink);
  font-weight: var(--pk-weight-medium);
  text-shadow: var(--pk-film-glow-lead);
}

/* A halo is a paint effect, not motion, so it stays under reduced motion —
   the preference is about movement, and removing the thing that makes the
   text legible would be a strange way to honour it. */

/* The chapter photograph steps aside for the film — in CSS, not in PHP.
   Whether a film SHIPPED is a server fact; whether it PLAYS is a client one,
   and films are desktop-only. Deciding this server-side removed the
   photograph from every phone and put nothing in its place, which is the
   wrong way round: the visitor who cannot have the film is precisely the one
   who still needs the picture.

   So the markup always carries it, and it is hidden only once a film is
   genuinely playing behind the words. A city photographed on top of the same
   city moving is one image too many, and the still one wins by sitting above
   — so the film it covers may as well not have loaded. */
[data-pk-videobg-state="playing"] .pk-chapter-hero__figure { display: none; }
[data-pk-videobg-state="playing"] .pk-chapter-hero {
  grid-template-columns: minmax(0, 1fr);
}

/* ==========================================================================
   28. CONTACT MARKS
   --------------------------------------------------------------------------
   The inline SVGs from includes/pk-icons.php, and the list they sit in.

   Every mark is fill="currentColor" with no colour of its own, so it takes
   the link colour, the hover colour and the theme without a single rule here
   naming a colour. That is the whole reason they are inline rather than an
   icon font or an <img>.
   ========================================================================== */
.pk-icon {
  flex: none;                 /* never squashed by a long label beside it */
  vertical-align: -0.14em;    /* optical centring against lowercase text */
}

/* A contact list: one mark, one label, per row. Replaces a column of bare
   links that gave the eye nothing to scan by — every row began with a
   different word and looked the same as every other. */
.pk-contact-list {
  display: grid;
  gap: var(--pk-space-3);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Direct children only. Written as `.pk-contact-list span` this also caught
   the label span INSIDE each link, which took the link colour off every
   contact row and left them looking like grey dead text. */
.pk-contact-list > li > a,
.pk-contact-list > li > span {
  display: inline-flex;
  align-items: center;
  gap: var(--pk-space-3);
  /* Long addresses wrap under their own label rather than under the mark. */
  overflow-wrap: anywhere;
}

/* The mark sits in a soft tile, which is what stops five different glyph
   shapes reading as five different sizes: the tile is identical every time,
   so the row starts at the same place whatever is in it. */
.pk-contact-list .pk-icon {
  box-sizing: content-box;
  padding: 0.42em;
  border-radius: var(--pk-radius-md);
  background: var(--pk-surface-sunk);
  color: var(--pk-ink-muted);
  transition: background-color var(--pk-dur-fast) var(--pk-ease),
              color var(--pk-dur-fast) var(--pk-ease);
}
.pk-contact-list a:hover .pk-icon,
.pk-contact-list a:focus-visible .pk-icon {
  background: var(--pk-brand-red-tint);
  color: var(--pk-brand-red-ink);
}

/* Non-link rows — a phone number that is not dialable, say — should not look
   clickable. The tile stays, the emphasis does not. Direct child, for the
   same reason as above. */
.pk-contact-list > li > span { color: var(--pk-ink-soft); }

/* Chips carry a mark at the size of their own text, not a tile: a chip is
   already a container, and a tile inside a pill is one border too many. */
.pk-chip .pk-icon {
  margin-right: 0.45em;
  opacity: 0.75;
}
a.pk-chip:hover .pk-icon,
a.pk-chip:focus-visible .pk-icon { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .pk-contact-list .pk-icon { transition: none; }
}

/* --- The description writing itself in ------------------------------------
   Word by word, not character by character, and the difference is not
   cosmetic. A character typewriter needs the text to arrive incrementally,
   which means either mutating textContent — where a screen reader announces
   the paragraph again on every keystroke and the text cannot be selected
   until it finishes — or a width animation, which only works on one line.
   The chapter descriptions run to three.

   So the FULL TEXT IS ALWAYS IN THE DOM. pkoug.js wraps each word in a span
   and the spans fade up on a stagger; nothing is added, removed or retyped.
   Assistive technology reads the finished paragraph, a crawler indexes it,
   and it can be selected and copied while it is still arriving.

   The blur is what makes it read as writing rather than as a list of words
   arriving: each one resolves out of the film behind it instead of simply
   appearing. It is one-shot and short, so the filter cost is paid once. */
.pk-film-word {
  display: inline-block;
  opacity: 0;
  transform: translateY(0.22em);
  filter: blur(5px);
  animation: pk-film-write 560ms var(--pk-ease-out) forwards;
  animation-delay: var(--pk-word-delay, 0ms);
  /* Without this the words animate before the compositor has the film, and
     the first few land on a gradient and then jump onto footage. */
  will-change: opacity, transform, filter;
}

@keyframes pk-film-write {
  to { opacity: 1; transform: none; filter: blur(0); }
}

/* Once the paragraph has finished arriving there is nothing left to promote,
   and a layer per word is not something to leave behind on a long page. */
.pk-film-word.is-written { will-change: auto; }

/* JavaScript adds the spans, so a visitor without it sees the paragraph as it
   has always been. This is the mid-session case: turn the preference on and
   every word that has not yet arrived is simply there. */
@media (prefers-reduced-motion: reduce) {
  .pk-film-word {
    opacity: 1;
    transform: none;
    filter: none;
    animation: none;
  }
}
