/* =============================================================================
   Gilead Press, components

   Every component the templates use, defined once. If a template needs
   something that is not in here, it is added HERE and to styleguide.html,
   never invented inline. A component that exists on one page and nowhere else
   is how a design system dies.

   Order:
     1  Buttons
     2  Forms
     3  Segmented controls
     4  Badges, chips and pills
     5  Cards: product, subject, plain
     6  Panels: buy, functional message, callout
     7  Lists: contents, definition, file rows
     8  Disclosure and accordion
     9  Tables
    10  Pagination
    11  Skeletons and loading
    12  Empty and error states
    13  Status blocks
    14  Order summary
    15  Progress
    16  Filters: rail, sheet, chips
    17  Sticky buy bar
    18  Prose and table of contents
    19  Trust row
    20  Copy control and reference
    21  Account menu and avatar
   ========================================================================== */


/* =============================================================================
   1. Buttons

   Three faces and one destructive. The primary is Deep Forest and is meant to
   be the darkest thing on the page. Press states DEEPEN: an alpha on a face
   composites it against Warm Ivory and the button gets lighter when you reach
   for it, which reads as the control retreating.
   ========================================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--gp-space-8);
  padding: var(--gp-space-12) var(--gp-space-24);
  border: 1px solid transparent;
  border-radius: var(--gp-radius);
  font-family: var(--gp-font-body);
  font-size: var(--gp-fs-button);
  line-height: var(--gp-lh-button);
  font-weight: var(--gp-fw-semi);
  text-align: center;
  text-decoration: none;
  cursor: pointer;
  /* 44px minimum target. Below this a thumb misses on a phone. */
  min-height: 44px;
  transition: background-color var(--gp-dur-micro) var(--gp-ease-out),
              border-color var(--gp-dur-micro) var(--gp-ease-out),
              color var(--gp-dur-micro) var(--gp-ease-out);
}

.btn--primary {
  background: var(--gp-accent-face);
  border-color: var(--gp-accent-face);
  color: var(--gp-on-accent);
}

.btn--primary:hover {
  background: var(--gp-accent-face-hover);
  border-color: var(--gp-accent-face-hover);
  color: var(--gp-on-accent);
}

.btn--secondary {
  background: transparent;
  border-color: var(--gp-accent-face);
  color: var(--gp-accent-face);
}

.btn--secondary:hover {
  background: var(--gp-accent-face);
  color: var(--gp-on-accent);
}

/* Quiet: a real button that is not competing. Used for Cancel, Clear all,
   Show more. Not a link, because it does something rather than going somewhere. */
.btn--quiet {
  background: transparent;
  border-color: var(--gp-rule-strong);
  color: var(--gp-ink);
}

.btn--quiet:hover {
  background: var(--gp-surface);
  border-color: var(--gp-ink-muted);
  color: var(--gp-ink);
}

.btn--danger {
  background: var(--gp-danger-ink);
  border-color: var(--gp-danger-ink);
  color: var(--gp-warm-ivory);
}

/* The pressed danger face. A literal, because it is a darkening of
   --gp-danger-ink that the token layer does not carry, and in the dark theme
   the ink is already light so pressing it must go the other way. */
.btn--danger:hover {
  background: var(--gp-danger-press, #7f231a);
  border-color: var(--gp-danger-press, #7f231a);
}

/* On the closing band the primary inverts: ivory face, forest label. A Deep
   Forest button on a Deep Forest band is invisible. */
.band .btn--primary {
  background: var(--gp-on-band);
  border-color: var(--gp-on-band);
  color: var(--gp-accent-face);
}

.band .btn--secondary {
  border-color: var(--gp-on-band);
  color: var(--gp-on-band);
}

.band .btn--secondary:hover {
  background: var(--gp-on-band);
  color: var(--gp-accent-face);
}

.btn--sm {
  padding: var(--gp-space-8) var(--gp-space-16);
  font-size: var(--gp-fs-small);
  min-height: 36px;
}

.btn--lg {
  padding: var(--gp-space-16) var(--gp-space-32);
  font-size: var(--gp-fs-lead);
  min-height: 52px;
}

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

.btn[disabled],
.btn[aria-disabled='true'] {
  opacity: 0.55;
  cursor: not-allowed;
}

/* Loading. The control KEEPS ITS WIDTH and swaps its label for the pending
   one, so the layout does not jump and the button does not appear to shrink
   at the moment the buyer is most anxious. */
.btn[aria-busy='true'] {
  cursor: progress;
}

/* An indeterminate spinner that is NOT inside a button. The receipt page's
   confirming state and the claim page's redeeming state both need one, and
   borrowing .btn__spinner would mean a class named for a button appearing
   where there is no button. Same animation, same reduced-motion treatment. */
.spinner {
  /* DISPLAY IS NOT OPTIONAL HERE, and its absence is what made this render as
     a small lopsided mark rather than a ring.

     Both spinners are authored as a <span>, which is display: inline, and an
     inline box ignores width and height entirely. So the 28px square was
     discarded, the element collapsed to zero, and the only thing left to see
     was 3px of border with a 50% radius around nothing: a tiny wedge, spinning.
     It was reported as a funny icon on "Preparing your order", which is
     exactly what it looked like. */
  display: inline-block;
  width: 28px;
  height: 28px;
  border: 3px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: gp-spin 640ms linear infinite;
}

.btn__spinner {
  display: inline-block;
  /* It sits beside the button's label, so it rides the text rather than the
     baseline. Without this the ring hangs low and the label looks lifted. */
  vertical-align: middle;
  width: 1em;
  height: 1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: gp-spin 640ms linear infinite;
}

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

/* ONE reduced-motion block for both, and it used to be three.

   The .spinner rule above was pasted a second time INSIDE this media query,
   bringing its own nested copy of this same query with it. The result declared
   the full-speed 640ms animation inside the block whose entire purpose is to
   slow it down, and buried .btn__spinner's own override two levels deep. It
   happened to end up at 1600ms through source order, which is why nobody
   noticed: a rule that is wrong and works is a rule that stays wrong.

   Slowed rather than stopped, deliberately. A spinner is the only thing saying
   the page has not died, so a reader who asked for less motion still needs to
   see that something is happening. */
@media (prefers-reduced-motion: reduce) {
  .spinner,
  .btn__spinner {
    animation-duration: 1600ms;
  }
}

/* A link that behaves as a text action. Underlined, because it is a link. */
.btn-text {
  display: inline-flex;
  align-items: center;
  gap: var(--gp-space-4);
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-semi);
  color: var(--gp-accent);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 0.18em;
}

.btn-text:hover { color: var(--gp-accent-face); }


/* =============================================================================
   2. Forms

   16px is the floor on every input. Below it iOS zooms the viewport on focus,
   which on a checkout reads as the page breaking.
   ========================================================================== */

.field {
  display: block;
  margin-bottom: var(--gp-space-24);
}

.field__label {
  display: block;
  margin-bottom: var(--gp-space-8);
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-semi);
  color: var(--gp-ink);
}

.field__optional {
  font-weight: var(--gp-fw-regular);
  color: var(--gp-ink-muted);
}

/* Helper text sits ABOVE the control when it changes what you type, and below
   when it explains what happens after. Both are wired with aria-describedby. */
.field__help {
  margin-bottom: var(--gp-space-8);
  font-size: var(--gp-fs-meta);
  line-height: var(--gp-lh-meta);
  color: var(--gp-ink-muted);
}

.field__help--after {
  margin: var(--gp-space-8) 0 0;
}

.input,
.textarea,
.select {
  width: 100%;
  padding: var(--gp-space-12) var(--gp-space-16);
  min-height: 48px;
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule-strong);
  border-radius: var(--gp-radius);
  font-family: var(--gp-font-body);
  font-size: 16px;
  line-height: 1.4;
  color: var(--gp-ink);
  transition: border-color var(--gp-dur-micro) var(--gp-ease-out);
}

.input:placeholder,
.textarea:placeholder {
  color: var(--gp-ink-muted);
  opacity: 1;
}

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

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

.select {
  appearance: none;
  padding-right: var(--gp-space-40);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2326211c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--gp-space-16) center;
  background-size: 18px;
}

/* Error. Never colour alone: the border changes, an icon appears, and the
   message is text. A red border on its own is invisible to a large minority. */
.input[aria-invalid='true'],
.textarea[aria-invalid='true'],
.select[aria-invalid='true'] {
  border-color: var(--gp-danger-ink);
  border-width: 2px;
}

.field__error {
  display: flex;
  align-items: flex-start;
  gap: var(--gp-space-8);
  margin-top: var(--gp-space-8);
  font-size: var(--gp-fs-meta);
  line-height: var(--gp-lh-meta);
  color: var(--gp-danger-ink);
  font-weight: var(--gp-fw-medium);
}

.field__error .icon {
  flex: none;
  margin-top: 0.15em;
}

.input[disabled],
.textarea[disabled],
.select[disabled] {
  background: var(--gp-surface-sunken);
  color: var(--gp-ink-muted);
  cursor: not-allowed;
}

/* Checkbox and radio. The control is 20px, the target is the whole row. */
.choice {
  display: flex;
  align-items: flex-start;
  gap: var(--gp-space-12);
  padding: var(--gp-space-8) 0;
  cursor: pointer;
  min-height: 44px;
}

.choice input[type='checkbox'],
.choice input[type='radio'] {
  flex: none;
  width: 20px;
  height: 20px;
  margin: 0;
  margin-top: 0.15em;
  accent-color: var(--gp-accent-face);
  cursor: pointer;
}

.choice__body {
  flex: 1;
  min-width: 0;
}

.choice__label {
  display: block;
  font-size: var(--gp-fs-small);
  color: var(--gp-ink);
}

.choice__note {
  display: block;
  margin-top: 2px;
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
}

.choice__count {
  margin-left: auto;
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
  font-variant-numeric: tabular-nums;
}

.choice[data-disabled='true'] {
  cursor: not-allowed;
  opacity: 0.55;
}

.fieldset {
  border: 0;
  padding: 0;
  margin: 0 0 var(--gp-space-24);
}

.fieldset__legend {
  padding: 0;
  margin-bottom: var(--gp-space-8);
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-semi);
  color: var(--gp-ink);
}

/* A honeypot. Present in the DOM, never visible, never announced, never
   reachable by keyboard. This is the spam defence, not a CAPTCHA. */
.honeypot {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.form-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gp-space-12);
  align-items: center;
}

/* The character counter appears late and only once it matters. */
.field__counter {
  margin-top: var(--gp-space-8);
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.field__counter[data-state='warn'] { color: var(--gp-warning-ink); }
.field__counter[data-state='over'] { color: var(--gp-danger-ink); }

/* One-time code. Wide tracking, tabular figures, and a real inputmode so the
   numeric keypad opens on a phone. */
.input--code {
  font-size: 24px;
  letter-spacing: 0.4em;
  text-align: center;
  font-variant-numeric: tabular-nums;
  padding-left: var(--gp-space-24);
}


/* =============================================================================
   3. Segmented controls

   Used by the currency switch, the subject page's kind filter, and the
   library's filters. Where the options NAVIGATE they are links with
   aria-current; where they CHANGE STATE they are radios in a radiogroup. The
   markup differs and the styling does not.
   ========================================================================== */

.segmented {
  display: inline-flex;
  padding: 3px;
  background: var(--gp-surface-sunken);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius-pill);
  gap: 2px;
}

.segmented__option {
  display: inline-flex;
  align-items: center;
  gap: var(--gp-space-8);
  padding: var(--gp-space-8) var(--gp-space-16);
  border: 0;
  border-radius: var(--gp-radius-pill);
  background: transparent;
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-medium);
  color: var(--gp-ink-muted);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  min-height: 36px;
}

.segmented__option:hover {
  color: var(--gp-ink);
}

.segmented__option[aria-current='true'],
.segmented__option[aria-checked='true'],
.segmented__option.is-active {
  background: var(--gp-surface);
  color: var(--gp-ink);
  font-weight: var(--gp-fw-semi);
  box-shadow: var(--gp-elevation-1);
}

/* A zero-count option is DISABLED WITH ITS COUNT VISIBLE, never hidden.
   Hiding it removes the visitor's ability to learn the thing exists. */
.segmented__option[aria-disabled='true'],
.segmented__option[disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}

.segmented__count {
  font-variant-numeric: tabular-nums;
  font-weight: var(--gp-fw-regular);
}

.band .segmented {
  background: rgba(250, 249, 244, 0.08);
  border-color: var(--gp-rule-on-band);
}

.band .segmented__option { color: var(--gp-on-band-muted); }

.band .segmented__option[aria-checked='true'],
.band .segmented__option[aria-current='true'] {
  background: var(--gp-on-band);
  color: var(--gp-accent-face);
  box-shadow: none;
}


/* =============================================================================
   4. Badges, chips and pills
   ========================================================================== */

/* The kind badge sits OVER the cover, not beside the title: a scanning eye
   reads the picture first, and a course reading as a book is the single most
   likely misunderstanding on this storefront.

   TOP RIGHT, not top left.

   ia/IA_PUBLIC.md section 3.4 says top-left, and that is wrong for this
   catalogue specifically: every cover in it carries the Gilead roundel in its
   own top-left corner, so a badge there lands on top of the imprint's own
   mark. Covering your publisher's logo with a label reading "Book" is worse
   than the small loss of putting the badge on the side the eye reaches second.
   If the cover art is ever redrawn without a corner mark, this goes back. */
.kind-badge {
  position: absolute;
  top: var(--gp-space-8);
  right: var(--gp-space-8);
  z-index: 1;
  padding: 3px var(--gp-space-8);
  background: rgba(255, 255, 255, 0.92);
  /* CHARCOAL, NOT --gp-ink, and this is not an oversight.

     The scrim above is a LITERAL white, because the badge floats over cover
     artwork and artwork does not change with the theme. --gp-ink does: it is
     Charcoal in the light theme and #f2f2ec in the dark one, so a badge
     reading var(--gp-ink) went near-white on its own near-white scrim the
     moment a reader switched to dark. Ink painted on a fixed ground must be
     as fixed as the ground, so this names the brand primitive directly.
     15.13:1 on ivory, and the scrim is whiter than ivory. */
  color: var(--gp-charcoal);
  border-radius: var(--gp-radius-sm);
  font-size: var(--gp-fs-eyebrow);
  font-weight: var(--gp-fw-semi);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  backdrop-filter: blur(4px);
}

/* A quiet chip for a subject or a format. Not interactive. */
.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--gp-space-4);
  padding: 2px var(--gp-space-8);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius-sm);
  background: var(--gp-surface);
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
  white-space: nowrap;
}

.chip--accent {
  border-color: transparent;
  background: rgba(102, 140, 36, 0.12);
  color: var(--gp-accent);
  font-weight: var(--gp-fw-medium);
}

.chip--free {
  border-color: transparent;
  background: var(--gp-success-tint);
  color: var(--gp-success-ink);
  font-weight: var(--gp-fw-semi);
}

/* A meta pill on a product page: "120 pages", "PDF", "2.1 MB". A pill whose
   field is missing is NOT RENDERED. It is never invented. */
.pill-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gp-space-8);
  margin-top: var(--gp-space-16);
  list-style: none;
  padding: 0;
}

.pill {
  padding: var(--gp-space-4) var(--gp-space-12);
  background: var(--gp-surface-sunken);
  border-radius: var(--gp-radius-sm);
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink);
  white-space: nowrap;
}

/* Status chips use the functional tokens, never the brand green: a status
   painted in the brand's own hue reads as furniture rather than as a state. */
.status {
  display: inline-flex;
  align-items: center;
  gap: var(--gp-space-4);
  padding: 2px var(--gp-space-8);
  border-radius: var(--gp-radius-sm);
  font-size: var(--gp-fs-meta);
  font-weight: var(--gp-fw-semi);
  white-space: nowrap;
}

.status--success { background: var(--gp-success-tint); color: var(--gp-success-ink); }
.status--info    { background: var(--gp-info-tint);    color: var(--gp-info-ink); }
.status--warning { background: var(--gp-warning-tint); color: var(--gp-warning-ink); }
.status--danger  { background: var(--gp-danger-tint);  color: var(--gp-danger-ink); }
.status--neutral { background: var(--gp-surface-sunken); color: var(--gp-ink-muted); }


/* =============================================================================
   5. Cards
   ========================================================================== */

.card {
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius);
  padding: var(--gp-space-24);
}

.card--flush { padding: 0; overflow: hidden; }

.card__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gp-space-16);
  margin-bottom: var(--gp-space-16);
}

/* ---------- The cover frame ----------
   A 1px rule so a white cover does not dissolve into a white card. The
   fallback is a Deep Forest panel with the TITLE set in it, never a generic
   book glyph: a missing cover on a bookshop is bad, and a title is more useful
   than an icon. */

.cover {
  position: relative;
  display: block;
  aspect-ratio: 2 / 3;
  overflow: hidden;
  background: var(--gp-surface-sunken);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius-sm);
}

.cover img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.cover--wide { aspect-ratio: 16 / 9; }

/* The cover on a SELLING page, not in a card.

   Capped and centred under 768px. Uncapped, a 360px phone renders the cover
   at roughly 358x537, which pushes the price and the buy button past 640px of
   scroll, and "the buy panel is above the fold on every breakpoint" is the
   load-bearing rule on that page. The cap is what buys the panel its space. */
.cover--detail {
  max-width: 280px;
  margin-inline: auto;
}

@media (min-width: 768px) {
  .cover--detail { max-width: none; margin-inline: 0; }
}

.cover--fallback {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--gp-space-16);
  background: linear-gradient(160deg, var(--gp-deep-forest), var(--gp-evergreen));
  border-color: var(--gp-evergreen);
}

/* THE BADGE IS EXCLUDED BY NAME, and the exclusion is the whole point of this
   selector, so do not simplify it back to `.cover--fallback span`.

   That is what it used to be, and it silently repainted the kind badge. The
   badge markup is `<span class="kind-badge">`, a sibling of this title span
   inside the same `.cover--fallback` link, so `.cover--fallback span` matched
   it too. At one class plus one type it scores 0-1-1, which OUTRANKS
   `.kind-badge` at 0-1-0 on specificity alone, in a flat cascade with no
   @layer to arbitrate. The badge therefore took this rule's colour, display
   face, size and weight instead of its own: cream text on the badge's own
   near-white scrim, about 1.0:1, invisible in both themes.

   `:not(.kind-badge)` is used in preference to raising `.kind-badge`'s
   specificity because it removes the collision rather than out-shouting it:
   the badge is no longer a candidate here at all, and any future span added
   inside a fallback cover is not silently swallowed either. It costs one
   class of specificity (now 0-2-1) on the title span, which nothing else
   competes for: these two are the only `.cover--fallback` rules in the four
   stylesheets. */
.cover--fallback span:not(.kind-badge) {
  font-family: var(--gp-font-display);
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-bold);
  line-height: 1.3;
  color: var(--gp-on-band);
  text-align: center;
}

/* ---------- The product card ----------
   ONE component, used on the home page, subject pages, both catalogues,
   related lists and the bundle block. Its shape does not change between them;
   only the count of them and the grid do.

   The whole card is ONE TAB STOP whose accessible name is the title, achieved
   with a stretched pseudo-element on the title's own link. It is not a div
   with an onClick and no name. */

.product-card {
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

/* ---------- The panel card, used in every grid and rail ----------

   The card is a SURFACE, not a transparent stack of elements on the page
   ground. White on Warm Ivory is what separates it from the page without
   asking a shadow to do the work, and it gives the cover an edge to sit flush
   against, which is what makes a grid of them read as a shelf rather than as
   four floating images.

   Scoped away from --row, which is a horizontal list item and deliberately has
   no panel of its own. */

.product-card:not(.product-card--row) {
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius-lg);
  overflow: hidden;
  transition: border-color var(--gp-dur-micro) var(--gp-ease-out),
              box-shadow var(--gp-dur-micro) var(--gp-ease-out),
              transform var(--gp-dur-micro) var(--gp-ease-out);
}

.product-card:not(.product-card--row):hover,
.product-card:not(.product-card--row):focus-within {
  border-color: var(--gp-rule-strong);
  box-shadow: var(--gp-elevation-3);
  transform: translateY(-3px);
}

@media (prefers-reduced-motion: reduce) {
  .product-card:not(.product-card--row):hover,
  .product-card:not(.product-card--row):focus-within { transform: none; }
}

/* The cover band. Fixed height, full width, flush to the card's top edge and
   clipped by the card's own radius, so the cover has no border and no radius
   of its own. Its shadow is gone too: the card carries the elevation now, and
   two shadows stacked on one object read as a mistake. */
.product-card:not(.product-card--row) .cover {
  height: var(--gp-cover-h);
  width: 100%;
  aspect-ratio: auto;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  transform: none;
}

.product-card:not(.product-card--row) .cover img {
  height: 100%;
  width: 100%;
  object-fit: cover;
  /* The covers put their logo at the top and their title near the middle, so a
     centred crop keeps both. Top-anchoring would push the title off the bottom
     of the band. */
  object-position: center;
}

/* The cover does not move on hover any more; the whole card does. */
.product-card:not(.product-card--row):hover .cover,
.product-card:not(.product-card--row):focus-within .cover {
  transform: none;
  box-shadow: none;
}

/* The cover is a BOOK, not a picture of one.

   A flat rectangle sitting directly on the page reads as an image; a shadow
   under it and a spine down its leading edge read as an object on a shelf.
   That distinction is most of the difference between a card grid that looks
   like a bookshop and one that looks like a template, and it costs two rules.

   It also does real work here specifically: every cover in this catalogue is
   full-bleed dark green, so without the shadow the covers merge into each
   other and into any dark ground behind them. */
.product-card .cover {
  box-shadow: var(--gp-elevation-2);
  transition: transform var(--gp-dur-micro) var(--gp-ease-out),
              box-shadow var(--gp-dur-micro) var(--gp-ease-out);
}

/* The spine. A hairline of the page ground down the leading edge, which reads
   as the gutter of a closed book. Inset so it survives the border radius. */
.product-card .cover:after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 5px;
  background: linear-gradient(
    to right,
    rgba(0, 0, 0, 0.22),
    rgba(0, 0, 0, 0.06) 60%,
    rgba(255, 255, 255, 0.10)
  );
  pointer-events: none;
}

.product-card:hover .cover,
.product-card:focus-within .cover {
  transform: translateY(-4px);
  box-shadow: var(--gp-elevation-3);
}

@media (prefers-reduced-motion: reduce) {
  .product-card:hover .cover,
  .product-card:focus-within .cover { transform: none; }
}

.product-card__title {
  font-size: var(--gp-fs-h4);
  line-height: var(--gp-lh-h4);
  font-weight: var(--gp-fw-bold);
  margin: 0;
}

.product-card__title a {
  color: var(--gp-ink);
  text-decoration: none;
}

.product-card__title a:after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: var(--gp-radius);
}

.product-card:hover .product-card__title a { color: var(--gp-accent-face); }

.product-card:has(a:focus-visible) {
  outline: 2px solid var(--gp-focus-outer);
  outline-offset: 4px;
  box-shadow: 0 0 0 2px var(--gp-focus-inner);
  border-radius: var(--gp-radius);
}

.product-card:has(a:focus-visible) a:focus-visible {
  outline: none;
  box-shadow: none;
}

.product-card__subtitle {
  font-size: var(--gp-fs-meta);
  line-height: var(--gp-lh-meta);
  color: var(--gp-ink-muted);
}

.product-card__meta {
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
  /* Inside the foot row this must be allowed to shrink. A course meta line
     ("18 lessons · 4h 20m") is twice the length of a book's ("92 pages"), and
     without this it wraps to two lines and shoves the price out of alignment
     with the other three cards in the row. The price never wraps; the meta
     gives way, because between the two the price is the one being compared. */
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Price and meta share the last line: the meta answers "how much of it is
   there" and the price answers "what does it cost", and a buyer scanning a
   grid weighs those two against each other. Putting them on one row is what
   lets the eye compare four cards in one pass down the right edge. */
.product-card__foot {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gp-space-12);
  margin-top: auto;
  padding-top: var(--gp-space-12);
  border-top: 1px solid var(--gp-rule);
}

/* In a panel the foot is inset to match the body, and its rule stops short of
   the card edge rather than cutting the card in half. */
.product-card:not(.product-card--row) .product-card__foot {
  margin: var(--gp-space-16) var(--gp-space-16) var(--gp-space-16);
}

@media (min-width: 640px) {
  .product-card:not(.product-card--row) .product-card__foot {
    margin: var(--gp-space-16) var(--gp-space-24) var(--gp-space-24);
  }
}

.product-card__price {
  font-size: var(--gp-fs-body);
  font-weight: var(--gp-fw-bold);
  font-family: var(--gp-font-display);
  color: var(--gp-ink);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* A free title says so in words, not as "NGN 0". */
.product-card__price--free {
  color: var(--gp-accent);
}

/* A compare-at figure ON A CARD, for the bundle block on a selling page. The
   buy panel has its own pair; a card had none, so a bundle card could show its
   price but never the saving. Same rule as everywhere else: the compare figure
   is the true arithmetic sum of the members' current prices, computed
   server-side, never typed. */
.product-card__compare {
  margin-right: var(--gp-space-8);
  color: var(--gp-ink-muted);
  text-decoration: line-through;
  font-weight: var(--gp-fw-regular);
  font-family: var(--gp-font-body);
  font-size: var(--gp-fs-meta);
}

.product-card__save {
  display: block;
  margin-top: 2px;
  font-family: var(--gp-font-body);
  font-size: var(--gp-fs-meta);
  font-weight: var(--gp-fw-semi);
  color: var(--gp-accent);
}

/* Any SECOND control inside a card must sit above the stretched pseudo-element
   or the card's own link swallows it. */
.product-card__action {
  position: relative;
  z-index: 1;
  align-self: flex-start;
}

.product-card__body {
  display: flex;
  flex-direction: column;
  gap: var(--gp-space-4);
  min-width: 0;
  flex: 1;
}

/* Inside a panel the body is inset; in the row variant it is not, because the
   row has no panel to be inset from. */
.product-card:not(.product-card--row) .product-card__body {
  padding: var(--gp-space-16) var(--gp-space-16) 0;
}

/* 20px would be the obvious card padding and is not available: the spacing
   scale is multiples of 8 and there is deliberately no 20. 16 at base, 24 once
   the card is wide enough to carry it. */
@media (min-width: 640px) {
  .product-card:not(.product-card--row) .product-card__body {
    padding: var(--gp-space-24) var(--gp-space-24) 0;
  }
}

/* The subject chip. Present on a MIXED list, where it is doing real work: the
   featured block draws across subjects and never four from one, so which shelf
   a title came from is information the reader wants and cannot infer. It is
   absent from a subject page's own grid, where every card would carry the same
   chip and it would be noise. */
.product-card__subject {
  align-self: flex-start;
  margin-bottom: var(--gp-space-4);
}

/* Horizontal variant: the free block on the home page, the library rows. */
.product-card--row {
  flex-direction: row;
  gap: var(--gp-space-16);
  align-items: flex-start;
}

.product-card--row .cover {
  flex: none;
  width: 88px;
}

@media (min-width: 640px) {
  .product-card--row .cover { width: 112px; }
}

/* ---------- The subject card ----------

   A shelf, not a tile. The rule across the top is the shelf edge: it is Pale
   Sage at rest and fills with Deep Forest on hover, left to right, which is
   the only motion on the card and is gated on reduced motion like everything
   else.

   The card has a floor so a subject with a long one-liner and a subject with
   none are the same height. A ragged row of subject cards makes the shelf look
   unfinished, which is the opposite of what this block is for. */

.subject-card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--gp-space-8);
  padding: var(--gp-space-32) var(--gp-space-24) var(--gp-space-24);
  min-height: 210px;
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius-lg);
  overflow: hidden;
  transition: border-color var(--gp-dur-micro) var(--gp-ease-out),
              box-shadow var(--gp-dur-micro) var(--gp-ease-out),
              transform var(--gp-dur-micro) var(--gp-ease-out);
}

/* The shelf edge. A pseudo-element rather than a border, because a border
   cannot animate its width without moving the content under it. */
.subject-card:before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  height: 3px;
  width: 100%;
  background: var(--gp-rule);
}

.subject-card:after {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  height: 3px;
  width: 0;
  background: var(--gp-accent-face);
  transition: width var(--gp-dur-sheet) var(--gp-ease);
}

.subject-card:hover,
.subject-card:focus-within {
  border-color: var(--gp-rule-strong);
  box-shadow: var(--gp-elevation-2);
  transform: translateY(-2px);
}

.subject-card:hover:after,
.subject-card:focus-within:after {
  width: 100%;
}

@media (prefers-reduced-motion: reduce) {
  .subject-card:hover,
  .subject-card:focus-within { transform: none; }
  .subject-card:after { transition: none; }
}

.subject-card__name {
  font-family: var(--gp-font-display);
  font-size: var(--gp-fs-h3);
  line-height: var(--gp-lh-h3);
  font-weight: var(--gp-fw-bold);
  margin: 0;
}

.subject-card__name a {
  color: var(--gp-ink);
  text-decoration: none;
}

.subject-card__name a:after {
  content: '';
  position: absolute;
  inset: 0;
}

.subject-card:hover .subject-card__name a { color: var(--gp-accent-face); }

.subject-card__line {
  font-size: var(--gp-fs-small);
  line-height: var(--gp-lh-small);
  color: var(--gp-ink-muted);
  flex: 1;
}

/* The count and the arrow sit on one baseline at the foot of the card, so
   every card in the row ends on the same line however long its one-liner is. */
.subject-card__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gp-space-12);
  margin-top: var(--gp-space-16);
  padding-top: var(--gp-space-16);
  border-top: 1px solid var(--gp-rule);
}

.subject-card__count {
  font-size: var(--gp-fs-meta);
  font-weight: var(--gp-fw-semi);
  color: var(--gp-accent);
}

.subject-card__foot .icon {
  color: var(--gp-accent-decorative);
  transition: transform var(--gp-dur-micro) var(--gp-ease-out);
}

.subject-card:hover .subject-card__foot .icon { transform: translateX(4px); }

@media (prefers-reduced-motion: reduce) {
  .subject-card:hover .subject-card__foot .icon { transform: none; }
}

/* An empty subject.

   Shown, and shown HONESTLY. It sits on the sunken ground rather than white,
   its count says what is true rather than nothing, and it still links, because
   the subject is real and the page behind it is a real page with a real empty
   state. What it must never do is look identical to a shelf that has books on
   it: that is the imprint advertising a gap and hoping nobody taps. */
.subject-card--empty {
  background: var(--gp-surface-sunken);
  border-style: dashed;
}

.subject-card--empty:before { background: transparent; }

.subject-card--empty .subject-card__name a { color: var(--gp-ink-muted); }
.subject-card--empty:hover .subject-card__name a { color: var(--gp-ink); }
.subject-card--empty .subject-card__count { color: var(--gp-ink-muted); font-weight: var(--gp-fw-regular); }
.subject-card--empty .subject-card__foot .icon { color: var(--gp-ink-muted); }


/* =============================================================================
   6. Panels
   ========================================================================== */

/* The buy panel. Above the fold on every breakpoint. */
.buy-panel {
  padding: var(--gp-space-24);
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius-lg);
  box-shadow: var(--gp-elevation-2);
}

.buy-panel__price {
  font-family: var(--gp-font-display);
  font-size: 28px;
  line-height: 1.2;
  font-weight: var(--gp-fw-bold);
  color: var(--gp-ink);
  font-variant-numeric: tabular-nums;
}

/* A compare-at price is rendered ONLY when it is the true arithmetic sum of
   currently charged prices. Never a typed anchor, never a percentage without
   the figure. */
.buy-panel__compare {
  margin-right: var(--gp-space-8);
  font-size: var(--gp-fs-lead);
  color: var(--gp-ink-muted);
  text-decoration: line-through;
  font-variant-numeric: tabular-nums;
}

/* THE COMPARE LINE UNDER THE PRICE.

   The struck figure used to sit inline BEFORE the price, which is the shape a
   bundle wants: two figures read as one sentence, "ten thousand, now five".
   Under the price is a different claim and wants a different shape. The price
   is the headline and reads first at 28px; the line beneath it is the
   justification, and it is smaller and quieter because a buyer who has already
   accepted the price should not have to read past a second figure to reach the
   button.

   The saving is stated IN WORDS WITH THE FIGURE, never as a bare percentage.
   "Save NGN 2,500" is checkable against the two numbers above it. "Save 33%"
   is not, and a percentage with no figure is the oldest way to make a discount
   look larger than it is. */
.buy-panel__compare-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--gp-space-8);
  margin-top: var(--gp-space-4);
}

/* In the row the strike no longer needs to push the price away from it. */
.buy-panel__compare-row .buy-panel__compare { margin-right: 0; }
.buy-panel__compare-row .buy-panel__save { margin-left: 0; }

/* The sticky bar carries the same pair, because a bar that shows only the
   lower figure while the panel above shows a saving reads as two prices for
   one book. It is one line there: the bar has one line to give. */
.buy-bar__compare {
  margin-right: var(--gp-space-8);
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-regular);
  color: var(--gp-ink-muted);
  text-decoration: line-through;
  font-variant-numeric: tabular-nums;
}

.buy-panel__save {
  margin-left: var(--gp-space-8);
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-semi);
  color: var(--gp-accent);
}

.buy-panel__note {
  margin-top: var(--gp-space-8);
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
}

.buy-panel__total {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gp-space-16);
  padding-top: var(--gp-space-16);
  margin-top: var(--gp-space-16);
  border-top: 1px solid var(--gp-rule);
  font-size: var(--gp-fs-lead);
  font-weight: var(--gp-fw-semi);
  font-variant-numeric: tabular-nums;
}

.buy-panel .btn--primary { margin-top: var(--gp-space-16); }

/* The what-happens-next line, stated BEFORE payment rather than after. */
.buy-panel__promise {
  margin-top: var(--gp-space-12);
  font-size: var(--gp-fs-meta);
  line-height: var(--gp-lh-meta);
  color: var(--gp-ink-muted);
  text-align: center;
}

/* The add-on panel. At most ONE optional paid add-on is ever rendered. */
.addon {
  margin-top: var(--gp-space-16);
  padding: var(--gp-space-16);
  border: 1px solid var(--gp-rule-strong);
  border-radius: var(--gp-radius);
  background: var(--gp-surface-sunken);
}

/* A REQUIRED add-on carries no control. A checkbox that cannot be unchecked
   is a lie about agency. */
.addon--included {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gp-space-12);
  font-size: var(--gp-fs-small);
}

/* ---------- Functional message panels ----------
   Four states, four treatments, and they are not interchangeable. Never the
   brand green for success. */

.msg {
  display: flex;
  gap: var(--gp-space-12);
  padding: var(--gp-space-16);
  border: 1px solid;
  border-radius: var(--gp-radius);
}

.msg .icon { flex: none; margin-top: 0.15em; }

.msg__body { min-width: 0; }

.msg__title {
  font-family: var(--gp-font-body);
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-semi);
  margin: 0 0 var(--gp-space-4);
}

.msg__text {
  font-size: var(--gp-fs-small);
  line-height: var(--gp-lh-small);
  margin: 0;
}

/* A heading that follows a message block needs air. The "Before you write"
   panel sits directly above the form's own heading on the contact page, and
   without this they read as one block. */
.msg + h2 { margin-top: var(--gp-space-40); }

.msg--success { background: var(--gp-success-tint); border-color: var(--gp-success-border); color: var(--gp-success-ink); }
.msg--info    { background: var(--gp-info-tint);    border-color: var(--gp-info-border);    color: var(--gp-info-ink); }
.msg--warning { background: var(--gp-warning-tint); border-color: var(--gp-warning-border); color: var(--gp-warning-ink); }
.msg--danger  { background: var(--gp-danger-tint);  border-color: var(--gp-danger-border);  color: var(--gp-danger-ink); }

/* Inside prose, a callout and a message ARE body copy, so they take the body
   size. .callout and .msg are 15px because they are usually an aside beside
   something larger; on a legal page the callout carries load-bearing text, and
   16px is a floor those pages hold deliberately. Small print is a design
   decision and this imprint's position is honesty. */
.prose .callout,
.prose .msg__text,
.prose .msg__title {
  font-size: var(--gp-fs-body);
  line-height: var(--gp-lh-body);
}

/* A callout carries no margin of its own, so on the subject page the medical
   position sat flush against the filter under it with nothing between them.
   Anything following a callout gets air. */
.callout + * { margin-top: var(--gp-space-32); }

/* A callout on paper: the dual-listing notice, the medical position. Quiet,
   ruled on the leading edge, not tinted. */
.callout {
  padding: var(--gp-space-16) var(--gp-space-24);
  border-left: 3px solid var(--gp-accent-decorative);
  background: var(--gp-surface);
  font-size: var(--gp-fs-small);
  line-height: var(--gp-lh-small);
}

/* The medical position is reproduced VERBATIM on any health page. It is not
   paraphrased, softened or shortened, and it is not decorated into a warning
   box: it is a plain statement and it reads as one. */
.callout--position {
  border-left-color: var(--gp-accent-face);
  font-family: var(--gp-font-display);
  font-size: var(--gp-fs-small);
}


/* =============================================================================
   7. Lists
   ========================================================================== */

/* The contents list on a product page. */
.contents {
  list-style: none;
  padding: 0;
  margin: 0;
  counter-reset: gp-contents;
}

.contents li {
  display: flex;
  gap: var(--gp-space-16);
  padding: var(--gp-space-12) 0;
  border-bottom: 1px solid var(--gp-rule);
  counter-increment: gp-contents;
}

.contents li:before {
  content: counter(gp-contents);
  flex: none;
  width: 2ch;
  font-size: var(--gp-fs-meta);
  font-variant-numeric: tabular-nums;
  color: var(--gp-ink-muted);
  padding-top: 0.15em;
}

.contents__title {
  font-weight: var(--gp-fw-medium);
  font-size: var(--gp-fs-small);
}

/* The remainder of a chapter list that has been split for the mobile
   "Show all 21 chapters" disclosure.

   `.contents` resets its own counter, so a second list restarts at 1, and the
   `start` attribute on <ol> does not move a CSS counter: that is the trap.
   The continuation sets the counter explicitly instead, one below the first
   chapter it contains, via a custom property the template supplies:
     <ol class="contents contents--continued" style=...>   NO. Templates carry
   no style attributes here, so the theme sets --gp-contents-start in PHP and
   the default below covers the eight-item split the spec asks for. */
.contents--continued {
  counter-reset: gp-contents var(--gp-contents-start, 8);
}

.contents__sub {
  display: block;
  margin-top: 2px;
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
}

/* "What you get". A definition list built from REAL FIELDS ONLY. */
.spec {
  display: grid;
  gap: 0;
  margin: 0;
}

.spec__row {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2px;
  padding: var(--gp-space-12) 0;
  border-bottom: 1px solid var(--gp-rule);
}

@media (min-width: 480px) {
  .spec__row {
    grid-template-columns: 160px 1fr;
    gap: var(--gp-space-16);
    align-items: baseline;
  }
}

.spec dt {
  font-size: var(--gp-fs-meta);
  font-weight: var(--gp-fw-semi);
  color: var(--gp-ink-muted);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.spec dd {
  margin: 0;
  font-size: var(--gp-fs-small);
  color: var(--gp-ink);
}

/* A downloadable file row. Everything the reader needs is visible BEFORE any
   tap: format, label, size, and the version only when it is not 1.0. */
.file-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gp-space-12);
  padding: var(--gp-space-16) 0;
  border-bottom: 1px solid var(--gp-rule);
}

.file-row__body {
  flex: 1;
  min-width: 180px;
}

.file-row__label {
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-medium);
}

.file-row__meta {
  margin-top: 2px;
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
  font-variant-numeric: tabular-nums;
}

.file-row__actions {
  display: flex;
  gap: var(--gp-space-8);
  flex-wrap: wrap;
}


/* =============================================================================
   8. Disclosure and accordion

   Answers are in the DOM at render and collapsed by height, never fetched on
   expand: expanding should not cost a request on a metered connection, and
   find-in-page must be able to match text inside a closed answer.
   ========================================================================== */

.accordion {
  border-top: 1px solid var(--gp-rule);
}

.accordion__item {
  border-bottom: 1px solid var(--gp-rule);
  /* NO scroll-margin-top HERE, AND THAT IS THE POINT.

     This carried one, matching the scroll-padding-top base.css sets on the
     scrollport, and the two ADD. A deep link to an answer landed 200px down
     instead of 100px: past the heading it was supposed to bring into view,
     which reads as an overshoot rather than as a missing rule.

     base.css says this in its own comment, above the scroll-padding
     declaration, and then three rules in this file did it anyway. One offset,
     set once, on the scrollport. */
}

.accordion__heading { margin: 0; }

.accordion__trigger {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--gp-space-16);
  width: 100%;
  padding: var(--gp-space-16) 0;
  font-family: var(--gp-font-body);
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-semi);
  color: var(--gp-ink);
  text-align: left;
  min-height: 48px;
}

.accordion__trigger:hover { color: var(--gp-accent-face); }

.accordion__trigger .icon {
  flex: none;
  margin-top: 0.15em;
  color: var(--gp-ink-muted);
  transition: transform var(--gp-dur-accordion) var(--gp-ease-out);
}

.accordion__trigger[aria-expanded='true'] .icon { transform: rotate(180deg); }

.accordion__panel {
  padding-bottom: var(--gp-space-16);
  font-size: var(--gp-fs-small);
  line-height: var(--gp-lh-small);
  max-width: var(--gp-measure-prose);
}

/* A plain disclosure: "Show all 21 chapters", "Show comments". */
.disclosure__trigger {
  display: inline-flex;
  align-items: center;
  gap: var(--gp-space-8);
  margin-top: var(--gp-space-16);
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-semi);
  color: var(--gp-accent);
}

.disclosure__trigger .icon {
  transition: transform var(--gp-dur-accordion) var(--gp-ease-out);
}

.disclosure__trigger[aria-expanded='true'] .icon { transform: rotate(180deg); }


/* =============================================================================
   9. Tables

   Under 768px a data table becomes stacked rows. A horizontally scrolling
   table on a phone hides the column that carries the number.
   ========================================================================== */

.table-wrap { overflow-x: auto; }

.table {
  width: 100%;
  font-size: var(--gp-fs-small);
}

.table th {
  padding: var(--gp-space-12) var(--gp-space-16);
  text-align: left;
  font-size: var(--gp-fs-meta);
  font-weight: var(--gp-fw-semi);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--gp-ink-muted);
  border-bottom: 1px solid var(--gp-rule-strong);
  white-space: nowrap;
}

.table td {
  padding: var(--gp-space-16);
  border-bottom: 1px solid var(--gp-rule);
  vertical-align: top;
}

.table .is-num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* An indented add-on line under its parent item. */
.table .is-child td:first-child { padding-left: var(--gp-space-32); }


/* =============================================================================
   10. Pagination

   Numbered links, never infinite scroll: infinite scroll spends a metered
   visitor's data on content they did not ask for, and it removes the footer,
   which is where the other subjects live.
   ========================================================================== */

.pagination {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--gp-space-8);
  margin-top: var(--gp-space-48);
  flex-wrap: wrap;
}

.pagination a,
.pagination span {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  padding: 0 var(--gp-space-12);
  border: 1px solid var(--gp-rule-strong);
  border-radius: var(--gp-radius);
  font-size: var(--gp-fs-small);
  color: var(--gp-ink);
  text-decoration: none;
  font-variant-numeric: tabular-nums;
}

.pagination a:hover {
  border-color: var(--gp-accent-face);
  color: var(--gp-accent-face);
}

.pagination [aria-current='page'] {
  background: var(--gp-accent-face);
  border-color: var(--gp-accent-face);
  color: var(--gp-on-accent);
  font-weight: var(--gp-fw-semi);
}

.pagination .is-gap {
  border: 0;
  min-width: auto;
  color: var(--gp-ink-muted);
}


/* =============================================================================
   11. Skeletons

   A skeleton has the SAME box count and the SAME aspect ratios as the real
   thing, so the page does not jump when data lands. Skeletons never fetch
   anything: on a metered connection the skeleton is free, and that is the
   point.
   ========================================================================== */

.skeleton {
  background: var(--gp-surface-sunken);
  border-radius: var(--gp-radius-sm);
  position: relative;
  overflow: hidden;
}

.skeleton:after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent);
  animation: gp-shimmer 1400ms ease-in-out infinite;
}

@keyframes gp-shimmer {
  from { transform: translateX(-100%); }
  to   { transform: translateX(100%); }
}

/* Reduced motion gets a static block. It still communicates "not yet here". */
@media (prefers-reduced-motion: reduce) {
  .skeleton:after { animation: none; opacity: 0.4; }
}

.skeleton--cover { aspect-ratio: 2 / 3; border-radius: var(--gp-radius-sm); }
.skeleton--line  { height: 0.9em; margin-bottom: var(--gp-space-8); }
.skeleton--line.is-short { width: 60%; }
.skeleton--line.is-mid   { width: 80%; }
.skeleton--button { height: 44px; border-radius: var(--gp-radius); }

/* The slow-connection line. Appears at eight seconds, not before. Honest
   information a mobile buyer can act on, and cheaper than the support message
   that arrives when a page appears to have hung. */
.slow-note {
  margin-top: var(--gp-space-16);
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
  text-align: center;
}

/* The offline bar. Persistent, under the header, NOT a toast: a toast
   disappears and the connection is still gone. */
.offline-bar {
  padding: var(--gp-space-12) 0;
  background: var(--gp-warning-tint);
  border-bottom: 1px solid var(--gp-warning-border);
  color: var(--gp-warning-ink);
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-medium);
  text-align: center;
}


/* =============================================================================
   12. Empty and error states

   Three empty shapes, and they are not the same thing. An empty state NAMES
   what caused it and offers a way out. Never a shrug.
   ========================================================================== */

.state {
  padding: var(--gp-space-48) var(--gp-space-24);
  text-align: center;
  border: 1px dashed var(--gp-rule-strong);
  border-radius: var(--gp-radius-lg);
  background: var(--gp-surface);
}

.state__title {
  font-family: var(--gp-font-display);
  font-size: var(--gp-fs-h3);
  line-height: var(--gp-lh-h3);
  margin: 0 0 var(--gp-space-8);
}

.state__text {
  max-width: 48ch;
  margin: 0 auto var(--gp-space-24);
  color: var(--gp-ink-muted);
  font-size: var(--gp-fs-small);
}

.state__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gp-space-12);
  justify-content: center;
}

/* A request that failed is an IN-PAGE block, never a toast: a toast
   disappears and the page behind it is still empty. */
.state--error {
  border-style: solid;
  border-color: var(--gp-danger-border);
  background: var(--gp-danger-tint);
}

.state--error .state__title { color: var(--gp-danger-ink); }
.state--error .state__text  { color: var(--gp-danger-ink); }


/* =============================================================================
   13. Status blocks

   The top of the receipt page. The only region whose content changes with the
   state. Uses the functional tokens, never the brand green.
   ========================================================================== */

.status-block {
  padding: var(--gp-space-32);
  border: 1px solid;
  border-radius: var(--gp-radius-lg);
  text-align: center;
}

.status-block__icon {
  width: 48px;
  height: 48px;
  margin: 0 auto var(--gp-space-16);
}

.status-block h1 {
  font-size: var(--gp-fs-h2);
  line-height: var(--gp-lh-h2);
  margin-bottom: var(--gp-space-8);
}

.status-block p {
  max-width: 52ch;
  margin-inline: auto;
  font-size: var(--gp-fs-small);
}

.status-block .btn { margin-top: var(--gp-space-24); }

.status-block--success { background: var(--gp-success-tint); border-color: var(--gp-success-border); color: var(--gp-success-ink); }
.status-block--info    { background: var(--gp-info-tint);    border-color: var(--gp-info-border);    color: var(--gp-info-ink); }
.status-block--warning { background: var(--gp-warning-tint); border-color: var(--gp-warning-border); color: var(--gp-warning-ink); }
.status-block--danger  { background: var(--gp-danger-tint);  border-color: var(--gp-danger-border);  color: var(--gp-danger-ink); }

.status-block--success h1,
.status-block--info h1,
.status-block--warning h1,
.status-block--danger h1 { color: inherit; }


/* =============================================================================
   14. Order summary
   ========================================================================== */

.summary {
  padding: var(--gp-space-24);
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius-lg);
}

.summary__item {
  display: flex;
  gap: var(--gp-space-16);
  padding-bottom: var(--gp-space-16);
}

.summary__item .cover {
  flex: none;
  width: 48px;
  border-radius: 2px;
}

.summary__item-body { flex: 1; min-width: 0; }

.summary__item-title {
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-semi);
}

.summary__line {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gp-space-16);
  padding: var(--gp-space-8) 0;
  font-size: var(--gp-fs-small);
  font-variant-numeric: tabular-nums;
}

.summary__line--addon {
  padding-left: var(--gp-space-16);
  color: var(--gp-ink-muted);
  font-size: var(--gp-fs-meta);
}

.summary__total {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gp-space-16);
  padding-top: var(--gp-space-16);
  margin-top: var(--gp-space-8);
  border-top: 1px solid var(--gp-rule-strong);
  font-size: 24px;
  font-family: var(--gp-font-display);
  font-weight: var(--gp-fw-bold);
  font-variant-numeric: tabular-nums;
}

.summary__total span:first-child {
  font-family: var(--gp-font-body);
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-semi);
}


/* =============================================================================
   15. Progress

   A determinate bar ONLY where the progress is genuinely measurable. There is
   no fake progress bar anywhere on this storefront: a fake progress bar on a
   paid transaction is a lie about a payment.
   ========================================================================== */

.progress {
  height: 4px;
  background: var(--gp-rule);
  border-radius: var(--gp-radius-pill);
  overflow: hidden;
}

.progress__bar {
  height: 100%;
  background: var(--gp-accent);
  border-radius: inherit;
}

.progress-row {
  display: flex;
  align-items: center;
  gap: var(--gp-space-12);
}

.progress-row .progress { flex: 1; }

.progress-row__label {
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}


/* =============================================================================
   16. Filters
   ========================================================================== */

.filter-rail {
  display: none;
}

/* The mobile filter bar carries the `Filters` button that opens the sheet.

   DECLARED BEFORE the 1024px block below, not after it. It was after, at the
   same specificity, so `display: flex` won the cascade and the Filters button
   rendered on desktop beside the persistent rail it exists to replace. Order
   is the whole fix; there is nothing clever here. */
.filter-bar {
  display: flex;
  align-items: center;
  gap: var(--gp-space-12);
  flex-wrap: wrap;
}

@media (min-width: 1024px) {
  .filter-rail {
    display: block;
    width: 240px;
    flex: none;
  }

  .filter-bar { display: none; }
}

.filter-group + .filter-group {
  margin-top: var(--gp-space-24);
  padding-top: var(--gp-space-24);
  border-top: 1px solid var(--gp-rule);
}

.filter-group__legend {
  font-size: var(--gp-fs-meta);
  font-weight: var(--gp-fw-semi);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--gp-ink-muted);
  margin-bottom: var(--gp-space-8);
}


.catalogue-layout {
  display: flex;
  gap: var(--gp-space-48);
  align-items: flex-start;
}

.catalogue-layout__main { flex: 1; min-width: 0; }

/* The filter sheet. Bottom sheet under 1024px. Filters apply on Show, not on
   each tap: the results are hidden behind the sheet, so applying each tap
   means refetching four times for one decision on a metered connection. */
.filter-sheet {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: var(--gp-z-sheet);
  max-height: 88vh;
  display: flex;
  flex-direction: column;
  background: var(--gp-surface);
  border-radius: var(--gp-radius-lg) var(--gp-radius-lg) 0 0;
  transform: translateY(100%);
  transition: transform var(--gp-dur-sheet) var(--gp-ease);
}

.filter-sheet[data-open='true'] { transform: none; }

.filter-sheet__handle {
  width: 36px;
  height: 4px;
  margin: var(--gp-space-12) auto;
  background: var(--gp-rule-strong);
  border-radius: var(--gp-radius-pill);
}

.filter-sheet__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gp-space-16);
  padding: 0 var(--gp-space-24) var(--gp-space-16);
  border-bottom: 1px solid var(--gp-rule);
}

.filter-sheet__body {
  flex: 1;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: var(--gp-space-16) var(--gp-space-24);
}

/* The count in the Show button updates live, so the visitor learns the
   consequence of a tap before committing to it. */
.filter-sheet__foot {
  padding: var(--gp-space-16) var(--gp-space-24);
  padding-bottom: max(var(--gp-space-16), env(safe-area-inset-bottom));
  border-top: 1px solid var(--gp-rule);
  background: var(--gp-surface);
}

/* A grid that is refetching keeps its results and dims. The skeleton belongs
   to FIRST PAINT only: swapping a populated grid back to skeletons every time
   a facet is ticked makes the page flash empty on each decision, and on a
   metered connection the visitor has already paid for those covers. */
.grid[aria-busy='true'],
.rail[aria-busy='true'] {
  opacity: 0.6;
  pointer-events: none;
  transition: opacity var(--gp-dur-micro) var(--gp-ease-out);
}

/* Active filter chips. How a visitor sees, on the grid itself, why they are
   looking at four results instead of nine. */
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gp-space-8);
  align-items: center;
  margin-bottom: var(--gp-space-24);
}

.chip-remove {
  display: inline-flex;
  align-items: center;
  gap: var(--gp-space-8);
  padding: var(--gp-space-4) var(--gp-space-8) var(--gp-space-4) var(--gp-space-12);
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule-strong);
  border-radius: var(--gp-radius-pill);
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink);
  min-height: 32px;
  cursor: pointer;
}

.chip-remove:hover {
  border-color: var(--gp-danger-border);
  color: var(--gp-danger-ink);
}


/* ---------- THE FILTER TOOLBAR ----------

   The facets used to be a 240px left rail. They are a bar across the top of
   the results now, which buys the grid its full width back and, more usefully,
   puts the controls where the eye already is: above the thing they change,
   rather than beside it where a scrolling reader loses them.

   One panel, three rows of decreasing weight: subjects as a scrollable chip
   row (the facet with the most values and the one that grows), then price and
   type as selects, then the count and sort on the closing row. It is the same
   information the rail carried, in reading order rather than in a column. */

.filter-toolbar {
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius-lg);
  margin-bottom: var(--gp-space-32);
}

.filter-toolbar__row {
  display: flex;
  align-items: center;
  gap: var(--gp-space-12);
  padding: var(--gp-space-16);
}

.filter-toolbar__row + .filter-toolbar__row {
  border-top: 1px solid var(--gp-rule);
}

.filter-toolbar__label {
  flex: none;
  font-size: var(--gp-fs-meta);
  font-weight: var(--gp-fw-semi);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--gp-ink-muted);
}

/* The subject row scrolls sideways rather than wrapping to three lines on a
   phone. Subjects are owner-created and the list grows; a wrapping row makes
   the toolbar's height a function of how many subjects exist. */
.filter-toolbar__scroll {
  display: flex;
  gap: var(--gp-space-8);
  overflow-x: auto;
  scrollbar-width: none;
  padding-bottom: 2px;
}

.filter-toolbar__scroll:-webkit-scrollbar { display: none; }

.filter-toolbar__end {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--gp-space-12);
}

/* A facet value as a toggle. Pressed state carries aria-pressed, so the
   control announces itself rather than relying on colour. */
.facet {
  display: inline-flex;
  align-items: center;
  gap: var(--gp-space-8);
  padding: var(--gp-space-8) var(--gp-space-16);
  min-height: 40px;
  white-space: nowrap;
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule-strong);
  border-radius: var(--gp-radius-pill);
  font-size: var(--gp-fs-small);
  color: var(--gp-ink);
  cursor: pointer;
  transition: background-color var(--gp-dur-micro) var(--gp-ease-out),
              border-color var(--gp-dur-micro) var(--gp-ease-out),
              color var(--gp-dur-micro) var(--gp-ease-out);
}

.facet:hover { border-color: var(--gp-ink-muted); }

.facet[aria-pressed='true'] {
  background: var(--gp-accent-face);
  border-color: var(--gp-accent-face);
  color: var(--gp-on-accent);
  font-weight: var(--gp-fw-semi);
}

.facet__count {
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
  font-variant-numeric: tabular-nums;
}

.facet[aria-pressed='true'] .facet__count { color: var(--gp-on-band-muted); }

/* A facet with nothing behind it is DISABLED WITH ITS COUNT SHOWING, never
   hidden: hiding it removes the visitor's ability to learn the shelf exists. */
/* AN EMPTY FACET CANNOT BE PRESSED, not merely dimmed. aria-disabled tells a
   screen reader the control is unavailable and does nothing at all to a mouse
   or a thumb, so a facet reading "Faith 0" was still pressable and still
   filtered the grid down to nothing. That is the same broken promise as a link
   to an empty subject, made by a different control.

   pointer-events rather than the disabled attribute, because a disabled button
   is removed from the tab order and a keyboard user then cannot discover that
   the subject exists but is empty. aria-disabled keeps it announced and
   reachable; this keeps it from doing anything. */
.facet[aria-disabled='true'],
.facet[disabled] {
  opacity: 0.5;
  cursor: not-allowed;
  pointer-events: none;
}

/* The ring still has to reach it, since it stays in the tab order. */
.facet[aria-disabled='true']:focus-visible { pointer-events: auto; }

/* A select sized to its content, for the toolbar's closing row. */
.select--inline {
  width: auto;
  min-height: 40px;
  padding-block: var(--gp-space-8);
  font-size: var(--gp-fs-small);
}

.filter-toolbar__count {
  font-size: var(--gp-fs-small);
  color: var(--gp-ink-muted);
  font-variant-numeric: tabular-nums;
}


/* =============================================================================
   17. The sticky buy bar

   Mobile only. Appears when the main buy panel scrolls out of view. NOT
   rendered at all in the owned or not-purchasable states: a sticky bar for an
   unavailable action is persistent noise.
   ========================================================================== */

.buy-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: var(--gp-z-header);
  display: flex;
  align-items: center;
  gap: var(--gp-space-16);
  padding: var(--gp-space-12) var(--gp-gutter);
  padding-bottom: max(var(--gp-space-12), env(safe-area-inset-bottom));
  background: var(--gp-surface);
  border-top: 1px solid var(--gp-rule);
  box-shadow: 0 -2px 12px rgba(7, 59, 42, 0.08);
  transform: translateY(100%);
  transition: transform var(--gp-dur-micro) var(--gp-ease);
}

.buy-bar.is-visible { transform: none; }

@media (min-width: 768px) {
  .buy-bar { display: none; }
}

.buy-bar__body { flex: 1; min-width: 0; }

.buy-bar__title {
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.buy-bar__price {
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-bold);
  font-variant-numeric: tabular-nums;
}


/* =============================================================================
   18. Prose and the table of contents

   Body copy is never below 16px, including on the legal pages. Small print is
   a design decision and this imprint's position is honesty.
   ========================================================================== */

.prose {
  max-width: var(--gp-measure-prose);
  font-size: var(--gp-fs-body);
  line-height: var(--gp-lh-body);
}

.prose > * + * { margin-top: var(--gp-space-16); }

.prose h2 {
  margin-top: var(--gp-space-48);
  font-size: var(--gp-fs-h2);
  line-height: var(--gp-lh-h2);
  /* The header offset lives on the scrollport in base.css. Repeating it here
     doubled it: see the note on .accordion__item. */
}

.prose h3 {
  margin-top: var(--gp-space-32);
  font-size: var(--gp-fs-h3);
  /* No scroll-margin-top: see the note on .accordion__item. */
}

.prose ul,
.prose ol { padding-left: var(--gp-space-24); }

.prose li + li { margin-top: var(--gp-space-8); }

.prose blockquote {
  padding-left: var(--gp-space-24);
  border-left: 3px solid var(--gp-accent-decorative);
  font-family: var(--gp-font-display);
  color: var(--gp-ink);
}

.updated {
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
  margin-top: var(--gp-space-8);
}

/* A FACT THE OWNER STILL OWES, made visible.

   These templates are being reviewed by the owner, and a blocked fact rendered
   as an HTML comment is invisible in a browser: "Last updated" with nothing
   after it reads as a bug rather than as a question. This marks the gap so a
   reviewer can see what is waiting on them.

   It is review furniture and it does not ship. Every one of these is deleted
   when the fact arrives, and a page that still has one is a page that is not
   ready to go live. */
.needs-owner {
  display: inline-block;
  padding: 1px var(--gp-space-8);
  border: 1px dashed var(--gp-warning-border);
  border-radius: var(--gp-radius-sm);
  background: var(--gp-warning-tint);
  color: var(--gp-warning-ink);
  font-family: var(--gp-font-body);
  font-size: var(--gp-fs-meta);
  font-style: normal;
  font-weight: var(--gp-fw-medium);
}

/* Anchors are PERMANENT. A support reply that links to
   /payments-policy#duplicate is worthless if the anchor moves in the next
   edit. */
.toc {
  padding: var(--gp-space-16) var(--gp-space-24);
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius);
  margin-bottom: var(--gp-space-32);
}

.toc__title {
  font-size: var(--gp-fs-meta);
  font-weight: var(--gp-fw-semi);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--gp-ink-muted);
  margin-bottom: var(--gp-space-8);
}

.toc ol {
  list-style: none;
  padding: 0;
  margin: 0;
}

.toc li + li { margin-top: var(--gp-space-8); }

.toc a {
  font-size: var(--gp-fs-small);
  color: var(--gp-ink);
  text-decoration: none;
}

.toc a:hover { color: var(--gp-accent); text-decoration: underline; }

/* Above 768px the contents is not a disclosure any more, so the panel is
   forced open whatever the button says.

   content-visibility is not optional here, for exactly the reason the footer
   needed it: disclosure.js upgrades a closed panel to hidden="until-found" so
   find-in-page can match inside it, and the UA implements that with
   content-visibility rather than display. Overriding display alone gives a
   list that is display:block and zero pixels tall. This is the second time
   this bug has been built; it is written down here so it is the last. */
/* ---------------------------------------------------------------------------
   FORCE-SHOWN PANELS. Read this before writing another one.

   A disclosure panel that a breakpoint re-opens must clear BOTH `display` and
   `content-visibility`. disclosure.js upgrades a closed panel to
   hidden="until-found" so find-in-page can match inside it, and the UA
   implements that with content-visibility, not display. Overriding display
   alone yields a panel that is display:block and zero pixels tall.

   This has now been built wrong three times: the footer columns, the table of
   contents, and the chapter list. So it is one declaration list, applied to
   all three, and any fourth adds its selector here rather than a fourth copy.
   ------------------------------------------------------------------------ */
@media (min-width: 640px) {
  .footer-group__list[hidden] {
    display: block !important;
    content-visibility: visible !important;
  }
}

@media (min-width: 768px) {
  .toc ol[hidden],
  .contents--continued[hidden] {
    display: block !important;
    content-visibility: visible !important;
  }

  /* The chapter list is one list again above this breakpoint, so the
     "Show all N chapters" control has nothing left to do and is hidden.

     IT IS HIDDEN BY AN EXPLICIT CLASS, not by guessing at the panel's id.
     This first shipped as [aria-controls$='chapters'], which matched nothing:
     the real id is steady-contents-rest. So on desktop the button stayed
     visible while the panel was force-shown, and pressing it did nothing at
     all. A selector that silently matches nothing is worse than no selector,
     because the rule looks present and is not. */
  .disclosure__trigger--split {
    display: none;
  }

  .toc__title {
    pointer-events: none;
  }

  .toc__title .icon { display: none; }
}

/* The disclosure button variant of the contents heading. */
.toc__title {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gp-space-12);
  width: 100%;
  text-align: left;
  font-family: var(--gp-font-body);
}

.toc__title .icon {
  transition: transform var(--gp-dur-accordion) var(--gp-ease-out);
}

.toc__title[aria-expanded='true'] .icon { transform: rotate(180deg); }

@media (min-width: 1024px) {
  .doc-layout {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 220px;
    gap: var(--gp-space-48);
    align-items: start;
  }

  /* Content fills its column here rather than holding the 68ch measure.

     The measure exists so a line of text on an open page does not run the full
     width of a desktop window. In .doc-layout the column ALREADY does that job:
     the contents rail and the gap take roughly a third of the container, so the
     text column is about 830px, and capping it again at 640 left a ribbon of
     text with a band of empty page beside it. One constraint is enough.

     .t-measure is in here too, not just .prose: the FAQ wraps its accordion in
     the measure utility rather than in prose, and it was showing exactly the
     same 574px ribbon inside an 868px column. */
  .doc-layout .prose,
  .doc-layout .t-measure,
  .doc-layout .accordion__panel { max-width: none; }

  .doc-layout .toc {
    position: sticky;
    top: calc(var(--gp-header-h) + var(--gp-space-24));
    order: 2;
    margin-bottom: 0;
  }
}


/* =============================================================================
   18b. The home hero, and the free block

   Two page-specific compositions that still belong here rather than in a style
   attribute, because a template that carries layout in `style=""` is a
   template the PHP conversion cannot factor into a template-part.
   ========================================================================== */

/* ---------- The home hero ----------

   Type on paper, now with one image beside it. That is a DEPARTURE from
   ia/IA_PUBLIC.md section 4.3, which specifies "type on paper, no image", and
   it is the owner's call: the right half of a 1280px hero was empty.

   What the departure does NOT relax is everything the rule was protecting:
   the ground is still paper, there is still no green band and no carousel,
   nothing here carries .reveal, and the image is a single static asset that is
   self-hosted rather than a slideshow or a background call.

   ORDER. On mobile the image comes FIRST, above the text. In the DOM the h1
   comes first, and the image is moved above it with `order`. That way the
   reading order and the heading order stay correct for a screen reader and for
   a search engine, while the eye still meets the picture first on a phone. The
   media is not focusable, so re-ordering it cannot desynchronise the tab
   order, which is the usual reason to avoid `order`. */

.hero {
  display: grid;
  gap: var(--gp-space-32);
  align-items: center;
}

@media (min-width: 900px) {
  .hero {
    grid-template-columns: minmax(0, 1fr) minmax(0, 0.85fr);
    gap: var(--gp-space-64);
  }
}

.hero__media {
  order: -1;               /* above the text on mobile */
  border-radius: var(--gp-radius-lg);
  overflow: hidden;
  background: var(--gp-surface-sunken);
}

@media (min-width: 900px) {
  .hero__media { order: 0; }   /* back to source order: text left, image right */
}

/* Constrained by HEIGHT, like the catalogue covers, so the crop is predictable
   at every width. Short on a phone, where the hero's job is to get out of the
   way of the h1 and the two buttons; taller on a desktop, where it has a
   column to fill. */
.hero__media img {
  display: block;
  width: 100%;
  object-fit: cover;

  /* The object-position values below are worked out, not guessed, and they
     differ per breakpoint because the visible SLICE of a portrait photograph
     changes with the container's ratio.

     The source is 900x1349. The reader's face sits at roughly 40% of its
     height and the open book she is holding at roughly 85%.

     At 900px and up the column is about 493 wide by 460 tall, so the image
     scales to 739 tall and 62% of it is visible. Centred at 58% that slice
     runs 27% to 89%, which holds the face AND the book. A centred crop, or the
     25% this first shipped with, cropped the book out entirely, which threw
     away the only thing in the picture that says "publisher".

     On a phone the strip is far shorter and only about 43% of the image is
     visible, so face and book cannot both be in it. The face wins there: a
     book with no reader is a stock photograph of an object. */
  object-position: center 38%;
  height: 300px;
}

@media (min-width: 640px) {
  .hero__media img { height: 340px; object-position: center 45%; }
}

@media (min-width: 900px) {
  .hero__media img { height: 460px; object-position: center 58%; }
}

/* The measure is short on purpose: the sentence is the argument, and a 22-word
   line set across a 1280px window is a line nobody reads as a sentence. */
.hero__lead {
  max-width: 34ch;
  margin-top: var(--gp-space-24);
}

.hero__actions {
  display: flex;
  gap: var(--gp-space-12);
  margin-top: var(--gp-space-32);
  max-width: 420px;
}

/* 50 percent each on mobile, per the spec. They stay equal above it because
   Browse books and Browse courses are genuinely peer choices. */
.hero__actions .btn { flex: 1; }

/* ---------- The feature card ----------

   Two of these sit below the catalogue row, and they are deliberately a
   DIFFERENT shape from the product card rather than a bigger one. A catalogue
   card is one of many and is built to be compared; a feature card is one of
   two and is built to be read. Same components, different job, so the
   composition changes: horizontal instead of vertical, an eyebrow saying why
   this one is here, room for a sentence, and its own call to action rather
   than a price the eye scans past.

   The cover bleeds to the card's leading edge and runs its full height. An
   inset thumbnail would make this a list row; a cover attached to the panel
   makes it a feature. */

.feature-card {
  position: relative;
  display: grid;
  grid-template-columns: 116px minmax(0, 1fr);
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius-lg);
  overflow: hidden;
  transition: border-color var(--gp-dur-micro) var(--gp-ease-out),
              box-shadow var(--gp-dur-micro) var(--gp-ease-out),
              transform var(--gp-dur-micro) var(--gp-ease-out);
}

@media (min-width: 640px) {
  .feature-card { grid-template-columns: 168px minmax(0, 1fr); }
}

.feature-card:hover,
.feature-card:focus-within {
  border-color: var(--gp-rule-strong);
  box-shadow: var(--gp-elevation-3);
  transform: translateY(-3px);
}

@media (prefers-reduced-motion: reduce) {
  .feature-card:hover,
  .feature-card:focus-within { transform: none; }
}

/* Full height, flush to the edge, clipped by the card's radius. `height: 100%`
   on a grid child plus object-fit is what keeps the two cards the same height
   when their text lengths differ. */
.feature-card .cover {
  height: 100%;
  width: 100%;
  aspect-ratio: auto;
  border: 0;
  border-radius: 0;
  box-shadow: none;
}

.feature-card .cover img {
  height: 100%;
  width: 100%;
  object-fit: cover;
  /* LEFT, not centre. This column is narrower and taller than the cover, so
     the crop takes width off both sides, and these covers set their mark and
     their title hard against the left margin. A centred crop sliced the first
     letter off the title. Anchoring left keeps the type and throws away the
     empty right-hand margin, which is the half that carries nothing. */
  object-position: left center;
}

.feature-card__body {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--gp-space-8);
  padding: var(--gp-space-24) var(--gp-space-16);
}

@media (min-width: 640px) {
  .feature-card__body { padding: var(--gp-space-32) var(--gp-space-24); }
}

.feature-card__title {
  font-size: var(--gp-fs-h3);
  line-height: var(--gp-lh-h3);
  margin: 0;
}

.feature-card__title a {
  color: var(--gp-ink);
  text-decoration: none;
}

.feature-card__title a:after {
  content: '';
  position: absolute;
  inset: 0;
}

.feature-card:hover .feature-card__title a { color: var(--gp-accent-face); }

.feature-card__line {
  font-size: var(--gp-fs-small);
  line-height: var(--gp-lh-small);
  color: var(--gp-ink-muted);
}

/* The action here is a SPAN styled as a button, and it sits UNDER the
   stretched pseudo-element on purpose. That is the opposite of
   .product-card__action, and the difference is the destination.

   On a product card, a second control goes somewhere else, so it has to be a
   real link raised above the card's own link. Here the button goes exactly
   where the card goes. Making it a real link would put two tab stops and two
   identical destinations on one card, which a screen reader reads out twice;
   raising it above the pseudo-element would leave a control that looks like a
   button and does nothing when clicked. So it is decorative, the card link
   covers it, and the whole card stays one tab stop named by its title. */
.feature-card__action {
  margin-top: var(--gp-space-8);
}

.feature-card__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--gp-space-8);
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
}

.feature-card__price {
  font-family: var(--gp-font-display);
  font-weight: var(--gp-fw-bold);
  font-size: var(--gp-fs-body);
  color: var(--gp-ink);
  font-variant-numeric: tabular-nums;
}


/* Two blocks of EQUAL WEIGHT, side by side once there is room.

   Used on the selling page for "What is inside" and "What you get", which
   answer the two questions a buyer actually has and which read better beside
   each other than stacked, because the second answers what the first costs you
   to obtain. Splits at 900px rather than 480: two ten-row lists in 200px
   columns are two lists nobody reads.

   It is also the shape "who this is for" and "who it is not for" need, and
   those two must stay equal weight or the credibility position inverts. */
.grid--pair {
  grid-template-columns: 1fr;
  gap: var(--gp-space-32);
}

@media (min-width: 900px) {
  .grid--pair {
    grid-template-columns: repeat(2, 1fr);
    gap: var(--gp-space-64);
    align-items: start;
  }
}


/* =============================================================================
   19. Trust row

   Icons in Olive at 24px. That is DECORATIVE use and is therefore inside the
   contrast rule; the text beside them is Charcoal.
   ========================================================================== */

/* THE COLOPHON.

   The band before the footer, and the last thing a visitor reads before the
   page ends. It was a thin row of four links, which is what a SaaS feature
   grid looks like and is not what this is.

   What it actually is, is a publisher's colophon: the standing statement of
   how the house works, set on one panel with the four claims divided by rules
   like columns of a printed page. That framing is why it is one white surface
   rather than four cards, and why the dividers are hairlines rather than gaps.

   Each item is a heading and one supporting line. Every supporting line here
   is either an approved positioning statement or a structural fact about the
   platform. None of them is a claim, and none may become one. */

.colophon {
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius-lg);
  overflow: hidden;
}

.colophon__head {
  padding: var(--gp-space-32) var(--gp-space-24) var(--gp-space-24);
}

@media (min-width: 1024px) {
  .colophon__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--gp-space-32);
    padding-inline: var(--gp-space-32);
  }
}

.colophon__title {
  font-family: var(--gp-font-display);
  font-size: var(--gp-fs-h3);
  line-height: var(--gp-lh-h3);
}

/* One line beside the heading, not under it. The panel is wide and the
   heading is short, so the space to its right is the natural home for the
   sentence that says what the four columns are. */
.colophon__lead {
  margin-top: var(--gp-space-8);
  font-size: var(--gp-fs-small);
  color: var(--gp-ink-muted);
  max-width: 52ch;
}

@media (min-width: 1024px) {
  .colophon__lead { margin-top: 0; text-align: right; }
}

.colophon__list {
  display: grid;
  grid-template-columns: 1fr;
  list-style: none;
  margin: 0;
  padding: 0;
}

@media (min-width: 640px)  { .colophon__list { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .colophon__list { grid-template-columns: repeat(4, 1fr); } }

.colophon__item {
  position: relative;
  padding: var(--gp-space-24);
  border-top: 1px solid var(--gp-rule);
}

/* Dividers between columns, never on the leading edge of a row. Using a
   pseudo-element rather than border-left means the first item in each row does
   not need a class to suppress it. */
@media (min-width: 640px) {
  .colophon__item:not(:nth-child(2n + 1)):before {
    content: '';
    position: absolute;
    top: var(--gp-space-24);
    bottom: var(--gp-space-24);
    left: 0;
    width: 1px;
    background: var(--gp-rule);
  }
}

@media (min-width: 1024px) {
  /* Four across, so the divider moves to every item that is not first in a row
     of four. The full declaration is repeated rather than inherited from the
     640px rule: at this width the third item does NOT match that rule's
     selector, so it would have had `content` and nothing else, which is an
     invisible divider and exactly the bug this comment exists to prevent. */
  .colophon__item:before { content: none; }

  .colophon__item:not(:nth-child(4n + 1)):before {
    content: '';
    position: absolute;
    top: var(--gp-space-24);
    bottom: var(--gp-space-24);
    left: 0;
    width: 1px;
    background: var(--gp-rule);
  }

  .colophon__item { border-top: 1px solid var(--gp-rule); }
}

/* The mark. Olive at 20px inside a tinted disc: decorative use, inside the
   contrast rule, and it gives the column a fixed optical starting point so
   four items of different text lengths still line up. */
.colophon__mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin-bottom: var(--gp-space-16);
  border-radius: 50%;
  background: rgba(102, 140, 36, 0.12);
  color: var(--gp-accent);
  transition: background-color var(--gp-dur-micro) var(--gp-ease-out);
}

.colophon__item:has(a:hover) .colophon__mark {
  background: var(--gp-accent-face);
  color: var(--gp-on-accent);
}

.colophon__mark .icon { width: 20px; height: 20px; }

.colophon__label {
  display: block;
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-semi);
  line-height: var(--gp-lh-small);
  color: var(--gp-ink);
  text-decoration: none;
}

/* Three of the four columns go somewhere and one does not. The arrow is what
   says which, and it appears only on the linked ones: giving the Paystack
   column an affordance it cannot honour is the small lie this avoids. */
.colophon__label .icon {
  width: 0.9em;
  height: 0.9em;
  display: inline-block;
  vertical-align: -0.1em;
  margin-left: var(--gp-space-4);
  color: var(--gp-accent-decorative);
  transition: transform var(--gp-dur-micro) var(--gp-ease-out);
}

.colophon__item:has(a:hover) .colophon__label .icon {
  transform: translateX(3px);
  color: var(--gp-accent-face);
}

@media (prefers-reduced-motion: reduce) {
  .colophon__item:has(a:hover) .colophon__label .icon { transform: none; }
}

/* A linked item stretches over the whole column so the target is the column,
   not the five words. An unlinked one is a plain statement of fact and gets no
   affordance at all, because there is nowhere for it to go. */
a.colophon__label:after {
  content: '';
  position: absolute;
  inset: 0;
}

a.colophon__label:hover { color: var(--gp-accent-face); }

.colophon__item:has(a:hover) { background: var(--gp-surface-sunken); }

.colophon__item:has(a:focus-visible) {
  outline: 2px solid var(--gp-focus-outer);
  outline-offset: -2px;
  box-shadow: inset 0 0 0 2px var(--gp-focus-inner);
}

.colophon__item:has(a:focus-visible) a:focus-visible {
  outline: none;
  box-shadow: none;
}

.colophon__note {
  margin-top: var(--gp-space-4);
  font-size: var(--gp-fs-meta);
  line-height: var(--gp-lh-meta);
  color: var(--gp-ink-muted);
}


/* ---------- CONTACT METHODS ----------

   The ways to reach a person, as a list of rows rather than a paragraph of
   links. A contact page that offers only a form is a contact page that says
   "we would rather you did not", and some readers will not use a form at all:
   they want an address they can write to from their own mail app, or an
   account they already follow.

   Each row is a mark, a label saying what the channel is FOR, and the value
   itself as the link. The label matters more than it looks: "Instagram" alone
   tells a reader nothing about whether a support question belongs there. */

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

.contact-list li + li {
  margin-top: var(--gp-space-16);
  padding-top: var(--gp-space-16);
  border-top: 1px solid var(--gp-rule);
}

.contact-method {
  display: flex;
  align-items: flex-start;
  gap: var(--gp-space-12);
}

.contact-method__mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  flex: none;
  border-radius: 50%;
  background: rgba(102, 140, 36, 0.12);
  color: var(--gp-accent);
}

.contact-method__mark .icon { width: 18px; height: 18px; }

.contact-method__body { min-width: 0; }

.contact-method__label {
  display: block;
  font-size: var(--gp-fs-meta);
  font-weight: var(--gp-fw-semi);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--gp-ink-muted);
}

.contact-method__value {
  display: block;
  margin-top: 2px;
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-medium);
  /* An address can be longer than a 300px aside. Break it rather than let it
     push the card wider than its column. */
  overflow-wrap: anywhere;
}

/* The closing caution sits off the last channel, not welded to it. */
.contact-list + p {
  margin-top: var(--gp-space-16);
  padding-top: var(--gp-space-16);
  border-top: 1px solid var(--gp-rule);
}

.contact-method__note {
  display: block;
  margin-top: 2px;
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
}


/* =============================================================================
   20. Copy control and the order reference

   A reference in 11px grey at the foot of a page is a reference that gets
   mistyped into a support message. It goes where a person can find it while
   looking at a screen in a hurry.
   ========================================================================== */

.reference {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gp-space-12);
  padding: var(--gp-space-16);
  background: var(--gp-surface-sunken);
  border-radius: var(--gp-radius);
}

.reference__label {
  font-size: var(--gp-fs-meta);
  color: var(--gp-ink-muted);
}

.reference__value {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: var(--gp-fs-body);
  font-weight: var(--gp-fw-semi);
  letter-spacing: 0.04em;
}

.mono {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.95em;
}


/* =============================================================================
   21. Account control
   ========================================================================== */

.avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  flex: none;
  border-radius: 50%;
  background: var(--gp-accent-face);
  color: var(--gp-on-accent);
  font-size: var(--gp-fs-meta);
  font-weight: var(--gp-fw-semi);
}

.account-control {
  display: inline-flex;
  align-items: center;
  gap: var(--gp-space-8);
  padding: var(--gp-space-4);
  border-radius: var(--gp-radius-pill);
  font-size: var(--gp-fs-small);
  color: var(--gp-ink);
}

.account-control:hover { background: var(--gp-surface); }

/* The unread dot. A number at md and up, a dot at base. */
.account-control__dot {
  position: absolute;
  top: 0;
  right: 0;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--gp-danger-ink);
  border: 2px solid var(--gp-ground);
}

/* My Library while signed out is VISIBLE-DISABLED, never hidden: a returning
   buyer who cleared their cookies needs to see that the place their books live
   exists. It still goes somewhere, to sign-in with a next parameter. */
.is-visible-disabled {
  opacity: 0.6;
}

/* The account rail, on the customer pages at lg and up.

   NAMED .account-rail, NOT .rail.

   It was .rail, and that collided head-on with layout.css's .rail, which is
   the horizontal snap-scroll row the home page's featured block uses. The
   account rail's `a` styling (display:flex, 12px 16px padding) was therefore
   applying to every link inside the featured row, including the cover links,
   which quietly inset every book cover by 16px and made the covers look
   matted. Two components cannot share a name; the more specific one gets the
   longer name. */

.account-rail {
  list-style: none;
  padding: 0;
  margin: 0;
}

.account-rail a {
  display: flex;
  align-items: center;
  gap: var(--gp-space-12);
  padding: var(--gp-space-12) var(--gp-space-16);
  border-radius: var(--gp-radius);
  font-size: var(--gp-fs-small);
  color: var(--gp-ink);
  text-decoration: none;
}

.account-rail a:hover { background: var(--gp-surface); }

.account-rail a[aria-current='page'] {
  background: var(--gp-surface);
  font-weight: var(--gp-fw-semi);
  color: var(--gp-accent-face);
  box-shadow: inset 2px 0 0 var(--gp-accent-face);
}


/* =============================================================================
   DASHBOARD COMPONENTS

   Only usable inside .gp-dashboard, because that is where the --gp-dash-*
   tokens resolve. A public page that borrows one of these gets a component
   with no values behind it, which is the intended failure: it means the class
   is in the wrong place.
   ========================================================================== */

/* ---------- The panel ----------
   A RESTING PANEL IS FLAT. Hairline, no shadow. Elevation 1 is for a card that
   is genuinely lifted, 2 for menus and popovers, 3 for dialogs. On a dense
   screen, reaching for a shadow on every card is how the screen turns to
   soup. */

.panel {
  background: var(--gp-dash-panel);
  border: 1px solid var(--gp-dash-hairline);
  border-radius: var(--gp-dash-radius-panel);
  box-shadow: var(--gp-dash-elevation-0);
}

.panel__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--gp-dash-space-4);
  padding: var(--gp-dash-card-padding);
  border-bottom: 1px solid var(--gp-dash-hairline);
}

.panel__title {
  font-family: var(--gp-dash-font-ui);
  font-size: var(--gp-dash-text-card);
  line-height: var(--gp-dash-leading-tight);
  font-weight: var(--gp-fw-semi);
  margin: 0;
}

.panel__body { padding: var(--gp-dash-card-padding); }

/* A panel whose body is a table pays no padding: the cells carry their own. */
.panel__body--flush { padding: 0; }

.panel__foot {
  padding: var(--gp-dash-space-4) var(--gp-dash-card-padding);
  border-top: 1px solid var(--gp-dash-hairline);
}

/* THE VERTICAL RHYTHM OF A DASHBOARD PAGE.

   .dash-grid spaces its own children, but nothing was spacing one block from
   the next, so a stat row sat hard against the filter under it and a panel sat
   hard against the panel under it. Any two consecutive blocks in the content
   column now get a card gap between them, which is the same 32px the grid uses
   inside itself. One rhythm, not two. */
.dash-main__inner > * + * { margin-top: var(--gp-dash-card-gap); }

/* A page head is a heading, not a block: it keeps its own tighter drop. */
.dash-main__inner > .dash-head + * { margin-top: 0; }

.dash-grid {
  display: grid;
  gap: var(--gp-dash-card-gap);
  grid-template-columns: 1fr;
}

/* Stacked panels inside a grid cell, and settings cards, take the same gap. */
.panel + .panel { margin-top: var(--gp-dash-card-gap); }

@media (min-width: 768px)  { .dash-grid--2 { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 640px)  { .dash-grid--3 { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .dash-grid--3 { grid-template-columns: repeat(3, 1fr); } }

/* Content and a narrower companion column. */
@media (min-width: 1024px) {
  .dash-grid--aside { grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }
}

/* ---------- The stat tile ----------
   A NUMBER A CUSTOMER CAN VERIFY, never a computed one. This surface shows
   what somebody owns and what they have spent, and both are facts on their own
   record. It never shows a figure derived from other figures, and it NEVER
   sums two currencies: naira and dollars are separate columns everywhere in
   this system, including here. */

@media (max-width: 639px) {
  .dash-main__inner > * + * { margin-top: var(--gp-dash-space-5); }
  .panel + .panel { margin-top: var(--gp-dash-space-5); }
  .dash-grid { gap: var(--gp-dash-space-5); }
  .panel__head, .panel__body { padding: var(--gp-dash-space-4); }
  .dash-table th, .dash-table td { padding-inline: var(--gp-dash-space-4); }
}

.stat {
  display: flex;
  align-items: flex-start;
  gap: var(--gp-dash-space-4);
  padding: var(--gp-dash-card-padding);
  background: var(--gp-dash-panel);
  border: 1px solid var(--gp-dash-hairline);
  border-radius: var(--gp-dash-radius-panel);
}

.stat__mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  flex: none;
  border-radius: var(--gp-dash-radius-control);
  background: rgba(102, 140, 36, 0.12);
  color: var(--gp-accent);
}

.stat__label {
  display: block;
  font-size: var(--gp-dash-text-micro);
  letter-spacing: var(--gp-dash-tracking-micro);
  text-transform: uppercase;
  font-weight: var(--gp-fw-semi);
  color: var(--gp-ink-muted);
}

.stat__value {
  display: block;
  margin-top: var(--gp-dash-space-1);
  font-family: var(--gp-dash-font-heading);
  font-size: var(--gp-dash-text-page);
  line-height: var(--gp-dash-leading-tight);
  font-variant-numeric: tabular-nums;
}

.stat__note {
  display: block;
  margin-top: var(--gp-dash-space-1);
  font-size: var(--gp-dash-text-meta);
  color: var(--gp-ink-muted);
}

/* ---------- The data table ----------
   Below 768px a table becomes stacked rows carrying their own labels. A
   horizontally scrolling table on a phone hides the column with the number in
   it, which on an orders screen is the only column that matters. */

.dash-table { width: 100%; font-size: var(--gp-dash-text-body); }

.dash-table th {
  padding: var(--gp-dash-space-3) var(--gp-dash-card-padding);
  text-align: left;
  font-size: var(--gp-dash-text-micro);
  letter-spacing: var(--gp-dash-tracking-micro);
  text-transform: uppercase;
  font-weight: var(--gp-fw-semi);
  color: var(--gp-dash-head-ink);
  border-bottom: 1px solid var(--gp-dash-row-rule);
  white-space: nowrap;
}

.dash-table td {
  padding: var(--gp-dash-space-4) var(--gp-dash-card-padding);
  border-bottom: 1px solid var(--gp-dash-row-rule);
  vertical-align: middle;
  line-height: var(--gp-dash-leading-normal);
}

.dash-table tbody tr:last-child td { border-bottom: 0; }
.dash-table tbody tr:hover { background: var(--gp-dash-row-hover); }

.dash-table .is-num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.dash-table .is-ref {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: var(--gp-dash-text-meta);
}

@media (max-width: 767px) {
  .dash-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
  .dash-table tr {
    display: block;
    padding: var(--gp-dash-space-4) var(--gp-dash-card-padding);
    border-bottom: 1px solid var(--gp-dash-row-rule);
  }
  .dash-table td {
    display: flex;
    justify-content: space-between;
    gap: var(--gp-dash-space-4);
    padding: var(--gp-dash-space-1) 0;
    border: 0;
    text-align: right;
  }
  /* The header the visually hidden thead would have given this cell. */
  .dash-table td::before {
    content: attr(data-label);
    font-size: var(--gp-dash-text-micro);
    letter-spacing: var(--gp-dash-tracking-micro);
    text-transform: uppercase;
    font-weight: var(--gp-fw-semi);
    color: var(--gp-dash-head-ink);
    text-align: left;
  }
  .dash-table .is-num { text-align: right; }
}

/* ---------- A library item ----------
   The cover is small here on purpose. On the storefront a cover is doing the
   selling; in a library the reader already owns it and is looking for a title
   they can name, so the title carries the row and the cover only identifies
   it. */

.owned {
  position: relative;
  display: flex;
  gap: var(--gp-dash-space-4);
  padding: var(--gp-dash-card-padding);
  background: var(--gp-dash-panel);
  border: 1px solid var(--gp-dash-hairline);
  border-radius: var(--gp-dash-radius-panel);
  transition: border-color var(--gp-dur-micro) var(--gp-ease-out),
              box-shadow var(--gp-dur-micro) var(--gp-ease-out);
}

.owned:hover,
.owned:focus-within {
  border-color: var(--gp-rule-strong);
  box-shadow: var(--gp-dash-elevation-1);
}

.owned .cover {
  flex: none;
  width: 72px;
  aspect-ratio: 2 / 3;
  border-radius: var(--gp-dash-radius-chip);
}

@media (min-width: 640px) { .owned .cover { width: 88px; } }

.owned__body { flex: 1; min-width: 0; }

.owned__title {
  font-family: var(--gp-dash-font-heading);
  font-size: var(--gp-dash-text-card);
  line-height: var(--gp-dash-leading-tight);
  margin: 0;
}

.owned__title a { color: var(--gp-ink); text-decoration: none; }
.owned__title a::after { content: ''; position: absolute; inset: 0; }
.owned:hover .owned__title a { color: var(--gp-accent-face); }

.owned__meta {
  margin-top: var(--gp-dash-space-2);
  font-size: var(--gp-dash-text-meta);
  color: var(--gp-ink-muted);
}

.owned__actions {
  position: relative;
  z-index: 1;
  margin-top: var(--gp-dash-space-3);
  display: flex;
  flex-wrap: wrap;
  gap: var(--gp-dash-space-2);
}

/* ---------- A definition row, for a receipt or an account card ---------- */

.dash-dl { margin: 0; }

.dash-dl > div {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2px;
  padding: var(--gp-dash-space-3) 0;
  border-bottom: 1px solid var(--gp-dash-row-rule);
}

.dash-dl > div:last-child { border-bottom: 0; }

@media (min-width: 480px) {
  .dash-dl > div {
    grid-template-columns: 180px minmax(0, 1fr);
    gap: var(--gp-dash-space-4);
    align-items: baseline;
  }
}

.dash-dl dt {
  font-size: var(--gp-dash-text-micro);
  letter-spacing: var(--gp-dash-tracking-micro);
  text-transform: uppercase;
  font-weight: var(--gp-fw-semi);
  color: var(--gp-dash-head-ink);
}

.dash-dl dd {
  margin: 0;
  font-size: var(--gp-dash-text-body);
  overflow-wrap: anywhere;
}

/* ---------- A settings section ----------
   Each card carries its OWN save control. One page-wide Save is wrong here: it
   makes changing a currency preference feel as consequential as changing an
   email address. */

.setting + .setting { margin-top: var(--gp-dash-card-gap); }

.setting__note {
  margin-top: var(--gp-dash-space-2);
  font-size: var(--gp-dash-text-meta);
  color: var(--gp-ink-muted);
  max-width: 60ch;
}


/* ---------- THE SWITCH ----------

   A setting that applies the moment you change it, which is what makes it a
   switch and not a checkbox. A checkbox is a value you are about to submit; a
   switch is a state that is already true.

   It is a real <input type="checkbox" role="switch">, not a div. The track and
   thumb are drawn from the input's own :checked state, so the control keeps
   its keyboard behaviour, its focus ring and its label association for free,
   and it still works if the CSS never loads.

   THE DISABLED STATE READS AS LOCKED, NOT AS FADED. Receipts cannot be turned
   off, and a control at 55% opacity looks broken rather than deliberate, so a
   locked switch keeps full contrast, takes the sunken ground, and is always
   accompanied by a line saying why. A missing control is a question; a locked
   one with a reason is an answer. */

.switch-row {
  display: flex;
  align-items: flex-start;
  gap: var(--gp-dash-space-4);
  padding: var(--gp-dash-space-4) 0;
  border-bottom: 1px solid var(--gp-dash-row-rule);
}

.switch-row:last-child { border-bottom: 0; }

.switch-row__body { flex: 1; min-width: 0; }

.switch-row__label {
  display: block;
  font-size: var(--gp-dash-text-body);
  font-weight: var(--gp-fw-medium);
  color: var(--gp-ink);
}

.switch-row__note {
  display: block;
  margin-top: 2px;
  font-size: var(--gp-dash-text-meta);
  line-height: var(--gp-dash-leading-normal);
  color: var(--gp-ink-muted);
}

.switch {
  position: relative;
  flex: none;
  width: 48px;
  height: 28px;
  margin: 0;
  appearance: none;
  border: 1px solid var(--gp-rule-strong);
  border-radius: var(--gp-radius-pill);
  background: var(--gp-surface-sunken);
  cursor: pointer;
  transition: background-color var(--gp-dur-micro) var(--gp-ease-out),
              border-color var(--gp-dur-micro) var(--gp-ease-out);
}

.switch::after {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--gp-surface);
  box-shadow: var(--gp-elevation-1);
  transition: transform var(--gp-dur-micro) var(--gp-ease);
}

.switch:checked {
  background: var(--gp-accent-face);
  border-color: var(--gp-accent-face);
}

.switch:checked::after { transform: translateX(20px); }

.switch:disabled {
  cursor: not-allowed;
  /* Full contrast on purpose. See the note above. */
  background: var(--gp-surface-sunken);
  border-color: var(--gp-rule-strong);
}

.switch:disabled:checked {
  background: var(--gp-ink-muted);
  border-color: var(--gp-ink-muted);
}

@media (prefers-reduced-motion: reduce) {
  .switch, .switch::after { transition: none; }
}


/* A confirmation that gets out of the way.

   Used by a control that saves the moment it changes, where a permanent
   message would accumulate one banner per setting until the card is mostly
   banners. It fades after three seconds. The FADE IS VISUAL ONLY: forms.js
   also writes the same words into the polite live region, so a screen reader
   hears the confirmation whether or not it is still on screen. */
.msg--transient {
  transition: opacity var(--gp-dur-sheet) var(--gp-ease-out);
}

.msg--transient[data-fading='true'] { opacity: 0; }

@media (prefers-reduced-motion: reduce) {
  .msg--transient { transition: none; }
}


/* ---------- THE BOOK HEAD, on a page for a book you own ----------

   Cover beside the title from 640px, stacked below it. It exists because
   nothing else fitted: .split and .dash-grid--aside both break at 1024px and
   leave an uncapped full-width cover through the 768 to 1023 band, and
   .cover--detail only caps below 768px.

   The cover is capped at 160px and the title takes the page step. That is the
   opposite weighting to a selling page, and deliberately so: on the storefront
   the cover is doing the selling, but a reader who already owns the book is
   looking for a title they can name. Here the title is the page and the cover
   only identifies it. */

.book-head {
  display: flex;
  flex-direction: column;
  gap: var(--gp-dash-space-4);
}

@media (min-width: 640px) {
  .book-head {
    flex-direction: row;
    align-items: flex-start;
    gap: var(--gp-dash-space-5);
  }
}

.book-head__cover {
  flex: none;
  width: 120px;
}

@media (min-width: 640px) {
  .book-head__cover { width: 160px; }
}

.book-head__cover .cover {
  width: 100%;
  border-radius: var(--gp-dash-radius-chip);
}

.book-head__body { flex: 1; min-width: 0; }

.book-head__body h1 {
  font-size: var(--gp-dash-text-page);
  line-height: var(--gp-dash-leading-tight);
}

.book-head__meta {
  margin-top: var(--gp-dash-space-3);
  font-size: var(--gp-dash-text-meta);
  color: var(--gp-ink-muted);
}

.book-head__actions {
  margin-top: var(--gp-dash-space-4);
  display: flex;
  flex-wrap: wrap;
  gap: var(--gp-dash-space-2);
}


/* ---------- THE LIBRARY CARD ON A PHONE ----------

   Cover on top, full width, at a fixed height rather than at its own 2:3
   ratio. A 358px-wide cover at 2:3 is 537px tall, which is most of a phone
   screen for one owned book, and the reader would scroll a whole viewport per
   title. The band is the same device the catalogue cards use and for the same
   reason: a constant height makes the title legible at every width. */

@media (max-width: 639px) {
  .owned {
    flex-direction: column;
    gap: var(--gp-dash-space-4);
  }

  .owned .cover {
    width: 100%;
    height: 180px;
    aspect-ratio: auto;
  }

  .owned .cover img {
    height: 100%;
    width: 100%;
    object-fit: cover;
    object-position: center 38%;
  }
}


/* ---------- ATTACHMENTS ----------

   What came WITH a book. Two kinds, and the distinction is the point:

     a FILE      the printable pack that ships alongside a title
     a BOOK      a title included in a bundle, which is a whole product with
                 its own page, its own files and its own entry in the library

   They are the same thing in the domain: an add-on whose target is itself a
   product. That is why one component renders both and why a book attachment
   links to its own page rather than offering a download here. Flattening a
   book into a file row would hide that the reader owns it outright.

   The word on screen is "attachments" because that is what a reader calls
   them. The word in the data is add-ons. Both are correct in their own place
   and neither should be translated into the other's context. */

.attachment {
  display: flex;
  align-items: flex-start;
  gap: var(--gp-dash-space-4);
  padding: var(--gp-dash-space-4) 0;
  border-bottom: 1px solid var(--gp-dash-row-rule);
}

.attachment__mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  flex: none;
  border-radius: var(--gp-dash-radius-control);
  background: rgba(102, 140, 36, 0.12);
  color: var(--gp-accent);
}

/* A book attachment shows its cover instead of an icon: it is a book, and the
   reader recognises it the way they recognise any other book they own. */
.attachment__cover {
  width: 44px;
  flex: none;
  border-radius: var(--gp-dash-radius-chip);
  overflow: hidden;
}

.attachment__body { flex: 1; min-width: 0; }

.attachment__title {
  font-size: var(--gp-dash-text-body);
  font-weight: var(--gp-fw-semi);
  margin: 0;
}

.attachment__title a { color: var(--gp-ink); text-decoration: none; }
.attachment__title a:hover { color: var(--gp-accent-face); text-decoration: underline; }

.attachment__meta {
  margin-top: 2px;
  font-size: var(--gp-dash-text-meta);
  color: var(--gp-ink-muted);
}

.attachment__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gp-dash-space-2);
  align-items: center;
}

/* The action drops to its own line at the same width the library card stacks
   at. Above this the row reads left to right; below it the title and the size
   would otherwise wrap around a button pinned to the right edge, which is how
   a two-line meta ends up reading as one line of text and one orphan. The
   indent keeps the button aligned with the body rather than the mark. */
@media (max-width: 639px) {
  .attachment { flex-wrap: wrap; }
  .attachment__actions { width: 100%; padding-left: 56px; }
}

/* The count beside "Attachments" on a library card. It is rendered ONLY when
   there is at least one: a card reading "Attachments 0" is a control that
   advertises an empty room.

   The control is a .btn, so it already has its own display, gap and target
   height. The only thing that has to change is the icon, which at the default
   20px overpowers a small button's label. */
.owned__attach .icon { width: 1em; height: 1em; }

/* ---------- THE LAST RULE IN A ROW STACK ----------

   A .file-row and an .attachment each carry a bottom border, which draws a
   line under the last one too: a rule with nothing beneath it, floating above
   the panel's own padding. That is the stray second line under the book.

   :last-child WILL NOT DO IT, and that is the whole reason this rule is
   written the way it is. These stacks ship rows with `hidden` on them, states
   built but not currently true, and a hidden row is still the last child. The
   A5 file row under the download panel is exactly that: hidden, last, and
   holding the border on the visible row above it.

   So the test is not "am I last" but "is there a visible row after me". If a
   fifth row type grows a border in this file, add its selector here. */

.file-row:not(:has(~ .file-row:not([hidden]))),
.attachment:not(:has(~ .attachment:not([hidden]))) {
  border-bottom: 0;
}


/* ---------- THE THEME TOGGLE ----------

   ONE BUTTON, NOT THREE. A light / dark / system segmented control is the
   honest model of the state, and it is also three targets in a masthead that
   has a logo, five links and a sign-in button in it already. The button
   toggles between the two themes and the stored value is what it lands on;
   "system" is the state before anybody has pressed it, not a third position a
   reader has to find their way back to.

   It says what pressing it DOES, not what the current state is. "Dark mode"
   beside a moon, on a light page. A control labelled with its own current
   state is the oldest ambiguity in this pattern and it is never resolved by
   the icon.

   Both glyphs are in the markup and one is hidden, rather than one glyph being
   swapped by script, so the button is correct in the HTML before any script
   runs and does not flicker through the wrong icon on load. */

.theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--gp-space-8);
  width: 40px;
  height: 40px;
  padding: 0;
  border: 1px solid var(--gp-rule-strong);
  border-radius: 50%;
  background: transparent;
  color: var(--gp-ink-muted);
  cursor: pointer;
  transition: color var(--gp-dur-micro) var(--gp-ease-out),
              border-color var(--gp-dur-micro) var(--gp-ease-out);
}

.theme-toggle:hover {
  color: var(--gp-ink);
  border-color: var(--gp-ink-muted);
}

.theme-toggle .icon { width: 18px; height: 18px; }

/* The pair. [data-theme] is stamped on <html> before paint, so this resolves
   on the first frame. Absent the attribute the light-theme glyph shows, which
   is the correct default for a page that has not been themed. */
.theme-toggle__dark { display: inline-flex; }
.theme-toggle__light { display: none; }
[data-theme='dark'] .theme-toggle__dark { display: none; }
[data-theme='dark'] .theme-toggle__light { display: inline-flex; }

/* On the dashboard topbar it matches the other icon controls exactly, so the
   row reads as one set rather than as three buttons and a fourth thing. */
.dash-top .theme-toggle {
  width: var(--gp-dash-control-height);
  height: var(--gp-dash-control-height);
  border-color: var(--gp-dash-hairline);
  background: var(--gp-dash-panel);
}


/* ---------- THE DASHBOARD SEARCH ----------

   The magnifier in the topbar was a button with no behaviour. This gives it
   one, and the behaviour is deliberately small: it expands a field in place
   and filters what is already on the page.

   WHY IN PLACE AND NOT A MODAL. Everything a customer can search here is their
   own library, which is at most a few dozen titles and is usually already
   rendered. A command palette over a list of six books is theatre. The field
   filters the rows underneath it as you type and says how many matched, and on
   a page with nothing to filter it submits to the library instead.

   The field is in the DOM at all times and is hidden with the hidden
   attribute, so the form is a real form before script runs and a submit still
   reaches the library with no JavaScript at all. */

.dash-search {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--gp-dash-space-2);
}

.dash-search__field {
  display: flex;
  align-items: center;
  gap: var(--gp-dash-space-2);
  height: var(--gp-dash-control-height);
  padding: 0 var(--gp-dash-space-3);
  border: 1px solid var(--gp-dash-hairline);
  border-radius: var(--gp-radius-pill);
  background: var(--gp-dash-panel);
}

.dash-search__input {
  width: 200px;
  max-width: 44vw;
  border: 0;
  background: none;
  font-family: var(--gp-font-body);
  font-size: var(--gp-dash-text-meta);
  color: var(--gp-ink);
}

/* THE RING IS DRAWN ONCE, ON THE WRAPPER.

   The input has no border of its own; the pill around it is the control the
   reader sees. So the ring belongs on the pill, and the input must not draw
   one as well.

   SUPPRESSING outline ALONE IS NOT ENOUGH, and that is the bug this replaces.
   The house focus ring is two-tone: an outline AND a box-shadow, set together
   in base.css. Cancelling only the outline left the shadow behind, so a
   focused field showed an ivory 2px ring with 4px corners sitting INSIDE a
   999px pill that had its own ring. That is the second border. Both halves
   have to go, and the radius with them, or a stray rounded rectangle survives
   inside a rounded one.

   The wrapper then takes the ring COMPLETE, both tones, rather than the single
   outline it had. The ring is defined once in base.css precisely so that no
   component gets to quietly ship half of it, and a pill on a white panel needs
   the light inner tone as much as anything else does. */
/* THE NATIVE CLEAR GLYPH IS SUPPRESSED, AND REPLACED.

   input[type=search] draws its own cancel button, and the browser draws it in
   a colour of its own choosing. It is a heavy near-black cross that belongs to
   no palette in this file: on the ivory pill it is the darkest thing in the
   topbar, and on the dark theme it stays dark and sits in a hole. It cannot be
   recoloured, because ::-webkit-search-cancel-button takes no color, only an
   appearance reset.

   So the native one is removed and a real button takes its place, inheriting
   --gp-ink-muted like every other quiet control. That also fixes two things
   the native glyph never did: it has an accessible name, and it returns focus
   to the input rather than leaving the caret nowhere.

   Firefox draws no cancel button at all, so this is the only one on that
   browser rather than a second. */
.dash-search__input::-webkit-search-cancel-button,
.dash-search__input::-webkit-search-decoration {
  -webkit-appearance: none;
  appearance: none;
}

.dash-search__clear {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  flex: none;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--gp-ink-muted);
  cursor: pointer;
}

.dash-search__clear:hover {
  background: var(--gp-dash-row-hover);
  color: var(--gp-ink);
}

.dash-search__clear .icon { width: 14px; height: 14px; }

.dash-search__input:focus,
.dash-search__input:focus-visible {
  outline: none;
  box-shadow: none;
  border-radius: 0;
}

.dash-search__field:focus-within {
  outline: 2px solid var(--gp-focus-outer);
  outline-offset: 2px;
  box-shadow: 0 0 0 2px var(--gp-focus-inner);
}

.dash-search__count {
  font-size: var(--gp-dash-text-micro);
  color: var(--gp-ink-muted);
  white-space: nowrap;
}

/* Below the topbar's comfortable width the field takes the row it needs rather
   than squeezing the avatar off the end. */
@media (max-width: 767px) {
  .dash-search[data-open='true'] {
    position: absolute;
    left: var(--gp-dash-space-4);
    right: var(--gp-dash-space-4);
    top: 50%;
    transform: translateY(-50%);
    z-index: 2;
    background: var(--gp-dash-canvas);
  }

  .dash-search[data-open='true'] .dash-search__field { flex: 1; }
  .dash-search__input { width: 100%; max-width: none; }
}


/* ---------- THE PICKER ----------

   A button that opens a panel of checkboxes. Used on checkout to add other
   titles to an order without leaving the page.

   IT IS NOT A <select multiple> AND IT IS NOT A LISTBOX. Each row is a real
   input[type=checkbox] with a real <label>, so it is operable, announced and
   submitted by the browser rather than by script. What the script adds is
   opening, closing and the running count. Everything a keyboard user does
   inside the panel is native behaviour.

   THE PANEL IS A DISCLOSURE, NOT A MENU. aria-expanded on the trigger,
   aria-hidden never touched, and no roving tabindex: Tab walks the boxes in
   order because they are checkboxes in a container, which is exactly what a
   reader expects of a list of things to tick. Wrapping them in role="menu"
   would take that away and give nothing back. */

/* display:block, not inline, so the panel below can be anchored to the COLUMN
   rather than to the width of the button. On a phone a dropdown pinned to a
   140px trigger either overflows the viewport or floats at an angle to
   everything around it. */
.picker { position: relative; display: block; }

.picker__panel {
  position: absolute;
  z-index: var(--gp-z-header);
  top: calc(100% + var(--gp-space-8));
  left: 0;
  width: min(420px, calc(100vw - var(--gp-space-32)));
  padding: var(--gp-space-8);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius-lg);
  background: var(--gp-surface);
  box-shadow: var(--gp-elevation-3);
}

/* Anchored right when the trigger is near the right edge of its column. */
.picker__panel--end { left: auto; right: 0; }

/* On a phone it takes the full column and stops being a dropdown pinned to a
   button. There is no room for anything else, and a panel that is narrower
   than its own rows is how a price column ends up wrapping. */
@media (max-width: 639px) {
  .picker__panel,
  .picker__panel--end {
    left: 0;
    right: 0;
    width: auto;
  }
}

.picker__list {
  max-height: 320px;
  overflow-y: auto;
  margin: 0;
  padding: 0;
  list-style: none;
}

.picker__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--gp-space-8);
  margin-top: var(--gp-space-8);
  padding-top: var(--gp-space-8);
  border-top: 1px solid var(--gp-rule);
}

/* A row inside a picker is a .choice with a cover and a price. The price is
   right-aligned and tabular so a column of them can be compared, which is the
   entire reason somebody opens this panel. */
.picker .choice {
  align-items: center;
  gap: var(--gp-space-12);
  padding: var(--gp-space-8);
  border-radius: var(--gp-radius);
}

.picker .choice:hover { background: var(--gp-surface-sunken); }

.picker__cover {
  width: 32px;
  flex: none;
  border-radius: var(--gp-radius-sm);
  overflow: hidden;
}

.picker__price {
  margin-left: auto;
  padding-left: var(--gp-space-12);
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-semi);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* The count on the trigger. Zero is not rendered: a button reading "Add books
   (0)" advertises an empty room, the same rule the attachment control follows. */
.picker__count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 20px;
  padding: 0 6px;
  border-radius: var(--gp-radius-pill);
  background: var(--gp-accent-face);
  color: var(--gp-on-accent);
  font-size: var(--gp-fs-eyebrow);
  font-weight: var(--gp-fw-bold);
}


/* ---------- THE MODAL ----------

   One dialog element, three bodies, used by the one-click purchase flow.

   IT IS A REAL <dialog> AND showModal() DOES THE HARD PART. The focus trap,
   the inert page behind it, Escape, and the top layer are all browser
   behaviour, and every one of them is a thing this codebase would otherwise
   hand-write badly. The mobile sheet predates that decision and keeps its own
   trap; a new dialog does not need to repeat it.

   ::backdrop is styled rather than a .scrim element, for the same reason. */

.modal {
  width: min(520px, calc(100vw - var(--gp-space-32)));
  max-height: min(90vh, 720px);
  padding: 0;
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius-lg);
  background: var(--gp-surface);
  color: var(--gp-ink);
  box-shadow: var(--gp-elevation-3);
}

.modal::backdrop { background: var(--gp-scrim); }

.modal__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--gp-space-16);
  padding: var(--gp-space-24) var(--gp-space-24) var(--gp-space-16);
}

.modal__title {
  margin: 0;
  font-family: var(--gp-font-display);
  font-size: var(--gp-fs-h3);
  line-height: var(--gp-lh-h3);
}

.modal__lead {
  margin: var(--gp-space-4) 0 0;
  font-size: var(--gp-fs-small);
  color: var(--gp-ink-muted);
}

.modal__close {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: 1px solid var(--gp-rule-strong);
  border-radius: 50%;
  background: transparent;
  color: var(--gp-ink-muted);
  cursor: pointer;
}

.modal__close:hover { color: var(--gp-ink); border-color: var(--gp-ink-muted); }

.modal__body {
  padding: 0 var(--gp-space-24);
  overflow-y: auto;
}

.modal__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--gp-space-12);
  padding: var(--gp-space-16) var(--gp-space-24) var(--gp-space-24);
}

.modal__total {
  font-size: var(--gp-fs-lead);
  font-weight: var(--gp-fw-bold);
  font-variant-numeric: tabular-nums;
}

/* THE PROCESSING BODY.

   It is centred and it is SPARSE, and both are deliberate. This is the fifteen
   seconds in which a buyer's money has left and nothing has arrived, and the
   two questions they have are "is it working" and "can I close this". The
   answer to the second is no, so the close control is genuinely absent here
   rather than present and disabled, and the text says not to close the window
   instead of leaving them to guess. */
.modal__state {
  padding: var(--gp-space-40) var(--gp-space-24);
  text-align: center;
}

.modal__state .progress { margin: var(--gp-space-24) auto 0; max-width: 320px; }

.modal__state-title {
  margin: var(--gp-space-16) 0 0;
  font-family: var(--gp-font-display);
  font-size: var(--gp-fs-h4);
}

.modal__state-text {
  margin: var(--gp-space-8) 0 0;
  font-size: var(--gp-fs-small);
  color: var(--gp-ink-muted);
}

.modal__mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: var(--gp-success-tint);
  color: var(--gp-success-ink);
}

.modal__mark .icon { width: 28px; height: 28px; }


/* ---------- THE WORDMARK IN TWO THEMES ----------

   The wordmark is a PNG of dark green ink on nothing. On the dark ground it is
   dark green on near-black: the logo does not go dim, it disappears, and the
   top-left corner of every page becomes empty.

   THERE IS NO CSS THAT RECOLOURS A PNG. filter: invert() turns Deep Forest
   into a magenta that belongs to no brand, and a mask loses the two-tone mark
   entirely. So both files ship in the markup and one is hidden, which is also
   why the reversed mark exists in assets/brand in the first place.

   BOTH IMAGES DECODE. That is the honest cost: roughly 9KB of second wordmark
   on a page that will only ever show one. It is accepted rather than solved
   with a script, because the alternative is a logo that pops in after paint,
   and a masthead that assembles itself in front of the reader is worse than
   nine kilobytes.

   ONLY ONE CARRIES THE ALT TEXT. The hidden one is aria-hidden with an empty
   alt, or the page announces "Gilead Press" twice in the same corner. */

.brand-mark--dark { display: none; }
[data-theme='dark'] .brand-mark--light { display: none; }
[data-theme='dark'] .brand-mark--dark { display: inline; }

/* The rail and the closing band are dark surfaces in BOTH themes, so a mark
   inside them is already the reversed one and must not swap with the theme. */
.dash-rail__home .brand-mark--dark,
.sheet--rail .brand-mark--dark,
.site-footer .brand-mark--dark { display: none; }

/* The toggle on the stripped shells. The auth shell is a centred column with
   nothing else in its top row, so the control is pinned to the corner rather
   than given a header of its own to sit in. */
.theme-toggle--corner {
  position: absolute;
  top: var(--gp-space-16);
  right: var(--gp-space-16);
  z-index: 1;
}


/* ---------- A TABLE THAT TOTALS ITSELF ----------

   The receipt's totals live in the table's own <tfoot> rather than in a
   separate block beneath it, so the figures land under the column they total.
   These rules give the foot the weight that says "this is the sum" without
   turning it into a second table. */

.table tfoot th,
.table tfoot td {
  border-top: 1px solid var(--gp-rule);
  padding-top: var(--gp-space-12);
}

/* A COLUMN HEADER AND A ROW HEADER ARE NOT THE SAME TYPE. .table th is set in
   uppercase micro-caps, which is right for "UNIT PRICE" at the top of a column
   and wrong for "Subtotal" beside a figure: caps there read as a section label
   rather than as the name of the number next to it, and "TOTAL PAID" starts
   shouting on a document somebody files. The tfoot takes sentence case back. */
.table tfoot th {
  text-align: right;
  text-transform: none;
  letter-spacing: normal;
  font-size: var(--gp-fs-small);
  font-weight: var(--gp-fw-regular);
  color: var(--gp-ink-muted);
}

/* The line the reader is actually looking for. Heavier, and it takes the ink
   colour back off the muted step: on a document somebody files, the amount
   paid is the one figure that must never be the quietest thing on the page. */
.table tfoot .is-total th,
.table tfoot .is-total td {
  border-top: 2px solid var(--gp-rule-strong);
  font-size: var(--gp-fs-lead);
  font-weight: var(--gp-fw-bold);
  color: var(--gp-ink);
}

/* Stacked below 768px the tfoot rows lose their columns, so the colspan label
   and its figure become a label-and-value pair like every other stacked cell
   rather than a right-aligned heading floating over a number. */
@media (max-width: 767px) {
  .table tfoot th { text-align: left; }
}


/* ---------- AN INDETERMINATE PROGRESS BAR ----------

   For the one case where the page genuinely does not know how far along
   something is: a card authorisation. The alternative is a determinate bar
   with a made-up percentage, which climbs to ninety and sits there, and that
   is the single most common lie in a checkout. A bar that says "still working"
   and nothing else is the honest version.

   No aria-valuenow, which is what makes it indeterminate to assistive
   technology as well as to the eye. A progressbar with no value is defined as
   exactly this and needs no extra attribute to say so.

   IT IS THE ONE ANIMATION IN THIS BUILD THAT MAY NOT SIMPLY BE TURNED OFF.
   Under reduced motion it stops travelling and pulses in place instead: a
   reader who has asked for less motion still needs to know the payment has not
   died, and a frozen bar during an authorisation is indistinguishable from a
   hung page. Reduced motion means less motion, not less information. */

.progress--indeterminate .progress__bar {
  width: 40%;
  animation: gp-progress-slide 1.1s var(--gp-ease) infinite;
}

@keyframes gp-progress-slide {
  from { transform: translateX(-100%); }
  to   { transform: translateX(250%); }
}

@media (prefers-reduced-motion: reduce) {
  .progress--indeterminate .progress__bar {
    width: 100%;
    animation: gp-progress-pulse 1.6s ease-in-out infinite;
  }

  @keyframes gp-progress-pulse {
    0%, 100% { opacity: 0.45; }
    50%      { opacity: 1; }
  }
}


/* ---------- VERTICAL RHYTHM IN A FORM COLUMN ----------

   THE PROBLEM THIS SOLVES. base.css strips the margin off every heading and
   paragraph, on purpose: spacing belongs to components, so that a card and a
   page and a table can each set their own without fighting a default. That
   works everywhere a component wraps the content. On checkout most of the left
   column is bare markup, a form with headings, fields and help text as direct
   children, and with no component around them nothing set any spacing at all.
   The result is a page where "Checkout" and "Pay in" touch, the section
   heading sits on top of the first label, and the sentence promising an email
   receipt is glued to the pay button.

   IT IS AN OPT-IN CLASS RATHER THAN A RULE ON .split, because .split is also
   the help pages and the legal pages, and each of those already sets its own
   rhythm through the components inside it.

   THE OWL, > * + *, so it applies BETWEEN things and never before the first or
   after the last. That is what stops a column starting with a gap above its
   own heading, and it is why the rule needs no :first-child exception for the
   ordinary case.

   HIDDEN SIBLINGS ARE EXCLUDED. This page is mostly states that are built and
   hidden: the offline bar, the naira-only note, the signed-in block. A plain
   owl selector counts them, so a page carrying six hidden states inherits six
   invisible gaps and the spacing looks arbitrary rather than wrong, which is
   harder to find. */

.form-flow > * + *:not([hidden]) { margin-top: var(--gp-space-24); }

/* A heading opens a new section and takes more air above it than the gap
   between two fields. It is the only place in this rule the step changes,
   because it is the only place the reader's task changes. */
.form-flow > h2 { margin-top: var(--gp-space-40); }
.form-flow > h2:first-child { margin-top: 0; }

/* A visually hidden heading is still a child, and giving it 40px of margin
   puts a gap on the page with nothing in it. */
.form-flow > h2.visually-hidden { margin-top: 0; }

/* Fields inside a group sit closer together than the groups do: they are one
   task, and 24px between an email and a name reads as two separate questions. */
.form-flow .field + .field { margin-top: var(--gp-space-16); }

/* Help text that follows the thing it explains, rather than preceding it,
   belongs to it and does not take the full step. */
.form-flow .field__help--after { margin-top: var(--gp-space-12); }

/* The page title above the two columns. It carries the name of the step, and
   the step begins under it. */
.page-title { margin-bottom: var(--gp-space-32); }


/* ---------- THE DASHBOARD SEARCH RESULTS ----------

   Grouped by section, because the answer to "where do I change my password"
   is a place, not a row. The group heading is a link to that screen, so a
   customer whose query nearly matched can go and look.

   IT IS NOT A LISTBOX. The results are links in lists, announced as links, and
   Enter follows one because that is what a link does. A combobox pattern here
   would mean owning roving tabindex, aria-activedescendant and a selection
   model, all to reproduce behaviour the browser already gives away, and every
   one of those is a thing that breaks quietly. The input carries
   aria-expanded so a screen reader knows a panel opened, and the count goes
   into the live region. */

.dash-search__results {
  position: absolute;
  z-index: var(--gp-z-header);
  top: calc(100% + var(--gp-dash-space-2));
  right: 0;
  width: min(380px, calc(100vw - var(--gp-space-32)));
  max-height: min(60vh, 460px);
  overflow-y: auto;
  padding: var(--gp-dash-space-2);
  border: 1px solid var(--gp-dash-hairline);
  border-radius: var(--gp-dash-radius-panel);
  background: var(--gp-dash-panel);
  box-shadow: var(--gp-dash-elevation-3);
}

.dash-search__group + .dash-search__group {
  margin-top: var(--gp-dash-space-2);
  padding-top: var(--gp-dash-space-2);
  border-top: 1px solid var(--gp-dash-row-rule);
}

.dash-search__group-head {
  display: flex;
  align-items: center;
  gap: var(--gp-dash-space-2);
  padding: var(--gp-dash-space-2) var(--gp-dash-space-3);
  font-size: var(--gp-dash-text-micro);
  font-weight: var(--gp-fw-bold);
  letter-spacing: var(--gp-dash-tracking-micro);
  text-transform: uppercase;
  text-decoration: none;
  color: var(--gp-ink-muted);
}

.dash-search__group-head:hover { color: var(--gp-accent); }
.dash-search__group-head .icon { width: 14px; height: 14px; }

.dash-search__list { margin: 0; padding: 0; list-style: none; }

.dash-search__item {
  display: block;
  padding: var(--gp-dash-space-2) var(--gp-dash-space-3);
  border-radius: var(--gp-dash-radius-control);
  text-decoration: none;
  color: var(--gp-ink);
}

/* Hover and keyboard focus land on the same appearance, so a reader arrowing
   down the list sees what a reader with a mouse sees. */
.dash-search__item:hover,
.dash-search__item:focus-visible { background: var(--gp-dash-row-hover); }

.dash-search__item-title {
  display: block;
  font-size: var(--gp-dash-text-body);
  font-weight: var(--gp-fw-medium);
}

.dash-search__item-note {
  display: block;
  font-size: var(--gp-dash-text-meta);
  color: var(--gp-ink-muted);
}

.dash-search__more,
.dash-search__empty {
  display: block;
  padding: var(--gp-dash-space-2) var(--gp-dash-space-3);
  font-size: var(--gp-dash-text-meta);
  color: var(--gp-ink-muted);
}

.dash-search__more { text-decoration: underline; }

/* The running count on the pill, beside the field. */
.dash-search__count:not(:empty)::before { content: '('; }
.dash-search__count:not(:empty)::after { content: ')'; }

/* On a phone the panel takes the width the topbar already claimed, rather than
   hanging off the right edge of a field that is itself pinned to the edges. */
@media (max-width: 767px) {
  .dash-search[data-open='true'] .dash-search__results {
    left: 0;
    right: 0;
    width: auto;
  }
}


/* ---------- WHERE A FILLED SECTION MEETS THE FOOTER ----------

   The footer carries 64px of top margin, which is right when the last thing
   above it sits on the page ground: the band needs air before it.

   It is wrong when the last section has a fill of its own. On book-detail the
   page ends with .section--sunken, which is tinted and already carries 80px of
   its own bottom padding, so the 64px became a stripe of ivory wedged between
   a tinted block and a dark one. Three bands in a row, and the thin one in the
   middle reads as a mistake rather than as spacing.

   The margin is dropped rather than the section's padding, because the padding
   is what keeps the section's own content off its edge and would be missed
   inside the block. This is written as a relationship rather than a per-page
   override, so any page that ends on a filled section is already handled and
   nobody has to remember. */

main:has(> .section--sunken:last-child) ~ .site-footer,
main:has(> .band:last-child) ~ .site-footer {
  margin-top: 0;
}


/* ---------- A SUBJECT WITH NOTHING IN IT ----------

   HOUSE RULE: A SUBJECT OR CATEGORY WITH NO PUBLISHED TITLES IS NEVER A LINK.
   It is still listed, everywhere it would otherwise appear, and it still says
   what it is. It simply does not navigate.

   WHY IT IS LISTED AT ALL. Three of the four subjects are empty today. Hiding
   them would make the shop look like a one-subject shop, which is not what it
   is: the catalogue is built to take more and the subjects are real editorial
   commitments. A reader who can see Faith on the list learns something true
   about where this publisher is going.

   WHY IT MUST NOT BE CLICKABLE. The other half of the same fact. A link is a
   promise that something is on the other side, and a reader on a phone who
   taps Faith, waits out a page load and lands on an empty shelf has been sent
   somewhere for nothing. Twice is enough for them to stop trusting the nav.

   IT IS RENDERED AS A <span>, NOT AS <a aria-disabled="true">. An anchor with
   an href is focusable and clickable no matter what ARIA says about it, so
   that pattern needs JavaScript to hold and fails open when the script does
   not run. A span cannot be clicked, cannot be focused, is not announced as a
   link, and needs nothing to enforce it. The count beside it is what says why.

   THIS IS NOT .is-visible-disabled, which is for a control that IS reachable
   but not yet, such as My Library before sign-in: that one is an anchor
   because there is somewhere to go. Here there is not. */

.is-empty {
  color: var(--gp-ink-muted);
  cursor: default;
}

/* On the closing band and the rail, the muted ink is the band's own. */
.site-footer .is-empty,
.sheet--rail .is-empty { color: var(--gp-on-band-muted); }

/* A card whose title is no longer a link loses the stretched pseudo-element
   that made the whole card one target, so nothing about it invites a tap. The
   arrow goes with it: an arrow is a direction, and there is nowhere to go. */
.subject-card--empty { cursor: default; }
.subject-card--empty .subject-card__foot .icon { display: none; }
.subject-card--empty .subject-card__name .is-empty { color: var(--gp-ink-muted); }
.subject-card--empty:hover { transform: none; box-shadow: none; border-color: var(--gp-rule); }
.subject-card--empty:hover:after { width: 0; }

/* ===========================================================================
   The account menu. An avatar in the header that opens the dashboard.

   It replaced a display name sitting in the nav doing nothing, beside a Sign
   out button holding a permanent slot for the rarest click on the site. The
   same width now reaches every dashboard page.

   POSITIONED, NOT FLOATED OVER. The panel is absolutely positioned inside a
   relative parent, so it cannot push the header's own layout around when it
   opens and cannot be clipped by it either.

   A DISCLOSURE, NOT AN ARIA MENU. The panel holds links, so they stay links:
   a keyboard user tabs them and a screen reader announces navigation. See
   assets/js/nav.js for why that is a deliberate choice and not an omission.
   ======================================================================== */

.account-menu {
  position: relative;
}

.account-menu__trigger {
  display: inline-flex;
  align-items: center;
  gap: var(--gp-space-4);
  padding: var(--gp-space-4);
  background: none;
  border: 0;
  border-radius: 999px;
  color: inherit;
  cursor: pointer;
}

/* The focus ring is on the trigger, not the avatar inside it, or the ring
   would trace a circle that is not the thing being focused. */
.account-menu__trigger:focus-visible {
  outline: 2px solid var(--gp-accent);
  outline-offset: 2px;
}

/* NO .avatar RULE HERE. It already exists in section 21 above, built for the
   dashboard topbar, and it is exactly right: 32px, circular, and coloured with
   --gp-accent-face / --gp-on-accent, both of which flip under [data-theme].
   
   The first draft of this section redefined .avatar with the same specificity,
   later in the same file, so it silently won everywhere and repainted the
   DASHBOARD avatar too. Worse, it used --gp-accent with --gp-pure-white, and
   --gp-accent becomes soft-leaf in dark mode, which tokens.css itself
   documents as 2.38:1 against ivory. White on that is close to invisible.
   
   Reusing the existing class is not a shortcut here; it is the fix. */

.account-menu__chevron {
  /* --gp-motion-fast does not exist; --gp-dur-micro is the real token. */
  transition: transform var(--gp-dur-micro) ease;
}

.account-menu__trigger[aria-expanded="true"] .account-menu__chevron {
  transform: rotate(180deg);
}

.account-menu__panel {
  position: absolute;
  inset-block-start: calc(100% + var(--gp-space-8));
  inset-inline-end: 0;
  /* The scale in tokens.css, not a number picked here. Its own comment says
     "declared once so no component invents a z-index", and the two sibling
     popovers, .picker__panel and .dash-search__results, both use this token.
     The first draft used a literal 60, which happened to work only because
     .site-header forms its own stacking context. */
  z-index: var(--gp-z-header);
  min-inline-size: 220px;
  padding: var(--gp-space-8);
  background: var(--gp-surface);
  border: 1px solid var(--gp-rule);
  border-radius: var(--gp-radius);
  /* Elevation is a state, not a decoration, per tokens.css. An open panel is
     genuinely lifted off the page, so it earns one. */
  box-shadow: var(--gp-elevation-2);
}

.account-menu__name {
  margin: 0;
  padding: var(--gp-space-8) var(--gp-space-12);
  color: var(--gp-ink-muted);
  font-size: 13px;
  /* A long display name wraps rather than widening the panel past the
     header. */
  overflow-wrap: anywhere;
}

.account-menu__list {
  margin: 0;
  padding: 0;
  list-style: none;
  border-block-start: 1px solid var(--gp-rule);
}

.account-menu__link {
  display: block;
  padding: var(--gp-space-8) var(--gp-space-12);
  border-radius: var(--gp-radius-sm);
  color: var(--gp-ink);
  text-decoration: none;
}

.account-menu__link:hover,
.account-menu__link:focus-visible {
  background: var(--gp-surface-sunken);
}

/* Sign out is set apart by a rule rather than by colour. It is not a
   destructive action and colouring it as one would cry wolf on a screen
   where revoking access genuinely is destructive. */
.account-menu__link--signout {
  margin-block-start: var(--gp-space-4);
  border-block-start: 1px solid var(--gp-rule);
  border-start-start-radius: 0;
  border-start-end-radius: 0;
  color: var(--gp-ink-muted);
}
