/* ==========================================================================
   components.css — PromptingPress Component Styles

   RULES:
   - Only CSS variables from base.css. No raw hex values, ever.
   - BEM naming: .block__element--modifier
   - Each component section clearly labeled.
   - To retheme: edit base.css tokens only. Do not add hex here.
   ========================================================================== */

/* ==========================================================================
   SHARED: Button
   .btn is the shared base; variants .btn--secondary / .btn--outline / .btn--ghost
   (primary = bare .btn) are selected per-instance via the `button_variant` prop.
   The --btn-* color tokens (--btn-bg / --btn-text / --btn-border-color / --btn-shadow,
   plus the HOVER pair --btn-hover-bg / --btn-hover-border-color added by #539)
   are registered design tokens in base.css and are the site-wide GLOBAL button surface.
   The premium `main .btn:not(...)` primary cascade and the `.cta .btn` / `.hero .btn`
   primary rules route their fill/border/ink/shadow fallbacks through --btn-* (#458), so
   setting --btn-bg / --btn-text / --btn-border-color / --btn-shadow at :root restyles
   EVERY composed primary button. --btn-bg / --btn-border-color / --btn-shadow register as
   `initial` (unset), so each consuming rule resolves its own literal until the token is
   set and an unset button is byte-identical; the two hover knobs register `initial` for the
   same reason; --btn-text keeps a concrete --color-bg
   default (its value equals the universal ink literal). Per-component slots
   (--cta-button-* / --cta-accent / --hero-button-* / --hero-accent) still win over the
   global tokens when set — --btn-* sits between those slots and the literal fallback. These tokens are NOT
   exposed as component style_slots: they are the site-wide tier, and a nested .btn an author
   hand-writes into a rich-text prop is MEANT to follow them. What a section's __pp_style cannot
   leak into a nested button is the PER-INSTANCE slot families, which issue 545 neutralises on
   every composed .btn that is not a renderer-owned button element (see that block below).
   ========================================================================== */

.btn {
  display: inline-block;
  padding: var(--btn-padding-y) var(--btn-padding-x);
  background-color: var(--btn-bg, var(--color-accent));
  color: var(--btn-text, var(--color-bg));
  font-weight: 600;
  font-size: 1rem;
  text-decoration: none;
  border: 2px solid var(--btn-border-color, var(--color-accent));
  border-radius: var(--btn-radius, var(--radius));
  box-shadow: var(--btn-shadow, none);
  cursor: pointer;
  transition: background-color var(--transition), border-color var(--transition), color var(--transition);
  min-height: 44px; /* accessibility: 44px min touch target */
  line-height: 1.4;
}

/* Hover twin of the .btn rest rule above: the global hover knobs (issue 539) sit exactly
   where --btn-bg / --btn-border-color sit at rest — first, ahead of the literal, since the
   bare primitive has no per-instance slots. This rule is the live hover winner only OUTSIDE
   `main` (inside it the premium `main .btn:not(...):hover` cascade wins on specificity), so
   it is what carries a site-wide retheme onto header/footer buttons. Unset, both fall to
   --color-accent-hover and the button is byte-identical.
   Note the border reaches .btn--outline too: `.btn--outline:hover` sets no border-color, so
   this declaration is its hover ring. That mirrors REST exactly (`.btn--outline` sets no
   border-color either, so --btn-border-color already rings it), which is the point — the
   global border knob should not change which variants it reaches between the two states.
   .btn--ghost and .btn--secondary set their own hover border and are unaffected. */
.btn:hover {
  background-color: var(--btn-hover-bg, var(--color-accent-hover));
  border-color: var(--btn-hover-border-color, var(--color-accent-hover));
  color: var(--color-bg);
}

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

.btn--outline {
  background-color: transparent;
  color: var(--color-accent);
}

.btn--outline:hover {
  background-color: var(--color-accent);
  color: var(--color-bg);
}

/* Secondary: muted surface fill, used for lower-emphasis actions. */
.btn--secondary {
  background-color: var(--color-surface);
  color: var(--color-text);
  border-color: var(--color-border);
}

.btn--secondary:hover {
  background-color: var(--color-border);
  border-color: var(--color-border);
  color: var(--color-text);
}

/* Ghost: borderless text-style button for tertiary actions. */
.btn--ghost {
  background-color: transparent;
  color: var(--color-accent);
  border-color: transparent;
}

.btn--ghost:hover {
  background-color: var(--color-surface);
  border-color: transparent;
  color: var(--color-accent);
}

/* ==========================================================================
   SHARED: border-trigger cascade immunity (issue 332)

   WordPress core's global stylesheet ships attribute-SUBSTRING selectors:

     html :where([style*="border-width"]) { border-style: solid }
     html :where([style*="border-color"]) { border-style: solid }

   Core's intent is the block editor's `style="border-width:2px"` shorthand. But
   our style slots render as inline CUSTOM PROPERTIES on the component root
   (style="--grid-item-border-width:0px"), and the substring lives in the property
   NAME — so the selector matches the root even when the value is 0 and the border
   the slot controls actually lives on a DESCENDANT (the card). A root that
   declared no border of its own then computed core's injected `solid` at the
   initial `medium` width: a visible 3px border nobody asked for. The 1.0-H dogfood
   hit exactly this and had to abandon two documented slots.

   The fix is immunity, not renaming: the slot names are public AI-facing surface.
   Specificity, precisely: `:where()` contributes zero, but the leading `html` type
   selector still counts, so core's rule weighs (0,0,1) — low, NOT zero. A class or
   attribute selector weighs (0,1,0) and outranks it outright. So declaring the border
   baseline on every element that can carry inline slot custom properties (the 12
   component roots, the per-card .grid__item of issue 306, and the per-row
   .section__panel-row of issue 334) makes core's rule a no-op
   for us — and it stays a no-op for slots that do not exist yet. (Note the corollary:
   a BARE ELEMENT selector of ours would only tie core at (0,0,1) and could lose on
   source order. The baseline must keep a class/attribute selector to hold its rank.)

   Both longhands are declared on purpose: `border-style: none` alone would defeat
   today's core rule, and `border-width: 0` keeps it defeated if core ever injects a
   width instead. The declared values ARE the CSS initial rendering (a style of
   `none` computes to a 0 width), so an unset component renders byte-identically.

   Placement is load-bearing: an attribute selector and a class selector both weigh
   (0,1,0), so this must stay ABOVE the component blocks — every component rule that
   really wants a border ( .hero, .cta, .section, .grid__item, .grid--dark, … ) then
   wins on source order. Do not move it below them. Nor inside an @media/@layer block:
   a breakpoint-scoped baseline leaves every other breakpoint exposed.

   Known consequence: at (0,1,0) this also outranks a BARE ELEMENT rule a site owner or
   plugin might put on a component root (e.g. `section { border-top: 1px solid }` in
   Customizer → Additional CSS, which loads after this file but weighs only (0,0,1)).
   That is the same specificity lever the fix uses against core, pointed outward. Site
   CSS that targets a class/attribute (`.section`, `[data-pp-component]`) still wins
   normally; only bare-element borders on the roots are suppressed.
   ========================================================================== */

[data-pp-component],
.grid__item,
.section__panel-row {
  border-style: none;
  border-width: 0;
}

/* ==========================================================================
   COMPONENT: nav
   Site header, logo, nav links, hamburger toggle.
   ========================================================================== */

:root {
  /* Static fallback for the sticky header's real height (issue 63) — JS
     (main.js) measures the actual rendered .site-header height on load and
     resize and overrides this, since header height varies with content,
     breakpoint, and font loading. Without this, an anchor jump to a
     component's id (hero, section, cta, etc. all support one) lands with
     the sticky header covering the heading, worst on mobile where the
     header is proportionally taller. Not a design token: computed, not
     AI-editable via update_design_token.
  */
  --pp-header-height: 65px;
}

/* Header chrome slots (issue 333). The header is template-owned (issue 223) and
   declares no style_slots, so these are consumed from the pp_header_* site options
   that base.php maps onto nav's props; the issue-305 slot guard does not cover them
   and HeaderChromeTest pins the consume-plus-fallback contract instead.

   `background`, NOT `background-color`: --header-bg is a gradient-typed option
   (color OR gradient), and a gradient is a CSS <image> — assigning one to
   background-color is invalid, so the browser drops the declaration and the header
   paints nothing. The shorthand accepts both a plain color and a gradient. Its
   reset of the other background-* longhands is a no-op here: this rule is the only
   place .site-header touches background at all. Unset, it resolves to the same
   --color-bg as before, so default rendering is unchanged. */
.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: var(--header-bg, var(--color-bg));
  border-bottom: 1px solid var(--color-border);
}

.nav__container {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 64px;
  gap: var(--space-md);
}

.nav__logo {
  font-family: var(--font-heading);
  font-weight: 700;
  font-size: 1.25rem;
  color: var(--header-text, var(--color-text));
  text-decoration: none;
  flex-shrink: 0;
  min-height: 44px;
  display: flex;
  align-items: center;
}

.nav__logo:hover {
  color: var(--color-accent);
}

/* Cap the header logo the same way the footer caps it (.site-footer__logo-image).
   The header is template-owned with zero style slots, so a real-world wordmark
   (e.g. 664x150) would otherwise render near intrinsic size and blow out the
   64px header row. Literal cap, consistent with the footer treatment.
   issue 299, issue 582 */
.nav__logo-image {
  display: block;
  max-height: 2.5rem;
  width: auto;
  object-fit: contain;
}

/* Hamburger toggle button */
.nav__toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  background: none;
  border: none;
  border-radius: var(--radius);
  color: var(--header-text, var(--color-text));
  cursor: pointer;
  transition: color var(--transition);
}

.nav__toggle:hover {
  color: var(--color-accent);
}

/* Open-state affordance (issue 426): the toggle swaps hamburger <-> close (X)
   purely from its own aria-expanded, so the same button visibly communicates
   state and is the obvious close control. main.js keeps aria-expanded truthful;
   these rules own the icon swap. The toggle is display:none at >=768px, so this
   is inert on desktop (rendered output there is byte-identical). */
.nav__toggle-icon--close {
  display: none;
}

.nav__toggle[aria-expanded="true"] .nav__toggle-icon--open {
  display: none;
}

.nav__toggle[aria-expanded="true"] .nav__toggle-icon--close {
  display: flex;
}

/* Nav menu — visible without JS (progressive enhancement); JS adds/removes hidden. */
.nav__menu {
  width: 100%;
  padding: var(--space-sm) 0 var(--space-md);
}

/* WP menu list */
.nav__menu ul {
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  list-style: none;
}

/* Nav links route through --header-link-color; the logo and toggle route through
   --header-text. Two slots, mirroring the footer's text-vs-link split (issue 300),
   so a header can carry a muted wordmark with brighter links (or the reverse).
   Hover keeps --color-accent (a global design token). The active/current link COLOR
   also routes through --header-link-color (issue 355), falling back to --color-accent
   when the operator hasn't set a header link color — so styling the header no longer
   leaves the active link stuck on the global accent (the common one-page anchor-nav
   case marks every link current). The active EMPHASIS (font-weight:700) is unchanged. */
.nav__menu ul li a {
  display: block;
  padding: var(--space-sm) var(--space-sm);
  color: var(--header-link-color, var(--color-text));
  text-decoration: none;
  border-radius: var(--radius);
  transition: color var(--transition);
}

.nav__menu ul li a:hover {
  color: var(--color-accent);
  text-decoration: underline;
}

/* Active link: WP sets current-menu-item on <li> — this is the primary active style.
   Color follows --header-link-color so an operator's pp_header_link_color reaches the
   active link too (issue 355); --color-accent stays the fallback, so an unset header
   renders byte-identically to before. The bold weight marks the current item either way. */
.nav__menu ul li.current-menu-item > a,
.nav__menu ul li.current_page_item > a {
  font-weight: 700;
  color: var(--header-link-color, var(--color-accent));
}

/* aria-current="page" is a secondary enhancement (e.g. set by other means). */
.nav__menu ul li a[aria-current="page"] {
  font-weight: 700;
  color: var(--header-link-color, var(--color-accent));
}

/* Mobile (issue 426): the nav menu is a DISCLOSURE PANEL below the header row,
   not a third item in the header's flex row. Before this, .nav__menu (width:100%)
   joined the nowrap flex row when un-hidden and was crushed into a ~94px column at
   the right edge while the sticky header grew 65px -> 229px. Taking the menu OUT of
   the flex flow (position:absolute) means opening it never reflows the logo/toggle
   row (byte-identical open vs closed) and never grows the sticky header. The panel
   is anchored to .nav__container (position:relative) so its left edge lines up under
   the logo via the existing container padding; top:100% drops it just under the row.
   It paints above page content because the whole .site-header is a z-index:100 sticky
   stacking context — this z-index only orders the panel within that context, adding
   no new stacking context of its own. Scoped to max-width:767px so the >=768px desktop
   nav is entirely untouched. Short panel -> no body scroll-lock (see main.js). The
   panel background routes the --header-bg chrome slot so a themed header carries into
   the panel too (issue 333/426). */
@media (max-width: 767px) {
  .nav__container {
    position: relative;
  }

  .nav__menu {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    z-index: 99;
    background: var(--header-bg, var(--color-bg));
    border-bottom: 1px solid var(--color-border);
    box-shadow: 0 8px 24px rgba(16, 24, 40, 0.12);
  }
}

/* Desktop: hamburger hidden, menu always visible */
@media (min-width: 768px) {
  .nav__toggle {
    display: none;
  }

  .nav__container {
    flex-wrap: nowrap;
  }

  /* When JS sets hidden on mobile, override it on desktop so menu is always shown. */
  .nav__menu[hidden] {
    display: flex;
  }

  .nav__menu {
    width: auto;
    padding: 0;
    display: flex;
    align-items: center;
  }

  .nav__menu ul {
    flex-direction: row;
    gap: var(--space-xs);
    align-items: center;
  }

  .nav__menu ul li a {
    padding: var(--space-xs) var(--space-sm);
  }
}

/* ==========================================================================
   NAV: Dropdown submenus (issue 381) — one level of nesting
   WP's default walker emits <li class="menu-item-has-children"> wrapping a
   nested <ul class="sub-menu">. main.js enhances each into an accessible
   disclosure: it injects a .nav__submenu-toggle button, sets .pp-has-dropdown
   on the <li>, and toggles .is-open. Pattern = WAI-ARIA *disclosure*
   navigation (button + aria-expanded), NOT a menubar — so there are no
   role="menu"/"menuitem" semantics here. Every selector is scoped under
   .nav__menu, so the footer and other WP menus are untouched.

   No-JS progressive enhancement (mirrors the hamburger): submenus stay
   visible/expanded until JS marks .pp-has-dropdown; on desktop, hover still
   reveals the dropdown without JS (keyboard users rely on the JS-injected
   disclosure button).
   ========================================================================== */

/* The disclosure button main.js injects after a parent link. */
.nav__submenu-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-xs);
  margin-left: calc(-1 * var(--space-xs));
  background: none;
  border: 0;
  color: inherit;
  cursor: pointer;
  line-height: 0;
}

.nav__submenu-toggle-icon {
  transition: transform var(--transition);
}

/* Rotate the chevron when the group is open. */
.nav__menu li.is-open > .nav__submenu-toggle .nav__submenu-toggle-icon {
  transform: rotate(180deg);
}

/* Mobile / no-breakpoint base: a submenu is a plain nested list. Without JS it
   stays visible. Once JS sets .pp-has-dropdown, the submenu collapses and the
   toggle owns visibility via .is-open (expand-in-place). */
.nav__menu li.pp-has-dropdown > .sub-menu {
  display: none;
}
.nav__menu li.pp-has-dropdown.is-open > .sub-menu {
  display: flex;
}

@media (min-width: 768px) {
  /* Anchor the dropdown to its parent item. */
  .nav__menu li.menu-item-has-children {
    position: relative;
  }

  /* Desktop: the submenu floats as a dropdown, hidden until hover (mouse) or
     an explicit .is-open toggle (the keyboard disclosure). The button's
     aria-expanded mirrors .is-open exactly, so keyboard state and ARIA never
     drift; hover is a pure mouse affordance (not exposed to assistive tech, so
     it needs no ARIA). :focus-within is deliberately NOT a reveal trigger — it
     would show the dropdown while aria-expanded still read false, and it would
     keep the dropdown visible after Escape (which returns focus to the still-
     inside-the-group toggle). The button owns the keyboard path instead. */
  /* min-width: 12rem is the dropdown panel's floor width (issue 582). A floating
     panel cannot size to its content without jittering as the menu changes: add or
     rename one child and the panel's width jumps under the cursor. 12rem is the
     width at which a typical menu label does not wrap, so the panel reads as a
     stable object rather than a reflow. No style slot is available to make it
     authorable — chrome declares ZERO style slots by ratified contract (issue 223),
     so a stated reason is the only disposition open to this literal.
     Note the panel is PARTLY token-reachable already: --header-bg routes its
     background (with a --color-surface fallback that differs from the mobile
     panel's --color-bg), --radius its corners. Its box-shadow is a literal, like
     this width. REOPENING CONDITION for both literals: the chrome model's own
     boundary moving — i.e. if chrome ever gains style slots, they become slot
     candidates rather than literals. */
  .nav__menu .sub-menu {
    position: absolute;
    top: 100%;
    left: 0;
    z-index: 10;
    min-width: 12rem;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    padding: var(--space-xs);
    background: var(--header-bg, var(--color-surface));
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: 0 8px 24px rgba(16, 24, 40, 0.12);
    display: none;
  }

  .nav__menu li.menu-item-has-children:hover > .sub-menu,
  .nav__menu li.is-open > .sub-menu {
    display: flex;
  }

  .nav__menu .sub-menu li a {
    padding: var(--space-xs) var(--space-sm);
    white-space: nowrap;
  }
}

/* ==========================================================================
   SHARED: Anchor scroll offset (issue 63)
   Every content component supports an `id` prop, rendering it as a direct-
   link/deep-link anchor target. Without this, jumping to #section-id lands
   the target at the very top of the viewport, under the sticky header
   (worst on mobile, where the header is proportionally taller) — covering
   the heading instead of revealing it.
   ========================================================================== */

.hero,
.section,
.cta,
.grid,
.faq,
.stats,
.logos,
.embed,
.testimonials,
.table-section {
  scroll-margin-top: var(--pp-header-height);
}

/* ==========================================================================
   COMPONENT: hero
   Full-width hero section. Variants: centered, left, split.
   ========================================================================== */

.hero {
  padding-top: var(--hero-padding-top, var(--space-xl));
  padding-bottom: var(--hero-padding-bottom, var(--space-xl));
  background: var(--hero-bg, var(--color-bg));
  border: var(--hero-border-width, 0) solid var(--hero-border-color, transparent);
  border-radius: var(--hero-radius, 0);
  box-shadow: var(--hero-shadow, none);
}

@media (min-width: 768px) {
  .hero {
    padding-top: var(--hero-padding-top, var(--space-2xl));
    padding-bottom: var(--hero-padding-bottom, var(--space-2xl));
  }
}

.hero__inner {
  display: flex;
  flex-direction: column;
  gap: var(--space-lg);
}

.hero__content {
  display: flex;
  flex-direction: column;
  gap: var(--hero-content-gap, var(--space-md));
  max-width: var(--hero-content-width, var(--measure-centered));
}

/* align-self keeps the pill sized to its text: .hero__content is a flex column,
   which blockifies inline-block and would otherwise stretch the eyebrow to the
   full content width. Centered/cover re-center it alongside their CTA overrides. */
.hero__eyebrow {
  display: inline-block;
  align-self: flex-start;
  padding: 0.35rem 0.85rem;
  margin-bottom: var(--space-sm);
  border: var(--hero-eyebrow-border-width, 0) solid var(--hero-eyebrow-border-color, transparent);
  border-radius: var(--hero-eyebrow-radius, 3px);
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: var(--hero-eyebrow-text-transform, uppercase);
  color: var(--hero-eyebrow-color, var(--color-text));
  background: var(--hero-eyebrow-bg, var(--color-surface-accent));
}

.hero__title {
  font-size: var(--hero-heading-size, clamp(2.5rem, 5vw, 4rem));
  font-weight: var(--hero-heading-weight, var(--font-weight-heading));
  color: var(--hero-heading-color, inherit);
  line-height: 1.03;
  /* Hero's heading measure (issue 578). The deleted rule capped `.hero--left` and
     `.hero--split` titles at `12ch`, and because `.hero__content` is a flex item that
     shrink-wraps to its WIDEST child, that cap narrowed the whole column — title,
     subtitle AND buttons — to 468px of a 1088px inner at 1280 (43%). `ch` is also
     viewport-local while the column is not: 24ch renders 896px at 1440/1280 but 792px
     at 1152 and 744px at 1024, so any value tight enough to do typographic work
     re-strands the column at exactly the laptop widths most of the audience uses. The
     smallest inert-to-1024 value is ~29ch, which is `none` with extra steps. On split
     the track binds first (~17ch), so 16/20/24ch and none are identical there. The
     default is therefore `none` for every layout — matching what centered and cover
     already do — and the real hero measure stays where it belongs, on
     --hero-content-width. The SLOT still ships: before this, no operator could widen a
     left hero without overriding this file.
     Declared on the base rule rather than the two layout selectors so the slot reaches
     all four layouts; unset it resolves to `none` on every one of them, which is the
     initial value, so centered and cover are unchanged. */
  max-width: var(--hero-heading-measure, none);
  /* Heading rhythm (issue 584). hero is the one band whose title carried NO
     margin-bottom declaration at all — the base.css universal `margin: 0` reset was
     the whole story, and .hero__content's flex `gap` supplies the visible spacing.
     The fallback is therefore `0`, which is exactly what the reset already computed,
     so an unset hero is byte-identical. What this slot is FOR on a hero is ADDING air
     below the headline: a value here stacks on top of --hero-content-gap rather than
     replacing it. It is deliberately NOT the hero's band-fusing lever — the title is
     never the hero's last visible element (the subheading and CTA group follow it), so
     the seam-closing knob there is --hero-padding-bottom. The slot lands anyway because
     the family is completed in one pass and the heading-rhythm surface must be uniform
     across all ten bands for an agent to reason about it. */
  margin-bottom: var(--hero-heading-margin-bottom, 0);
}

.hero__title-accent {
  color: var(--hero-heading-accent-color, var(--color-accent));
}

@media (min-width: 768px) {
  .hero__title {
    font-size: var(--hero-heading-size, clamp(3rem, 4.5vw, 4.5rem));
  }
}

.hero__subtitle {
  font-size: var(--hero-subheading-size, 1.0625rem);
  color: var(--hero-subheading-color, var(--color-muted));
  max-width: 40ch;
  /* The `1.6` literal here duplicated --line-height-body's exact shipped value
     (base.css), which `body` already consumes — so a site that retuned body leading
     moved every paragraph EXCEPT this one. Route the token with its own value as the
     fallback: byte-identical at the shipped value, and it only changes render on a
     site that has already retuned --line-height-body, which is the point (issue 577). */
  line-height: var(--line-height-body, 1.6);
}

/* align-self places the GROUP's box; justify-content packs the buttons INSIDE it. The
   two are independent, and only align-self was declared: while the box shrink-wraps its
   buttons the distinction is invisible, but as soon as the buttons WRAP the box fills the
   content column and the rows pack left again — centered hero included. Declare the
   packing explicitly rather than inheriting the flexbox initial value (issue 338). */
.hero__cta-group {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
  align-self: flex-start;
  justify-content: flex-start;
}

.hero__cta {
  align-self: flex-start;
}

/* Centered variant */
.hero--centered .hero__inner {
  align-items: center;
  text-align: center;
}

.hero--centered .hero__subtitle {
  max-width: none;
}

.hero--centered .hero__eyebrow {
  align-self: center;
}

.hero--centered .hero__cta-group {
  align-self: center;
  justify-content: center;
}

.hero--centered .hero__cta {
  align-self: center;
}

/* The proof row is the one flex row .hero__content never shrink-wraps (it sets no
   align-items, so .hero__proof stretches). Its BOX is therefore centered already; its
   ITEMS are not, and text-align:center — which this layout does inherit — has no say in
   where a flex container puts its items. Centering follows the layout variant, so a
   centered hero needs no second instruction from the operator (issue 338). */
.hero--centered .hero__proof {
  justify-content: center;
}

/* Left variant — used by inner pages; compact vertical rhythm */
.hero--left {
  padding-top: var(--hero-padding-top, var(--space-xl));
  padding-bottom: var(--hero-padding-bottom, var(--space-xl));
}

.hero--left .hero__inner {
  align-items: flex-start;
  text-align: left;
}

/* Split variant: text left, image right at lg+ */
.hero--split .hero__inner {
  align-items: flex-start;
  text-align: left;
}

@media (min-width: 1024px) {
  .hero--split .hero__inner {
    display: grid;
    grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
    align-items: center;
    gap: var(--space-xl);
  }

  .hero--split .hero__content {
    max-width: var(--hero-content-width, 40rem);
  }
}

/* Cover variant: full-width background image with overlay */
/* A flex row whose single item is .container. The container's auto inline margins already
   absorb the free space, so this centering is what already happens — declared here only so
   the row states its intent instead of resting on the flexbox initial value (issue 338). */
.hero--cover {
  position: relative;
  background-size: cover;
  background-position: var(--hero-bg-position, center);
  background-repeat: no-repeat;
  min-height: 70vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

.hero__overlay {
  position: absolute;
  inset: 0;
  background: var(--hero-overlay-bg, var(--overlay-bg));
}

.hero--cover .container {
  position: relative;
  z-index: 1;
}

.hero--cover .hero__inner {
  align-items: center;
  text-align: center;
}

.hero--cover .hero__title {
  color: var(--hero-heading-color, var(--color-bg));
}

/* .hero--cover lays the same --overlay-bg scrim (via .hero__overlay) over an arbitrary
   image as the section/cta/stats bg-image bands. The accented title substring paints
   its OWN color (0,1,0) and does NOT inherit the near-white .hero--cover .hero__title
   above, so it renders bare --color-accent at 1.16:1 over the overlay. Route the default
   through the overlay accent role (#463) so the guarantee is uniform across all overlay
   bands; the per-instance --hero-heading-accent-color slot still wins. Scoped to
   .hero--cover — the plain (non-overlay) hero variants keep bare accent. */
.hero--cover .hero__title-accent {
  color: var(--hero-heading-accent-color, var(--color-accent-on-overlay));
}

.hero--cover .hero__subtitle {
  color: var(--hero-subheading-color, var(--color-bg));
  opacity: 0.85;
  max-width: none;
}

.hero--cover .hero__eyebrow {
  align-self: center;
}

.hero--cover .hero__cta-group {
  align-self: center;
  justify-content: center;
}

.hero--cover .hero__cta {
  align-self: center;
}

/* Cover centers its content like .hero--centered, so its proof row packs the same way. */
.hero--cover .hero__proof {
  justify-content: center;
}

/* NOTE (issue 535): the cover band's outline/ghost button routing used to live here as
   `.hero--cover .btn--outline` { border-color/color: var(--color-bg) }. It NEVER painted:
   `.hero .btn--outline` below has the IDENTICAL specificity [0,2,0] and wins on source
   order, so a cover hero's outline primary rendered var(--hero-heading-color, var(--color-text))
   = near-black ink on the dark scrim (measured ~3.6:1 over the worst-case overlay-over-
   white composite, and far worse over a dark photo). The rules moved DOWN to sit after
   `.hero .btn--outline`, where they actually win, and their literals now route through
   the --color-accent-on-overlay role. See "Dark-band routing for the hero's cover band"
   below. Do not re-add a cover button rule up here: it would be dead again. */

.hero__image-wrap {
  flex-shrink: 0;
}

.hero__surface {
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
  justify-content: center;
  min-height: 100%;
  /* Inner proof/artifact surface — per-instance style slots (#24). Each falls back to
     the original "soft SaaS" treatment so unset heroes render unchanged; the site-builder
     AI overrides these to reach a Brand Book target (e.g. a 2px brutalist artifact). */
  padding: var(--hero-surface-padding, var(--space-lg));
  border: var(--hero-surface-border-width, 1px) solid
    var(--hero-surface-border-color, var(--color-border));
  border-radius: var(--hero-surface-radius, calc(var(--radius) * 2));
  background: var(--hero-surface-bg,
    linear-gradient(180deg, var(--color-bg) 0%, var(--color-surface) 100%));
  box-shadow: var(--hero-surface-shadow, 0 20px 50px rgba(16, 24, 40, 0.08));
}

.hero__surface > * {
  margin: 0;
}

.hero__surface .hero__proof {
  margin-top: 0;
}

.hero__surface-label {
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--color-muted);
}

.hero__surface-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}

.hero__surface-item {
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  padding-top: var(--space-sm);
  border-top: 1px solid var(--color-border);
}

.hero__surface-item:first-child {
  padding-top: 0;
  border-top: 0;
}

.hero__surface-key {
  font-size: 0.8125rem;
  color: var(--color-muted);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.hero__surface-value {
  font-size: 1rem;
  font-weight: 600;
  color: var(--color-text);
}

.hero__image {
  width: 100%;
  height: auto;
  aspect-ratio: var(--hero-image-aspect-ratio, auto);
  object-fit: cover;
  object-position: var(--hero-image-position, center);
  border-radius: var(--hero-image-radius, var(--radius));
}

/* Hero accent: scoped button colors via style slots.
   Only FILLED buttons take the accent as a background. Outline/ghost variants
   must keep their transparent fill so the secondary CTA stays visually distinct
   from the primary (a `.hero .btn` background here outranks `.btn--outline`'s
   transparent and would otherwise flatten both CTAs into identical filled buttons). */
.hero .btn:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  /* Per-instance filled-button fill slot (issue 514, analogous to --cta-button-bg on
     .cta .btn): --hero-button-bg wins the (masked here) background-color AND, via the
     premium `main .btn:not(...)` winner, the visible gradient-clearing fill. This
     in-block consumption is the slot-contract keystone (StyleSlotContractTest). Unset it
     falls to --hero-accent then the #458 global chain, so an unset button is byte-identical.
     Global button surface routed under the hero slot (#458): --hero-accent still wins when
     set; unset, fill falls to --btn-bg then --color-accent. Border honors its own
     --btn-border-color knob, else FOLLOWS the fill (--hero-button-bg, then --btn-bg) so a
     recolored button keeps a matching ring, else --color-accent.
     Per-instance RING slot (issue 584): --hero-button-border leads the border chain, in
     exactly the position --cta-button-border holds on the cta primary. Before it, the hero
     primary's ring could only be moved by the BAND accent (--hero-accent, which repaints
     every accented element in the band) or by the site-wide --btn-border-color — there was
     no way to ring THIS button alone. A new link at the HEAD of an existing chain cannot
     change an unset render, so this is byte-identical by construction. Its :hover twin is
     on the rule below, per the positional-twin discipline: a ring added at rest only would
     dissolve under the pointer, which is the #535 defect. */
  background-color: var(--hero-button-bg, var(--hero-accent, var(--btn-bg, var(--color-accent))));
  border-color: var(--hero-button-border, var(--hero-accent, var(--btn-border-color, var(--hero-button-bg, var(--btn-bg, var(--color-accent))))));
}
/* Per-instance filled-button HOVER fill slot (issue 530, the rest idiom above applied to
   hover): --hero-button-hover-bg wins the (masked here) background-color AND, via the
   premium `main .btn:not(...):hover` winner, the visible gradient-clearing hover fill.
   This in-block consumption is the slot-contract keystone (StyleSlotContractTest) — the
   VISIBLE win happens in the premium rule, exactly as --hero-button-bg does at rest.
   Unset it falls to --hero-accent-hover then the literal, so an unset button is
   byte-identical. Border mirrors the REST chain's ordering above (--hero-accent first, then
   its own global knob, then FOLLOWS the fill) with each knob swapped for its hover equivalent.
   The global hover tier (issue 539) completes that mirror: --btn-hover-bg and
   --btn-hover-border-color take the positions --btn-bg and --btn-border-color hold at rest.
   Routing the fill HERE is not redundant with the premium rule — it is load-bearing. This
   rule is [0,6,0] and the premium hover winner is [0,5,1], so background-COLOR is decided
   here while background-IMAGE is decided there. Today the gradient image masks whatever this
   rule computes; the moment --btn-hover-bg clears that image to `none` in the premium rule,
   THIS declaration becomes the visible pixel. Insert the tier only in the premium rule and a
   site-wide hover retheme computes correctly, clears the gradient, and is then overridden
   right back to --color-accent-hover by this line — the knob would be dead on every hero
   primary. Same reason --btn-bg is in the rest chain above. */
.hero .btn:not(.btn--outline):not(.btn--ghost):not(.btn--secondary):hover {
  background-color: var(--hero-button-hover-bg, var(--hero-accent-hover, var(--btn-hover-bg, var(--color-accent-hover))));
  /* --hero-button-hover-border is the positional twin of --hero-button-border on the
     rest rule above (issue 584): the rest chain with every knob swapped for its hover
     equivalent, so no authoring configuration can make the ring flip across the pointer
     transition. Head of chain, so byte-identical unset. */
  border-color: var(--hero-button-hover-border, var(--hero-accent-hover, var(--btn-hover-border-color, var(--hero-button-hover-bg, var(--btn-hover-bg, var(--color-accent-hover))))));
}
/* Ink + elevation slots for the hero primary button (issue 514, the --cta-button-color /
   --cta-button-shadow idiom scoped to .cta__button). Kept at [0,4,0] — BELOW the premium
   `main .btn:not(...)` winner [0,4,1] — so the premium cascade stays the visible winner
   and the hover ink/elevation handoff is untouched; this rule only WIRES the slots inside
   the hero block for the slot-contract keystone. Default `none` shadow: the premium winner
   supplies the bevel via its own var(--hero-button-shadow, <bevel>) fallback, so an unset
   button is byte-identical; --hero-button-shadow: none flattens rest + hover. NOTE: this
   selector ALSO matches a cta2 authored as the filled `primary` variant (hero.php renders it
   as `hero__cta hero__cta--secondary btn`, with no variant class) — that match was the #514
   leak. The cta2 isolation rule below (issue 526) resets these slots on the secondary CTA,
   which is what keeps this rule effectively primary-only. */
.hero__cta:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  color: var(--hero-button-color, var(--btn-text, var(--color-bg)));
  box-shadow: var(--hero-button-shadow, none);
}
/* Outline (secondary) hero CTA: foreground tracks --hero-heading-color, which is defined
   to contrast --hero-bg, so the ghost button stays readable on any hero palette
   (the accent is not guaranteed to contrast the hero background). On hover it
   fills with the accent and flips text to the hero background color. */
.hero .btn--outline {
  color: var(--hero-heading-color, var(--color-text));
  border-color: var(--hero-heading-color, var(--color-text));
}
.hero .btn--outline:hover {
  background-color: var(--hero-accent, var(--color-accent));
  border-color: var(--hero-accent, var(--color-accent));
  color: var(--hero-bg, var(--color-bg));
}

/* Dark-band routing for the hero's cover band (issue 535, the #474 mechanism applied to
   the PRIMARY button). `.hero--cover` lays the same --overlay-bg scrim over an ARBITRARY
   image as the section/cta/stats bg-image bands, so a transparent-fill button paints its
   ink and ring straight onto that scrim. Two separate defaults failed there:
     - outline inherited `.hero .btn--outline`'s var(--hero-heading-color, var(--color-text)) —
       near-black #101828 on the scrim, ~3.6:1 against the worst-case overlay-over-WHITE
       composite and far worse over a dark photo. The older `.hero--cover .btn--outline`
       rule that was meant to handle this never won (identical [0,2,0], earlier in source);
       these rules sit AFTER `.hero .btn--outline` so source order makes them the winner.
     - ghost fell all the way to the shared `.btn--ghost` var(--color-accent) at 1.17:1.
   Both now fall back to --color-accent-on-overlay (4.59:1, base.css), the same role
   .hero--cover .hero__title-accent already uses (#463), so no new colour is invented.
   The per-instance slot still wins: --hero-heading-color is the surface `.hero .btn--outline`
   already reads, and it also drives .hero--cover .hero__title, so an author who sets it
   has already chosen a colour that reads on their own scrim. Deliberately kept on
   --hero-heading-color rather than --hero-button-color: that slot is documented in schema.json as
   the FILLED primary's ink, and widening it to the transparent variants would change a
   published slot's meaning. Scoped to `.hero--cover .btn--*` (not `.hero__cta`) so an
   outline button authored inside the `proof` HTML surface is covered too; the second CTA
   keeps its own [0,4,0] rules and is handled by the cover cta2 block below.
   This is a REST-state fix only: on hover both variants paint their own contrasting fill,
   so the ink leaves the scrim and needs no role token. Outline's hover rule already
   outranks this one; ghost's ties with it and needs the explicit restoration below. */
.hero--cover .btn--outline {
  color: var(--hero-heading-color, var(--color-accent-on-overlay));
  border-color: var(--hero-heading-color, var(--color-accent-on-overlay));
}
.hero--cover .btn--ghost {
  color: var(--hero-heading-color, var(--color-accent-on-overlay));
}
/* HOVER restoration for the cover ghost (issue 535, the same leak the cta rules below
   document). `.hero--cover .btn--ghost` [0,2,0] ties with the shared `.btn--ghost:hover`
   [0,2,0] and follows it, so without this the on-overlay ink survived onto a hover that
   fills with the near-white --color-surface — near-invisible. The outline twin needs no
   restoration: `.hero .btn--outline:hover` is [0,3,0] and already outranks it. Value
   copied verbatim from `.btn--ghost:hover`. */
.hero--cover .btn--ghost:hover {
  color: var(--color-accent);
}

/* Separation ring for the FILLED button on the cover band (issue 535, defect 2's
   class-triggerable half; the .cta--has-bg-image twin lives in the cta block and carries
   the fuller rationale). The premium gradient fill measures well under 2:1 against the
   worst-case scrim composite, so the button's SHAPE disappears and only its label carries
   it; its border followed the fill, so it added nothing. Here the border stops following
   the fill and bottoms out at the on-overlay role (4.59:1, base.css) — the "border falling
   back to a role token distinct from the fill" the recorded direction names.
   --hero-accent and the PER-INSTANCE fill link --hero-button-bg stay ahead of the role, so a
   cover hero an author deliberately flattened to a brand colour keeps its MATCHING ring
   rather than gaining a near-white one. That is the scope #535's promise was written for.
   BOTH GLOBAL tiers are deliberately ABSENT from this chain: the ring knob --btn-border-color
   (issue 564) and the fill knob --btn-bg (issue 565). On a cover band the terminal is not a
   bare default, it is a measured 4.59:1 separation role; leaving either global knob above it
   let a site-wide retheme silently defeat the one thing this rule exists to guarantee. The
   fill knob reached it by the border-follows-fill link: a site setting only --btn-bg (a
   plausible button retheme aimed at no band in particular) repainted every unauthored ring on
   every cover hero to that colour, unmeasured against the scrim — the #565 defect, the same
   shape as #564's, one variable along. --color-accent-on-overlay is declared at :root
   (base.css), so it is always set — parking either knob BELOW it would have been provably
   dead code rather than a demotion, which is why both links are removed outright instead of
   reordered. A site that genuinely wants its own ring on this band still has the per-instance
   slots above, which is the documented escape hatch.
   What changes, stated as resolved values rather than CSS bytes: only configurations that
   author a GLOBAL knob and render a cover band. The unset render is computed-value-identical,
   and so is every per-instance-authored render — the two knobs these rules stop honouring are
   precisely the two broad ones. (Unlike the cta twins, no ORDER moved in this chain — the hero
   already ranked --hero-accent above its fill link, so #564 and #565 each only deleted a link.)
   The 4.59:1 figure is the UNAUTHORED default, not an invariant: --hero-accent and the
   per-instance fill still sit above the role and can put an author's colour on this ring,
   which is how #535 shipped it. What changed is that a knob nobody aimed at THIS band cannot.
   [0,5,0], the SAME as `.hero .btn:not(...)` above, so it wins only by following it in
   source order (pinned in css-lint) — not by outranking it. `.hero__cta` is used rather
   than the bare `.btn` the two rules above use so the ring stays on the hero's own CTA
   buttons instead of any `.btn` an author writes into the `proof` surface. The :hover twin
   is [0,6,0] to beat `.hero .btn:not(...):hover`, whose border also follows the fill.
   There is no `theme: inverted` hero, so the Q2 not-ringed case does not arise here. */
.hero--cover .hero__cta:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  /* --hero-button-border leads (issue 584), exactly as --cta-button-border leads the cta
     primary's overlay ring below. This rule is the LIVE border winner on a cover hero, so a
     ring slot absent here would be dead on the one band where a per-instance ring matters
     most — while the schema still promised it applied. It sits ABOVE the measured
     --color-accent-on-overlay role for the same reason the band accent and the per-instance
     fill do, and #564/#565 stated that scope explicitly: every PER-INSTANCE link above the
     role may still put an author's colour on this ring; only a GLOBAL knob nobody aimed at
     this band may not. Head of chain, so byte-identical unset. */
  border-color: var(--hero-button-border, var(--hero-accent, var(--hero-button-bg, var(--color-accent-on-overlay))));
}
.hero--cover .hero__cta:not(.btn--outline):not(.btn--ghost):not(.btn--secondary):hover {
  /* The rest twin's chain with every knob swapped for its hover equivalent, so the two are
     positional twins and the ring cannot change role on pointer-enter. --btn-hover-border-color
     (issue 564) and --btn-hover-bg (issue 565) are absent for the same reason their resting
     counterparts are: a GLOBAL knob must not defeat the measured on-overlay separation role.
     Dropping either from ONE state only would have produced exactly the rest->hover flip the
     #543 twins exist to prevent, so each pair of halves moved together. */
  border-color: var(--hero-button-hover-border, var(--hero-accent-hover, var(--hero-button-hover-bg, var(--color-accent-on-overlay))));
}

/* Second CTA button (cta2): per-instance bg/border/color override, independent
   of the primary button (issue 111). Targeted via the dedicated .hero__cta--secondary
   class (hero.php) rather than a positional :nth-child selector — this
   codebase's CSS lint guard forbids nth-child/nth-of-type entirely, since a
   position-based selector would silently reattach to the wrong element if
   composition content were ever reordered. button_text/button2_text are fixed,
   named props (not a reorderable array), so a dedicated class is both safe
   and simpler than the positional alternative. Applies regardless of which
   variant cta2 renders as — the primary button could independently also be
   set to any of the 4 variants via button_variant, and this must not leak onto
   it. One rule per variant, each falling back to that variant's own existing
   default, so an unset override renders byte-identically to today. */
.hero .hero__cta-group .hero__cta--secondary:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  /* Global button surface routed through cta2's OWN chains (issue 554). --btn-bg takes the
     position it holds in the hero PRIMARY's fill chain above — below the per-instance slots,
     ahead of the literal — so a site-wide retheme paints BOTH hero buttons. Before this the
     link was missing here and nowhere else: --btn-bg reached every other filled surface on
     the theme (hero primary, cta primary, cta button2, section panel CTA, bare .btn) and this
     one button alone stayed --color-accent. Worse than merely unthemed: --btn-bg ALREADY
     reached cta2's background-IMAGE through the shared premium rule (via the #526 isolation
     re-pointing), clearing the gradient while this rule kept painting the accent, so a
     rethemed cta2 rendered a FLAT ACCENT pill next to a brand-coloured primary. Rendered,
     both states, 1280 and 375. Unset, the chain still bottoms out at --color-accent. */
  background-color: var(--hero-button2-bg, var(--hero-accent, var(--btn-bg, var(--color-accent))));
  /* Border FOLLOWS the fill when its own knobs are unset (issue 526, the #514 idiom the
     primary already uses above): --hero-button2-border wins, then --hero-accent, then the
     global ring knob, then --hero-button2-bg — so a filled cta2 recolored with the fill slot
     alone keeps a matching ring instead of a --color-accent one around a brand-colored
     button. Unset, the chain still bottoms out at --color-accent, byte-identical.
     --btn-border-color / --btn-bg sit at exactly the positions they hold in the hero
     PRIMARY's rest ring (`.hero .btn:not(...)` above): the global ring knob AFTER
     --hero-accent, --btn-bg at the tail of the border-follows-fill link (issue 554).
     ORDERING IS LOAD-BEARING: the band accent must stay ahead of --btn-border-color, or a
     site setting both --hero-accent and --btn-border-color SPLITS the very pair this issue
     exists to join (the primary would ring itself with --hero-accent while cta2 rang itself
     with the global knob). This used to be the hero's order ALONE — the cta family ranked
     --btn-border-color ahead of its own accent, and each component was merely
     self-consistent. Issue 564 moved the cta family onto this order too, because that split
     was letting a site-wide ring retheme defeat an authored --cta-accent and, on photo
     bands, the measured on-overlay separation role. Both components now rank the band accent
     first, so the order is consistent WITHIN each pair and ACROSS the two families. */
  border-color: var(--hero-button2-border, var(--hero-accent, var(--btn-border-color, var(--hero-button2-bg, var(--btn-bg, var(--color-accent))))));
  color: var(--hero-button2-color, var(--btn-text, var(--color-bg)));
}
.hero .hero__cta-group .hero__cta--secondary:not(.btn--outline):not(.btn--ghost):not(.btn--secondary):hover {
  /* Global hover tier (issue 554, closing the gap #539 deliberately mirrored onto hover
     rather than half-fixing). --btn-hover-bg takes the position --btn-bg holds in the rest
     chain above, matching the hero primary's hover fill exactly. #539 left this out ON
     PURPOSE while the rest chain lacked its twin — wiring only hover would have made a site
     setting both knobs render --color-accent at rest and FLASH to the operator's colour on
     hover. Both halves land together here, so rest and hover stay symmetric. */
  background-color: var(--hero-button2-hover-bg, var(--hero-accent-hover, var(--btn-hover-bg, var(--color-accent-hover))));
  /* Border follows the hover fill, but only from the LAST fallback position (issue 538,
     the issue's Option 3 — the recorded decision). #530 left the fill out of this chain
     entirely: unlike the hover FILL, the hover BORDER was never masked by the premium
     gradient, so it has always painted, and inserting the fill AHEAD of --hero-accent-hover
     would have repainted an explicitly authored accent ring on compositions that already
     ship --hero-button2-hover-bg (shipped since #111, live in production).
     Inserting it AFTER --hero-accent-hover changes exactly one case: --hero-button2-hover-bg
     SET and both --hero-button2-hover-border and --hero-accent-hover unset — the common
     "I only recolored the hover fill" author, who used to get a --color-accent-hover ring
     around a brand-colored pill (the rest-state mismatch #526 removed). Every authored
     value still wins: --hero-button2-hover-border first, then --hero-accent-hover, and with
     the fill slot unset the chain still bottoms out at --color-accent-hover, so an unset
     cta2 renders byte-identically. Same shape and same order as the hero PRIMARY's hover
     ring ABOVE (`.hero .btn:not(...):hover`, and its .hero--cover twin), which already
     routes its own fill slot from this position — do not follow `main .btn:not(...):hover`
     further down the file as the precedent; that rule routes no fill slot at all.
     OVERLAY BANDS, stated plainly because this chain is the only hover-ring winner there.
     #535 gave the filled PRIMARY a separation ring on `.hero--cover` / bg-image bands, whose
     whole point is that a ring FOLLOWING the fill is worthless over a scrim, so it bottoms out
     at --color-accent-on-overlay instead. That ring never reached the SECOND button: its twin
     at `.hero--cover .hero__cta:not(...):hover` is [0,6,0] and this rule is [0,7,0]. So on a
     cover band a fill-only cta2 used to hover to a --color-accent-hover ring and now hovers
     to a ring matching its fill. Neither clears WCAG 1.4.11 against the scrim (--color-accent
     measures ~1.17:1 there), and the REST ring has followed the fill since #526, so this makes
     hover consistent with rest rather than removing a compliant edge. It does remove the last
     incidental fill-vs-ring edge, which is why #543 shipped its cta2 separation ring as BOTH a
     rest and a hover twin (`.hero--cover .hero__cta-group .hero__cta--secondary:not(...)` and
     its :hover, below) — a rest-only ring would have dissolved again the moment the pointer
     landed. Each of those two rules matches its OWN base twin's specificity ([0,6,0] rest,
     [0,7,0] hover) and follows it in source order, so THIS chain still decides every
     non-cover band.
     NOT mirrored onto the outline/ghost/secondary cta2 hover rules, for two different
     reasons. Ghost is structurally different: its border bottoms out at `transparent`, so
     following the fill would ADD a ring rather than match one. Outline and secondary do
     paint a real ring, and a fill-only recolor does leave it stale there — but those two
     variants do not follow the fill in EITHER state (their REST borders don't either), so
     they are left whole rather than made half-consistent. On all three, set
     --hero-button2-hover-border explicitly for a ring that tracks a recolored hover fill.
     GLOBAL HOVER TIER (issue 554): --btn-hover-border-color sits after --hero-accent-hover
     and --btn-hover-bg at the tail of the border-follows-fill link — the same two positions
     their resting twins hold in the rest chain above, and the same two the hero PRIMARY's
     hover ring uses. #538's Option-3 order (accent-hover AHEAD of the hover fill) is
     preserved verbatim between them, so the one case #538 bought is untouched and the #548
     accent-above-own-fill property still holds. */
  border-color: var(--hero-button2-hover-border, var(--hero-accent-hover, var(--btn-hover-border-color, var(--hero-button2-hover-bg, var(--btn-hover-bg, var(--color-accent-hover))))));
  color: var(--hero-button2-hover-color, var(--btn-text, var(--color-bg)));
}

.hero .hero__cta-group .hero__cta--secondary.btn--outline {
  background-color: var(--hero-button2-bg, transparent);
  color: var(--hero-button2-color, var(--hero-heading-color, var(--color-text)));
  border-color: var(--hero-button2-border, var(--hero-heading-color, var(--color-text)));
}
.hero .hero__cta-group .hero__cta--secondary.btn--outline:hover {
  background-color: var(--hero-button2-hover-bg, var(--hero-accent, var(--color-accent)));
  border-color: var(--hero-button2-hover-border, var(--hero-accent, var(--color-accent)));
  color: var(--hero-button2-hover-color, var(--hero-bg, var(--color-bg)));
}

.hero .hero__cta-group .hero__cta--secondary.btn--secondary {
  background-color: var(--hero-button2-bg, var(--color-surface));
  color: var(--hero-button2-color, var(--color-text));
  border-color: var(--hero-button2-border, var(--color-border));
}
.hero .hero__cta-group .hero__cta--secondary.btn--secondary:hover {
  background-color: var(--hero-button2-hover-bg, var(--color-border));
  border-color: var(--hero-button2-hover-border, var(--color-border));
  color: var(--hero-button2-hover-color, var(--color-text));
}

.hero .hero__cta-group .hero__cta--secondary.btn--ghost {
  background-color: var(--hero-button2-bg, transparent);
  color: var(--hero-button2-color, var(--color-accent));
  border-color: var(--hero-button2-border, transparent);
}
.hero .hero__cta-group .hero__cta--secondary.btn--ghost:hover {
  background-color: var(--hero-button2-hover-bg, var(--color-surface));
  border-color: var(--hero-button2-hover-border, transparent);
  color: var(--hero-button2-hover-color, var(--color-accent));
}

/* Dark-band routing for the SECOND CTA's transparent-fill variants on the cover band
   (issue 535, Q3). #474 fixed exactly this defect for the cta component's button2 but was
   scoped to that component; hero's cta2 has the same trigger and was left behind.
   `button2_variant` DEFAULTS to `outline` (hero.php), so simply setting button2_text on a cover
   hero shipped a sub-AA control without the author ever choosing a variant: the rules
   above resolve var(--hero-button2-color, var(--hero-heading-color, var(--color-text))) to near-black
   #101828 on the scrim (~3.6:1 measured), and ghost fell to var(--color-accent) at 1.17:1.
   Both defaults now route through --color-accent-on-overlay, matching the primary above
   and .hero--cover .hero__title-accent (#463). The --hero-button2-color / --hero-button2-border
   slots still win, so the #61/#86 dark-surface-slot contract holds.
   These carry the SAME [0,4,0] specificity as the base cta2 variant rules they override,
   so they depend on following them in source order — do not move them above, and do not
   add a competing `.hero--cover` cta2 rule for THESE variants further down the file. (The
   #543 filled-cta2 ring below is not a competitor: it `:not()`s outline/ghost/secondary, so
   its match set is disjoint from these two rules'.) Hover needs no
   restoration here (unlike the primary and the cta component's): the cta2 `:hover` rules
   are [0,5,0] and already outrank these, so the routed ink cannot leak into a state where
   the button paints its own fill. A cta2 authored as the FILLED `primary` variant is not
   handled here — it keeps its own [0,6,0]/[0,7,0] rules and gets the separation ring from
   the #543 twin pair below, which is where a change to the filled cta2's border belongs. */
.hero--cover .hero__cta-group .hero__cta--secondary.btn--outline {
  color: var(--hero-button2-color, var(--color-accent-on-overlay));
  border-color: var(--hero-button2-border, var(--color-accent-on-overlay));
}
.hero--cover .hero__cta-group .hero__cta--secondary.btn--ghost {
  color: var(--hero-button2-color, var(--color-accent-on-overlay));
}

/* Separation ring for the FILLED cta2 on the cover band (issue 543), the twin of the
   cta component's button2 ring — that rule carries the fuller rationale. #535 gave this
   ring to the filled PRIMARY only (`.hero--cover .hero__cta:not(...)` above, [0,5,0]),
   which the cta2 rules outrank at [0,6,0]/[0,7,0], so a filled PAIR on a cover hero
   rendered one ringed button beside one dissolving into the scrim.
   Note the block right above deliberately left the filled cta2 alone ("an explicit author
   choice, not a default ... out of scope"). That scoping is what this issue reverses, with
   the gate's approval: the defect is the PAIR's asymmetry, not the variant's default.
   Both chains are the base cta2 chains with the terminal --color-accent /
   --color-accent-hover swapped for --color-accent-on-overlay (4.59:1) AND the global tier
   removed entirely — the ring knob by issue 564, the fill knob by issue 565.
   --hero-button2-border, --hero-accent and the PER-INSTANCE fill still win at rest;
   --hero-button2-hover-border, --hero-accent-hover and then the per-instance hover fill still
   win on hover.
   The contract used to be "the base chain VERBATIM, only the terminal changes" — #554 added
   the global tier here on exactly that reasoning, so a cover hero would not be the one band a
   site-wide retheme failed to reach. #564 and #565 together narrow it to: base chain verbatim
   MINUS the global tier. On this band the terminal is a measured 4.59:1 separation role rather
   than an ordinary default, and a site-wide retheme sitting above it defeated the guarantee the
   rule exists to make — the ring knob directly, the fill knob through the border-follows-fill
   link. Both are REMOVED rather than demoted because --color-accent-on-overlay is declared at
   :root (base.css) and therefore always set, so any link below it is dead code. Per-instance
   slots stay above everything as the escape hatch, which is why #535's matching-ring promise
   survives here for an author who flattens THIS button with --hero-button2-bg.
   Both halves move together in each case: dropping a knob from hover only would re-open the
   rest->hover flip these twins exist to prevent.
   [0,6,0] rest / [0,7,0] hover — equal to the base cta2 rules, so these win by SOURCE
   ORDER only and must stay below them (pinned in css-lint). The hover twin is not
   optional: #538 made the base hover ring follow the hover fill, so a rest-only ring
   would dissolve again the moment the pointer landed — the same WCAG 1.4.11 hole
   #535's rendered inspection caught on the primary. */
.hero--cover .hero__cta-group .hero__cta--secondary:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  border-color: var(--hero-button2-border, var(--hero-accent, var(--hero-button2-bg, var(--color-accent-on-overlay))));
}
.hero--cover .hero__cta-group .hero__cta--secondary:not(.btn--outline):not(.btn--ghost):not(.btn--secondary):hover {
  border-color: var(--hero-button2-hover-border, var(--hero-accent-hover, var(--hero-button2-hover-bg, var(--color-accent-on-overlay))));
}

/* cta2 slot isolation + premium fill routing (issue 526). Style slots are emitted as
   inline custom properties on the .hero ROOT, so #514's primary-button slots INHERIT
   down to the second CTA as well; when cta2 is authored as a filled `primary` variant it
   also matches the shared premium `main .btn:not(...)` winner, and the PRIMARY's fill and
   elevation repainted it (the #514 leak). A declaration ON the cta2 element beats the
   inherited value regardless of source order, so this one rule fixes both halves:
     - `--hero-button-bg: var(--hero-button2-bg)` — a var() that fails to substitute makes the
       custom property GUARANTEED-INVALID, so with --hero-button2-bg unset every downstream
       var(--hero-button-bg, <fallback>) takes its fallback (the premium chain's own
       --cta-button-bg -> --btn-bg -> gradient literal): unset stays byte-identical and the
       leak is killed. With --hero-button2-bg SET, the flat color resolves the premium
       `background:` SHORTHAND to `background: <color>`, which resets background-image to
       none and CLEARS the gradient that used to MASK the slot (the pre-existing half of
       the bug) — the same mechanism #514 used for the primary: slot leads, then the
       btn-token, then the literal.
     - `--hero-button-color` / `--hero-button-shadow: initial` — `initial` IS the
       guaranteed-invalid value for a custom property, so cta2 falls back to its own ink
       rule above and to the premium bevel instead of the primary's ink/elevation.
       Consequence, accepted with the fix: flattening the PRIMARY with
       --hero-button-shadow: none no longer flattens cta2 with it (that coupling WAS the
       leak). cta2 has no elevation slot of its own today; a --hero-cta2-shadow slot is
       the follow-up if a flat pair is wanted.
   Applied to EVERY cta2 variant on purpose: outline/ghost/secondary never consume
   --hero-button-* (all three consumers are :not(.btn--outline):not(.btn--ghost):not(
   .btn--secondary) rules), so those variants stay byte-identical while the isolation
   holds even if a future rule wires the slots more widely.
     - `--hero-button-hover-bg: var(--hero-button2-hover-bg)` (issue 530) — the same
       guaranteed-invalid re-pointing applied to the HOVER surface, which closes the last
       half of this defect class. Before #530 the premium hover rule read only
       --cta-button-hover-bg, so a filled cta2 flashed back to the theme gradient on hover
       (the accepted trait #514/#526 shipped) AND the primary's hover fill leaked onto it.
       One declaration fixes both: cta2's own hover fill now resolves the premium
       `background:` SHORTHAND to a flat color (clearing the gradient background-image), and
       with --hero-button2-hover-bg unset the chain falls through to the premium literal rather
       than to the primary's --hero-button-hover-bg. Rest and hover are now isolated
       symmetrically. */
.hero .hero__cta-group .hero__cta--secondary {
  --hero-button-bg: var(--hero-button2-bg);
  --hero-button-hover-bg: var(--hero-button2-hover-bg);
  --hero-button-color: initial;
  --hero-button-shadow: initial;
}

/* Split ratio: grid column ratios at desktop */
@media (min-width: 1024px) {
  .hero--split[data-pp-split-ratio="60-40"] .hero__inner {
    grid-template-columns: 3fr 2fr;
  }
  .hero--split[data-pp-split-ratio="40-60"] .hero__inner {
    grid-template-columns: 2fr 3fr;
  }
}

@media (max-width: 767px) {
  .hero__content {
    max-width: var(--hero-content-width, none);
  }

  .hero__surface {
    padding: var(--hero-surface-padding, var(--space-md));
  }
}

/* Vertical align: cover + split variants (desktop only) */
@media (min-width: 1024px) {
  .hero--cover[data-pp-vertical-align="top"] {
    align-items: flex-start;
  }

  .hero--cover[data-pp-vertical-align="bottom"] {
    align-items: flex-end;
  }

  .hero--split[data-pp-vertical-align="top"] .hero__inner {
    align-items: start;
  }
  .hero--split[data-pp-vertical-align="bottom"] .hero__inner {
    align-items: end;
  }

  /* stretch (#477): make the split media track the content column's height so
     one asset balances any headline length, instead of a fixed-aspect card
     that dwarfs a 4-5 line headline. align-items:stretch sizes BOTH grid
     tracks to the row height, which the taller (content) column defines; the
     image-wrap track then fills it and the image sizes to that box. Scoped to
     the exact "stretch" attribute value, so top/center/bottom are untouched.
     With height:100% here and the base width:100%, both dimensions are definite,
     so any --hero-image-aspect-ratio is ignored by the CSS sizing algorithm and
     the image fills the box (no aspect-ratio re-declaration needed, which also
     keeps the --hero-image-aspect-ratio slot un-bypassed). object-fit:cover is
     already the .hero__image default — the component's established crop idiom on
     the bg-image and aspect-ratio paths. */
  .hero--split[data-pp-vertical-align="stretch"] .hero__inner {
    align-items: stretch;
  }
  .hero--split[data-pp-vertical-align="stretch"] .hero__image-wrap {
    height: 100%;
  }
  .hero--split[data-pp-vertical-align="stretch"] .hero__image {
    height: 100%;
  }
}

/* Proof slot: trust signals after CTA.
   justify-content is the row's packing, and it is NOT inherited from the layout: the
   left/split heroes want it at flex-start, and leaving it at the flexbox initial value
   (normal -> flex-start) got them that for free — which is exactly why the centered hero
   silently shipped a left-packed proof line. The variant rules above win on specificity with
   or without this declaration; it is here so the row STATES its packing, and a reader does
   not have to recall the flexbox initial value to know what a left hero does (issue 338). */
.hero__proof {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
  align-items: center;
  justify-content: flex-start;
  margin-top: var(--space-md);
  font-size: 0.875rem;
  /* Route the proof color through a per-instance slot so a dark hero can lift it
     off the dark background; unset resolves to --color-muted, byte-identical to
     the prior hardcoded value (issue 296). */
  color: var(--hero-proof-color, var(--color-muted));
}

/* ==========================================================================
   COMPONENT: section
   Generic text + optional image section.
   ========================================================================== */

.section {
  padding-top: var(--section-padding-top, var(--pp-band-padding));
  padding-bottom: var(--section-padding-bottom, var(--pp-band-padding));
  background: var(--section-bg, transparent);
  border-top: var(--section-border-width, 0) solid var(--section-border-color, transparent);
  border-bottom: var(--section-border-width, 0) solid var(--section-border-color, transparent);
  border-radius: var(--section-radius, 0);
  box-shadow: var(--section-shadow, none);
}

/* .section__body is the OUTER wrapper of .section__content (section.php). The inner
   .section__content already routes max-width through --section-body-measure, but this
   outer 40rem literal capped it, so the slot silently no-opped (issue 302). Route the
   outer cap through the same slot with 40rem as the fallback (unset stays 40rem, the
   narrower of the two, so output is unchanged). The remaining headings keep 40rem. */
.section__body {
  max-width: var(--section-body-measure, 40rem);
}

/* The six-selector heading cap that used to sit here is GONE (issue 578). It capped
   .table-section__heading, .faq__heading, .logos__heading, .embed__heading, .cta__title
   and .stats__heading from ONE rule reading --cta-heading-measure — a CTA slot on five
   foreign elements. The five non-cta components could neither SET it (the write path
   rejects a foreign slot as invalid_style_slot) nor have it RESOLVE (inline slot
   properties land on the owning component's root, never on a sibling band). Because the
   rule lived in the SECTION block it was also invisible to every per-component audit.
   Each of the six now declares its own cap, reading its own slot, inside its own
   COMPONENT block, defaulting to var(--measure-heading) (base.css, 40rem — the value all
   six render today, so unset output is unchanged). .section__title was NEVER one of the
   six and gains no cap; hero was capped separately and is exempt from --measure-heading. */

.section__header--center {
  text-align: center;
}

.section__eyebrow {
  display: inline-block;
  padding: 0.35rem 0.85rem;
  margin-bottom: var(--space-sm);
  border: var(--section-eyebrow-border-width, 0) solid var(--section-eyebrow-border-color, transparent);
  border-radius: var(--section-eyebrow-radius, 3px);
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: var(--section-eyebrow-text-transform, uppercase);
  color: var(--section-eyebrow-color, var(--color-text));
  background: var(--section-eyebrow-bg, var(--color-surface-accent));
}

.section__title {
  font-size: var(--section-heading-size, var(--pp-band-heading-size));
  color: var(--section-heading-color, var(--pp-section-title-theme-color, var(--color-text)));
  /* Section's heading measure (issue 578). Default `none`, and that is deliberate: the
     section title has never carried a cap, section is the most-used band in the product,
     and giving it the 40rem the other eight route would change the rendered line length
     of every stored section heading. Section is therefore EXEMPT from --measure-heading
     alongside hero. `none` is max-width's initial value, so declaring it here is
     byte-identical — it exists to give the operator the knob the other bands have. */
  max-width: var(--section-heading-measure, none);
  /* Top-side header rhythm (title -> subheading gap). issue 336 made the subheading's
     BOTTOM margin authorable; this routes the title's own bottom margin through a
     slot so the whole header rhythm is slot-driven (issue 343). No header-scoping
     needed: the title is a heading, not the header's last child, so base.css's
     `p:last-child` reset never reached it. Today's literal stays the fallback. */
  margin-bottom: var(--section-heading-margin-bottom, var(--space-md));
}

.section__title-accent {
  color: var(--section-heading-accent-color, var(--color-accent));
}

.section__subheading {
  color: var(--section-subheading-color, var(--color-muted));
  margin-top: 0;
}
/* base.css `p:last-child { margin-bottom: 0 }` (0,1,1) outranks a bare
   `.section__subheading` (0,1,0), and the subheading is always the header's last
   child, so the component's declared bottom rhythm never reached the page (issue 336).
   Own the spacing at header scope (0,2,0): it wins the cascade without weakening
   the global prose reset, and does not depend on the subheading staying last. */
.section__header > .section__subheading {
  margin-bottom: var(--section-subheading-margin-bottom, var(--space-md));
}


.section__content {
  color: var(--section-body-color, var(--pp-section-text-theme-color, var(--color-text)));
  max-width: var(--section-body-measure, 42rem);
  /* Body type slots (issue 470): the section body text has no font-size/weight of
     its own here — the `main > .section .section__content` premium/mobile rules
     (further down this file) set it at every breakpoint, and those (0,2,1) rules
     outrank this base (0,1,0) at ALL viewport widths. `inherit` is therefore
     byte-identical to declaring nothing (unchanged output, incl. the 767-768px
     sub-pixel gap between the two media queries), while giving the slot-contract
     keystone an in-block consumption. Mirrors .cta__body's base (var(--cta-body-size,
     inherit)). Setting either slot routes through the premium/mobile rules, which
     read the SAME slot with today's literal as the fallback. */
  font-size: var(--section-body-size, inherit);
  font-weight: var(--section-body-weight, inherit);
  /* Body list-marker colour (issue 339): read by the shared marker rules only
     when the operator opts into a non-disc body_marker; inert otherwise. */
  --pp-list-marker-color: var(--section-body-marker-color, var(--color-accent));
}

/* Restore list markers + indent for lists authored in section.body rich text.
   The global reset (base.css *{padding:0} + ul,ol{list-style:none}) strips both,
   so any <ul>/<ol> in section.body rendered as flush-left, unmarked lines,
   indistinguishable from stacked paragraphs. Re-declare markers, indent, and
   rhythm scoped to .section__content (where wp_kses_post($body) lands) — the
   .section__content ul selector (0,1,1) outweighs the base ul,ol reset (0,0,1).
   No style slot governs list-style/padding here, so no slot routing. issue 295 */
.section__content ul,
.section__content ol {
  padding-left: var(--space-lg);
  margin: 0 0 var(--space-md);
}

/* The disc here is intentionally beaten, on SOURCE ORDER at equal specificity
   (0,1,1), by the shared issue-339 marker rules (.section__content--marker-* > ul)
   further down this file. Do NOT raise this rule's specificity or move it below
   that block, or non-disc body markers stop painting (the tests/e2e/style-render
   issue-339 pins guard this). */
.section__content ul {
  list-style: disc;
}

.section__content ol {
  list-style: decimal;
}

.section__content li {
  margin-bottom: var(--space-xs);
}

.section__content li:last-child {
  margin-bottom: 0;
}

/* Inline-items row (issue 475): a band of short items with a CSS-generated
   separator between them (the "trust strip" pattern — e.g. a slim post-hero band
   of short items with a colored middot between each). It sits in the section body
   scope, right after .section__content, and reads the SAME #470 body type slots so
   the items inherit the section body size/weight (the brand band's 15px/600).
   Unset (no body_items) emits no markup, so output stays byte-identical.

   Centering + wrapping (issue 489): the DEFAULT row is a left-packed
   (justify-content: flex-start) flex row that shrink-wraps its content (width:
   fit-content) and is centered as a BLOCK via auto side margins. A single-line row
   therefore reads centered exactly like before; a row that WRAPS fills the available
   width and its lines pack from the left. Left-packing is what lets the
   hanging-separator clip below hide line-leading separators — an edge-clip cannot
   hide a line-leading separator on a per-line-CENTERED row (the separator is not at
   the box edge). overflow: hidden is the clip surface; the separator ::before is
   pulled left of this box on every line-leading item and disappears.

   Per-line alignment (issue 510): justify-content reads the
   --section-inline-items-align style slot (start | center). Unset resolves to
   flex-start, so the default row above is byte-identical to before. 'center'
   (with the --center modifier the renderer derives, below) switches to per-line
   centering + a TRAILING separator; a centered line ends mid-box, so its trailing
   middot cannot be edge-clipped and stays VISIBLE as a subtle line-end dot — the
   documented trade of choosing center. */
.section__inline-items {
  list-style: none;
  margin: var(--space-md) auto 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: var(--section-inline-items-align, flex-start);
  width: fit-content;
  max-width: 100%;
  column-gap: var(--space-md);
  row-gap: var(--space-xs);
  overflow: hidden;
  font-size: var(--section-body-size, inherit);
  font-weight: var(--section-body-weight, inherit);
}

/* Body-less strip flush-top (issue 488): the row's top margin is a separation
   from preceding body copy, so on a body_items-only band (no body copy above it)
   it zeroes and the band's symmetric padding centres the row. The renderer adds
   this modifier only when no body copy precedes the row; a band WITH body copy
   keeps the base var(--space-md) top margin, byte-identical to before. Same
   specificity as the base rule (0,1,0) and declared immediately after it, so
   source order wins; the doubled class keeps specificity (0,2,0) above any future
   equal-specificity rule that might target the base row, and only margin-top is
   overridden (the auto side margins that centre the fit-content row as a block,
   and the 0 bottom, are untouched). */
.section__inline-items.section__inline-items--flush-top {
  margin-top: 0;
}

/* Hanging-separator clip (issue 489): each item is pulled left by exactly the
   separator's occupied width (the ::before's fixed box + its right margin =
   --space-sm + --space-xs). On a line-leading item — the first item of the row AND
   the first item of every wrapped line — the ::before then sits entirely left of
   the ul's content box and is clipped by overflow: hidden, so no middot ever dangles
   at the start of a line. The item text lands exactly at the content edge. On a
   mid-line item the ::before sits between two items and is fully visible, giving the
   symmetric "A · B" separator. The negative margin lives on the ITEM, not the ul:
   pulling the ul via its own margin would move its clip region with it and clip
   nothing. */
.section__inline-item {
  margin: 0 0 0 calc(-1 * (var(--space-sm) + var(--space-xs)));
  /* Let a pathological single unbreakable label wrap instead of being clipped by
     the row's overflow: hidden (base.css only sets overflow-wrap on p/headings, and
     a flex item's default min-width: auto would otherwise refuse to shrink). */
  min-width: 0;
  overflow-wrap: break-word;
}

/* The separator is a CSS-generated middot before EVERY item (line-leading ones are
   clipped, see above), so it is never a content character: it can be slot-colored and
   stays out of the accessibility tree. The `/ ""` alternative-text empties its a11y
   name, and the <ul role="list"> keeps list semantics intact. The glyph is a FIXED
   (middot) stated default with no slot. Its canonical STATED REASON and reopening
   condition live on the authoring surface an agent actually reads — the
   --section-separator-color description in components/section/schema.json, and "Stated
   defaults" in components/section/README.md. Keep them there when editing; this comment
   is a pointer, not the record. It is a fixed-width
   inline-block box (--space-sm) so the
   left-pull above is an exact token value independent of the glyph's advance width.
   Color routes through --section-separator-color, defaulting to the muted role like
   sibling text; on the inverted band --color-muted is already remapped to the light
   on-inverted color (.pp-section--inverted below), so the default follows the sibling
   text there automatically — no separate inverted rule. The bg-image overlay default
   is remapped below (mirrors the #461/#463 links/markers). */
.section__inline-items li::before {
  content: "\00b7" / "";
  display: inline-block;
  width: var(--space-sm);
  text-align: center;
  margin-right: var(--space-xs);
  color: var(--section-separator-color, var(--color-muted));
}

/* Centered per-line alignment (issue 510): the --center modifier is derived by the
   renderer when --section-inline-items-align is 'center'. justify-content: center is
   already supplied by the slot on the base rule; this modifier carries only what a
   raw keyword cannot express — the switch from the leading, edge-clipped ::before
   separator to a TRAILING ::after one that survives centering. On a centered row the
   hanging-clip geometry does not apply, so the per-item left pull is zeroed (the
   ::before is suppressed anyway) and the separator is emitted as an ::after on every
   item except the last (:not(:last-child)). At a wrap point the trailing middot sits
   at a centered line's right edge — mid-box, not the box edge — so overflow: hidden
   cannot clip it and it stays VISIBLE as a subtle line-end dot (the documented trade;
   there is no pure-CSS centered-and-artifact-free option). Line-leading items on
   wrapped lines carry NO separator, so a wrapped line never opens with a dangling
   middot. Same slot color routing as ::before, so --section-separator-color and the
   inverted/overlay defaults apply identically in both modes. */
.section__inline-items--center .section__inline-item {
  margin-left: 0;
}
.section__inline-items--center li::before {
  content: none;
}
.section__inline-items--center li:not(:last-child)::after {
  content: "\00b7" / "";
  display: inline-block;
  width: var(--space-sm);
  text-align: center;
  margin-left: var(--space-xs);
  color: var(--section-separator-color, var(--color-muted));
}

/* Image variants: side-by-side grid at md+ */
.section__grid {
  display: flex;
  flex-direction: column;
  gap: var(--space-lg);
}

@media (min-width: 768px) {
  .section__grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    align-items: center;
    gap: var(--space-xl);
  }
}

.section__image {
  width: 100%;
  height: auto;
  aspect-ratio: var(--section-image-aspect-ratio, auto);
  object-fit: cover;
  object-position: var(--section-image-position, center);
  border-radius: var(--section-image-radius, var(--radius));
}

/* text-panel layout: text column + styleable content panel (issue 104).
   Reuses .section__grid (two columns at >=768px, stacked text-then-panel on
   mobile) but top-aligns the columns instead of centering like the image
   variants. The panel box and text route through --section-panel-* slots. The
   panel deliberately uses its own classes (not .section__content/.section__title)
   so the desktop premium-typography cascade cannot clobber the panel text on a
   dark panel — --section-panel-text stays the authority. */
@media (min-width: 768px) {
  .section--text-panel .section__grid {
    align-items: start;
  }
}

.section__panel {
  padding: var(--section-panel-padding, var(--space-lg));
  background: var(--section-panel-bg, var(--color-surface));
  color: var(--section-panel-text, var(--color-text));
  /* Panel font (issue 334): inherit by default (unchanged), var(--font-mono) for
     a monospace spec/stat panel. Descendants inherit, so heading/body/list/rows
     all pick it up unless a nested rule sets its own family. */
  font-family: var(--section-panel-font, inherit);
  border: var(--section-panel-border-width, 0) solid var(--section-panel-border-color, transparent);
  border-radius: var(--section-panel-radius, var(--radius));
}

.section__panel-heading {
  color: var(--section-panel-text, var(--color-text));
  margin-bottom: var(--space-md);
}

.section__panel-body {
  margin-top: 0;
  margin-bottom: var(--space-md);
}

/* Restore list markers + indent stripped by the base reset (base.css
   ul,ol{list-style:none} + *{padding:0}), same approach as .section__content
   (issue 295). No style slot governs list-style/padding here. issue 104.
   This disc is intentionally beaten on SOURCE ORDER at equal specificity (0,1,0)
   by the shared issue-339 .pp-marker-list rules below when panel_items_marker opts
   in — do not raise its specificity or move it below that block. */
.section__panel-list {
  list-style: disc;
  padding-left: var(--space-lg);
  margin: 0 0 var(--space-md);
  /* Panel list-marker colour (issue 339): inert until panel_items_marker opts in. */
  --pp-list-marker-color: var(--section-panel-marker-color, var(--color-accent));
}

.section__panel-list li {
  margin-bottom: var(--space-xs);
}

.section__panel-list li:last-child {
  margin-bottom: 0;
}

/* Paired rows (issue 334): a { label, value } entry renders as a two-part row
   inside the same panel list, label left / value right AT >=768px (issue 568
   stacks the pair below that; see the media block after the value rule).
   A row is NOT a bullet, so it carries its own list-style:none and its glyph
   marker is suppressed below.
   Row text colour routes through the SAME --section-panel-text slot as the rest
   of the panel, which is item_eligible so a per-row style map can recolour one
   row (emphasise/de-emphasise) via cascade proximity on the inline row style. */
.section__panel-row {
  list-style: none;
  display: flex;
  justify-content: space-between;
  gap: var(--space-md);
  color: var(--section-panel-text, var(--color-text));
}

.section__panel-row-value {
  text-align: right;
}

/* Mobile stack (issue 568). Below 768px the two-column geometry starves the
   value: at 375 the row content box is 247px (343px viewport-minus-page-padding,
   less the panel's 2 x --space-lg, less the list's --space-lg indent) and the
   label takes its share off the front, leaving the value ~170px — a 5-12 word
   comparison value wrapped to FOUR right-aligned lines beside a one-word label.
   Stacking hands the value the full 247px (+45%) and one shared left edge with
   its label. This is a DEFAULT, not a knob: no responsive slot, no new prop, and
   >=768px is untouched.

   Five properties, all scoped to this breakpoint:
     1. flex-direction: column   label above value.
     2. value text-align: left   one shared left edge. The `right` on the rule
                                 above is the half of the defect that survives
                                 stacking, so it must be answered explicitly.
     3. gap: --space-xs (4px)    TIGHT intra-pair gap — the value belongs to the
                                 label directly above it.
     4. row + row: 16px          LOOSE inter-pair rhythm, so four pairs read as
                                 four facts rather than one eight-line block.
     5. pair type treatment      stacked, POSITION no longer distinguishes label
                                 from value (they share size, weight and colour),
                                 so a short label reads as a first line OF the
                                 value. A smaller, tracked label over a heavier
                                 value is what makes the pair read as label-then-
                                 fact. Accepted on the issue's mandatory 375px
                                 rendered check, not on this declaration.

   Property 4 is an ADJACENT-SIBLING margin-top, not a margin-bottom on the row.
   `.section__panel-list li` (0,1,1) already owns margin-bottom and its
   `:last-child` companion (0,1,2) zeroes it, so any margin-bottom answer has to
   out-specify BOTH and then re-implement the last-child zero — and a bare
   `.section__panel-row` (0,1,0) would silently lose outright. `+` (0,2,0) writes
   margin-top, which nothing else on a panel li sets. It fires on every row after
   the first, INCLUDING the last, but it only ever adds space ABOVE a row, so it
   can never leave a trailing gap below the last row (i.e. above a panel CTA),
   and it never fires between a bullet and a row.

   Known GROUPING consequence in a MIXED list (string bullets + paired rows, a
   shape the grammar actively encourages): a paired row that follows a plain
   bullet keeps the list's own 4px, which is now about the same optical distance
   as a value sits from its own label — so that bullet can read as a third line
   of the pair below it. Before this change every entry was one line, so the flat
   4px implied no grouping at all and the ambiguity did not exist. Widening the
   bullet/row boundary is NOT this issue's ruled change (property 4 is the rhythm
   between PAIRS), so it is recorded here as a follow-up, not fixed.

   Property 5 is typographic on purpose, never colour. Row colour routes through
   the item_eligible --section-panel-text slot (a per-row style map recolours one
   row) and --section-panel-bg is author-controlled and may be dark, so a
   hardcoded muted label colour would both fight the slot and put an unreviewable
   contrast pair on the page.

   Which element carries which signal follows the theme's OWN stacked key/value
   idiom, `.hero__surface-key` / `.hero__surface-value` above — the same geometry
   (flex column, gap --space-xs), and there the KEY is the small tracked one while
   the VALUE carries weight 600. A panel row is the same shape playing the same
   two roles, so it splits the same way rather than inventing a second convention.
   Two deliberate divergences from that idiom, both forced: no `color:
   var(--color-muted)` (see the slot/dark-panel note above), and no
   `text-transform: uppercase` (a panel label is authored free text and is
   routinely a proper noun — "WordPress", "PHP" — which uppercasing mangles;
   .hero__surface-key's 0.08em tracking is calibrated for uppercase, so the label
   keeps `.section__eyebrow`'s gentler 0.04em instead).

   The SIZE step carries the distinction; the WEIGHT is a bonus, not the proof.
   The theme ships no webfont (--font-body is `system-ui, sans-serif`), so which
   weights exist is the client's business: in a bare Linux/CI font environment
   600 resolves to the same face as 400 and renders PIXEL-IDENTICAL — the 375px
   A/B for this issue produced byte-identical PNGs for a weight-only treatment.
   A rendered pin that asserts ONLY the weight would therefore go green over an
   invisible distinction; the size step is what a rendered pin must assert. Size
   and tracking also survive --section-panel-font: var(--font-mono), shot too. */
@media (max-width: 767px) {
  .section__panel-row {
    flex-direction: column;
    gap: var(--space-xs);
  }

  .section__panel-row + .section__panel-row {
    margin-top: var(--space-md);
  }

  .section__panel-row-label {
    font-size: 0.8125rem;
    letter-spacing: 0.04em;
  }

  .section__panel-row-value {
    font-weight: 600;
    text-align: left;
  }
}

/* Suppress the shared issue-339 marker glyph on paired rows only. The marker
   layer paints via `.pp-marker-list--{marker} > li::before` (specificity 0,1,2);
   this two-class selector (0,2,1) outranks it so a mixed list keeps markers on
   its string bullets while rows stay markerless. content:none also neutralizes
   the base .pp-marker-list `li::before` reserved box. */
.section__panel-list > .section__panel-row::before {
  content: none;
}

/* Panel CTA spacing + per-instance filled-button slots (issue 104, extended by 536).
   Variant selection still comes from the panel_cta_variant prop (primary/secondary/
   outline/ghost) and the shared .btn primitive; the premium `main .btn:not(...)`
   cascade is still the visible winner for a filled panel CTA. What changed in #536 is
   that the panel now exposes its OWN per-instance fill slots — --section-panel-cta-bg /
   -color / -shadow — routed as the OUTERMOST value of that premium chain (the #514/#526
   idiom), so a branded section can paint a flat accent button through composition style
   slots alone instead of being stuck under the premium gradient.

   The rule below is the slot-contract keystone (StyleSlotContractTest): it wires all
   three slots inside the section block on type-compatible properties. It sits BELOW the
   premium winner [0,4,1] in specificity, so it never changes what a button actually
   paints — the VISIBLE win happens in the premium rule, exactly as the hero's
   .hero .btn / .hero__cta keystones do for --hero-button-*.

     specificity map:
       .section__panel-cta                         [0,1,0]  <- spacing only
       .section__panel-cta:not(x):not(y):not(z)    [0,4,0]  <- this keystone
       main .btn:focus / :focus-visible            [0,2,1]
       main .btn:not(x):not(y):not(z)              [0,4,1]  <- the live winner, always

   All three slots share ONE variant carve-out, which is what makes the primary-only
   contract in schema.json true of the ELEVATION slot as well as the fill and ink. An
   uncarved [0,1,0] box-shadow line would outrank the shared `.btn` rule and paint a
   drop shadow on a transparent outline/ghost panel CTA — a slot the schema says never
   reaches them. The carve-out also keeps the transparent variants' focus glow intact:
   `main .btn:focus` [0,2,1] stays their box-shadow winner because this rule cannot
   match them at all. (On the FILLED variant the premium rest rule [0,4,1] already
   outranks the focus rule, so nothing changes there either — that is pre-existing
   shared-button behavior, not something this rule introduces.) */
.section__panel-cta {
  margin-top: var(--space-sm);
}

/* Filled (primary) panel CTA only — outline/ghost/secondary keep their transparent
   treatment, so the fill slot never flattens them into look-alike filled buttons (the
   same carve-out `.hero .btn:not(...)` makes). Border honors its own global knob first,
   then FOLLOWS the fill (the #526 border-follows-fill convention) so a fill-only recolor
   keeps a matching ring; unset it falls through --btn-* to today's literals. The `none`
   elevation default mirrors `.hero__cta:not(...)`: the premium winner supplies the real
   bevel through its own var(--section-panel-cta-shadow, <bevel>) fallback, so an unset
   button is byte-identical and `none` flattens rest + hover. */
.section__panel-cta:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  background-color: var(--section-panel-cta-bg, var(--btn-bg, var(--color-accent)));
  border-color: var(--section-panel-cta-border, var(--btn-border-color, var(--section-panel-cta-bg, var(--btn-bg, var(--color-accent)))));
  color: var(--section-panel-cta-color, var(--btn-text, var(--color-bg)));
  box-shadow: var(--section-panel-cta-shadow, none);
}

/* Per-instance RING slots for the panel CTA (issue 584), and the ONE hover rule this
   surface has ever had.

   The gap this closes: the panel CTA was the only filled-button surface with NEITHER of
   the two tiers its siblings carry above the site-wide knob. A hero primary reaches its
   ring through --hero-button-border (rest rule above) and a cta primary through
   --cta-button-border; the panel CTA had only --btn-border-color, so its ring could be
   recoloured site-wide or not at all. --section-panel-cta-border and its hover twin sit
   at the head of the chain in exactly the position --cta-button-border holds.

   WHY BOTH HERE AND IN THE PREMIUM BLOCK, exactly as #536 did for the fill slot: this
   keystone is [0,4,0] and the shared premium winner `main .btn:not(x):not(y):not(z)` is
   [0,4,1] (hover: [0,5,0] vs [0,5,1]), so these declarations never decide a composed
   panel CTA's border — they WIRE the slots inside the section block, which is the
   slot-contract keystone (StyleSlotContractTest). The VISIBLE win happens in the premium
   rules, where both slots also lead the chain. Route only here and the slots would be
   dead; route only there and the keystone contract breaks. The hero needs no premium
   entry because `.hero .btn:not(...)` is [0,5,0] and already outranks [0,4,1].

   The hover chain deliberately carries NO fill link. #536 shipped the panel CTA
   resting-state-only and this issue does not revisit that: there is no
   --section-panel-cta-hover-bg to follow, so the ring's hover twin covers the RING and
   nothing else. Unset, the chain resolves --btn-hover-border-color then
   --color-accent-hover, which is precisely what `.btn:hover` computes — so this rule is
   byte-identical both under the premium winner (masked) and in the one context where it
   would win, a panel CTA rendered outside `main`. */
.section__panel-cta:not(.btn--outline):not(.btn--ghost):not(.btn--secondary):hover {
  border-color: var(--section-panel-cta-hover-border, var(--btn-hover-border-color, var(--color-accent-hover)));
}

/* Section background variants.
   These theme rules set background-color and border-* as BARE LITERALS at [0,1,0],
   after .section's own slot-routed declarations at the same specificity. --section-bg
   was therefore dead on BOTH themed sections; --section-border-width /
   --section-border-color were dead on the muted one only, since .pp-section--inverted
   declares no border and its borders still resolved from .section. Meanwhile the
   annotation surface an agent reads already promised the override wins
   (tests/AiContextTest.php testAdjacencyOverrideBeatsThemeBucket).
   Route them through the slots with the theme literal as the fallback, following
   .cta--full-width / .cta--dark / .cta--inverted — unset output is byte-identical, an
   authored slot now actually renders (issue 577).
   The `background-color` LONGHAND is deliberate where cta uses the `background`
   shorthand: it leaves .section--has-bg-image's background-size/position/repeat alone
   on a themed image band. --section-bg is `gradient`-typed, and a gradient is invalid
   on background-color, so an authored gradient is ignored HERE and painted by
   .section's own `background: var(--section-bg, transparent)` shorthand above — the
   value still renders, one rule up. */
.pp-section--dark {
  background-color: var(--section-bg, var(--color-surface));
  color: var(--color-text);
  border-top: var(--section-border-width, 1px) solid var(--section-border-color, var(--color-border));
  border-bottom: var(--section-border-width, 1px) solid var(--section-border-color, var(--color-border));
}

/* --color-muted carries .section__subheading; --section-*-theme-color carry the
   elements the desktop typography block re-colors at higher specificity (issue 222). */
.pp-section--inverted {
  background-color: var(--section-bg, var(--color-bg-inverted));
  color: var(--color-bg);
  --color-muted: var(--color-bg);
  --pp-section-title-theme-color: var(--color-bg);
  --pp-section-text-theme-color: var(--color-bg);
}

/* The text-panel is a self-contained LIGHT surface sitting on the dark band, with
   its own --section-panel-text authority (see the .section__panel comment block).
   Its heading is an <h3.section__panel-heading> whose own rule (0,1,0) routes color
   through --section-panel-text, but a bare `h3` here (0,1,1) outranks it and paints
   the panel heading in the band's light title color — light-on-light, invisible on
   the panel. Carve the panel heading out so the panel slot wins inside the panel
   while --section-heading-color keeps governing the on-band title. (#424) */
.pp-section--inverted .section__title,
.pp-section--inverted h2:not(.section__panel-heading),
.pp-section--inverted h3:not(.section__panel-heading) {
  color: var(--section-heading-color, var(--color-bg));
}

.pp-section--inverted .section__content {
  color: var(--section-body-color, var(--color-bg));
}

/* The accented substring inside a title paints its OWN color (0,1,0) and does not
   inherit the light .pp-section--inverted .section__title above, so on the dark band it
   rendered bare --color-accent at 3.23:1. The --has-bg-image twin has routed the
   overlay accent role since #463; the INVERTED twin was simply never written. Add it,
   pointing at the ratified on-inverted role (8.33:1); the per-instance
   --section-heading-accent-color slot still wins (issue 577). */
.pp-section--inverted .section__title-accent {
  color: var(--section-heading-accent-color, var(--color-accent-on-inverted));
}

/* PANEL CTA CARVE-OUT (#551) — see the shared block above `.section--has-bg-image a`
   for the full rationale. The band's link ink is an ON-BAND role; the panel is a
   self-contained LIGHT surface, and `.section__panel-cta` is the only anchor the
   section renderer can put inside it, so the band role must not reach it. */
.pp-section--inverted a:not(.section__panel-cta) {
  /* On the dark band the light-surface accent fails WCAG AA (3.23:1), so links
     fall back to the on-inverted accent role (issue 437). An explicit
     --section-body-link-color slot still wins. */
  color: var(--section-body-link-color, var(--color-accent-on-inverted));
}

.pp-section--inverted a:not(.section__panel-cta):hover {
  color: var(--section-body-link-hover-color, var(--color-accent-on-inverted-hover));
}

/* Section with background image. The dark overlay below makes this a dark surface,
   so it needs the same theme text defaults as the inverted variant: the desktop
   typography block outranks every rule here and would otherwise fall back to the
   light-theme token, painting dark text on the overlay (issue 248). */
.section--has-bg-image {
  position: relative;
  background-size: cover;
  background-position: var(--section-bg-position, center);
  background-repeat: no-repeat;
  --pp-section-title-theme-color: var(--color-bg);
  --pp-section-text-theme-color: var(--color-bg);
}

.section__overlay {
  position: absolute;
  inset: 0;
  background: var(--section-overlay-bg, var(--overlay-bg));
}

.section--has-bg-image > .container {
  position: relative;
  z-index: 1;
}

/* Same self-contained-panel carve-out as the inverted band above: a bg-image
   section is a dark surface (overlay), so its bare `h2,h3` rule (0,1,1) would
   clobber the light panel's own --section-panel-text heading authority (0,1,0)
   and render the panel heading invisible on the light panel. (#424) */
.section--has-bg-image .section__title,
.section--has-bg-image h2:not(.section__panel-heading),
.section--has-bg-image h3:not(.section__panel-heading) {
  color: var(--section-heading-color, var(--color-bg));
}

.section--has-bg-image .section__content,
.section--has-bg-image .section__body {
  color: var(--section-body-color, var(--color-bg));
}

/* The dark overlay makes this a dark surface over an arbitrary image, so the
   light-surface accent (--color-accent) is only 1.16:1 over the overlay-over-white
   worst case and fails WCAG AA. Route the default through the overlay accent role
   (#461); an explicit --section-body-link-color slot still wins. Mirrors the inverted-band
   pattern above, but with the overlay role (on-inverted is tuned to the solid
   inverted bg, not the arbitrary-image overlay). */
/* PANEL CTA CARVE-OUT (#551) — the load-bearing scope call for the whole band-ink family.

   These band rules are ON-BAND roles: they exist because the band is a DARK surface. But
   the selector is band-WIDE, and `.section__panel` is a self-contained LIGHT surface
   (--color-surface, #f4f7fb) sitting ON that dark band with its own --space-lg of padding.
   The section renderer emits exactly ONE anchor inside it (section.php:270,
   `.section__panel-cta`), so before this carve-out the band's near-white overlay ink
   painted a transparent panel button's label onto the light panel:

     specificity map (the issue's own [0,2,0] figure was wrong — `a` is a TYPE selector):
       .btn--outline / .btn--ghost / .btn--secondary        [0,1,0]
       .section--has-bg-image a                             [0,1,1]  <- outranked them
       .section--has-bg-image a:not(.section__panel-cta)    [0,2,1]  <- this rule
       main .btn:not(x):not(y):not(z)                       [0,4,1]  <- filled CTA, untouched

     measured on the light panel, worst-case white bg image (rendered, headless Chromium):
       bg-image  outline/ghost/secondary  rest 1.04:1   hover ghost 1.07 / secondary 1.33
       inverted  outline/ghost/secondary  rest 1.99:1   hover ghost 1.46 / secondary 1.18
       default band (the correct control)  rest 5.14 / 16.52   hover 5.43 / 5.14 / 13.36
     After the carve-out every band matches the default-band control exactly.

   This is the same carve-out class #424 made for the panel HEADING (`h2,h3:not(
   .section__panel-heading)` above), #463 made for the panel LIST MARKERS (by scoping the
   marker remap to .section__content — "Panel markers stay bare accent"), and #542 made for
   the panel CTA's FOCUS RING (by refusing to route section bands at all). This is that same
   call one layer down, on the ink.

   Why `:not(.section__panel-cta)` and not a panel-wide `:not(.section__panel *)`: both were
   prototyped and render byte-identically, because the CTA is the ONLY anchor the panel can
   contain (panel_heading/panel_body/panel_items are all esc_html — pinned in
   SectionTextPanelTest). But a complex `:not()` is a Selectors-4 feature, and an engine that
   does not support it drops the ENTIRE rule — which would strip the on-overlay ink from real
   on-band links, a worse failure than the bug. The compound `:not()` has no such mode.
   Durability against FUTURE band rules comes from the closed-set css-lint pin, not from the
   selector shape: any new band-WIDE anchor rule that sets `color` must carry this carve-out.

   The filled (primary) panel CTA is deliberately untouched: its ink comes from the premium
   chain at [0,4,1], which already outranked these rules, so --section-panel-cta-color (#536)
   keeps winning everywhere. NOTE: this raises these rules from [0,1,1] to [0,2,1], so custom
   CSS overriding them at [0,1,1] would now lose — the supported override path is the
   --section-body-link-color / --section-body-link-hover-color slot, which is unchanged and still wins. */
.section--has-bg-image a:not(.section__panel-cta) {
  color: var(--section-body-link-color, var(--color-accent-on-overlay));
}

.section--has-bg-image a:not(.section__panel-cta):hover {
  color: var(--section-body-link-hover-color, var(--color-accent-on-overlay-hover));
}

/* The accented title substring paints its OWN color (0,1,0) and does NOT inherit
   the near-white .section--has-bg-image .section__title above, so on the dark
   overlay-over-image band it renders bare --color-accent at 1.16:1 (fails even the
   3:1 large-text bar). Route the default through the overlay accent role (#463,
   the same fix #461 gave links/numbers); the per-instance --section-heading-accent-color
   slot still wins. on-inverted is tuned to the solid inverted bg, not this overlay. */
.section--has-bg-image .section__title-accent {
  color: var(--section-heading-accent-color, var(--color-accent-on-overlay));
}

/* Body list markers on the overlay band: a non-disc body_marker paints its glyph
   from --pp-list-marker-color, whose default is bare --color-accent (1.16:1 here).
   Re-map the default to the overlay accent role (#463); the --section-body-marker-color
   slot still wins. Panel markers stay bare accent — a bg-image section's panel is a
   self-contained LIGHT surface (#424), not on the overlay. */
.section--has-bg-image .section__content {
  --pp-list-marker-color: var(--section-body-marker-color, var(--color-accent-on-overlay));
}

/* Inline-items separator on the overlay band (issue 475): unlike the inverted band,
   .section--has-bg-image does NOT remap --color-muted, so the muted default would
   stay dark and vanish on the dark overlay. Route the default through the light
   on-overlay text color like sibling body text (.section--has-bg-image .section__content
   above resolves to --color-bg); the --section-separator-color slot still wins.
   Mirrors the inverted band's automatic --color-muted remap. */
.section--has-bg-image .section__inline-items li::before {
  color: var(--section-separator-color, var(--color-bg));
}
/* Same on-overlay routing for the centered (issue 510) trailing ::after separator,
   so the light default and the --section-separator-color slot behave identically to
   the leading ::before in the default mode. */
.section--has-bg-image .section__inline-items--center li:not(:last-child)::after {
  color: var(--section-separator-color, var(--color-bg));
}

/* ==========================================================================
   COMPONENT: faq
   Native details/summary accordion. No JS required.
   ========================================================================== */

.faq {
  padding-top: var(--faq-padding-top, var(--pp-band-padding));
  padding-bottom: var(--faq-padding-bottom, var(--pp-band-padding));
  background: var(--faq-bg, var(--color-surface));
}

.faq__eyebrow {
  display: inline-block;
  padding: 0.35rem 0.85rem;
  margin-bottom: var(--space-sm);
  border: var(--faq-eyebrow-border-width, 0) solid var(--faq-eyebrow-border-color, transparent);
  border-radius: var(--faq-eyebrow-radius, 3px);
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: var(--faq-eyebrow-text-transform, uppercase);
  color: var(--faq-eyebrow-color, var(--color-text));
  background: var(--faq-eyebrow-bg, var(--color-surface-accent));
}

.faq__heading {
  /* Route the heading size through the slot (issue 304). This base rule governs
     widths below the desktop premium typography breakpoint, where the heading
     renders at the h2 base size (base.css); the desktop clamp is routed through
     the same slot below. Fallback keeps unset output byte-identical. */
  font-size: var(--faq-heading-size, var(--pp-band-heading-size));
  /* Header rhythm: the gap below the heading (before the accordion list). faq
     renders no subheading, so this is the faq analogue of #343's title->subheading
     slot. Route the heading's own bottom margin through a slot so faq's header
     rhythm is slot-driven like section/grid; the desktop and mobile premium rules
     below route the same slot. No header-scoping needed: the heading is an <h2>,
     not the header's last child, so base.css's `p:last-child` reset never reached
     it. Today's literal stays the fallback — unset output byte-identical (issue 352). */
  margin-bottom: var(--faq-heading-margin-bottom, var(--space-lg));
  color: var(--faq-heading-color, var(--pp-faq-heading-theme-color, var(--color-text)));
  /* faq's own heading measure (issue 578), severed from the shared six-selector rule
     that read --cta-heading-measure from inside the section block. Fallback routes the
     shared --measure-heading token (40rem), the value this heading renders today. */
  max-width: var(--faq-heading-measure, var(--measure-heading));
}

.faq__heading-accent {
  color: var(--faq-heading-accent-color, var(--color-accent));
}

.faq__list {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}

.faq__item {
  border: 1px solid var(--faq-item-border-color, var(--color-border));
  /* faq's own item radius (issue 577). The shared premium rule in the final cascade
     used to cap grid cards AND faq items from ONE selector reading --grid-item-radius,
     so faq consumed a GRID slot on a faq element: faq
     could neither set it (the write path rejects a foreign slot as
     invalid_style_slot) nor have it resolve (inline slot properties land on the owning
     component's root). Severing that selector requires a faq-side name, which is why
     this slot is forced rather than optional. The fallback is 4px, not var(--radius):
     faq only ever renders inside <main>, where the final-cascade rule below wins with
     4px, so a 6px terminal here would be unreachable AND disagree with the schema's
     stated default. */
  border-radius: var(--faq-item-radius, 4px);
  background: var(--faq-item-bg, var(--color-bg));
  overflow: hidden;
}

.faq__question {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-md) var(--space-lg);
  font-weight: 600;
  color: var(--faq-question-color, var(--color-text));
  cursor: pointer;
  list-style: none;
  gap: var(--space-md);
  min-height: 44px;
  transition: color var(--transition);
}

/* Remove default marker in WebKit */
.faq__question::-webkit-details-marker {
  display: none;
}

/* Custom chevron via CSS */
.faq__question::after {
  content: '';
  display: block;
  flex-shrink: 0;
  width: 10px;
  height: 10px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
  transition: transform var(--transition);
}

.faq__item[open] > .faq__question {
  color: var(--faq-question-open-color, var(--color-accent));
}

.faq__item[open] > .faq__question::after {
  transform: rotate(225deg);
}

.faq__answer {
  padding: 0 var(--space-lg) var(--space-md);
  color: var(--faq-answer-color, var(--color-muted));
  line-height: 1.6;
  /* faq's prose measure (issue 578). Default `none`: the answer has never been capped —
     it already sits inside the accordion item's own padded box — so `none` (max-width's
     initial value) is byte-identical and the slot exists to let an operator tighten a
     long answer to a reading measure without touching this file. */
  max-width: var(--faq-body-measure, none);
}

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

.faq__item[open] > .faq__answer {
  animation: faq-open 150ms ease;
}

.faq__empty {
  padding: var(--space-md) 0;
}

/* FAQ background variants. .faq already carries a --color-surface band by
   default, so --dark only adds the framing borders (mirrors .grid--dark /
   .pp-section--dark). --inverted swaps the band for the inverted surface and
   supplies the theme-color DEFAULT the desktop typography rule resolves at
   use-time — the desktop `main > .faq .faq__heading` rule outranks any theme
   selector, so the theme cannot win by specificity and instead feeds the
   heading's own fallback chain (slot > theme > token), the issue 222 pattern.
   .faq__item keeps its light background, so question/answer text stays dark —
   no theming needed there (mirrors .grid--inverted keeping cards light). */
.faq--dark {
  background: var(--faq-bg, var(--color-surface));
  border-top: 1px solid var(--color-border);
  border-bottom: 1px solid var(--color-border);
}

.faq--inverted {
  background: var(--faq-bg, var(--color-bg-inverted));
  --pp-faq-heading-theme-color: var(--color-bg);
}

.faq--inverted .faq__heading {
  color: var(--faq-heading-color, var(--color-bg));
}

/* ==========================================================================
   COMPONENT: grid
   Responsive card grid. 1-col → 2-col md → 3-col lg.
   Cards are for real content objects, not decoration.
   ========================================================================== */

.grid {
  padding-top: var(--grid-padding-top, var(--pp-band-padding));
  padding-bottom: var(--grid-padding-bottom, var(--pp-band-padding));
  background: var(--grid-bg, transparent);
}

.grid__header--center {
  text-align: center;
  margin-left: auto;
  margin-right: auto;
}

.grid__eyebrow {
  display: inline-block;
  padding: 0.35rem 0.85rem;
  margin-bottom: var(--space-sm);
  border: var(--grid-eyebrow-border-width, 0) solid var(--grid-eyebrow-border-color, transparent);
  border-radius: var(--grid-eyebrow-radius, 3px);
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: var(--grid-eyebrow-text-transform, uppercase);
  color: var(--grid-eyebrow-color, var(--color-text));
  background: var(--grid-eyebrow-bg, var(--color-surface-accent));
}

.grid__heading {
  color: var(--grid-heading-color, var(--pp-grid-heading-theme-color, var(--color-text)));
  font-size: var(--grid-heading-size, var(--pp-band-heading-size));
  max-width: var(--grid-heading-measure, var(--measure-heading));
  /* Top-side header rhythm (heading -> subheading gap), mirror of the issue 336
     subheading BOTTOM slot on the adjacent property (issue 343). The heading is
     not the header's last child, so base.css's `p:last-child` reset never won
     here — a plain slot on the heading suffices. Today's literal is the fallback. */
  margin-bottom: var(--grid-heading-margin-bottom, var(--space-lg));
}

.grid__heading-accent {
  color: var(--grid-heading-accent-color, var(--color-accent));
}

.grid__header--center .grid__heading {
  max-width: var(--grid-heading-measure, var(--measure-heading));
  margin-left: auto;
  margin-right: auto;
}

.grid__subheading {
  color: var(--grid-subheading-color, var(--pp-grid-subheading-theme-color, var(--color-muted)));
  margin-top: 0;
  max-width: 40rem;
}
/* base.css `p:last-child { margin-bottom: 0 }` (0,1,1) outranks a bare
   `.grid__subheading` (0,1,0), and the subheading is always the header's last
   child, so the component's declared bottom rhythm never reached the page (issue 336).
   Own the spacing at header scope (0,2,0): it wins the cascade without weakening
   the global prose reset, and does not depend on the subheading staying last. */
.grid__header > .grid__subheading {
  margin-bottom: var(--grid-subheading-margin-bottom, var(--space-lg));
}


.grid__header--center .grid__subheading {
  margin-left: auto;
  margin-right: auto;
}

.grid__list {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--grid-gap, var(--space-lg));
  list-style: none;
}

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

/* Steps variant: 3-col at desktop (always 3 items) */
@media (min-width: 1024px) {
  .grid--steps .grid__list {
    grid-template-columns: repeat(3, 1fr);
  }
}

.grid__item {
  display: flex;
  flex-direction: column;
  background: var(--grid-item-bg, var(--color-surface));
  border: var(--grid-item-border-width, 1px) solid var(--grid-item-border-color, var(--color-border));
  border-radius: var(--grid-item-radius, var(--radius));
  box-shadow: var(--grid-item-shadow, none);
  overflow: hidden;
  transition: transform var(--transition);
}

.grid__item:hover {
  transform: translateY(-2px);
}

.grid__item-image-wrap {
  aspect-ratio: 16 / 9;
  overflow: hidden;
}

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

/* Item image icon treatment (issue 380). Opt-in via the grid
   `image_treatment: "icon"` prop, emitted by grid.php as the grid--image-icon
   variant class on the section. Renders each card image at a small fixed icon
   size instead of the default 16:9 cover banner above: no aspect-ratio crop,
   sized by the --grid-item-icon-size slot (length, default 48px), and
   object-fit: contain so the whole glyph/logo shows rather than being cropped
   to fill. Unset (default 'banner') emits no class, so neither selector matches
   and the banner rendering above is byte-identical. Applies at ALL breakpoints
   (NOT nested in a min-width block), so the icon stays icon-sized on mobile too;
   the <768px single-column collapse is untouched. Scoped under .grid--image-icon
   (only ever on cards — grid.php never emits the class on steps, which render no
   item images). --grid-item-icon-size embeds no WP-core border-trigger substring,
   so the issue 332 immunity baseline is unaffected; the value routes through
   var(--slot, 48px) so the slot-contract bypass guard does not flag the literal. */
.grid--image-icon .grid__item-image-wrap {
  aspect-ratio: auto;
  width: var(--grid-item-icon-size, 48px);
  height: var(--grid-item-icon-size, 48px);
  overflow: visible;
  /* Follow the card's --grid-item-text-align (issue 380, maintainer 7A). The
     fixed-width icon box is a flex child of .grid__item (a flex column); per the
     issue 338 flex trap, text-align on the body cannot move it, so a centered
     card would otherwise render a left-pinned icon over centered text. It reuses
     the SAME derived companion the Read-more link already follows (--pp-grid-link
     -align, issue 361), emitted grid-level and per-card from the one authored
     --grid-item-text-align slot (left/start/justify->flex-start, center->center,
     right/end->flex-end) — no second slot, no second companion. Unset emits no
     companion, so the flex-start fallback keeps every icon card left-aligned,
     byte-identical. Only meaningful in icon mode (the banner wrap is full-width,
     so align-self is a no-op there). */
  align-self: var(--pp-grid-link-align, flex-start);
}

.grid--image-icon .grid__item-image {
  object-fit: contain;
}

.grid__item-body {
  padding: var(--grid-item-padding, var(--space-md));
  display: flex;
  flex-direction: column;
  gap: var(--grid-item-gap, var(--space-sm));
  flex: 1;
  /* Authorable TEXT alignment (issue 357). The body's text-bearing children
     (title, text, bullets) are full-width flex items (align-items:stretch), so
     text-align inherits into them and governs their INLINE content. The
     .grid__item-link is a content-width flex item placed by align-self, so per
     the issue 338 flex trap text-align cannot move its box; it follows the SAME
     alignment through the derived --pp-grid-link-align companion instead (issue
     361, on .grid__item-link below), so a centered card centers its link too.
     Default left is byte-identical to today's rendering (LTR theme, no rtl.css);
     an unset slot emits no inline custom property, so the fallback keeps every
     existing card left-aligned. Consumed via the shared align-typed slot. */
  text-align: var(--grid-item-text-align, left);
}

@media (min-width: 768px) {
  .grid__item-body {
    padding: var(--grid-item-padding, var(--space-lg));
  }
}

/* When a card has no image, the body is the only child — add top padding for balance.
   Routed through --grid-item-padding, but NOT for the reason issue 577 gave. The issue
   listed this as a declaration that DEFEATS the slot; it does not — this rule is [0,2,0]
   and `main > .grid .grid__item-body:first-child` (which has routed the slot at both
   breakpoints since issue 302) is [0,3,1], and every composition renders inside <main>,
   so this declaration wins nowhere in practice.
   It is routed because the slot-contract guard requires it: once --grid-item-padding
   drives padding-top on the .grid__item-body SUBJECT anywhere (the featured-card rule in
   the final cascade), every padding-top on that subject must route the slot or it is a
   dead-slot bypass by definition (StyleSlotContractTest, issue 305). Uniform routing on
   a subject is the invariant, not "this rule is the cascade winner". Byte-identical. */
.grid__item-body:first-child {
  padding-top: var(--grid-item-padding, var(--space-lg));
}

.grid__item-title {
  font-size: var(--grid-item-title-size, 1.25rem);
  font-weight: 700;
  color: var(--grid-item-title-color, var(--color-text));
}

.grid__item-text {
  color: var(--grid-item-text-color, var(--color-muted));
  font-size: 0.9375rem;
  line-height: 1.6;
  flex: 1;
}

/* Issue 349 — an explicit per-instance --grid-item-text-color must win over a
   text_role color preset at ALL breakpoints. text_role adds .text-meta / .text-kicker
   (utilities.css), each a (0,1,0) `color` rule enqueued AFTER components.css, so it
   defeats the (0,1,0) base rule on the source-order tie below the 768px premium
   breakpoint — the slot was honored on desktop and dead on mobile. These (0,2,0)
   companion rules out-specify the utility regardless of enqueue order (mirroring how
   the desktop `main > .grid .grid__item-text` premium rule already does at (0,2,1)),
   so the slot always wins when set. The fallback is the exact role token, so an UNSET
   slot still renders the role colour — byte-identical to today at both breakpoints
   (desktop stays governed by the premium rule, which is strictly higher at (0,2,1)).
   Only meta/kicker set a colour; mono/label do not, so they need no companion rule. */
.grid__item-text.text-meta {
  color: var(--grid-item-text-color, var(--text-meta-color));
}

.grid__item-text.text-kicker {
  color: var(--grid-item-text-color, var(--text-kicker-color));
}

.grid__item-bullets {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  color: var(--grid-item-text-color, var(--color-muted));
  font-size: 0.9375rem;
  line-height: 1.5;
  flex: 1;
  /* Map grid's authorable bullet colour onto the shared marker plumbing var.
     Unset resolves to --color-accent, byte-identical to the historical grid
     treatment (issue 339). */
  --pp-list-marker-color: var(--grid-item-bullet-color, var(--color-accent));
}

/* ── Shared list-marker treatment (issue 339) ───────────────────────────────
   Promotes the check-mark bullet — historically locked inside grid cards — to a
   reusable marker any list-rendering surface can opt into. A marker is a GLYPH
   plus a COLOUR, both authorable; nothing here encodes a use-case. `disc` is the
   untouched default and adds NO class, so a list that does not opt in renders
   byte-identically. check / dash / arrow are just marker values.

   The base .pp-marker-list is INERT on its own (indent + empty positioned ::before,
   no glyph); a glyph comes only from the paired --check/--dash/--arrow modifier.
   section.php always emits the base and a modifier together, so a bare base never
   ships. A future consumer must pair them too.

   Colour flows in through --pp-list-marker-color (internal plumbing, NOT a schema
   style_slot): each consuming component maps its own authorable colour slot onto
   it inside that component's own block (grid: --grid-item-bullet-color above; section:
   --section-panel-marker-color / --section-body-marker-color).

   One definition, three consumers:
   - grid cards        .grid__item-bullet         (always a check, unchanged)
   - text-panel list   .pp-marker-list on the <ul> the renderer emits
   - section.body list .section__content--marker-* on the container we control,
                       scoped to its DIRECT-CHILD <ul> so nested/plugin lists keep
                       their default disc.
   These rules sit at grid's historical bullet position — after the COMPONENT:
   section block — so the section body/panel selectors below beat the issue-295
   disc rules on source order at equal specificity. --------------------------- */
.pp-marker-list,
.section__content--marker-check > ul,
.section__content--marker-dash > ul,
.section__content--marker-arrow > ul {
  list-style: none;
  padding-left: 0;
}

.grid__item-bullet,
.pp-marker-list > li,
.section__content--marker-check > ul > li,
.section__content--marker-dash > ul > li,
.section__content--marker-arrow > ul > li {
  position: relative;
  padding-left: 1.5rem;
}

.grid__item-bullet::before,
.pp-marker-list > li::before,
.section__content--marker-check > ul > li::before,
.section__content--marker-dash > ul > li::before,
.section__content--marker-arrow > ul > li::before {
  position: absolute;
  left: 0;
  font-weight: 700;
  color: var(--pp-list-marker-color, var(--color-accent));
}

/* Glyph per marker value. */
.grid__item-bullet::before,
.pp-marker-list--check > li::before,
.section__content--marker-check > ul > li::before {
  content: "\2713"; /* check mark */
}

.pp-marker-list--dash > li::before,
.section__content--marker-dash > ul > li::before {
  content: "\2013"; /* en dash */
}

.pp-marker-list--arrow > li::before,
.section__content--marker-arrow > ul > li::before {
  content: "\2192"; /* rightwards arrow */
}

.grid__item-link {
  font-weight: 600;
  font-size: 0.9rem;
  color: var(--grid-item-link-color, var(--color-accent));
  text-decoration: none;
  /* Follows the card's --grid-item-text-align via the derived --pp-grid-link-align
     companion (issue 361): grid.php maps left/start/justify->flex-start,
     center->center, right/end->flex-end. UNSET emits no companion, so the
     flex-start fallback is byte-identical to the historical left-pinned link. */
  align-self: var(--pp-grid-link-align, flex-start);
  margin-top: auto;
}

.grid__item-link:hover {
  /* Issue 581 (A-18) — the hover half of the card-link pair. This used to be a bare
     literal, waived in the issue 309 dead-slot ledger because routing it through the
     REST slot (--grid-item-link-color) would have made hover render identical to rest
     whenever an author set the resting colour, destroying the hover feedback. Routing
     it through its own POSITIONAL TWIN has neither problem: unset, the fallback is the
     same var(--color-accent-hover) literal it always was (byte-identical), and set, the
     author is deliberately choosing a hover colour rather than accidentally flattening
     one. The waiver retires with this change. */
  color: var(--grid-item-link-hover-color, var(--color-accent-hover));
  text-decoration: underline;
}

.grid__empty {
  grid-column: 1 / -1;
  padding: var(--space-md) 0;
}

/* Steps variant: same card chrome as the default grid, plus a numbered badge.
   This padding SHORTHAND is DELIBERATELY NOT routed through --grid-item-padding. Issue
   577 listed it as a bypass on the premise that "a steps card has no .grid__item-body
   wrapper carrying it"; grid.php renders .grid__item-body UNCONDITIONALLY (steps cards
   included), and that body already routes the slot, so the slot was never dead here.
   Routing this outer box through the SAME slot would inset a steps card by 2X for an
   authored X (card + nested body) and desync the connector below, which derives its
   offset from this literal. The card's outer padding wants its own name, not this one. */
.grid--steps .grid__item {
  position: relative;
  padding: var(--space-lg) var(--space-md) var(--space-md);
}

.grid--steps .grid__step-number {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  margin-bottom: var(--space-md);
  border-radius: 50%;
  background: var(--grid-step-bg, var(--color-accent));
  color: var(--grid-step-text-color, var(--color-bg));
  font-size: 1.25rem;
  font-weight: 700;
  line-height: 1;
}

@media (max-width: 767px) {
  .grid--steps .grid__step-number {
    width: 2.5rem;
    height: 2.5rem;
    font-size: 1.125rem;
  }
}

/* Steps connector: a thin line between badges at desktop, replacing the old
   triangle arrow (already invisible in practice, and flagged as noisy — #56). */
@media (min-width: 1024px) {
  .grid--steps .grid__item:not(:last-child)::after {
    content: "";
    position: absolute;
    top: calc(var(--space-lg) + 1.375rem);
    left: 100%;
    width: var(--grid-gap, var(--space-lg));
    height: 1px;
    background: var(--grid-item-border-color, var(--color-border));
  }
}

/* Grid theme variants.
   NOTE (#442, #570 DG-4, #605): the `--dark` modifier class is a MISNOMER, and it is
   kept DELIBERATELY — it paints the LIGHT `--color-surface` tinted band, not a dark
   one. The genuinely dark band is `--inverted`.

   Read this before "finishing the cleanup". The theme enum's tinted INPUT VALUE was
   renamed to `muted` (#442) and the legacy `dark` input value was REMOVED outright
   (#605) — `theme: "dark"` is now rejected at write. This CLASS NAME did not go with
   it. Input aliasing and output naming are different things: `pp_theme_class()`
   (lib/helpers.php) maps the canonical `muted` to this `--dark` class, and that
   mapping is kept because renaming the class would change the emitted HTML of the
   installed base and invalidate every rule below plus every `styling.variant_classes`
   declaration, for no authoring gain. So: no value anyone can write is called `dark`,
   and this class is still correct. Every band component's `--dark` variant follows
   this pattern. */
.grid--dark {
  background: var(--grid-bg, var(--color-surface));
  border-top: 1px solid var(--color-border);
  border-bottom: 1px solid var(--color-border);
}

/* Theme text defaults. The desktop typography block re-declares `color` at higher
   specificity than any theme variant can reach (`main > .grid .grid__heading` is
   [0,2,1]; this rule is [0,2,0]), so a theme cannot win by selector. It supplies an
   inheritable DEFAULT instead, which the element's own fallback chain resolves at
   use-time regardless of which selector won: slot > theme > global token (issue 222).
   Names are component-scoped on purpose: a shared token would inherit into a
   non-inverted component nested in an inverted one and paint light-on-light.
   Deliberately NOT applied to .grid__item-* — inverted cards keep a light
   background below, so their text must stay dark. */
.grid--inverted {
  background: var(--grid-bg, var(--color-bg-inverted));
  --pp-grid-heading-theme-color: var(--color-bg);
  --pp-grid-subheading-theme-color: var(--color-bg);
}

.grid--inverted .grid__heading {
  color: var(--grid-heading-color, var(--color-bg));
}

.grid--inverted .grid__item {
  background: var(--grid-item-bg, var(--color-bg));
}

/* Card top bar + featured first-card treatment (issue 293). These composed-page
   rules live here (not the final cascade, where they used to sit) so the issue 293
   slots are consumed inside the COMPONENT: grid block per the slot-contract guard.
   Cascade-equivalent: no other rule anywhere styles .grid__item::before, and the
   :first-child rule outranks the final cascade's [0,3,1] base rules by specificity
   in either order; the premium later winners (border-color, base box-shadow) still
   win exactly as before. Unset output is byte-identical — every fallback below is
   the literal that used to be hardcoded. */
main > .grid:not(.grid--steps) .grid__item::before {
  content: "";
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  /* The top bar routes through the issue 293 bar slots on EVERY card; the
     featured :first-child rule below re-consumes them with louder defaults.
     The slots are shared (not featured-only) because the slot-contract guard
     collapses pseudo-classes onto the base subject, so a featured-only slot
     would flag these base literals as unwaivable bypasses. Setting the slots
     pins one bar on every card; --grid-item-bar-height: 0 removes it. */
  height: var(--grid-item-bar-height, 2px);
  background: var(--grid-item-bar-color, var(--color-border));
}

/* The whole featured first-card treatment below (this block, its ::before bar,
   the larger title, the extra body padding-top, and the dark-theme lift) carries
   a :not(.grid--uniform) guard. The grid `card_emphasis: uniform` prop emits
   .grid--uniform, which drops every featured rule so the first card falls through
   to the shared all-cards rules and renders identically to its siblings — a
   symmetric/peer card row (issue 226). The DEFAULT (featured) emits no class, so
   the guard never matches an existing page and output stays byte-identical. */
main > .grid:not(.grid--steps):not(.grid--uniform) .grid__item:first-child {
  /* Featured-card accent border DEFAULT tier; a per-instance --grid-item-border-color
     overrides it, mirroring --grid-item-bg below, so a declared style slot never
     silently no-ops on the first card (issue 226). Note: the premium later-cascade
     :first-child rule re-declares border-color with a --color-accent-strong
     fallback and currently wins when the slot is unset; this declaration is the
     base tier that takes over if that premium layer is ever removed. */
  border-color: var(--grid-item-border-color, var(--color-border-accent));
  /* Featured-card accent fill is the DEFAULT; a per-instance --grid-item-bg
     overrides it so the first card stays uniform with the rest when an author
     sets an explicit card color (e.g. uniform dark cards on a dark band). The
     texture-stripe line color routes through the issue 293 slot; transparent
     removes the stripe. */
  background:
    linear-gradient(90deg, var(--grid-featured-texture-color, rgba(37, 99, 235, 0.055)) 1px, transparent 1px) 0 0 / 2.75rem 100%,
    var(--grid-item-bg, linear-gradient(180deg, var(--color-surface-accent) 0%, var(--color-surface) 100%));
  /* Featured glow: the issue 293 featured-shadow slot wins first, then the shared
     --grid-item-shadow exactly as before, then the glow literal. The mobile
     featured rule in the final cascade re-declares this same chain. */
  box-shadow: var(--grid-featured-shadow, var(--grid-item-shadow,
    inset 0 0 0 1px rgba(37, 99, 235, 0.055),
    0 18px 42px rgba(37, 99, 235, 0.10)));
}

main > .grid:not(.grid--steps):not(.grid--uniform) .grid__item:first-child::before {
  height: var(--grid-item-bar-height, 4px);
  background: var(--grid-item-bar-color, linear-gradient(90deg, var(--color-accent), color-mix(in srgb, var(--color-accent) 18%, transparent)));
}

/* ==========================================================================
   COMPONENT: table
   Data table with horizontal scroll at ANY viewport, not only on mobile: .table-wrap
   declares overflow-x: auto with no media query, and .table is width: max-content with
   min-width: 100%, so the wrapper scrolls whenever the table outgrows the band.
   ========================================================================== */

.table-section {
  /* Band padding joins the shared symmetric rhythm (issue 438): route each edge
     through the component's own slot, falling back to --pp-band-padding so this
     band agrees with every other band's own edges at every breakpoint instead of
     hardcoding var(--space-xl) (64px), which rendered it 76.8/64 asymmetric when
     adjacent. The adjacent-top edge routes through --pp-band-padding-adjacent-top
     in the shared cascade below. */
  padding-top: var(--table-padding-top, var(--pp-band-padding));
  padding-bottom: var(--table-padding-bottom, var(--pp-band-padding));
}

.table-section__heading {
  /* Band heading joins the shared responsive scale (issue 436): route font-size
     through the component's own size slot, falling back to --pp-band-heading-size
     so this heading agrees with every other band title at every viewport instead
     of inheriting the flat h2 element rule (30px). The color slot (issue 438)
     completes the minimal band-heading surface, falling back to the h2 default
     var(--color-text) so unset output is unchanged. */
  font-size: var(--table-heading-size, var(--pp-band-heading-size));
  color: var(--table-heading-color, var(--color-text));
  /* Heading rhythm (issue 584): band fusing needs this gap zeroable per instance.
     The fallback is this rule's own prior literal — byte-identical unset. */
  margin-bottom: var(--table-heading-margin-bottom, var(--space-lg));
  /* table's own heading measure (issue 578), severed from the shared six-selector rule
     that read --cta-heading-measure from inside the section block. */
  max-width: var(--table-heading-measure, var(--measure-heading));
}

.table-wrap {
  width: 100%;
  max-width: 100%;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior-x: contain;
  border: 1px solid var(--color-border);
  border-radius: var(--radius);
}

.table {
  width: max-content;
  min-width: 100%;
  border-collapse: collapse;
  font-size: 0.9375rem;
  background-color: var(--color-bg);
}

.table__caption {
  caption-side: bottom;
  padding: var(--space-sm) var(--space-md);
  font-size: 0.875rem;
  color: var(--color-muted);
  text-align: left;
}

.table__head {
  background-color: var(--color-surface);
}

.table__header {
  padding: var(--space-sm) var(--space-md);
  text-align: left;
  font-weight: 700;
  color: var(--color-text);
  border-bottom: 2px solid var(--color-border);
  white-space: nowrap;
}

.table__row {
  border-bottom: 1px solid var(--color-border);
  transition: background-color var(--transition);
}

.table__row:last-child {
  border-bottom: none;
}

.table__row:hover {
  background-color: var(--color-surface);
}

.table__cell {
  padding: var(--space-sm) var(--space-md);
  color: var(--color-text);
  vertical-align: top;
  white-space: normal;
  overflow-wrap: anywhere;
}

.table-section__empty {
  padding: var(--space-md) 0;
}

/* ==========================================================================
   COMPONENT: cta
   Call-to-action block. Variants: full-width (centered), inline (flex row).
   ========================================================================== */

.cta {
  padding-top: var(--cta-padding-top, var(--pp-band-padding));
  padding-bottom: var(--cta-padding-bottom, var(--pp-band-padding));
  background: var(--cta-bg, transparent);
  border-top: var(--cta-border-width, 0) solid var(--cta-border-color, transparent);
  border-bottom: var(--cta-border-width, 0) solid var(--cta-border-color, transparent);
  border-radius: var(--cta-radius, 0);
  box-shadow: var(--cta-shadow, none);
}

.cta__inner {
  display: flex;
  flex-direction: column;
  gap: var(--cta-inner-gap, var(--space-lg));
}

.cta__eyebrow {
  display: inline-block;
  padding: 0.35rem 0.85rem;
  margin-bottom: var(--space-sm);
  border: var(--cta-eyebrow-border-width, 0) solid var(--cta-eyebrow-border-color, transparent);
  border-radius: var(--cta-eyebrow-radius, 3px);
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: var(--cta-eyebrow-text-transform, uppercase);
  color: var(--cta-eyebrow-color, var(--color-text));
  background: var(--cta-eyebrow-bg, var(--color-surface-accent));
}

.cta__title {
  font-size: var(--cta-heading-size, var(--pp-band-heading-size));
  color: var(--cta-heading-color, inherit);
  /* cta keeps the slot it always owned; only the fallback moves to the shared
     --measure-heading token (issue 578). Same 40rem, now retunable in one write. */
  max-width: var(--cta-heading-measure, var(--measure-heading));
  /* Heading rhythm (issue 584): the band-fusing step documented in
     ai-instructions/style-component.md needs this gap authorable per instance. The
     fallback is this rule's own prior literal, so an unset cta is byte-identical.
     cta's is --space-xs, not --space-lg: the CTA title sits directly above its body
     line, not above a content block. */
  margin-bottom: var(--cta-heading-margin-bottom, var(--space-xs));
}

.cta__title-accent {
  color: var(--cta-heading-accent-color, var(--color-accent));
}

.cta__body {
  color: var(--cta-body-color, var(--pp-cta-body-theme-color, var(--color-muted)));
  font-size: var(--cta-body-size, inherit);
  margin-bottom: 0;
  /* cta's prose measure (issue 578). Default `none`: the body carries no cap of its own
     today — on the full-width layout the .cta__text wrapper caps title AND body together
     at --cta-heading-measure — so `none` is byte-identical, and the slot lets an operator
     give the body a shorter measure than the title without touching the wrapper. */
  max-width: var(--cta-body-measure, none);
}

/* CTA accent: scoped button colors via style slots. Excludes outline/ghost/
   secondary — .cta .btn (2 classes) has HIGHER specificity than .btn--outline/
   --ghost/--secondary (1 class each), so without this exclusion the accent
   fill unconditionally wins over those variants' own transparent/surface
   fill regardless of source order, producing invisible or wrong-colored
   buttons (issue 111 — confirmed empirically: outline and ghost render with
   button text the same color as the button background). Same root cause and
   fix pattern already applied to hero's equivalent rule above.

   Route fill through --cta-button-bg / --cta-button-hover-bg, border through
   --cta-button-border / --cta-button-hover-border (issue 420). This rule is the
   composed primary button's actual cascade winner for the background-color/
   border-color LONGHANDS ([0,5,0] beats the slot block `.cta__button:not(...)`
   [0,4,0] AND the premium `main .btn:not(...)` [0,4,1]), so a bare accent here
   silently defeated the flat-button slots the premium layer (issue 412) already
   routed — the fill survived accent even with gradient/bevel/ink flattened.
   The accent chain is the FALLBACK, so an unset button is byte-identical. The
   border honors its own --cta-button-border slot when set (matching the sibling
   `.cta__button` block and the premium winner); when that slot is unset it FOLLOWS
   the fill (var(--cta-button-bg, ...)) so a flat button keeps no accent ring. */
.cta .btn:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  /* Global button surface routed under the cta slots (#458): component slots (--cta-button-*
     / --cta-accent) still win; unset, fill falls to --btn-bg then --color-accent
     (byte-identical). Border honors --cta-button-border, then the BAND ACCENT, then the
     global --btn-border-color knob, else FOLLOWS the fill chain (so a flat --cta-button-bg
     OR a global --btn-bg keeps no accent ring, issue 420 preserved).
     ORDER (issue 564): --cta-accent outranks --btn-border-color, and this chain is now the
     POSITIONAL TWIN of its :hover rule below — every link sits at the same rank as its hover
     equivalent. That is the whole point: with rest and hover positionally identical, no
     authoring configuration can make the ring change ROLE on pointer-enter — whichever kind of
     link wins at rest, its hover twin wins on hover. (The ring can still change COLOUR, of
     course, whenever the author gives a knob and its hover twin different values. That is the
     hover state doing its job; what is gone is the ring jumping to a different KIND of link.)
     It also puts
     this family on the hero's shipped order (`.hero .btn` and `.hero__cta--secondary`, whose
     border-color declarations sit above this one in this file), so a narrower authored band
     accent is not defeated by a broader site-wide knob.
     Two of #564's three repainted configurations touch THIS chain — the letters below are
     #564's own enumeration, and (b) is missing here on purpose: (b) is the overlay-band case,
     which belongs to the ring twins further down, not to this plain-band rule. (a) --cta-accent
     WITH --btn-border-color (the accent now wins, matching the fill side and the hero), and
     (c) --cta-accent WITH --cta-button-bg (the accent now wins at rest, which is what
     RETIRES the old rest->hover flip — the hover chain already resolved to the accent
     there, so rest and hover disagreed on one band). (c) is #538's Option 2, reopened
     DELIBERATELY by the maintainer on #564 (issuecomment-5106604500) after #538 reserved
     it. Unset and every single-knob configuration are byte-identical, verified by rendered
     resolution across all ten configurations. --btn-border-color still outranks the fill
     link, so #554's "a global knob reaches this button" contract is untouched. */
  background-color: var(--cta-button-bg, var(--cta-accent, var(--btn-bg, var(--color-accent))));
  border-color: var(--cta-button-border, var(--cta-accent, var(--btn-border-color, var(--cta-button-bg, var(--btn-bg, var(--color-accent))))));
}
.cta .btn:not(.btn--outline):not(.btn--ghost):not(.btn--secondary):hover {
  /* Global hover tier (issue 539) mirrors the rest chain above: --btn-hover-bg last before
     the literal in the fill, --btn-hover-border-color straight after this rule's own hover
     border slot. As on the hero, routing the fill here is load-bearing rather than
     decorative — this rule is [0,6,0] and decides background-COLOR, while the premium
     [0,5,1] hover rule decides background-IMAGE; once the global knob clears the gradient
     there, this declaration is what actually paints.
     ORDER (issue 548, extended by issue 564): --cta-accent-hover outranks the hover FILL in
     the BORDER chain (#538's Option-3 order) AND, since #564, the global
     --btn-hover-border-color knob as well. Accent-above-own-FILL is uniform across every
     filled hover chain this theme ships — hero primary, hero cta2, cta button2, this one and
     their overlay/cover twins. Since #564 accent-above-the-GLOBAL-knob is uniform across them
     too: the cta family used to rank --btn-hover-border-color first while the hero ranked
     --hero-accent-hover first, and that split is what let a site-wide ring knob defeat an
     authored band accent on cta bands only. Both components now rank the band accent first.
     The invariant is still narrower than "accent leads": --cta-button-hover-border, the
     per-instance ring slot, remains above everything. It is the escape hatch for a site that
     genuinely wants a different ring here.
     --btn-hover-border-color keeps its position ABOVE the per-instance hover FILL link, so
     #539's "an explicitly authored global ring beats one merely inferred from a fill" still
     holds and #554's coverage contract is untouched. Only the accent/global pair moved.
     The REST chain above is now this chain's POSITIONAL TWIN (issue 564): every link holds
     the same rank as its resting equivalent, so the rest/hover ring FLIP this comment used to
     document is gone rather than merely tolerated. With --cta-accent, --cta-accent-hover and
     both fill slots authored, the ring is the ACCENT in both states instead of matching the
     fill at rest and flipping on pointer-enter. Retiring that flip is #538's Option 2, which
     #538 reserved to the maintainer and #564 reopened deliberately
     (issuecomment-5106604500). Verified under a real pointer, not only at the settled hover
     value: the #540 snap transition-property contract is untouched, so both states are
     settled states. */
  background-color: var(--cta-button-hover-bg, var(--cta-accent-hover, var(--btn-hover-bg, var(--color-accent-hover))));
  border-color: var(--cta-button-hover-border, var(--cta-accent-hover, var(--btn-hover-border-color, var(--cta-button-hover-bg, var(--btn-hover-bg, var(--color-accent-hover))))));
}

/* CTA button (single button): per-instance bg/border/color override,
   independent of --cta-accent (issue 111). One rule per variant, each falling
   back to that variant's own existing default, so an unset override renders
   byte-identically to today. */
.cta__button:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  background-color: var(--cta-button-bg, var(--cta-accent, var(--color-accent)));
  border-color: var(--cta-button-border, var(--cta-accent, var(--color-accent)));
  color: var(--cta-button-color, var(--btn-text, var(--color-bg)));
  /* Consume --cta-button-shadow in the cta block so the slot-contract keystone sees it
     wired (issue 412), scoped to the PRIMARY variant only (matching the slot's doc and
     the premium `main .btn:not(...)` winner). Default `none`: the premium winner ([0,4,1])
     supplies the bevel via its own var(--cta-button-shadow, <bevel>) fallback, so an unset
     primary button is byte-identical; setting --cta-button-shadow: none flattens rest +
     hover. Outline/ghost/secondary keep no shadow (they never match the premium winner). */
  box-shadow: var(--cta-button-shadow, none);
}
.cta__button:not(.btn--outline):not(.btn--ghost):not(.btn--secondary):hover {
  background-color: var(--cta-button-hover-bg, var(--cta-accent-hover, var(--color-accent-hover)));
  border-color: var(--cta-button-hover-border, var(--cta-accent-hover, var(--color-accent-hover)));
  color: var(--cta-button-hover-color, var(--btn-text, var(--color-bg)));
}

.cta__button.btn--outline {
  background-color: var(--cta-button-bg, transparent);
  color: var(--cta-button-color, var(--color-accent));
  border-color: var(--cta-button-border, var(--color-accent));
}
.cta__button.btn--outline:hover {
  background-color: var(--cta-button-hover-bg, var(--color-accent));
  border-color: var(--cta-button-hover-border, var(--color-accent));
  color: var(--cta-button-hover-color, var(--color-bg));
}

.cta__button.btn--secondary {
  background-color: var(--cta-button-bg, var(--color-surface));
  color: var(--cta-button-color, var(--color-text));
  border-color: var(--cta-button-border, var(--color-border));
}
.cta__button.btn--secondary:hover {
  background-color: var(--cta-button-hover-bg, var(--color-border));
  border-color: var(--cta-button-hover-border, var(--color-border));
  color: var(--cta-button-hover-color, var(--color-text));
}

.cta__button.btn--ghost {
  background-color: var(--cta-button-bg, transparent);
  color: var(--cta-button-color, var(--color-accent));
  border-color: var(--cta-button-border, transparent);
}
.cta__button.btn--ghost:hover {
  background-color: var(--cta-button-hover-bg, var(--color-surface));
  border-color: var(--cta-button-hover-border, transparent);
  color: var(--cta-button-hover-color, var(--color-accent));
}

/* Second CTA button (issue 474): the pair row + its own per-instance slots.
   The wrapper is rendered ONLY when button2_text is set (cta.php), so a
   single-button cta keeps today's markup and computed styles byte-for-byte.
   flex-wrap + the shared `main .btn { width: 100% }` mobile rule is what makes
   the pair stack one-per-row under 768px with no mobile-specific rule here —
   the same mechanism .hero__cta-group relies on (confirmed by rendering at
   375px). justify-content is STATED rather than inherited from the flexbox
   initial value, per the issue 338 lesson: the packing only becomes visible
   once the buttons wrap, so leaving it implicit ships a silently left-packed
   row on a centered band. */
.cta__buttons {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-sm);
  justify-content: flex-start;
  /* Deliberately NO flex-shrink: 0 here (matching .hero__cta-group). The individual
     buttons keep their own flex-shrink from .cta__button, so they never squash; making
     the WRAPPER rigid instead would pin it at both buttons' min-width (~432px) in the
     inline layout's `space-between` row, which both prevents the flex-wrap above from
     ever firing there AND crushes the text block. Measured at 768px: rigid wrapper
     forces the title to 3 lines, shrinkable wrapper wraps the pair to two rows and
     keeps the title at the 2 lines a single-button inline cta renders. */
}

.cta--full-width .cta__buttons {
  justify-content: center;
}

/* Per-instance bg/border/color override for the SECOND button, independent of
   the primary (the issue 111 idiom the primary already has, mirrored from the
   hero's cta2 rules). Targeted via the dedicated .cta__button--secondary class
   rather than a positional :nth-child selector — this codebase's CSS lint guard
   forbids nth-child/nth-of-type entirely, and button_text/button2_text are
   fixed named props (not a reorderable array), so a dedicated class is both
   safe and simpler. Three classes + the :not() chain puts these at [0,6,0],
   above `.cta .btn:not(...)` at [0,5,0] (the primary's fill winner), so the
   primary's accent chain cannot repaint the second button. One rule per
   variant, each falling back to THAT variant's own existing default, so an
   unset override renders byte-identically to the matching single button. */
.cta .cta__buttons .cta__button--secondary:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  /* Fill chain mirrors the PRIMARY's winner (`.cta .btn:not(...)` above) exactly,
     including the site-wide --btn-bg token from #458. Dropping --btn-bg here would
     make a `primary` + `primary` pair render two DIFFERENT fills on any site that
     rethemes the global button surface: the primary would paint --btn-bg while the
     second fell through to --color-accent. Unset, both bottom out identically. */
  background-color: var(--cta-button2-bg, var(--cta-accent, var(--btn-bg, var(--color-accent))));
  /* Border FOLLOWS the fill when its own knobs are unset (the #514/#526 idiom, in the
     PRIMARY's exact order AT REST — and since #564 the hover twin below no longer diverges
     from THIS rest order either, so this button's two states are positional twins):
     --cta-button2-border wins, then the BAND ACCENT (#564), then the global
     --btn-border-color knob, then the fill chain — so a filled second button recolored
     with the fill slot alone still keeps a MATCHING ring, while one recolored with
     --cta-accent gets the accent ring in BOTH states rather than the fill at rest and the
     accent on hover. Unset, the chain still bottoms out at --color-accent, byte-identical;
     so is every single-knob configuration. See the primary's rest chain above for the two
     repainted configurations, both recorded on #564. */
  border-color: var(--cta-button2-border, var(--cta-accent, var(--btn-border-color, var(--cta-button2-bg, var(--btn-bg, var(--color-accent))))));
  color: var(--cta-button2-color, var(--btn-text, var(--color-bg)));
}
.cta .cta__buttons .cta__button--secondary:not(.btn--outline):not(.btn--ghost):not(.btn--secondary):hover {
  background-color: var(--cta-button2-hover-bg, var(--cta-accent-hover, var(--btn-hover-bg, var(--color-accent-hover))));
  /* Border follows the hover fill from the LAST fallback position (issue 538, Option 3 —
     the recorded decision), the exact twin of the hero's cta2 hover ring above. #530 left
     the fill out entirely because the hover BORDER, unlike the hover FILL, was never masked
     by the premium gradient: inserting --cta-button2-hover-bg AHEAD of --cta-accent-hover
     would have repainted the authored accent ring on any site running both slots, and
     --cta-button2-hover-bg shipped in v1.10.0 and is live in production.
     Behind --cta-accent-hover it changes exactly one case: the fill slot SET with both
     --cta-button2-hover-border and --cta-accent-hover unset, i.e. the fill-only author who
     used to get a --color-accent-hover ring around a brand-colored pill. Authored values
     still win in their existing order, and unset the chain still bottoms out at
     --color-accent-hover (byte-identical).
     REST-vs-HOVER PARITY (issue 564): this hover chain and the REST chain above are now
     POSITIONAL TWINS — each link holds the same rank as its resting equivalent. The
     asymmetry this comment used to document (fill-coloured ring at rest, accent-coloured
     ring on hover, for a site setting --cta-accent, --cta-accent-hover AND both fill slots)
     is gone. Closing it required moving the REST chain onto the accent-above-fill order,
     i.e. #538's Option 2 — reserved to the maintainer by #538 and reopened deliberately on
     #564 (issuecomment-5106604500). The StyleSlotContractTest pin that used to STOP this
     reorder is now a positive pin OF it; do not re-invert it without a new decision.
     What is NOT asymmetric any more is the pair: issue 548 moved the cta PRIMARY's filled
     hover rule onto this same accent-above-fill order, so both buttons of a cta band now resolve
     their rings in the same ORDER. Same order is not the same colour: each button still reads
     its OWN hover-border and hover-fill slots, so the pair matches wherever the winning link
     is a shared knob and differs wherever it is a per-button one. What is gone is the pair
     disagreeing about WHICH KIND of link wins. The hero pair never diverged either: its rest and
     hover chains both put the fill last.
     Same overlay-band consequence as the hero's cta2 (see that comment): this [0,7,0] rule
     outranks #535's [0,6,0] `.cta--has-bg-image .cta__button:not(...):hover` separation ring,
     which never reached button2 anyway. A fill-only button2 on a photo band now hovers to a
     ring matching its fill instead of --color-accent-hover; neither clears 1.4.11 over the
     scrim and rest already followed the fill, which is why #543 shipped button2's own
     separation ring as a rest AND a hover twin (below; each matches its own base twin's
     specificity — [0,6,0] rest, [0,7,0] hover — and follows it in source order). On every
     band WITHOUT the bg-image class this chain is still the winner.
     Not mirrored onto the outline/ghost/secondary button2 hover rules — see the hero cta2
     comment above for the two separate reasons (ghost bottoms out at `transparent`;
     outline/secondary don't follow the fill in either state). */
  /* Global hover tier (issue 539) takes the position --btn-border-color holds in this
     button's rest chain: after its own hover border slot and after the BAND ACCENT (#564
     moved the accent above the global knob in both states), ahead of the per-instance hover
     fill link. --btn-hover-bg joins the tail of that link so a site-wide hover fill still
     rings itself, and the global ring knob still outranks the fill link so #539's
     authored-beats-inferred rule and #554's coverage contract both hold. */
  border-color: var(--cta-button2-hover-border, var(--cta-accent-hover, var(--btn-hover-border-color, var(--cta-button2-hover-bg, var(--btn-hover-bg, var(--color-accent-hover))))));
  color: var(--cta-button2-hover-color, var(--btn-text, var(--color-bg)));
}

.cta .cta__buttons .cta__button--secondary.btn--outline {
  background-color: var(--cta-button2-bg, transparent);
  color: var(--cta-button2-color, var(--color-accent));
  border-color: var(--cta-button2-border, var(--color-accent));
}
.cta .cta__buttons .cta__button--secondary.btn--outline:hover {
  background-color: var(--cta-button2-hover-bg, var(--color-accent));
  border-color: var(--cta-button2-hover-border, var(--color-accent));
  color: var(--cta-button2-hover-color, var(--color-bg));
}

.cta .cta__buttons .cta__button--secondary.btn--secondary {
  background-color: var(--cta-button2-bg, var(--color-surface));
  color: var(--cta-button2-color, var(--color-text));
  border-color: var(--cta-button2-border, var(--color-border));
}
.cta .cta__buttons .cta__button--secondary.btn--secondary:hover {
  background-color: var(--cta-button2-hover-bg, var(--color-border));
  border-color: var(--cta-button2-hover-border, var(--color-border));
  color: var(--cta-button2-hover-color, var(--color-text));
}

.cta .cta__buttons .cta__button--secondary.btn--ghost {
  background-color: var(--cta-button2-bg, transparent);
  color: var(--cta-button2-color, var(--color-accent));
  border-color: var(--cta-button2-border, transparent);
}
.cta .cta__buttons .cta__button--secondary.btn--ghost:hover {
  background-color: var(--cta-button2-hover-bg, var(--color-surface));
  border-color: var(--cta-button2-hover-border, transparent);
  color: var(--cta-button2-hover-color, var(--color-accent));
}

/* Dark-band routing for the SECOND button's transparent-fill variants (issue 474,
   7A decision). outline/ghost paint their ink and ring DIRECTLY on the band, and
   the light-surface --color-accent measures 3.23:1 on --color-bg-inverted and
   1.17:1 over the worst-case bg-image scrim — both below AA. `outline` is the
   DEFAULT for the second button, so simply setting button2_text on an inverted
   closing CTA would otherwise ship a sub-AA control without the author ever
   choosing a variant. Route the FALLBACK through the same named role tokens this
   component already uses on its sibling dark-band elements — .cta--inverted
   .cta__body a (#437), .cta--has-bg-image .cta__body a (#461), and
   .cta--has-bg-image .cta__title-accent (#463) — so no new color is invented and
   the #61/#86 dark-surface-slot contract holds: --cta-button2-color /
   --cta-button2-border still win when set, and unset falls to the AA tint.
   Same [0,4,0] specificity as the base variant rules above, so these must stay
   AFTER them in source order. Scoped to the second button: the primary's
   explicitly-authored outline/ghost on a dark band is the pre-existing, wider
   class and is deliberately untouched here. Hover is unchanged — it fills with
   the accent (outline) or the light surface (ghost), so the hover ink already
   sits on its own contrasting fill rather than on the band. */
.cta--inverted .cta__buttons .cta__button--secondary.btn--outline {
  color: var(--cta-button2-color, var(--color-accent-on-inverted));
  border-color: var(--cta-button2-border, var(--color-accent-on-inverted));
}
.cta--inverted .cta__buttons .cta__button--secondary.btn--ghost {
  color: var(--cta-button2-color, var(--color-accent-on-inverted));
}

.cta--has-bg-image .cta__buttons .cta__button--secondary.btn--outline {
  color: var(--cta-button2-color, var(--color-accent-on-overlay));
  border-color: var(--cta-button2-border, var(--color-accent-on-overlay));
}
.cta--has-bg-image .cta__buttons .cta__button--secondary.btn--ghost {
  color: var(--cta-button2-color, var(--color-accent-on-overlay));
}

/* Dark-band routing for the PRIMARY button's transparent-fill variants (issue 535).
   #474 fixed this for the SECOND button and deliberately left the primary — an
   explicitly authored `button_variant: outline|ghost` on a dark band — as the wider
   pre-existing class. This is that class. The mechanism, the role tokens and the slot
   precedence are identical to the button2 rules above; only the selector differs:
   --color-accent paints 3.23:1 on --color-bg-inverted and 1.17:1 over the worst-case
   bg-image scrim, both below AA, and both now fall back to the role token this component
   already uses for its dark-band body links (#437/#461) and title accent (#463).
   Both figures are base.css's, not re-derived here: 8.33:1 on inverted, 4.59:1 over the overlay.
   These sit at [0,3,0] — deliberately BELOW the button2 rules above at [0,4,0] — so the
   second button keeps its own routing and the #474/#526/#530 pins stay green even though
   button2 also carries the .cta__button class. --cta-button-color / --cta-button-border
   still win when set. REST state only — the hover restoration block below explains why
   these four rules must not be allowed to reach `:hover`. */
.cta--inverted .cta__button.btn--outline {
  color: var(--cta-button-color, var(--color-accent-on-inverted));
  border-color: var(--cta-button-border, var(--color-accent-on-inverted));
}
.cta--inverted .cta__button.btn--ghost {
  color: var(--cta-button-color, var(--color-accent-on-inverted));
}

.cta--has-bg-image .cta__button.btn--outline {
  color: var(--cta-button-color, var(--color-accent-on-overlay));
  border-color: var(--cta-button-border, var(--color-accent-on-overlay));
}
.cta--has-bg-image .cta__button.btn--ghost {
  color: var(--cta-button-color, var(--color-accent-on-overlay));
}

/* HOVER restoration (issue 535). The four rest rules above are [0,3,0] — the SAME
   specificity as the `.cta__button.btn--outline:hover` / `.btn--ghost:hover` rules they
   follow (a pseudo-class counts as a class), so source order alone made them win on
   hover too and the dark-band ink leaked into a state it was never meant to reach.
   Rendered, that was worse than the bug being fixed: outline hover fills with
   --color-accent, so on-inverted ink over it measured 2.58:1, and ghost hover fills with
   the near-white --color-surface, where the on-overlay ink is effectively invisible.
   These rules re-assert each variant's OWN documented hover values at [0,4,0].
   The dark-band routing is a REST-state fix by design: on hover both variants paint a
   contrasting fill of their own, so the ink no longer sits on the band and needs no
   role token. The button2 rules above never needed this — their selectors carry one more
   ancestor class, so their `:hover` siblings already outrank them. Values here are copied
   verbatim from those hover rules; changing one without the other reopens this leak. */
.cta--inverted .cta__button.btn--outline:hover,
.cta--has-bg-image .cta__button.btn--outline:hover {
  color: var(--cta-button-hover-color, var(--color-bg));
  border-color: var(--cta-button-hover-border, var(--color-accent));
}
.cta--inverted .cta__button.btn--ghost:hover,
.cta--has-bg-image .cta__button.btn--ghost:hover {
  color: var(--cta-button-hover-color, var(--color-accent));
}

/* Separation ring for the FILLED button on the bg-image band (issue 535, defect 2's
   class-triggerable half; the .hero--cover twin of this rule lives in the hero block).
   The premium gradient fill measures well under 2:1 against the worst-case overlay-over-
   white composite — the button's SHAPE vanishes and only its label carries it — and the
   border FOLLOWED the fill, so it added nothing. Here the border stops following the fill
   and bottoms out at the on-overlay role (4.59:1, base.css) instead, giving the pill a
   visible edge.
   Every PER-INSTANCE link ahead of the role is preserved in the `.cta .btn:not(...)` order —
   --cta-button-border, then --cta-accent, then the per-instance fill --cta-button-bg — so a
   button whose ring an author already colours keeps exactly the colour it had.
   Dropping those links and jumping straight to the role would silently repaint every
   authored --cta-accent ring on a photo band near-white.
   The GLOBAL tier is deliberately ABSENT here in full: the ring knob --btn-border-color
   (issue 564) and the fill knob --btn-bg (issue 565). On this band the terminal is a measured
   4.59:1 separation role, not an ordinary default, and a site-wide retheme sitting above it
   defeated the guarantee this rule exists to make — the ring knob directly (the #564 defect),
   the fill knob through the border-follows-fill link (the #565 defect: a site setting only
   --btn-bg repainted every unauthored photo-band ring to a colour never measured against the
   scrim). --color-accent-on-overlay is declared at :root (base.css) and therefore always set,
   so parking either knob below it would be dead code rather than a demotion — hence removal.
   Per-instance slots remain above everything as the escape hatch. Stated as resolved values
   rather than CSS bytes: the unset render is computed-value-identical, and so is every
   per-instance-authored render — only the two GLOBAL knobs stopped being honoured here.
   One more configuration moved on this chain, and it is not a global-knob case: --cta-accent
   also rose above --cta-button-bg (mirroring the base rest chain), so a band authoring the
   accent AND the per-instance fill now rings on the accent at rest instead of the fill. That
   is #564's class (c), the same repaint the plain band takes, and it is what keeps this rule a
   positional twin of its :hover below.
   SCOPE OF THE HARDENING, stated so it is not over-read: what left this chain is the GLOBAL
   tier, both halves of it, and nothing else. #564 removed the ring knob and recorded that the
   fill knob --btn-bg was still routed here — the gap it deliberately declined to widen into,
   filed as #565. #565 closed it: the border-follows-fill link that let a flattened button keep
   a matching ring (#535) is kept, but only from the PER-INSTANCE slot --cta-button-bg upward.
   That is the scope #535's promise was written for — an author who flattens THIS band. A
   site-wide fill retheme is not that, and can no longer pull this ring off the measured role.
   So read the 4.59:1 figure as the UNAUTHORED default, not an invariant: every PER-INSTANCE
   link above the role (the ring slot, the band accent, the band's own fill) can still put a
   colour of the author's choosing on this ring, by design. What #564 and #565 changed is only
   that a knob nobody aimed at THIS band can no longer do it.
   [0,5,0] matches `.cta .btn:not(...)` above, so this wins only by staying AFTER it in
   source order (pinned in css-lint). The :hover twin is [0,6,0] to beat
   `.cta .btn:not(...):hover`, whose border also follows the fill — without it the ring
   reappeared at rest and dissolved again the moment the pointer landed, which is the
   state a user is most likely looking at (WCAG 1.4.11 applies to hover too).
   The INVERTED band is deliberately NOT ringed (issue 535 Q2): its filled button measures
   3.23:1 fill-vs-band, which already clears the 3:1 non-text bar, so a ring there would be
   a visual change with no measured defect behind it. A band an author darkened with
   --cta-bg carries no class at all and is tracked in #541 as a trigger-mechanism design
   problem, not a colour problem. A filled SECOND button keeps its own [0,6,0]/[0,7,0] rules
   and is therefore NOT ringed by this rule; it gets the same treatment from its own twin
   pair below (issue 543), which is where a change to the second button's ring belongs.
   "Same treatment" now means the same MECHANISM, the same terminal AND the same chain ORDER:
   issue 548 moved this rule onto #538's Option-3 order (--cta-accent-hover AHEAD of the hover
   fill), which button2's twin below already used. Before that, a site authoring
   --cta-accent-hover TOGETHER with both per-button hover fills hovered to two different ring
   colours on this one band, side by side; that residue is gone. Issue 564 completed the
   alignment: the rest twin now carries the accent-above-fill order too, so the two states are
   positional twins and the ring cannot change role on pointer-enter. With the accent knob
   unset the fill still rings itself from its later position, and with both unset the chain
   reaches the same on-overlay role, so the fill-only and unset renders this rule shipped with
   are unchanged. StyleSlotContractTest pins both orders. */
.cta--has-bg-image .cta__button:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  border-color: var(--cta-button-border, var(--cta-accent, var(--cta-button-bg, var(--color-accent-on-overlay))));
}
.cta--has-bg-image .cta__button:not(.btn--outline):not(.btn--ghost):not(.btn--secondary):hover {
  border-color: var(--cta-button-hover-border, var(--cta-accent-hover, var(--cta-button-hover-bg, var(--color-accent-on-overlay))));
}

/* The SAME separation ring for the filled SECOND button (issue 543). #535 scoped its ring
   to the filled PRIMARY, so a `primary` + `primary` pair on a photo band rendered one
   button with a visible edge next to one that dissolved into the scrim — same fill, same
   band, different treatment. This is not a defect #535 introduced: button2 was equally
   invisible before it, and fixing the neighbour is what made the asymmetry visible.
   Mechanism, rationale and measured ratios are the PRIMARY's above, unchanged: the fill
   is under 2:1 against the worst-case overlay-over-white composite, so the border stops
   following it and bottoms out at the on-overlay role (4.59:1, base.css).
   Both chains are their base rules' chains (`.cta .cta__buttons .cta__button--secondary:not(...)`
   and its :hover, above) with --color-accent / --color-accent-hover swapped for the role
   token AND the global tier removed — the ring knob by issue 564, the fill knob by issue 565 —
   so every PER-INSTANCE link keeps winning in its existing position: --cta-button2-border,
   --cta-accent and the band's own rest fill at rest; --cta-button2-hover-border,
   --cta-accent-hover then its own hover fill on hover. Since #564 those two orders are
   positional twins rather than the deliberate asymmetry this comment used to record — see the
   base rule's comment.
   Both global knobs are absent for the reason the primary's ring above spells out: on this band
   the terminal is a measured 4.59:1 role, --color-accent-on-overlay is always set at :root,
   so a global knob could only sit above the role (defeating it) or below it (dead). Removing
   each from BOTH halves keeps the twins from disagreeing across the pointer transition.
   Consequence, accepted with the fix and identical to the primary's: an author who recolours
   only THIS BAND's fill (--cta-button2-bg) still gets a ring matching that fill rather than a
   near-white one. The ring follows a colour an author aimed at this band; it bottoms out at
   the role token when nothing band-level is authored — a site-wide --btn-bg included.
   [0,6,0] rest / [0,7,0] hover — the SAME specificity as the base button2 rules, so
   these win only by following them in source order (pinned in css-lint, the #535 idiom).
   They must also stay ABOVE the `.cta--dark` theme block below, which warns against
   exactly this kind of rule being added down there; that boundary is pinned too, so the
   warning is enforced rather than merely written.
   The SOLID inverted band is deliberately NOT ringed (issue 535 Q2): its filled button
   measures 3.23:1 fill-vs-band, already clearing the 3:1 non-text bar. A band carrying
   BOTH classes is a different case and DOES ring: cta.php emits the theme class and the
   bg-image class independently, so `theme: "inverted"` + `background_image` renders
   `.cta--inverted.cta--has-bg-image` with the scrim over the inverted background, and the
   overlay role must win there — on-inverted is only ~2.2:1 over an arbitrary image. Same
   precedence #535 and #542 already pin for the primary and the focus ring.
   Blind spot, shared with the whole class-triggered role family (#461/#535/#541/#542): a
   scrim an author LIGHTENS with --cta-overlay-bg still carries this class and still gets
   the near-white ring, whose 4.59:1 is measured against the default scrim. */
.cta--has-bg-image .cta__buttons .cta__button--secondary:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  border-color: var(--cta-button2-border, var(--cta-accent, var(--cta-button2-bg, var(--color-accent-on-overlay))));
}
.cta--has-bg-image .cta__buttons .cta__button--secondary:not(.btn--outline):not(.btn--ghost):not(.btn--secondary):hover {
  border-color: var(--cta-button2-hover-border, var(--cta-accent-hover, var(--cta-button2-hover-bg, var(--color-accent-on-overlay))));
}

/* button2 slot isolation + premium fill routing (the issue 526 mechanism applied
   to cta). Style slots are emitted as inline custom properties on the .cta ROOT,
   so the PRIMARY button's --cta-button-* slots INHERIT down to the second button;
   when button2 is authored as a filled `primary` variant it also matches the
   shared premium `main .btn:not(...)` winner, and the primary's fill, ink and
   elevation repaint it. A declaration ON the button2 element beats the inherited
   value regardless of source order, so this one rule fixes it:
     - `--cta-button-bg: var(--cta-button2-bg)` — a var() that fails to substitute
       makes the custom property GUARANTEED-INVALID, so with --cta-button2-bg unset
       every downstream var(--cta-button-bg, <fallback>) takes its fallback (the
       premium chain's own --btn-bg -> gradient literal): unset stays byte-identical
       and the leak is killed. With --cta-button2-bg SET, the flat color resolves the
       premium `background:` SHORTHAND to `background: <color>`, which resets
       background-image to none and CLEARS the gradient that would otherwise MASK the
       slot — the same mechanism #514/#526 used for the hero.
     - `--cta-button-color: initial` — `initial` IS the guaranteed-invalid value for a
       custom property, so button2 falls back to its own ink rule above instead of the
       primary's ink. This half is DEFENSIVE today rather than load-bearing: button2's own
       rules sit at [0,6,0]/[0,7,0] and already outrank every current --cta-button-color
       consumer (.cta__button:not(...) at [0,4,0], the variant rules at [0,2,0], and the
       premium `main .btn:not(...)` at [0,4,1]). It is kept so the isolation holds even if
       a future rule wires the slot more widely.
       The ELEVATION half is load-bearing (button2 has no elevation rule of its own) and
       is handled by the re-point below — see the --cta-button-shadow comment. Flattening
       the PRIMARY with --cta-button-shadow: none still does not flatten button2; that is
       what --cta-button2-shadow is for (issue 581 completed this follow-up).
   Applied to EVERY button2 variant on purpose. The transparent-fill variants DO reach
   --cta-button-* consumers (`.cta__button.btn--outline` and its --secondary/--ghost
   siblings read --cta-button-bg/-color/-border at [0,2,0], and button2 carries the
   .cta__button class too), so they are not immune by construction — they are covered
   because button2's own variant rules at [0,4,0] outrank those. Resetting the slots on
   every variant keeps the isolation true on its own terms rather than resting on that
   specificity margin, which a future edit could shift.
     - `--cta-button-hover-bg: var(--cta-button2-hover-bg)` (issue 530) — the same
       guaranteed-invalid re-pointing applied to the HOVER surface. It closes both halves of
       the hover defect at once: button2's own hover fill now resolves the premium
       `background:` SHORTHAND to a flat color (clearing the gradient background-image that
       used to MASK it), and the PRIMARY's --cta-button-hover-bg no longer leaks onto
       button2 (the cross-button coupling found in #474's review — setting the primary's
       hover fill also cleared button2's hover gradient). Unset, the var() is
       guaranteed-invalid, so the chain takes the premium hover literal: byte-identical.
       Rest and hover are now isolated symmetrically. */
.cta .cta__buttons .cta__button--secondary {
  --cta-button-bg: var(--cta-button2-bg);
  --cta-button-hover-bg: var(--cta-button2-hover-bg);
  --cta-button-color: initial;
  /* Issue 581 (A-18) — the elevation half, re-pointed rather than hard-invalidated.
     `initial` killed the primary's leak but left button2 with NO elevation slot of its
     own, which the comment above recorded as the follow-up. Re-pointing at
     --cta-button2-shadow uses the identical guaranteed-invalid idiom as
     --cta-button-bg (#526) and --cta-button-hover-bg (#530) two lines up: UNSET, the
     var() cannot substitute, so the property is guaranteed-invalid exactly as `initial`
     was and every downstream var(--cta-button-shadow, <bevel>) still takes its own
     fallback — byte-identical. SET, button2's elevation enters the premium chain at the
     position button2's elevation belongs. The isolation stays load-bearing either way:
     the PRIMARY's inherited --cta-button-shadow still cannot reach button2, so
     --cta-button-shadow: none flattens the primary only. */
  --cta-button-shadow: var(--cta-button2-shadow);
}

/* Full-width: centered block with surface background */
.cta--full-width {
  background: var(--cta-bg, var(--color-surface));
  border-top: var(--cta-border-width, 1px) solid var(--cta-border-color, var(--color-border));
  border-bottom: var(--cta-border-width, 1px) solid var(--cta-border-color, var(--color-border));
}

.cta--full-width .cta__inner {
  align-items: center;
  text-align: center;
}

/* Full-width centering, in the BASE rules (issue 412, Symptom 2). `.cta__inner` centers
   its children and caps `.cta__title`/`.cta__body` at --cta-heading-measure, but the
   `.cta__text` wrapper had no width constraint: a long body stretched it to the full
   inner width, so the capped title left-pinned inside it (text-align only centers the
   glyphs WITHIN that left-pinned box). Cap `.cta__text` at the same content width and
   center it, so an authored full-width CTA with ANY id centers title/body at the
   default --cta-heading-measure — no id-scoped rule and no widened max-width needed. */
.cta--full-width .cta__text {
  max-width: var(--cta-heading-measure, var(--measure-heading));
  margin-inline: auto;
}

/* Inline: flex row, text left, button right */
.cta--inline .cta__inner {
  align-items: flex-start;
  text-align: left;
}

@media (min-width: 768px) {
  .cta--inline .cta__inner {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
  }
}

.cta__button {
  flex-shrink: 0;
  /* Consume --cta-button-shadow in the cta block so the slot-contract keystone sees
     it wired (issue 412). Default `none`: the premium `main .btn` winner ([0,4,1]) supplies
     the bevel via its own var(--cta-button-shadow, <bevel>) fallback, so an unset
     button is byte-identical; setting --cta-button-shadow: none flattens rest + hover. */
  box-shadow: var(--cta-button-shadow, none);
}

/* CTA theme variants */
.cta--dark {
  background: var(--cta-bg, var(--color-surface));
  border-top: var(--cta-border-width, 1px) solid var(--cta-border-color, var(--color-border));
  border-bottom: var(--cta-border-width, 1px) solid var(--cta-border-color, var(--color-border));
}

/* NOTE (issues 474, 535, 543): the dark-band routing for BOTH buttons' outline/ghost
   variants, and the bg-image separation ring for BOTH the filled primary (#535) and the
   filled second button (#543), live ABOVE with the other button rules, not in this theme
   block. They span [0,3,0]–[0,7,0] and every one of them depends on source order against
   the base button rules they follow. Do not add a competing `.cta--inverted` /
   `.cta--has-bg-image` button rule down here: it would win by source order and silently
   defeat the AA routing. */
.cta--inverted {
  background: var(--cta-bg, var(--color-bg-inverted));
  --pp-cta-body-theme-color: var(--color-bg);
}

.cta--inverted .cta__title {
  color: var(--cta-heading-color, var(--color-bg));
}

/* Inverted twin of the #463 --has-bg-image title-accent rule, which was never written
   (issue 577). Same reason: the accent substring paints its own color and does not
   inherit the light title above it, so it rendered bare --color-accent at 3.23:1 on the
   dark band. Routes the ratified on-inverted role (8.33:1); the per-instance
   --cta-heading-accent-color slot still wins. */
.cta--inverted .cta__title-accent {
  color: var(--cta-heading-accent-color, var(--color-accent-on-inverted));
}

/* The `:not(.cta--has-bg-image)` is load-bearing (issue 577). cta.php emits the theme
   class and the bg-image class INDEPENDENTLY, so `theme:"inverted"` + `background_image`
   renders BOTH. The 12.76:1 below is measured against the SOLID inverted band; on the
   overlay band the same alpha measures 3.87:1, which is the failure rows 6+7 exist to
   correct. Without this carve-out the opacity would survive on the combined band (only
   `color` is re-declared by the --has-bg-image rule) and silently defeat the
   correction there. */
.cta--inverted:not(.cta--has-bg-image) .cta__body {
  color: var(--cta-body-color, var(--color-bg));
  /* Deliberate de-emphasis; measured at 12.76:1 against a 4.5:1 bar. Reopen if the
     inverted band token moves such that the measured ratio falls below 7:1. */
  opacity: 0.85;
}
/* cta__body became an inline-HTML surface in #439 (a/strong/em/br), so an inverted
   CTA can now carry a real body link sitting DIRECTLY on the dark band. The light-
   surface accent (--color-accent) is only 3.23:1 there and fails WCAG AA, so the
   link's default routes through the on-inverted accent role (#437). It reads through
   the existing --cta-body-color slot first so the #61/#86 dark-surface-slot contract
   holds (an author fixes this instance's link colour via the safe --cta-body-color
   surface, no late-cascade patch); unset, it falls back to the AA on-inverted tint.
   Scoped to .cta__body so the premium CTA button (.cta__button, owned by the
   `main .btn` cascade) is untouched. */
.cta--inverted .cta__body a {
  color: var(--cta-body-color, var(--color-accent-on-inverted));
}

.cta--inverted .cta__body a:hover {
  color: var(--cta-body-color, var(--color-accent-on-inverted-hover));
}

/* CTA with background image */
/* Same dark-overlay surface as .section--has-bg-image — same theme text default
   (issue 248). */
.cta--has-bg-image {
  position: relative;
  background-size: cover;
  background-position: var(--cta-bg-position, center);
  background-repeat: no-repeat;
  /* Was `border: none` — a SHORTHAND whose border-style:none suppressed the
     slot-routed longhands .cta declares above, so --cta-border-width /
     --cta-border-color were dead on every background-image band. Restate the two
     longhands .cta actually declares (it sets no left/right border anywhere) with the
     unset result preserved: 0-width transparent, which paints nothing exactly as
     `border: none` did (issue 577). */
  border-top: var(--cta-border-width, 0) solid var(--cta-border-color, transparent);
  border-bottom: var(--cta-border-width, 0) solid var(--cta-border-color, transparent);
  /* THE CHAIN POSITION for this band's body ink (issue 577). Both .cta__body rules —
     the base one and the premium `main > .cta .cta__body` that outranks it at desktop —
     read --cta-body-color first and this theme property second, so the overlay-band
     contrast correction has to land HERE to reach the rendered ink at every breakpoint.
     Setting it on the band rather than restating a terminal in each rule is what the
     ruling means by "a role token or a chain position, never a literal". The inverted
     band keeps --color-bg: it measures 12.76:1 and was ratified as-is. */
  --pp-cta-body-theme-color: var(--color-muted-on-overlay);
}

.cta__overlay {
  position: absolute;
  inset: 0;
  background: var(--cta-overlay-bg, var(--overlay-bg));
}

.cta--has-bg-image .container {
  position: relative;
  z-index: 1;
}

.cta--has-bg-image .cta__title {
  color: var(--cta-heading-color, var(--color-bg));
}

/* MEASURED CONTRAST CORRECTION (issue 577). This band's worst case is the
   --overlay-bg scrim over a pure-WHITE image, an effective background of
   rgb(115,115,115) — the same worst case --color-accent-on-overlay is calibrated
   against (base.css). `color: --color-bg` at `opacity: 0.85` composites to
   rgb(231,232,234) and measures 3.87:1 there, below the 4.5:1 normal-text bar. With
   the opacity gone, --color-bg itself reaches only 4.658:1, so break-even alpha on
   this band is ~0.968: the literal was spending a margin that does not exist. The
   de-emphasis intent survives as a ROLE TOKEN rather than a second literal — see
   --color-muted-on-overlay in base.css for the measurement and the (tiny) budget.
   The terminal is NOT restated here: the band sets --pp-cta-body-theme-color, and this
   rule reads it, so the role is named in exactly one place. */
.cta--has-bg-image .cta__body {
  color: var(--cta-body-color, var(--pp-cta-body-theme-color));
}

/* A cta__body link sits DIRECTLY on the dark overlay-over-image band. The light-
   surface accent fails WCAG AA there, so its default routes through the overlay
   accent role (#461), mirroring the .cta--inverted .cta__body a rule above but with
   the overlay role (not on-inverted, which is tuned to the solid inverted bg). Reads
   through the existing --cta-body-color slot first so the #61/#86 dark-surface-slot
   contract holds; unset, it falls back to the AA on-overlay tint. Scoped to .cta__body
   so the premium CTA button (owned by the main .btn cascade) is untouched. */
.cta--has-bg-image .cta__body a {
  color: var(--cta-body-color, var(--color-accent-on-overlay));
}

.cta--has-bg-image .cta__body a:hover {
  color: var(--cta-body-color, var(--color-accent-on-overlay-hover));
}

/* The accented title substring paints its OWN color (0,1,0) and does NOT inherit
   the near-white .cta--has-bg-image .cta__title above, so on the dark overlay-over-
   image band it renders bare --color-accent at 1.16:1. Route the default through the
   overlay accent role (#463, mirroring the cta body-link fix from #461); the
   per-instance --cta-heading-accent-color slot still wins. */
.cta--has-bg-image .cta__title-accent {
  color: var(--cta-heading-accent-color, var(--color-accent-on-overlay));
}


/* ==========================================================================
   COMPONENT: footer
   Site footer with nav and copyright.
   ========================================================================== */

/* Dark-marketing-footer chrome (issue 300). The footer is template-owned
   (issue 223) with no composition style_slots, so its background/text/link
   colors come from whitelisted site options (pp_footer_bg / pp_footer_text /
   pp_footer_link_color) that base.php emits as inline --footer-* custom
   properties. Each property routes through var(--footer-*, <existing-literal>)
   so an unset footer renders byte-identical to before. These are NOT schema
   style_slots (footer declares none), so they are outside the issue 305 slot
   guard; FooterChromeTest pins the consume-plus-fallback contract instead. */
/* `background`, NOT `background-color` (issue 333): --footer-bg is gradient-typed
   (color OR gradient), and a gradient is a CSS <image> — background-color would drop
   it as invalid and the footer would paint nothing. The shorthand takes both. Its
   longhand reset is a no-op: this rule is the only place .site-footer sets background. */
.site-footer {
  background: var(--footer-bg, var(--color-surface));
  color: var(--footer-text, inherit);
  border-top: 1px solid var(--color-border);
  padding-top: var(--space-lg);
  padding-bottom: var(--space-lg);
}

/* Footer layout (issue 427). The inner stacks two rows: the column grid, then
   the copyright line (or, when pp_footer_note is set, the delimited bottom bar
   renders OUTSIDE .site-footer__inner — see below, unchanged from issue 335). */
.site-footer__inner {
  display: flex;
  flex-direction: column;
  gap: var(--space-lg);
}

/* The brand · nav · contact columns (issue 427). Mobile: a single-column grid
   stacks them in DOM order (brand -> nav -> contact) with a consistent rhythm.
   Desktop (>=1024px): grid-auto-flow:column makes exactly ONE equal 1fr track
   PER present column, so a fully configured footer is three aligned columns and
   a sparse one (only the always-present nav) degrades to a single full-width
   column with no phantom empty tracks. align-items:start aligns the column tops.
   Every direct child here is a real column (the copyright is a sibling of this
   grid, never inside it), which is what makes grid-auto-flow:column safe. */
.site-footer__columns {
  display: grid;
  gap: var(--space-lg);
}

@media (min-width: 1024px) {
  .site-footer__columns {
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
    align-items: start;
    gap: var(--space-xl);
  }
}

/* Column headings (issue 427/335). One consistent level and treatment across
   every column: both the menu and contact headings are an h2.site-footer__heading.
   When a label option (pp_footer_menu_label / pp_footer_contact_label) is unset,
   the column is headless-but-styled — no default heading text is injected, which
   keeps the option contract byte-identical (unset emits nothing) while the grid
   still reads as a deliberate row. See .site-footer__heading below for the type. */

/* Brand column (logo + optional blurb) and the optional contact block. Minimal,
   single-axis styling — the footer stays a flat marketing footer, not a builder
   (issue 300 scope). */
.site-footer__brand {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}

/* Social-icon row (issue 382), landing in the slot reserved by issue 427: a
   horizontal, wrapping row under the brand blurb. The row is a <ul>, so the
   list chrome is reset here. Each icon link routes its color through the footer
   link slot (--footer-link-color, muted fallback) exactly like the nav links,
   so the row follows pp_footer_link_color and an unset footer is unchanged; the
   inline SVG inherits that color via fill:currentColor. */
.site-footer__social {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-sm);
  margin-top: var(--space-sm);
  padding: 0;
  list-style: none;
}

.site-footer__social-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--footer-link-color, var(--color-muted));
  transition: color var(--transition);
}

.site-footer__social-link:hover {
  color: var(--color-accent);
}

.site-footer__social-icon {
  display: block;
  width: 1.25rem;
  height: 1.25rem;
}

/* max-width: 32ch is the footer's ONLY measure cap (issue 582). The footer is a
   tight dark-marketing-footer surface, not a general footer builder: a brand blurb
   is a short descriptor sitting beside two or three menu columns, not body copy. A
   `ch` unit keeps it short at ANY type size, because it scales with the font rather
   than fixing a pixel width the next type-scale change would invalidate. No style
   slot is available to make it authorable — chrome declares ZERO style slots by
   ratified contract (issue 223) — so a stated reason is the only disposition open
   to this literal. REOPENING CONDITION: the chrome model's own boundary moving,
   i.e. if chrome ever gains style slots this becomes a slot candidate. */
.site-footer__blurb {
  font-size: 0.9rem;
  max-width: 32ch;
  margin-bottom: 0;
}

.site-footer__contact {
  font-size: 0.9rem;
  line-height: 1.6;
}

/* The contact block is an <address> (issue 427). <address> defaults to italic;
   footer contact info is not, so reset it. Its email/phone links route the same
   --footer-link-color chrome slot as the nav links (fallback --color-muted, so
   an unset footer is unchanged), underlined for the actionable affordance. */
.site-footer__address {
  font-style: normal;
}

.site-footer__address a {
  color: var(--footer-link-color, var(--color-muted));
  text-decoration: underline;
  transition: color var(--transition);
}

.site-footer__address a:hover {
  color: var(--color-accent);
}

/* Cap the footer logo the same way the nav caps it (.nav__logo-image). The
   footer is template-owned with zero style slots, so a real-world wordmark
   (e.g. 664x150) would otherwise render near intrinsic size and dominate the
   footer. Literal cap, consistent with the nav treatment. issue 299 */
.site-footer__logo-image {
  display: block;
  max-height: 2.5rem;
  width: auto;
  object-fit: contain;
}

.site-footer__nav ul {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm) var(--space-md);
  list-style: none;
}

.site-footer__nav ul li a {
  color: var(--footer-link-color, var(--color-muted));
  text-decoration: none;
  font-size: 0.9rem;
  transition: color var(--transition);
}

.site-footer__nav ul li a:hover {
  color: var(--color-accent);
  text-decoration: underline;
}

/* Copyright routes color through --footer-text (fallback --color-muted, so an
   unset footer is unchanged). The old markup carried a .text-muted utility that
   hardcoded --color-muted and would win over --footer-text on a dark footer;
   footer.php dropped that class so the copyright follows the footer text color.
   issue 300 */
.site-footer__copyright {
  font-size: 0.875rem;
  color: var(--footer-text, var(--color-muted));
  margin-bottom: 0;
}

/* Footer structure (issue 335). Optional column headings + a delimited bottom
   bar. Emitted only when the matching pp_footer_* option is set, so an unset
   footer renders exactly as issue 300 left it. Styling is deliberately NEUTRAL
   (no color/size opinion baked in): the heading inherits the footer text color
   through --footer-text (so it follows whatever the dark/light footer sets),
   and the bottom bar's divider reuses --color-border — the SAME token the
   footer's own top border uses. The capability is the structure and the slots
   that drive it, not a specific look. */
.site-footer__heading {
  font-size: 0.9rem;
  font-weight: 600;
  margin-bottom: var(--space-sm);
  color: var(--footer-text, inherit);
}

/* Bottom bar: a delimited band below the main footer flow. Full-width divider
   (via --color-border, matching .site-footer's own border-top), with the
   copyright and the optional secondary note on opposite ends on wider screens. */
.site-footer__bottom {
  border-top: 1px solid var(--color-border);
  margin-top: var(--space-md);
  padding-top: var(--space-md);
}

.site-footer__bottom-inner {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}

@media (min-width: 768px) {
  .site-footer__bottom-inner {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
  }
}

.site-footer__note {
  font-size: 0.875rem;
  color: var(--footer-text, var(--color-muted));
  margin-bottom: 0;
}

/* ==========================================================================
   COMPONENT: stats
   Horizontal row of large-number metrics with labels.
   ========================================================================== */

.stats {
  padding-top: var(--stats-padding-top, var(--pp-band-padding));
  padding-bottom: var(--stats-padding-bottom, var(--pp-band-padding));
  background: var(--stats-bg, transparent);
  /* Contained rounded metrics card (issue 383). max-width caps the band and the
     auto side-margins center it; the radius rounds the band's own background box.
     Defaults ('none' / '0') are inert — with max-width: none the block fills its
     container, so margin-inline: auto computes to 0, and an unset band renders
     byte-identically to the pre-383 full-bleed. */
  max-width: var(--stats-max-width, none);
  margin-inline: auto;
  border-radius: var(--stats-radius, 0);
}

.stats__heading {
  /* Route the heading size through the slot (issue 304). stats has no premium
     typography rule, so the fallback is the h2 base size (base.css) — unset
     output is byte-identical, and an operator can now tune the scale. */
  font-size: var(--stats-heading-size, var(--pp-band-heading-size));
  /* Heading rhythm (issue 584): band fusing needs this gap zeroable per instance.
     The fallback is this rule's own prior literal — byte-identical unset. */
  margin-bottom: var(--stats-heading-margin-bottom, var(--space-lg));
  /* stats' own heading measure (issue 578), severed from the shared six-selector rule
     that read --cta-heading-measure from inside the section block. */
  max-width: var(--stats-heading-measure, var(--measure-heading));
  /* Center the heading BOX, not just its text (issue 367 — same class as issue 354).
     The cap above gives this h2 a 40rem box by default;
     a block h2 fills to that cap inside the wider .container, so without auto
     inline margins the 40rem box pins to the container's left edge (measured
     x 96-736 at 1280px) and text-align: center only centers WITHIN that
     left-pinned box — the heading sits left of page center for any title.
     Auto side-margins center the box under the already-centered stats list
     (.stats__list is justify-content: center, spanning the full container).
     Both sides auto so it centers regardless of writing direction; collapses to
     0 when the heading is as wide as the container (narrow viewports), so no
     band is introduced. Physical margins mirror the landed issue-354 fix
     (.section--centered .section__content) and the file's centering convention. */
  margin-left: auto;
  margin-right: auto;
  text-align: center;
  color: var(--stats-heading-color, var(--color-text));
}

.stats__heading-accent {
  color: var(--stats-heading-accent-color, var(--color-accent));
}

.stats__list {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-lg) var(--space-xl);
  list-style: none;
  padding: 0;
  margin: 0;
}

.stats__item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-xs);
  min-width: 8rem;
}

.stats__number {
  /* Typography is slot-driven (issue 472). The fallbacks reproduce the pre-472
     output exactly: `inherit` is what an absent font-family declaration already
     did (the number took the page body font), and 700 was the literal. The
     fallbacks deliberately do NOT route through var(--font-heading) /
     var(--font-weight-heading): --font-weight-heading is 650, not 700, and a
     site that sets a distinct heading face is precisely the site whose rendered
     numbers would move — so heading-system parity is opt-in, never a default. */
  font-family: var(--stats-number-font, inherit);
  font-size: var(--stats-number-size, 2.5rem);
  font-weight: var(--stats-number-weight, 700);
  color: var(--stats-number-color, var(--color-accent));
  line-height: 1;
}

.stats__label {
  font-size: 0.875rem;
  color: var(--stats-label-color, var(--color-muted));
  text-align: center;
}

/* Stats variants */
.stats--dark {
  background: var(--stats-bg, var(--color-surface));
  border-top: 1px solid var(--color-border);
  border-bottom: 1px solid var(--color-border);
}

.stats--inverted {
  background: var(--stats-bg, var(--color-bg-inverted));
}

.stats--inverted .stats__heading {
  color: var(--stats-heading-color, var(--color-bg));
}

.stats--inverted .stats__number {
  /* Accent-colored large text on the dark band: the light-surface accent is only
     3.23:1 here (dim). Route the fallback through the on-inverted accent role so
     the default clears the 3:1 large-text bar (8.33:1); an explicit
     --stats-number-color slot still wins (issue 437). */
  color: var(--stats-number-color, var(--color-accent-on-inverted));
}

/* `:not(.stats--has-bg-image)` for the same reason as the cta twin above (issue 577):
   stats.php emits the theme class and the bg-image class independently, and this rule
   is the EARLIER of the two, so before the carve-out a combined band inherited this
   0.75 while the --has-bg-image rule supplied only `color` — dimmer than the 0.85 it
   replaced, i.e. a contrast REGRESSION on the exact band row 7 is correcting. */
.stats--inverted:not(.stats--has-bg-image) .stats__label {
  color: var(--stats-label-color, var(--color-bg));
  /* Deliberate de-emphasis; measured at 10.22:1 against a 4.5:1 bar. Reopen if the
     inverted band token moves such that the measured ratio falls below 7:1. */
  opacity: 0.75;
}

/* Stats with background image */
.stats--has-bg-image {
  position: relative;
  background-size: cover;
  background-position: var(--stats-bg-position, center);
  background-repeat: no-repeat;
}

/* stats is one of the four background-image components and was the only one reading the
   raw --overlay-bg token with no per-instance slot in front of it — hero, section and
   cta all have theirs. Route it (issue 577); unset is byte-identical. */
.stats__overlay {
  position: absolute;
  inset: 0;
  background: var(--stats-overlay-bg, var(--overlay-bg));
}

.stats--has-bg-image > .container {
  position: relative;
  z-index: 1;
}

.stats--has-bg-image .stats__heading {
  color: var(--stats-heading-color, var(--color-bg));
}

.stats--has-bg-image .stats__number {
  /* Accent-colored number on the dark overlay-over-image band: the light-surface
     accent is only 1.16:1 over the overlay-over-white worst case. Route the default
     through the overlay accent role (#461, ≥4.5:1); an explicit --stats-number-color
     slot still wins. on-inverted is tuned to the solid inverted bg, not this overlay. */
  color: var(--stats-number-color, var(--color-accent-on-overlay));
}

/* MEASURED CONTRAST CORRECTION (issue 577) — the twin of the .cta--has-bg-image
   .cta__body correction above, same band, same worst case rgb(115,115,115), same
   measurement: rgb(231,232,234) at 3.87:1 against a 4.5:1 bar. Same remedy: the
   de-emphasis moves to the --color-muted-on-overlay role token, never a literal. */
.stats--has-bg-image .stats__label {
  color: var(--stats-label-color, var(--color-muted-on-overlay));
}

/* The accented heading substring paints its OWN color (0,1,0) and does NOT inherit
   the near-white .stats--has-bg-image .stats__heading above, so on the dark overlay-
   over-image band it renders bare --color-accent at 1.16:1. Route the default through
   the overlay accent role (#463, mirroring the stats-number fix from #461); the
   per-instance --stats-heading-accent-color slot still wins. */
.stats--has-bg-image .stats__heading-accent {
  color: var(--stats-heading-accent-color, var(--color-accent-on-overlay));
}

/* ==========================================================================
   COMPONENT: logos
   Flex-wrap image grid. Works for logo strips (no labels) and icon-category
   tiles (with labels). Items are always image-based.
   ========================================================================== */

.logos {
  /* Band padding joins the shared symmetric rhythm (issue 438); see .table-section. */
  padding-top: var(--logos-padding-top, var(--pp-band-padding));
  padding-bottom: var(--logos-padding-bottom, var(--pp-band-padding));
}

.logos__heading {
  /* Band heading joins the shared responsive scale (issue 436); color slot (issue
     438) falls back to the h2 default var(--color-text) so unset output is unchanged. */
  font-size: var(--logos-heading-size, var(--pp-band-heading-size));
  color: var(--logos-heading-color, var(--color-text));
  /* Heading rhythm (issue 584): band fusing needs this gap zeroable per instance.
     The fallback is this rule's own prior literal — byte-identical unset. */
  margin-bottom: var(--logos-heading-margin-bottom, var(--space-lg));
  /* logos' own heading measure (issue 578), severed from the shared six-selector rule
     that read --cta-heading-measure from inside the section block. */
  max-width: var(--logos-heading-measure, var(--measure-heading));
}

.logos__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  /* Strip rhythm (issue 584): the ONE band-level gap on this component, routed so an
     operator can tighten a dense logo wall or open out a four-tile category strip
     without a global --space-lg retune. The fallback is this rule's own prior literal
     — byte-identical unset. The intra-item gap on .logos__item--labeled below stays a
     token literal: it is the image-to-label nudge, not the strip's rhythm. */
  gap: var(--logos-gap, var(--space-lg));
  list-style: none;
  padding: 0;
  margin: 0;
}

.logos__item {
  display: flex;
  align-items: center;
  justify-content: center;
}

.logos__item--labeled {
  flex-direction: column;
  gap: var(--space-sm);
  min-width: 6rem;
}

/* Logo image height (issue 584), mirroring --grid-item-icon-size's shape: ONE slot
   routed at BOTH cap sites, each keeping its own current literal as the fallback.
   Unset, the label-driven switch survives exactly as shipped — 3rem unlabelled here,
   2.5rem labelled below (verified rendered: 48px / 40px, tests/e2e/style-render.spec.ts
   "#583 logos mixed labeled/unlabeled strip"). SET, both caps collapse to the one
   authored value and the label-driven switch stops applying; that is deliberate and is
   stated in the slot description, because an operator asking for "make the logos
   bigger" means the strip, not one half of it. There is no separate labelled-only
   slot: two knobs for one visual job is the family this gate is completing, not
   extending. NOT a dark-band unblocker — see the schema note. */
.logos__image {
  max-height: var(--logos-image-size, 3rem);
  width: auto;
  object-fit: contain;
}

.logos__item--labeled .logos__image {
  max-height: var(--logos-image-size, 2.5rem);
}

.logos__label {
  font-size: 0.8125rem;
  color: var(--color-muted);
  text-align: center;
}

/* Logos variants */
.logos--dark {
  background-color: var(--color-surface);
  border-top: 1px solid var(--color-border);
  border-bottom: 1px solid var(--color-border);
}

.logos--inverted {
  background-color: var(--color-bg-inverted);
}

.logos--inverted .logos__heading {
  /* Route the inverted heading through the same slot (issue 438) so an explicit
     --logos-heading-color still wins here; unset falls back to var(--color-bg). */
  color: var(--logos-heading-color, var(--color-bg));
}

.logos--inverted .logos__label {
  color: var(--color-bg);
  /* Deliberate de-emphasis; measured at 10.22:1 against a 4.5:1 bar. Reopen if the
     inverted band token moves such that the measured ratio falls below 7:1. */
  opacity: 0.75;
}

/* ==========================================================================
   COMPONENT: embed
   Generic WP shortcode / plugin content wrapper.
   ========================================================================== */

.embed {
  /* Band padding joins the shared symmetric rhythm (issue 438); see .table-section. */
  padding-top: var(--embed-padding-top, var(--pp-band-padding));
  padding-bottom: var(--embed-padding-bottom, var(--pp-band-padding));
}

.embed__heading {
  /* Band heading joins the shared responsive scale (issue 436); color slot (issue
     438) falls back to the h2 default var(--color-text) so unset output is unchanged. */
  font-size: var(--embed-heading-size, var(--pp-band-heading-size));
  color: var(--embed-heading-color, var(--color-text));
  /* Heading rhythm (issue 584): band fusing needs this gap zeroable per instance.
     The fallback is this rule's own prior literal — byte-identical unset. */
  margin-bottom: var(--embed-heading-margin-bottom, var(--space-lg));
  /* embed's own heading measure (issue 578), severed from the shared six-selector rule
     that read --cta-heading-measure from inside the section block. */
  max-width: var(--embed-heading-measure, var(--measure-heading));
}

.embed__content {
  /* Was a bare 40rem literal, so an operator could not narrow or widen embedded body
     copy at all (issue 578). The literal stays the fallback: this is a BODY measure, not
     a heading one, so it deliberately does NOT route --measure-heading — the two are
     independent surfaces that happen to share a number today. */
  max-width: var(--embed-body-measure, 40rem);
  /* Body ink joins the slot surface (issue 577). The heading half of issue 438's work
     was routed and this half was not, leaving --embed-heading-color slotted beside an
     unrouted token reference on the inverted twin below. The fallback is `inherit`
     (the .hero__title idiom in this sheet), so an unset slot resolves to whatever the
     element already inherited and the base band is byte-identical under any ancestor,
     not just the shipped body color. */
  color: var(--embed-body-color, inherit);
}

/* Embed variants */
.embed--dark {
  background-color: var(--color-surface);
  border-top: 1px solid var(--color-border);
  border-bottom: 1px solid var(--color-border);
}

.embed--inverted {
  background-color: var(--color-bg-inverted);
}

.embed--inverted .embed__heading {
  /* Route the inverted heading through the same slot (issue 438); unset falls back
     to var(--color-bg). */
  color: var(--embed-heading-color, var(--color-bg));
}

.embed--inverted .embed__content {
  /* Route the inverted body through the same slot (issue 577), matching the heading
     rule above; unset falls back to var(--color-bg) exactly as before. */
  color: var(--embed-body-color, var(--color-bg));
}

.embed--inverted a {
  /* Dark-band body link → on-inverted accent role for AA (issue 437). */
  color: var(--color-accent-on-inverted);
}

.embed--inverted a:hover {
  color: var(--color-accent-on-inverted-hover);
}

/* ==========================================================================
   COMPONENT: testimonials
   Customer quotes with attribution. 'grid' variant: 1-col → 2-col md →
   3-col lg card grid. 'stack' variant: single centered column, no card
   chrome, for one or two large pull-quotes.
   ========================================================================== */

.testimonials {
  padding-top: var(--testimonials-padding-top, var(--pp-band-padding));
  padding-bottom: var(--testimonials-padding-bottom, var(--pp-band-padding));
  background: var(--testimonials-bg, transparent);
}

.testimonials__header--center {
  text-align: center;
  margin-left: auto;
  margin-right: auto;
}

.testimonials__eyebrow {
  display: inline-block;
  padding: 0.35rem 0.85rem;
  margin-bottom: var(--space-sm);
  border: var(--testimonials-eyebrow-border-width, 0) solid var(--testimonials-eyebrow-border-color, transparent);
  border-radius: var(--testimonials-eyebrow-radius, 3px);
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: var(--testimonials-eyebrow-text-transform, uppercase);
  color: var(--testimonials-eyebrow-color, var(--color-text));
  background: var(--testimonials-eyebrow-bg, var(--color-surface-accent));
}

.testimonials__heading {
  /* Band heading joins the shared responsive scale (issue 436). */
  font-size: var(--testimonials-heading-size, var(--pp-band-heading-size));
  color: var(--testimonials-heading-color, var(--color-text));
  /* Was a bare 40rem literal — the same measure the other bands got from the shared
     rule, just spelled differently. Routed through its own slot and the shared token
     (issue 578); unset output is unchanged. */
  max-width: var(--testimonials-heading-measure, var(--measure-heading));
  /* Top-side header rhythm (heading -> subheading gap), mirror of the issue 336
     subheading BOTTOM slot on the adjacent property (issue 343). The heading is
     not the header's last child, so base.css's `p:last-child` reset never won
     here — a plain slot on the heading suffices. Today's literal is the fallback. */
  margin-bottom: var(--testimonials-heading-margin-bottom, var(--space-lg));
}

.testimonials__heading-accent {
  color: var(--testimonials-heading-accent-color, var(--color-accent));
}

.testimonials__header--center .testimonials__heading {
  margin-left: auto;
  margin-right: auto;
}

.testimonials__subheading {
  color: var(--testimonials-subheading-color, var(--color-muted));
  margin-top: 0;
  max-width: 40rem;
}
/* base.css `p:last-child { margin-bottom: 0 }` (0,1,1) outranks a bare
   `.testimonials__subheading` (0,1,0), and the subheading is always the header's last
   child, so the component's declared bottom rhythm never reached the page (issue 336).
   Own the spacing at header scope (0,2,0): it wins the cascade without weakening
   the global prose reset, and does not depend on the subheading staying last. */
.testimonials__header > .testimonials__subheading {
  margin-bottom: var(--testimonials-subheading-margin-bottom, var(--space-lg));
}


.testimonials__header--center .testimonials__subheading {
  margin-left: auto;
  margin-right: auto;
}

.testimonials__list {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--testimonials-gap, var(--space-lg));
}

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

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

.testimonials__item {
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
  margin: 0;
  padding: var(--testimonials-item-padding, var(--space-lg));
  background: var(--testimonials-item-bg, var(--color-surface));
  border: var(--testimonials-item-border-width, 1px) solid var(--testimonials-item-border-color, var(--color-border));
  border-radius: var(--testimonials-item-radius, var(--radius));
  box-shadow: var(--testimonials-item-shadow, none);
}

.testimonials__quote {
  margin: 0;
  padding: 0;
  border-left: none;
  font-style: normal;
  color: var(--testimonials-quote-color, var(--color-text));
  flex: 1;
}

.testimonials__quote p {
  margin: 0;
  position: relative;
  padding-left: 1.75rem;
}

.testimonials__quote p::before {
  content: "\201C";
  position: absolute;
  left: 0;
  top: -0.1em;
  font-size: 1.75em;
  line-height: 1;
  font-weight: 700;
  color: var(--testimonials-quote-mark-color, var(--color-accent));
}

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

.testimonials__avatar {
  width: 2.75rem;
  height: 2.75rem;
  border-radius: 50%;
  object-fit: cover;
  flex-shrink: 0;
}

.testimonials__attribution-text {
  display: flex;
  flex-direction: column;
}

.testimonials__author {
  font-weight: 700;
  color: var(--testimonials-author-color, var(--color-text));
}

.testimonials__meta {
  font-size: 0.875rem;
  color: var(--testimonials-meta-color, var(--color-muted));
}

.testimonials__empty {
  grid-column: 1 / -1;
  padding: var(--space-md) 0;
}

/* Stack variant: single centered column, no card chrome, larger quotes */
.testimonials--stack .testimonials__list {
  grid-template-columns: 1fr;
  max-width: 42rem;
  margin: 0 auto;
}

.testimonials--stack .testimonials__item {
  padding: 0;
  background: transparent;
  border: none;
  box-shadow: none;
  text-align: center;
  align-items: center;
}

.testimonials--stack .testimonials__quote {
  font-size: 1.375rem;
}

.testimonials--stack .testimonials__quote p {
  padding-left: 0;
}

.testimonials--stack .testimonials__quote p::before {
  display: block;
  position: static;
  margin-bottom: var(--space-sm);
}

/* Theme variants */
.testimonials--dark {
  background: var(--testimonials-bg, var(--color-surface));
  border-top: 1px solid var(--color-border);
  border-bottom: 1px solid var(--color-border);
}

.testimonials--inverted {
  background: var(--testimonials-bg, var(--color-bg-inverted));
}

.testimonials--inverted .testimonials__heading {
  color: var(--testimonials-heading-color, var(--color-bg));
}

.testimonials--inverted .testimonials__item {
  background: var(--testimonials-item-bg, var(--color-bg));
}

.testimonials--inverted.testimonials--stack .testimonials__item {
  background: transparent;
}

.testimonials--inverted.testimonials--stack .testimonials__quote {
  color: var(--testimonials-quote-color, var(--color-bg));
}

.testimonials--inverted.testimonials--stack .testimonials__author {
  color: var(--testimonials-author-color, var(--color-bg));
}

/* The third line of the attribution had no inverted-stack twin (issue 577). Its quote
   and author siblings both get one above; --testimonials-meta-color did not, so on the
   dark band the role/company line kept resolving to var(--color-muted) — a
   light-surface secondary ink. No comment, issue or test recorded an intent either
   way, so the omission is unexplained rather than deliberate. Same shape as its two
   siblings: light default, per-instance slot still wins. */
.testimonials--inverted.testimonials--stack .testimonials__meta {
  color: var(--testimonials-meta-color, var(--color-bg));
}
/* The quote became an inline-HTML surface in #439 (a/strong/em/br). In the STACK
   layout on an inverted band the quote sits DIRECTLY on the dark band (the card bg
   is transparent above), so a body link there fails WCAG AA on --color-accent
   (3.23:1); route it through the on-inverted accent role (issue 437's pattern).
   The GRID layout keeps its LIGHT .testimonials__item card, where the link stays on
   --color-accent (already AA on the light card) — hence this remap is scoped to the
   stack variant only. */
.testimonials--inverted.testimonials--stack .testimonials__quote a {
  color: var(--color-accent-on-inverted);
}

.testimonials--inverted.testimonials--stack .testimonials__quote a:hover {
  color: var(--color-accent-on-inverted-hover);
}

/* ==========================================================================
   SHARED: Adjacent-Sibling Rhythm
   Sets the top padding of a section-level component that follows another one
   inside <main>. First component keeps its own padding naturally (the `+`
   combinator skips it). Desktop only — mobile spacing stays uniform.
   Routes through the shared --pp-band-padding-adjacent-top, which is pinned to
   --pp-band-padding (issue 430), so this adjacent top equals the band's own
   edges (symmetric) instead of a tighter tier. Every band type now carries its own
   padding slot and routes its adjacent-top edge through a per-component rule —
   table/logos/embed joined in issue 438, hero in issue 577 — so this catch-all is
   the shared DEFINITION the per-component rules fall back to rather than the rule
   any particular band actually lands on. (It previously claimed to cover "components
   with no padding slot (hero)"; hero has declared --hero-padding-top /
   --hero-padding-bottom since it was written — components/hero/schema.json — and
   this catch-all's [0,2,1] was silently outranking that slot on the adjacent edge,
   which is the dead slot issue 577 fixes.) Either way every band consumes the one
   shared rhythm definition (issue 431).
   ========================================================================== */

@media (min-width: 768px) {
  main > [data-pp-component] + [data-pp-component] {
    padding-top: var(--pp-band-padding-adjacent-top);
  }
}

/* HERO adjacent-top (issue 577), deliberately placed HERE and not with the other nine
   per-component adjacent rules further down.
   ------------------------------------------------------------------------------
   TWO things make hero's rule different from its nine siblings:

   1. THE FALLBACK IS NOT THE SHARED BAND TIER. The other nine fall back to
      --pp-band-padding-adjacent-top. Hero falls back to its OWN opener rhythm
      (--space-2xl desktop / --space-xl mobile — the same values .hero's base rules
      use), because hero's opt-out from the shared band rhythm is a stated intentional
      difference and the adjacent edge is the one place the catch-all was quietly
      overriding it. Hero keeps its opener rhythm on the adjacent edge too. This is a
      deliberate render change: an unset adjacent hero's top edge moves from the band
      tier (~5rem desktop / 3.35rem mobile) to 7rem / 4rem, which also makes an unset
      adjacent hero SYMMETRIC with its own bottom edge.

   2. PLACEMENT IS LOAD-BEARING. It must stay ABOVE the `[data-pp-spacing]` block
      below. That block is [0,2,1] at desktop, identical to this rule, so source order
      decides: an explicit compact/spacious override must keep governing BOTH edges of
      an adjacent hero (issue 434's invariant, pinned at both breakpoints in
      tests/e2e/style-render.spec.ts). Moving this rule down beside its siblings would
      out-order that restatement and shave a spaced hero's top edge again. Mobile needs
      no such care: the mobile block already restates the spacing rules AFTER its
      per-component list, so hero's mobile rule lives with its siblings down there.

   3. THE LEFT VARIANT GETS ITS OWN TWIN. OQ-1 (ii)'s principle is "hero keeps ITS OWN
      opener rhythm on the adjacent edge", and .hero--left's own opener rhythm is
      --space-xl on BOTH edges ("used by inner pages; compact vertical rhythm"), not
      --space-2xl. A single flat value would have made the adjacent top edge follow the
      WRONG hero's rhythm: measured, an adjacent .hero--left rendered 112px top against
      its own 64px bottom at 1280 — an asymmetry the ruling never contemplated, on every
      inner page. The twin below applies the same principle per variant, which also
      makes left/split symmetric at both breakpoints. An image-less `split` hero degrades
      to .hero--left (issue 440), so it is covered by the same rule. */
@media (min-width: 768px) {
  main > [data-pp-component] + .hero {
    padding-top: var(--hero-padding-top, var(--space-2xl));
  }
  /* Equal specificity to the rule above, placed after it so the left variant wins. */
  main > [data-pp-component] + .hero--left {
    padding-top: var(--hero-padding-top, var(--space-xl));
  }
}

/* ==========================================================================
   SHARED: Text-Only Layout
   Text-only: body fills the container (no max-width) so titles and headings
   match the visual width of adjacent full-width components (grid, table, CTA).
   Only the prose content block is constrained for readable line length.
   Centered: body is constrained to --measure-centered with center-aligned text.
   ========================================================================== */

/* Issue 581 (A-28) — the `.section--text-only .section__title` font-size rule that used
   to sit here was deleted. It re-declared the base `.section__title` rule VERBATIM at
   higher specificity with no comment, so it could only ever resolve to the value the
   base rule already produced. css-lint pins the equivalence structurally: every
   font-size declaration on every rule that can match a .section__title routes the same
   slot to the same fallback, so no specificity ordering can produce a different value
   and removing one redundant declaration site cannot move a rendered pixel. */

.section--text-only .section__content {
  max-width: var(--section-body-measure, var(--measure-centered));
}

/* ==========================================================================
   HERO: Spacing + Width overrides (data attributes)
   Only hero.php emits data-pp-spacing / data-pp-width (components/hero/hero.php:90-91),
   and by ruling no blanket non-hero width or spacing controls exist. These selectors
   used to be written GENERICALLY, with a comment saying hero was the only emitter — CSS
   that reads as an invitation to extend, which is the drift the ruling names. They are
   now scoped to `.hero`, so the restriction is enforced by the selector rather than by a
   comment (issue 578).

   SPECIFICITY IS UNCHANGED, and that is load-bearing for the issue-434 rhythm pins
   below: `[data-pp-component][data-pp-spacing="x"]` is two attributes (0,2,0) and
   `.hero[data-pp-spacing="x"]` is a class plus an attribute (0,2,0) — identical, so the
   equal-specificity/source-order relationship with `main > [data-pp-component] +
   [data-pp-component]` (0,2,1) is preserved at both breakpoints. Byte-identical: no
   element other than a hero root can carry these attributes today.
   ========================================================================== */

.hero[data-pp-spacing="compact"] {
  padding-top: var(--space-lg);
  padding-bottom: var(--space-lg);
}

.hero[data-pp-spacing="spacious"] {
  padding-top: var(--space-2xl);
  padding-bottom: var(--space-2xl);
}

@media (min-width: 768px) {
  main > .hero[data-pp-spacing="compact"] {
    padding-top: var(--space-lg);
    padding-bottom: var(--space-lg);
  }
  main > .hero[data-pp-spacing="spacious"] {
    padding-top: var(--space-3xl);
    padding-bottom: var(--space-3xl);
  }
}

.hero[data-pp-width="narrow"] .container {
  /* The literal 56rem here DUPLICATED --measure-centered's exact shipped value
     (base.css), so a site that retuned its centered measure moved every centered body
     EXCEPT a narrow hero's container. Route the token with its own value as the
     fallback-free reference: byte-identical at the shipped value, and it only changes
     render on a site that has already retuned --measure-centered (issue 578). */
  max-width: var(--measure-centered);
}

.hero[data-pp-width="full"] .container {
  max-width: 100%;
}

/* ==========================================================================
   SHARED: Section Centered Variant
   Centered text layout with constrained width, no image.
   ========================================================================== */

.section--centered .section__body {
  text-align: center;
  margin-left: auto;
  margin-right: auto;
  max-width: var(--section-body-measure, var(--measure-centered));
}

.section--centered .section__title {
  text-align: center;
}

/* The centered layout centers the OUTER .section__body (max-width --measure-centered,
   56rem) but its inner .section__content carries a NARROWER cap (--section-body-measure,
   42rem) with no auto margins, so the body copy left-pinned inside the wider centered
   wrapper while the title sat centered — a visible ~112px asymmetry (issue 354). Give
   the inner wrapper auto side-margins so the measure centers under the heading. Scoped
   to .section--centered only: text-only / image-left / image-right / text-panel keep
   .section__content left-pinned (byte-identical). Text is already centered here via the
   inherited text-align:center on .section--centered .section__body. */
.section--centered .section__content {
  margin-left: auto;
  margin-right: auto;
}

/* ==========================================================================
   SHARED: Pagination
   Renders under the post grid on the blog index / archives (issue 126).
   ========================================================================== */

.pp-pagination {
  padding: var(--space-lg) 0;
}

.pp-pagination__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: var(--space-xs);
  list-style: none;
}

.pp-pagination__list a.page-numbers,
.pp-pagination__list span.page-numbers {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 2.5rem;
  height: 2.5rem;
  padding: 0 var(--space-sm);
  border-radius: var(--radius);
  font-size: 0.9375rem;
  font-weight: 600;
  color: var(--color-text);
  text-decoration: none;
}

.pp-pagination__list a.page-numbers:hover {
  background: var(--color-surface);
}

.pp-pagination__list span.page-numbers.current {
  background: var(--color-accent);
  color: var(--color-bg);
}

.pp-pagination__list a.page-numbers.dots {
  color: var(--color-muted);
}

/* Premium body-section typography. */
@media (min-width: 768px) {
  main > .section .section__title,
  main > .grid .grid__heading,
  main > .faq .faq__heading {
    font-weight: 560;
    line-height: 1.12;
  }

  /* Per-instance heading-color slots must survive this desktop typography rule (#86).
     Color is split per selector so each component's slot wins instead of a shared
     hardcoded var(--color-text). The theme-color var sits between the slot and the
     global token so a theme variant (e.g. .grid--inverted) still supplies the right
     default here — this rule outranks every theme selector, so without it the theme
     lost and inverted text rendered dark-on-dark above 768px (issue 222).

     font-size joins the same per-selector split (issue 302): the premium scale used
     to live in the shared rule above as a bare clamp(), outranking the base
     .section__title / .grid__heading rules ([0,1,0]) and silently ignoring a
     declared --section-heading-size / --grid-heading-size. Route each through its
     size slot so a set slot still wins at this [0,2,1] specificity. faq routes
     through --faq-heading-size too (issue 304). The fallback is now the shared
     --pp-band-heading-size (issue 436) instead of a per-rule clamp(): the shared
     clamp is already fluid at every viewport, so this desktop rule and the base
     rule resolve to the same value when the slot is unset (~38.4px at 1280px,
     matching the prior desktop clamp) — the rule stays only to keep the slot
     winning at desktop specificity, not to redefine the size.

     margin-bottom joins the split for section/grid (issue 343): this premium rule
     hardcoded the title -> subheading gap (1.65rem here, 1.25rem in the max-width
     rule below) at [0,2,1], outranking the base [0,1,0] rules and silently ignoring
     a declared --section-heading-margin-bottom / --grid-heading-margin-bottom. Route
     each through its slot with 1.65rem as the fallback so unset output is unchanged.
     faq now routes the same way through --faq-heading-margin-bottom (issue 352);
     faq was left a bare literal in issue 343's scope and is completed here.

     issue 436 note: the font-size fallback here is now the shared --pp-band-heading-size
     instead of this rule's old per-rule clamp(2.25rem, 3vw, 2.62rem). Because that
     shared value is itself a fluid clamp, this desktop rule and the base rule resolve
     to the SAME size when the slot is unset — so this rule exists only to keep a set
     slot winning at this [0,2,1] specificity, not to redefine the size. Unset output
     is preserved at the 1280px desktop anchor (~38.4px) and at the >=1400px ceiling
     (2.62rem), but it is NOT byte-identical across the whole old desktop range: the
     old clamp was floored flat at 36px for ~768-1071px, whereas the shared fluid
     clamp ramps from ~32.5px at 768px up to 36px near 1071px. That ~3.5px tablet-band
     softening is the deliberate cost of unifying section/grid/faq onto one fluid scale
     (issue 436) and is called out in the CHANGELOG. */
  main > .grid .grid__heading {
    color: var(--grid-heading-color, var(--pp-grid-heading-theme-color, var(--color-text)));
    font-size: var(--grid-heading-size, var(--pp-band-heading-size));
    margin-bottom: var(--grid-heading-margin-bottom, 1.65rem);
  }

  main > .section .section__title {
    color: var(--section-heading-color, var(--pp-section-title-theme-color, var(--color-text)));
    font-size: var(--section-heading-size, var(--pp-band-heading-size));
    margin-bottom: var(--section-heading-margin-bottom, 1.65rem);
  }

  main > .faq .faq__heading {
    color: var(--faq-heading-color, var(--pp-faq-heading-theme-color, var(--color-text)));
    font-size: var(--faq-heading-size, var(--pp-band-heading-size));
    margin-bottom: var(--faq-heading-margin-bottom, 1.65rem);
  }

  main > .section .section__content,
  main > .section .section__content p {
    color: var(--section-body-color, var(--pp-section-text-theme-color, var(--color-text-secondary)));
    font-size: var(--section-body-size, 1.065rem);
    font-weight: var(--section-body-weight, 430);
    line-height: 1.76;
  }

  main > .section--text-only .section__content {
    max-width: var(--section-body-measure, 49rem);
  }

  main > .section .section__content p + p {
    margin-top: 1.05rem;
  }

  main > .grid .grid__item-title {
    color: var(--grid-item-title-color, var(--color-text));
    font-size: var(--grid-item-title-size, 1.13rem);
    font-weight: 670;
    line-height: 1.28;
  }

  main > .grid:not(.grid--steps) .grid__item-title {
    font-size: var(--grid-item-title-size, 1.14rem);
  }

  main > .grid .grid__item-text {
    color: var(--grid-item-text-color, var(--color-text-secondary));
    font-size: 1.005rem;
    font-weight: 430;
    line-height: 1.68;
  }

  main > .grid .grid__item-body,
  main > .grid .grid__item-body:first-child {
    padding: var(--grid-item-padding, 2rem);
  }

  main > .grid--steps .grid__item-title {
    max-width: 17rem;
    font-size: var(--grid-item-title-size, 1.04rem);
    font-weight: 680;
  }

  main > .grid--steps .grid__item-text {
    font-size: 0.995rem;
    line-height: 1.66;
  }

  main > .faq .faq__question {
    color: var(--faq-question-color, var(--color-text));
    font-size: 1rem;
    font-weight: 560;
    line-height: 1.45;
  }

  main > .faq .faq__answer {
    color: var(--faq-answer-color, var(--color-text-secondary));
    font-size: 1rem;
    font-weight: 430;
    line-height: 1.68;
  }

  main > .cta .cta__body {
    color: var(--cta-body-color, var(--pp-cta-body-theme-color, var(--color-text-secondary)));
    font-size: var(--cta-body-size, 1.04rem);
    font-weight: 430;
    line-height: 1.66;
  }
}

@media (max-width: 767px) {
  main > .section .section__title,
  main > .grid .grid__heading,
  main > .faq .faq__heading {
    font-weight: 560;
    line-height: 1.15;
  }

  /* margin-bottom split per-selector so each component's heading-margin slot
     reaches the title at this breakpoint too (issue 343); fallbacks preserve
     today's 1.25rem. faq now routes the same way (issue 352); it was left a bare
     literal in issue 343's scope and is completed here. */
  main > .grid .grid__heading {
    margin-bottom: var(--grid-heading-margin-bottom, 1.25rem);
  }

  main > .section .section__title {
    margin-bottom: var(--section-heading-margin-bottom, 1.25rem);
  }

  main > .faq .faq__heading {
    margin-bottom: var(--faq-heading-margin-bottom, 1.25rem);
  }

  /* Section body split out of the shared mobile type rule (issue 470) so its own
     --section-body-size / --section-body-weight slots reach it here.
     The fallback used to CHAIN through var(--cta-body-size, 1rem), so an unset section
     body followed a CTA slot at this breakpoint — a cta authoring surface acting as a
     section slot. That chain is severed (issue 578) and the literal is the fallback
     directly. Byte-identical: --cta-body-size is declared on the CTA component root, so
     it can never resolve on a .section subtree, and its unset result was always 1rem. */
  main > .section .section__content,
  main > .section .section__content p {
    font-size: var(--section-body-size, 1rem);
    font-weight: var(--section-body-weight, 430);
    line-height: 1.65;
  }

  /* grid and faq took their mobile body size from --cta-body-size (issue 578): a CTA
     slot on a grid card and a faq answer. Neither could set it (the write path rejects a
     foreign slot) nor have it resolve (inline slot properties land on the owning root),
     so its unset result was always the 1rem literal — severing it is byte-identical. The
     literal stays here; neither component declares a body-size slot to route instead.
     Split from cta, which keeps reading the slot it actually owns, below. */
  main > .grid .grid__item-text,
  main > .faq .faq__answer {
    font-size: 1rem;
    font-weight: 430;
    line-height: 1.65;
  }

  main > .cta .cta__body {
    font-size: var(--cta-body-size, 1rem);
    font-weight: 430;
    line-height: 1.65;
  }

  main > .grid .grid__item-title {
    font-size: var(--grid-item-title-size, 1.06rem);
    font-weight: 660;
    line-height: 1.28;
  }

  main > .grid .grid__item-body,
  main > .grid .grid__item-body:first-child {
    padding: var(--grid-item-padding, 1.55rem);
  }

  main > .faq .faq__question {
    font-size: 0.98rem;
    font-weight: 560;
    line-height: 1.42;
  }
}

/* True final cascade: composed card-grid hierarchy. */
main > .grid .grid__list {
  gap: var(--grid-gap, 1rem);
}

main > .grid .grid__item {
  position: relative;
  /* Route the all-cards border through the slot so a per-instance
     --grid-item-border-color is honored on cards 2..N, not just the featured
     first card (issue 292 — regression of issue 226). Unset keeps the
     neutral --color-border default. */
  border-color: var(--grid-item-border-color, var(--color-border));
  box-shadow: var(--grid-item-shadow, 0 12px 28px rgba(15, 23, 42, 0.045));
}

main > .grid:not(.grid--steps) .grid__item {
  /* The decorative 1px rule stays; the fill layer routes through the
     per-instance card-bg slot so a set --grid-item-bg (e.g. a dark-band card
     color) is honored instead of being clobbered by this late cascade rule.
     Unset keeps the original bg→surface gradient. */
  background:
    linear-gradient(90deg, rgba(37, 99, 235, 0.028) 1px, transparent 1px) 0 0 / 3rem 100%,
    var(--grid-item-bg, linear-gradient(180deg, var(--color-bg) 0%, var(--color-surface) 100%));
}

/* The card top-bar and featured first-card rules moved into the COMPONENT: grid
   block (issue 293); the cascade-equivalence argument lives with the rules there. */

main > .grid:not(.grid--steps):not(.grid--uniform) .grid__item:first-child .grid__item-title {
  color: var(--grid-item-title-color, var(--color-text));
}

main > .grid .grid__item-link {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  padding-top: 0.2rem;
}

main > .grid .grid__item-link::after {
  content: "->";
  color: currentColor;
  font-weight: 700;
}

@media (min-width: 768px) {
  main > .grid:not(.grid--steps) .grid__list[data-pp-count="1"] {
    /* A lone card must never sit in a two-column track (issue 297): the base
       768px rule sets repeat(2, 1fr), stranding a single item in the left
       column with dead space on the right. One full-width track spans the
       container so the card's left edge aligns with the section rail, mirroring
       the count-2/count-3 "span the container" treatment (issues 303/224). This
       sits at 768px, not 1024px like the other count rules, because the
       stranding exists from the first two-column breakpoint, and no count-1
       override in the 1024px block re-declares it, so it holds through desktop. */
    grid-template-columns: minmax(0, 1fr);
  }
}

@media (min-width: 1024px) {
  main > .grid .grid__list {
    gap: var(--grid-gap, 1.05rem);
  }

  main > .grid:not(.grid--steps) .grid__list[data-pp-count="4"] {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    max-width: 58rem;
    margin-right: auto;
    margin-left: auto;
  }

  main > .grid--steps .grid__list[data-pp-count="4"] {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    max-width: 56rem;
    margin-right: auto;
    margin-left: auto;
  }

  main > .grid:not(.grid--steps) .grid__list[data-pp-count="3"] {
    /* Three-across is the common marketing feature row. No max-width: the row
       spans the container like grid--steps does, instead of being centered and
       narrowed the way the 2- and 4-card rules are. */
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  main > .grid:not(.grid--steps) .grid__list[data-pp-count="2"] {
    /* Two-across rows span the container like the 3-card rule, instead of being
       capped at 52rem and centered. The centered cap left the pair on a narrower
       rail than the intro copy above it (measured at desktop: pair left-edge 304px
       vs section rail 176px), so "intro text + two feature cards" could never read
       as one aligned section. Removing the max-width + auto margins aligns the pair
       with the section rail, mirroring the count-3 treatment (issue 303, sibling of
       issue 224). */
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  main > .grid:not(.grid--steps):not(.grid--uniform) .grid__item:first-child .grid__item-body {
    /* Featured-card breathing room, routed through the slot (issue 577). At (0,6,1)
       this is the last word on the featured card's body padding-top, so an authored
       --grid-item-padding used to no-op on card 1 at >=1024. The literal stays as the
       fallback, so unset output is byte-identical — which also means the 0.25rem
       featured/non-featured residue survives UNSET (2.25rem here vs 2rem elsewhere);
       authoring the slot is what collapses it. */
    padding-top: var(--grid-item-padding, 2.25rem);
  }

  main > .grid:not(.grid--steps):not(.grid--uniform) .grid__item:first-child .grid__item-title {
    font-size: var(--grid-item-title-size, 1.22rem);
    line-height: 1.22;
  }

  main > .grid--dark:not(.grid--steps):not(.grid--uniform) .grid__item:first-child {
    transform: translateY(-0.18rem);
  }

  main > .grid--dark:not(.grid--steps):not(.grid--uniform) .grid__item:first-child:hover {
    transform: translateY(-0.34rem);
  }
}

/* Explicit desktop column-count override (issue 379). Opt-in via the grid
   `columns` prop (integer 1-4), emitted by grid.php as data-pp-columns on the
   list. Forces that many equal-width tracks at >=768px, overriding the
   item-count auto-derivation (the data-pp-count rules above). Unset emits no
   attribute, so none of these selectors match and the auto-derived default is
   byte-identical. Placed AFTER the 1024px count block so, at equal (0,4,1)
   specificity, source order wins at every >=768px width (the count-N rules and
   the base repeat(2) rule both lose to this). max-width/margins are reset so a
   forced count spans the container uniformly regardless of item count (the
   count-4 rule otherwise caps at 58rem centered). minmax(0, 1fr) keeps forced
   narrow tracks from overflowing (orphan/overflow safety). Scoped to cards
   (:not(.grid--steps)); the steps layout keeps its fixed process grain. The
   <768px single-column collapse is untouched. No style slot is involved, so the
   slot-contract bypass guard does not treat these literals as offenders. */
@media (min-width: 768px) {
  main > .grid:not(.grid--steps) .grid__list[data-pp-columns="1"] {
    grid-template-columns: minmax(0, 1fr);
    max-width: none;
    margin-right: 0;
    margin-left: 0;
  }

  main > .grid:not(.grid--steps) .grid__list[data-pp-columns="2"] {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    max-width: none;
    margin-right: 0;
    margin-left: 0;
  }

  main > .grid:not(.grid--steps) .grid__list[data-pp-columns="3"] {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    max-width: none;
    margin-right: 0;
    margin-left: 0;
  }

  main > .grid:not(.grid--steps) .grid__list[data-pp-columns="4"] {
    grid-template-columns: repeat(4, minmax(0, 1fr));
    max-width: none;
    margin-right: 0;
    margin-left: 0;
  }
}

@media (max-width: 767px) {
  main > .grid .grid__list {
    gap: var(--grid-gap, 0.85rem);
  }

  main > .grid:not(.grid--steps):not(.grid--uniform) .grid__item:first-child {
    /* Same featured-shadow chain as the component-block rule (issue 293), so the
       slot does not silently no-op below 768px; only the glow literal differs. */
    box-shadow: var(--grid-featured-shadow, var(--grid-item-shadow, 0 14px 32px rgba(37, 99, 235, 0.09)));
  }
}

/* Per-instance button slots reach the component's OWN buttons and nothing else (issue 545).

   THE DEFECT. Every per-instance filled-button slot family (--hero-button-* / --hero-button2-* /
   --cta-button-* / --cta-button2-* / --section-panel-cta-*) is emitted by the renderer as an
   inline custom property on the COMPONENT ROOT, and the three selector shapes that consume
   them — `main .btn:not(...)` (the premium winner below), `.hero .btn:not(...)` and
   `.cta .btn:not(...)` — read them by INHERITANCE rather than through the component's own
   button class. Custom properties inherit to every descendant, so those slots also reached a
   `.btn` an AUTHOR hand-writes into a rich-text prop, and the component's per-instance button
   styling repainted it. Two surfaces INSIDE A BAND THAT EMITS BUTTON SLOTS can hold such a button
   today, both filtered by wp_kses_post (which permits `class` on `<a>`): `section.body`
   (section.php:209,232,301) and `hero.proof` (hero.php:138,144). Three other components have a
   wp_kses_post surface — faq.answer (faq.php:52), table cells (table.php:47), embed.content
   (embed.php:37) — and they are inert rather than excluded: none emits a button slot family, and
   components never nest (no component template calls pp_get_component), so a `.btn` written there
   has nothing to inherit in the first place. The cta component has no such surface at all —
   `cta.body` goes through pp_kses_inline (helpers.php:141), whose `a` allowlist is href/title
   only, so a class cannot survive there. Consequently only 14 of the 26 properties below are
   load-bearing (--hero-button-* / --cta-button-* / --section-panel-cta-*); the 12 cta2 entries
   (--hero-button2-* / --cta-button2-*) are inert by SCOPING — every chain that reads them is already
   selector-scoped to an owned button — and the --cta-button-* seven are inert by SANITIZER. All
   are kept for the same reason #526 resets slots on every cta2 variant: the isolation should hold
   on its own terms, not on a sanitizer's current allowlist or on another rule's current selector.

   THE FIX, and why it is shaped this way. A declaration ON the element beats an inherited
   value regardless of source order, and `initial` IS the guaranteed-invalid value for a custom
   property — so every downstream `var(--slot, <fallback>)` takes its fallback and the button
   resolves exactly what an unset button resolves. That is the #526/#530 isolation idiom
   (which re-points a slot on a SECOND button) generalised one step: here it neutralises the
   whole family on any composed `.btn` that is not one of the three button elements a renderer
   owns. Nothing above is touched — no chain, no fallback order, no literal — so every OWNED
   button is byte-identical BY CONSTRUCTION rather than by argument: this selector cannot match
   one. That is what keeps #530/#535/#536/#538/#539/#542/#543/#548/#551/#554 intact.

   The alternative shape (a private --pp-* twin per slot, re-pointed on each owned button, so
   the public slot is never read by an inheriting selector) was prototyped side by side and
   renders pixel-identically. It was rejected on two counts: it rewrites all ten tuned chains
   plus their pins for no rendering difference, and it moves each slot's only consumption onto
   a custom-property target, which the StyleSlotContractTest keystone (slots must be consumed
   on a type-COMPATIBLE property inside the component block) would have had to be weakened to
   accept. Its one advantage — a forgotten new slot fails DEAD instead of leaking — is bought
   back by the css-lint pin that derives this list from components.css itself, so a new family
   slot that is missing here fails the build.

   The global --btn-* tier is deliberately NOT neutralised. A site-wide button token is
   site-wide by definition: an author-written .btn SHOULD follow a --btn-bg retheme exactly as
   the bare .btn primitive and every composed button do. What must not reach it is the
   PER-INSTANCE styling of the band that happens to contain it. Nor are the band-level accent
   slots (--hero-accent / --cta-accent / --hero-heading-color) neutralised: they colour every accented
   element in the band by design, and a nested button is one of those elements. Concretely and
   REACHABLY: `.hero .btn:not(.btn--outline):not(.btn--ghost):not(.btn--secondary)` is [0,5,0],
   which outranks the premium winner at [0,4,1], so on a hero with --hero-accent set an
   author-written .btn in `proof` takes the accent as its ring (and its hover ring from the
   [0,6,0] twin). That is the band accent doing its job — the same treatment every other
   accented element in the band gets — and it is pinned as intended behaviour in css-lint's
   INTENTIONALLY_REACHING list and in the e2e band-accent case. The cta family has the same
   shape one tier down (the border chains fall past the neutralised --cta-button-border to
   --cta-accent), though it is unreachable today: a .cta has no prop that can carry a nested
   .btn.

   Escape hatch, accepted: an author who hand-writes one of the excluded classes onto their own
   button opts back into whichever band's slots that button sits inside (`class="btn hero__cta"`
   in a hero's proof, `class="btn section__panel-cta"` in a section's body). The component classes
   are internal, and treating a deliberate copy of one as intent is better than out-guessing it. The same holds for a hand-written
   `style="--hero-button-bg: …"` — wp_kses_post permits `style`, and an inline declaration beats
   this rule, so the author's own value paints their own button. Neither is the leak: the leak
   was the BAND's styling arriving somewhere nobody asked for it. */
main .btn:not(.hero__cta):not(.cta__button):not(.section__panel-cta) {
  --hero-button-bg: initial;
  --hero-button-border: initial;
  --hero-button-color: initial;
  --hero-button-hover-bg: initial;
  --hero-button-hover-border: initial;
  --hero-button-shadow: initial;
  --hero-button2-bg: initial;
  --hero-button2-border: initial;
  --hero-button2-color: initial;
  --hero-button2-hover-bg: initial;
  --hero-button2-hover-border: initial;
  --hero-button2-hover-color: initial;
  --cta-button-bg: initial;
  --cta-button-border: initial;
  --cta-button-color: initial;
  --cta-button-hover-bg: initial;
  --cta-button-hover-border: initial;
  --cta-button-hover-color: initial;
  --cta-button-shadow: initial;
  --cta-button2-bg: initial;
  --cta-button2-border: initial;
  --cta-button2-color: initial;
  --cta-button2-hover-bg: initial;
  --cta-button2-hover-border: initial;
  --cta-button2-hover-color: initial;
  --section-panel-cta-bg: initial;
  --section-panel-cta-border: initial;
  --section-panel-cta-color: initial;
  --section-panel-cta-hover-border: initial;
  --section-panel-cta-shadow: initial;
}

/* True final cascade: premium CTA treatment.
   Layout/typography apply to every button. The FILLED treatment (gradient
   background-image, accent border, light text) applies to primary buttons only:
   outline/ghost/secondary variants are transparent by design, and a gradient
   background-IMAGE here would paint over their transparent background-COLOR
   (a different layer that `background-color: transparent` cannot clear),
   flattening them into low-contrast filled buttons.

   This block is the later-cascade WINNER for a composed primary button ([0,4,1]
   beats the cta block's `.cta__button:not(...)` at [0,4,0]), so the filled
   treatment's literals are routed through the documented cta-button slots (issue 412,
   the issue-302 idiom): the gradient/bevel/ink defaults are the FALLBACKS, so an unset
   button renders byte-identically, while an author setting --cta-button-bg (a flat
   color), --cta-button-color (ink), or --cta-button-shadow: none gets a FLAT primary
   button on the DEFAULT variant. The --cta-button-* custom properties are emitted
   inline only on the .cta root, and custom-property inheritance is DOM-scoped, so a
   hero/standalone `.btn` (never inside a .cta) never inherits them. That covers the
   CROSS-component case only: a `.btn` INSIDE the .cta does inherit them, and the issue 545
   neutralisation rule above is what keeps them off any button the renderer does not own. */
main .btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 13.25rem;
  min-height: 3rem;
  padding: 0.82rem 1.18rem;
  border-radius: 6px;
  font-size: 0.96rem;
  font-weight: 640;
  line-height: 1.2;
  text-align: center;
  transition:
    background-color var(--transition),
    border-color var(--transition),
    box-shadow var(--transition),
    color var(--transition),
    transform var(--transition);
}

main .btn:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  border-width: 1px;
  border-style: solid;
  border-color: var(--cta-button-border, var(--color-accent-strong));
  /* Superseded for background by the later "True final cascade" block; --section-panel-cta-bg
     and --hero-button-bg lead the chain here too (issues 514/536) so both rest primary-fill
     rules stay uniform. */
  background: var(--section-panel-cta-bg, var(--hero-button-bg, var(--cta-button-bg,
    linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent-strong) 100%))));
  box-shadow: var(--section-panel-cta-shadow, var(--hero-button-shadow, var(--cta-button-shadow,
    inset 0 1px 0 rgba(255, 255, 255, 0.18),
    0 10px 24px color-mix(in srgb, var(--color-accent-strong) 16%, transparent))));
  /* NOTE (#458): border-color/background/box-shadow above are SUPERSEDED by the
     identical-specificity "True final cascade" block below (source order wins). Only `color`
     is the live winner for a composed primary, routed through the global --btn-text knob
     (--cta-button-color still wins when set; unset falls to --color-bg, byte-identical). The
     live fill/border/shadow are routed through --btn-bg/--btn-border-color/--btn-shadow in
     that later block. The hero exposes its own per-instance ink slot here (issue 514,
     analogous to --cta-button-color): --hero-button-color wins when set, unset falls to
     the cta slot then --btn-text then --color-bg (byte-identical). Defined only on a .hero
     root by the renderer (so a cta/section/standalone primary never inherits it) and reset
     on .hero__cta--secondary by the issue 526 isolation rule (so the hero's own second CTA
     does not inherit it either). The section's panel CTA exposes the same ink slot at the
     head of the chain (issue 536): --section-panel-cta-color is emitted only on a .section
     root, and the section RENDERER emits exactly ONE button surface (.section__panel-cta),
     so no #526-style isolation rule is needed. Like every root-emitted button slot,
     --hero-button-* included, it used to inherit onto a `.btn` an author hand-writes into the
     wp_kses_post `body` HTML — the pre-existing trait of the whole family. Issue 545 closed
     that for all five families at once by neutralising them on non-owned composed buttons
     (see the block above this one), so the slot now reaches exactly .section__panel-cta. */
  color: var(--section-panel-cta-color, var(--hero-button-color, var(--cta-button-color, var(--btn-text, var(--color-bg)))));
}

main .btn:not(.btn--outline):not(.btn--ghost):not(.btn--secondary):hover {
  border-color: var(--cta-button-hover-border, var(--color-accent));
  /* Superseded for background by the later "True final cascade" block; --hero-button-hover-bg
     leads the chain here too (issue 530) and the global --btn-hover-bg closes it (issue 539)
     so both hover primary-fill rules stay uniform, the way #514 kept both REST rules
     uniform. */
  background: var(--hero-button-hover-bg, var(--cta-button-hover-bg, var(--btn-hover-bg,
    linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent-hover) 100%))));
  box-shadow: var(--cta-button-shadow,
    inset 0 1px 0 rgba(255, 255, 255, 0.2),
    0 14px 30px color-mix(in srgb, var(--color-accent-strong) 22%, transparent));
  color: var(--cta-button-hover-color, var(--color-bg));
  transform: translateY(-1px);
  text-decoration: none;
}

main .btn:hover {
  transform: translateY(-1px);
  text-decoration: none;
}

main .btn:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 4px;
}

/* Outline/ghost keep a transparent fill inside the premium cascade — this clears
   the gradient background-IMAGE layer the filled rule would otherwise leave on
   them, so the variant's own foreground/border treatment stays readable. */
main .btn--outline,
main .btn--ghost {
  background: transparent;
}

@media (max-width: 767px) {
  main .btn {
    width: 100%;
    min-width: 0;
    max-width: 20rem;
    min-height: 2.95rem;
    padding-right: 1rem;
    padding-left: 1rem;
  }
}

/* True final cascade: CTA color and elevation correction.
   Scoped away from outline/ghost/secondary: this block re-applies the gradient
   fill and has the SAME specificity as `main .btn--outline` (both 0,1,1, since
   `main` is a type selector), so without the :not() it would win by source order
   and re-fill the transparent variants — the secondary-CTA contrast bug. */
main .btn:not(.btn--outline):not(.btn--ghost):not(.btn--secondary) {
  /* The FILL and the RING snap; the bevel, the ink and the lift keep easing (issue 540).
     `main .btn` above sets a five-property transition. On a FILLED premium button two of those
     five animate a colour nobody chose and, worse, nobody ever saw. The resting fill is a
     gradient background-IMAGE, and background-image is not interpolable, so it drops to `none`
     the instant a flat hover slot resolves the shorthand. What the tween then ramps FROM, in
     full view, is the background-COLOR that gradient had been masking — and that colour is NOT
     declared here: it comes from the higher-specificity component twins `.hero .btn:not(...)`
     and `.cta .btn:not(...)` [0,5,0] (the two-rule split documented at the top of this file).
     Look THERE, not in this block, when tracing where a mid-tween colour came from.

     Measured: 7-8 frames (~120ms) of an unchosen colour across SEVEN flashing configurations
     spanning the five composed button surfaces — the four per-instance hover slots
     (--hero-button-hover-bg, --hero-button2-hover-bg, --cta-button-hover-bg,
     --cta-button2-hover-bg), the section panel CTA, and the hero and cta primaries reached
     through the global --btn-hover-bg tier. Example: rgb(80, 74, 195) violet on a button
     authored blue-at-rest, red-on-hover. The 1px ring rides the same tween one layer out
     (rgb(150, 43, 84) on that button), which is why border-color leaves the list too: once the
     fill snaps, the ring is the last thing still crossing colours nobody picked, and one
     guarantee that holds for the whole surface beats a 7-frame hairline animation.
     box-shadow, color and transform stay because none of them is implicated: no gradient masks
     them, so their endpoints are the two states the cascade actually resolves, at rest and on
     hover, with nothing hidden in between.

     Scoped to the filled premium selector on purpose. outline/ghost/secondary keep the full
     five-property list from `main .btn`: their fill and border ARE visible at rest, so their
     tweens ramp between two states that were never masked — honest animation, left alone. Note
     `.btn--secondary` is VISUALLY filled (it paints --color-surface) yet is excluded here, so a
     `secondary` second button still cross-fades while a `primary` one snaps. The split is by
     variant, not by whether the button looks filled.

     Two consequences, both accepted with the issue-540 decision rather than overlooked:
       1. the ring's cross-fade goes on EVERY filled premium button, unset ones included, where
          both endpoints (--color-accent -> --color-accent-hover) were already unmasked;
       2. so does the FILL's cross-fade on a button whose rest AND hover slots are both set to
          flat colours — the pairing the docs recommend. Both endpoints there are unmasked, so
          by the test used above that tween was honest too. CSS cannot condition a transition
          list on whether a custom property was set, and the fill and the gradient arrive
          through ONE `background` shorthand that resolves to either a colour or an image, so no
          selector can separate the masked case from the paired one. Snapping the whole surface
          is the price of the guarantee.
     Settled rest and hover values are untouched in every one of those cases (the byte-identity
     bar this repo holds elsewhere); only the FEEL changes, animated -> instant.

     Longhand, not the `transition` shorthand, so this stays the exact delta: duration, timing
     and delay keep coming from `main .btn`'s var(--transition). One caveat if that list is ever
     retuned: CSS Transitions L1 matches the other lists against transition-property BY INDEX
     and ignores the surplus, so shortening the property list to three re-points entries 1-3
     onto box-shadow/color/transform. That is a no-op only because all five entries are the same
     var(--transition) today; give `main .btn` per-property timings and this block silently
     inherits the wrong three. All three currently run at 150ms ease (verified in-browser,
     computed duration `0.15s, 0.15s, 0.15s, 0.15s, 0.15s` against `box-shadow, color,
     transform`). Pinned in tests/js/css-lint.test.js and by the rendered #540 case in
     tests/e2e/style-render.spec.ts. */
  transition-property: box-shadow, color, transform;
  /* Live fill/border/shadow winner for EVERY composed primary (incl. the generic
     .section__panel-cta, which has no cta/hero ancestor). Global button surface routed here
     (#458): component slots (--cta-button-* / --cta-accent) still win; unset, each falls
     through --btn-* to today's literal (gradient / --color-accent-strong / bevel), so an
     unset premium button is byte-identical. Border is INDEPENDENT of the fill here (matches
     the existing accent-strong-vs-gradient treatment), so it routes --btn-border-color but
     does NOT follow --btn-bg. The one exception is the section panel CTA's own fill slot
     (issue 536, the #526 border-follows-fill convention): --section-panel-cta-bg sits just
     ahead of the literal, so a fill-only panel recolor keeps a matching ring instead of a
     stray accent-strong outline, while every knob above it still wins exactly as before
     and an unset button still resolves --color-accent-strong (byte-identical).
     --section-panel-cta-border now LEADS the chain (issue 584). This rule is the panel
     CTA's only border winner ([0,4,1] over the section block's [0,4,0] keystone), so the
     head is where its per-instance ring slot has to sit to do anything at all — the same
     reason --section-panel-cta-bg leads the fill chains below. It is emitted only on a
     .section root, so a hero/cta/standalone primary never inherits it and every knob
     below keeps winning for them exactly as before; unset, the chain is unchanged. */
  border-color: var(--section-panel-cta-border, var(--cta-button-border, var(--cta-accent, var(--btn-border-color, var(--section-panel-cta-bg, var(--color-accent-strong))))));
  /* Hero per-instance fill slot (issue 514): --hero-button-bg is the visible fill
     winner for a hero primary. A FLAT color value here resolves the shorthand to
     `background: <color>`, which resets background-image to `none` and CLEARS the
     premium gradient (the whole bug: the hero rule only set background-COLOR, which the
     gradient background-IMAGE covered). Unset, it falls through --cta-button-bg /
     --btn-bg to the gradient literal, so an unset button is byte-identical. Defined only on
     a .hero root by the renderer (cta/section/standalone primaries never inherit it), and
     re-pointed at --hero-button2-bg on .hero__cta--secondary by the issue 526 isolation rule —
     which is how a filled second CTA gets its OWN gradient-clearing fill here.
     The section's panel CTA is the last member of that masked-fill class (issue 536):
     .section__panel-cta has no .hero/.cta ancestor, so THIS rule is its only fill winner
     and --section-panel-cta-bg leads the chain the same way. It is emitted only on a
     .section root and the section RENDERER emits exactly one button surface, so no
     isolation re-pointing is needed (see the ink chain above for the one inheritance
     caveat the whole slot family shares); unset, it falls straight through to the
     hero/cta/--btn-bg chain and the gradient literal (byte-identical). */
  background: var(--section-panel-cta-bg, var(--hero-button-bg, var(--cta-button-bg, var(--btn-bg,
    linear-gradient(180deg, var(--color-accent-strong) 0%, var(--color-accent-hover) 100%)))));
  box-shadow: var(--section-panel-cta-shadow, var(--hero-button-shadow, var(--cta-button-shadow, var(--btn-shadow,
    inset 0 1px 0 rgba(255, 255, 255, 0.16),
    0 10px 22px color-mix(in srgb, var(--color-accent-strong) 14%, transparent)))));
}

main .btn:not(.btn--outline):not(.btn--ghost):not(.btn--secondary):hover {
  /* Global hover BORDER (issue 539) mirrors the rest twin's ordering above: after
     --cta-button-hover-border and --cta-accent-hover, ahead of the literal. Border stays
     INDEPENDENT of the fill in this rule exactly as at rest, so --btn-hover-bg is
     deliberately absent here. The rest chain's --section-panel-cta-bg link has no hover
     counterpart because the panel CTA has no per-instance hover fill slot (#536 shipped it
     resting-state-only), so that one link is simply absent — the same way
     --btn-hover-border-color used to be.
     --section-panel-cta-hover-border leads this chain (issue 584) as the positional twin
     of --section-panel-cta-border on the rest rule above. The RING gets its hover twin;
     the resting-only posture for the FILL is unchanged, which is why this chain still has
     no --section-panel-cta-*-bg link. Head of chain, so byte-identical unset. */
  border-color: var(--section-panel-cta-hover-border, var(--cta-button-hover-border, var(--cta-accent-hover, var(--btn-hover-border-color, var(--color-accent)))));
  /* Hero per-instance HOVER fill slot (issue 530): --hero-button-hover-bg is the visible
     hover fill winner for a hero primary, exactly as --hero-button-bg is at rest. A FLAT
     color here resolves the shorthand to `background: <color>`, which resets background-image
     to `none` and CLEARS the premium hover gradient — the whole bug: every component-level
     hover rule sets background-COLOR, which this gradient background-IMAGE covered, so every
     per-instance hover fill slot was dead on a filled button. Unset, it falls through
     --cta-button-hover-bg to the gradient literal, so an unset button is byte-identical.
     Defined only on a .hero root by the renderer (cta/section/standalone primaries never
     inherit it), and re-pointed at --hero-button2-hover-bg on .hero__cta--secondary (and
     --cta-button-hover-bg at --cta-button2-hover-bg on .cta__button--secondary) by the
     issue 530 isolation declarations — which is how a filled SECOND button gets its OWN
     gradient-clearing hover fill here, isolated from the primary's.
     The global --btn-hover-bg closes the chain (issue 539), in the position --btn-bg holds in
     the rest twin above. It is deliberately NOT re-pointed by any isolation declaration: a
     global knob is meant to move both buttons of a pair together, exactly as --btn-bg does at
     rest. This is also the panel CTA's only hover fill winner — .section__panel-cta has no
     .hero/.cta ancestor and no hover rule of its own — so the global tier reaches it here even
     though #536 gave it no per-instance hover slot. That asymmetry is intended: the global
     tier is site-wide by definition, and a knob that skipped one filled surface would be the
     surprising behaviour. */
  background: var(--hero-button-hover-bg, var(--cta-button-hover-bg, var(--btn-hover-bg,
    linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent-strong) 100%))));
  /* --hero-button-shadow flattens hover too (issue 514, matching --cta-button-shadow's
     rest+hover contract): set `none` and both rest and hover lose the bevel. Unset falls
     to --cta-button-shadow then the hover bevel literal (byte-identical).
     --section-panel-cta-shadow joins at the head for the same reason (issue 536): an
     elevation slot that flattened rest but not hover would re-grow a bevel mid-interaction.
     This is the elevation contract only — the hover FILL is deliberately untouched here
     (#536 excludes hover; the panel CTA has no hover fill slot, so a flat rest fill still
     reverts to the premium hover gradient, the pre-#530 trait hero/cta both had). */
  box-shadow: var(--section-panel-cta-shadow, var(--hero-button-shadow, var(--cta-button-shadow,
    inset 0 1px 0 rgba(255, 255, 255, 0.18),
    0 14px 30px color-mix(in srgb, var(--color-accent-strong) 20%, transparent))));
}

main .btn:focus,
main .btn:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 4px;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.18),
    0 0 0 4px color-mix(in srgb, var(--color-accent) 14%, transparent),
    0 14px 30px color-mix(in srgb, var(--color-accent-strong) 20%, transparent);
}

/* Dark-band routing for the FOCUS RING (issue 542). Same pattern #437/#461/#463 established
   for links and accents and #474/#535 finished for button ink and rings — only the surface
   is new.

   The rule above is the live focus indicator for EVERY composed button, and `outline-offset`
   paints it OUTSIDE the button, so the ring sits on the BAND, not on the button's fill.
   The bare light-surface --color-accent measures there:

     --color-bg-inverted (theme:"inverted")                  3.23:1
     --overlay-bg scrim over a WHITE image, rgb(115,115,115)  1.17:1   <- WCAG 1.4.11 failure
     same scrim over a mid-grey image, rgb(58,58,58)          2.06:1   <- WCAG 1.4.11 failure

   Rendered, the overlay case is the real defect: the blue ring vanishes into the scrim and
   the only edge left is #535's white separation ring, which is present at rest too — focus
   becomes indistinguishable from not-focus. Both bands now bottom out at the role token
   base.css already defines for them (8.33:1 and 4.59:1 respectively).

   Only the outline-COLOR longhand is overridden: width, style, offset and the box-shadow
   glow all stay with the base rules, so this changes which colour an already-painted ring
   uses and nothing else. The glow (`0 0 0 4px color-mix(--color-accent 14%, transparent)`)
   is deliberately left on the bare accent: at 14% alpha it is a decorative halo, not the
   indicator carrying the 1.4.11 obligation, and routing it would repaint a surface with no
   measured defect. Note this attaches to `:focus`, not `:focus-visible` — one selector
   covering both base rules. On a COMPOSED button that also means pointer focus, because
   `main .btn:focus` [0,2,1] already outranks base.css's `:focus:not(:focus-visible)`
   suppression [0,2,0] and has always painted a ring on click; this recolours that ring too.
   Nothing new is revealed: a button whose ring is suppressed has `outline-style: none`, and
   a colour alone cannot paint it.

   These sit at [0,3,0] and beat the `main .btn:focus` winner at [0,2,1] on SPECIFICITY, not
   on source order, so they are safe anywhere in the file. The order BETWEEN the two blocks
   below IS load-bearing, and is a semantic precedence rule rather than a formatting accident:
   ONE root can carry both an inverted class and a bg-image class — cta.php:75 and
   section.php:186 both concatenate the theme class and the bg-image class independently — so
   the two blocks tie at [0,3,0] and the OVERLAY role must win. on-inverted (#9dafee) is only
   2.21:1 over the worst-case scrim, so a bg-image band that is also themed inverted would
   otherwise get a ring that fails 1.4.11 harder than the bug being fixed. Pinned in css-lint.

   SECTION BANDS ARE DELIBERATELY NOT ROUTED, and this is the load-bearing scope call here.
   A section's only rendered button is `.section__panel-cta` (section.php:270), which lives
   inside `.section__panel` — a self-contained LIGHT surface (`--color-surface`, #f4f7fb) that
   sits ON the dark band with `--space-lg` of its own padding, so the ring at 4px offset is
   drawn on the PANEL, never on the band. Routing it would take that ring from 5.18:1 today
   down to 2.02:1 (on-inverted) or 1.04:1 (on-overlay) — strictly worse than the bug being
   fixed. This is the same carve-out #424/#463 already made for the panel's list markers (see
   "Panel markers stay bare accent" above). Do not "complete the set" by adding
   `.pp-section--inverted .btn:focus` / `.section--has-bg-image .btn:focus`.

   Scoped to `.btn` on the roots whose RENDERERS put a button ON the band: cover hero and cta.
   A dark band that renders no on-band button (grid/stats/faq/logos/embed/testimonials, and
   section per the paragraph above) can only receive a `.btn` through author-written rich-text
   HTML — the author-written nested button of #545, deliberately not covered here. That issue
   neutralised the per-instance FILL slots on such a button; its focus ring is a separate
   surface with no per-instance slot at all, and it keeps the bare accent, as it always has.
   Non-button focusables on dark bands
   (a link in `.cta__body`, nav) also keep the bare accent ring: #437/#461 routed their INK,
   not their outline, and widening this to every focusable is a separate blast radius.

   No new slot family: the focus ring has no per-instance slot today and does not gain one
   here, so there is nothing authored to preserve ahead of the role token. An author who
   recolours a button's BORDER (--cta-button-border and friends) keeps that border exactly —
   the focus outline is an independent surface and is deliberately not chained to it. The
   routing is class-triggered, so it shares the whole role family's blind spot: a band you
   darken yourself with `--cta-bg`, or a scrim you lighten with `--cta-overlay-bg`, carries
   the same classes and gets the same ring regardless (identical exposure to #461's link ink).

   NOTE on the inverted band: 3.23:1 technically clears the 3:1 non-text bar, and #535 Q2
   used exactly that figure to refuse a separation ring on the inverted filled primary. This
   rule routes it anyway, which is a deliberate, recorded divergence rather than an oversight:
   a 2px hairline reads far weaker than an area fill at the same ratio, and today the resting
   border of an `outline` button on that band (#9dafee, 8.33:1) is MORE prominent than the
   focus ring meant to mark the active element. Do not "restore consistency" by dropping the
   inverted half — the css-lint pins will stop you, on purpose. */
.cta--inverted .btn:focus {
  outline-color: var(--color-accent-on-inverted);
}

.hero--cover .btn:focus,
.cta--has-bg-image .btn:focus {
  outline-color: var(--color-accent-on-overlay);
}

/* Adjacent-sibling rhythm, restored (issue 302). This premium rule used to flatten
   the vertical rhythm to a uniform clamp() at ALL widths, outranking the base
   adjacent-sibling rule (SHARED: Adjacent-Sibling Rhythm) AND defeating every
   --*-padding-top slot on an adjacent component. The shared adjacent-top tier is
   now --pp-band-padding-adjacent-top, pinned to --pp-band-padding so the adjacent
   top equals the band's own edges (symmetric, issue 430; formerly the tighter
   var(--space-lg) two-tier value). Drop the flat override; the base rule carries
   the shared default that the per-component rules fall back to (since issue 577
   every band type, hero included, has a per-component adjacent-top rule).
   For the slot-bearing components, route the adjacent top-padding through the slot
   with the shared --pp-band-padding-adjacent-top as the fallback so the slot
   controls adjacent spacing too AND every band consumes the one rhythm definition
   (issue 431 — testimonials joins the list; its adjacent-top slot was dead here).
   Desktop-scoped and placed last so it wins over the base rule; mobile keeps the
   uniform rhythm below (mobile spacing is intentionally uniform).
   HERO IS DELIBERATELY ABSENT FROM THIS LIST at desktop. Its adjacent-top rule lives
   up in the SHARED: Adjacent-Sibling Rhythm block, above the [data-pp-spacing]
   restatement, and falls back to hero's own opener rhythm rather than the shared band
   tier — see the long comment there for both reasons (issue 577). */
@media (min-width: 768px) {
  main > [data-pp-component] + .section {
    padding-top: var(--section-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .grid {
    padding-top: var(--grid-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .cta {
    padding-top: var(--cta-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .stats {
    padding-top: var(--stats-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .faq {
    padding-top: var(--faq-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .testimonials {
    padding-top: var(--testimonials-padding-top, var(--pp-band-padding-adjacent-top));
  }
  /* table/logos/embed join the per-component adjacent-top routing (issue 438) so
     their adjacent-top edge tracks the same shared tier AND an explicit
     --<comp>-padding-top wins on that edge too, not just their own base edges. */
  main > [data-pp-component] + .table-section {
    padding-top: var(--table-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .logos {
    padding-top: var(--logos-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .embed {
    padding-top: var(--embed-padding-top, var(--pp-band-padding-adjacent-top));
  }
}

/* Own section padding, routed through each component's padding slots (issue 302).
   This premium rule re-declared padding-top/bottom as bare clamp() literals,
   outranking the base .section/.grid/.cta rules ([0,1,0]) and silently ignoring a
   declared --*-padding-top/bottom. Split per component and route through the slot
   with the shared --pp-band-padding as the fallback. faq now routes through
   --faq-padding-top/bottom too (issue 304). The clamp() literal moved into the one
   rhythm definition (issue 431); stats/testimonials get their own tier via their
   base rules, which now route through the same --pp-band-padding. */
.section {
  padding-top: var(--section-padding-top, var(--pp-band-padding));
  padding-bottom: var(--section-padding-bottom, var(--pp-band-padding));
}

.grid {
  padding-top: var(--grid-padding-top, var(--pp-band-padding));
  padding-bottom: var(--grid-padding-bottom, var(--pp-band-padding));
}

.cta {
  padding-top: var(--cta-padding-top, var(--pp-band-padding));
  padding-bottom: var(--cta-padding-bottom, var(--pp-band-padding));
}

.faq {
  padding-top: var(--faq-padding-top, var(--pp-band-padding));
  padding-bottom: var(--faq-padding-bottom, var(--pp-band-padding));
}

/* The card radius used to be capped for grid AND faq from ONE selector reading the
   GRID slot; issue 577 split it so each component drives its own — see the .faq__item
   comment in the COMPONENT: faq block. Both sides keep the 4px literal as the
   fallback, so unset output is byte-identical on both components. */
main > .faq .faq__item {
  border-radius: var(--faq-item-radius, 4px);
}
main > .grid .grid__item {
  border-radius: var(--grid-item-radius, 4px);
  /* Later-cascade winner for cards 2..N: route it through --grid-item-border-color
     too (matching the earlier all-cards rule and the :first-child rule below)
     or the slot stays ignored on every non-featured card (issue 292). */
  border-color: var(--grid-item-border-color, var(--color-border));
  box-shadow: var(--grid-item-shadow, 0 10px 24px rgba(15, 23, 42, 0.055));
}

main > .grid:not(.grid--steps):not(.grid--uniform) .grid__item:first-child {
  /* Later-cascade winner: this is the border-color that actually applies. Route it
     through --grid-item-border-color too (matching the earlier rule) or the slot stays
     ignored on the first card (issue 226). */
  border-color: var(--grid-item-border-color, var(--color-accent-strong));
}

main .btn {
  /* This premium-CTA block is the later-cascade winner for composed buttons
     (it overrides the base `.btn { border-radius: var(--btn-radius, var(--radius)) }`
     at equal specificity + source order), so the button radius must be routed
     through --btn-radius HERE or the token stays inert on every rendered button
     (issue 369). --btn-radius is registered defaulting to 4px, so unset output
     is byte-identical to the previous hardcoded 4px; an override (e.g. 100px)
     pills the button without touching the global --radius (or anything that
     reads it), so the button radius is no longer chained to the card radius. */
  border-radius: var(--btn-radius, 4px);
}
@media (max-width: 767px) {
  /* Mobile rhythm defaults to a uniform value (SHARED: Adjacent-Sibling Rhythm
     is desktop-only, so there is no two-tier default here). This generic rule
     [0,2,1] beats the per-component .section/.grid/.cta rules below [0,1,0], so
     without the per-component adjacent rules a declared --*-padding-top would
     silently no-op on an adjacent mobile component's TOP edge (same dead-slot
     class as issue 302). Route the slot per component with the shared
     --pp-band-padding-adjacent-top as the fallback (3.35rem at this breakpoint
     via its :root override); unset output stays uniform. testimonials joins the
     list so its adjacent-top slot is live on mobile too (issue 431). */
  main > [data-pp-component] + [data-pp-component] {
    padding-top: var(--pp-band-padding-adjacent-top);
  }
  main > [data-pp-component] + .section {
    padding-top: var(--section-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .grid {
    padding-top: var(--grid-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .cta {
    padding-top: var(--cta-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .stats {
    padding-top: var(--stats-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .faq {
    padding-top: var(--faq-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .testimonials {
    padding-top: var(--testimonials-padding-top, var(--pp-band-padding-adjacent-top));
  }
  /* table/logos/embed adjacent-top on mobile too (issue 438). */
  main > [data-pp-component] + .table-section {
    padding-top: var(--table-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .logos {
    padding-top: var(--logos-padding-top, var(--pp-band-padding-adjacent-top));
  }
  main > [data-pp-component] + .embed {
    padding-top: var(--embed-padding-top, var(--pp-band-padding-adjacent-top));
  }
  /* hero joins the list on mobile (issue 577) — the generic rule above was outranking
     its declared --hero-padding-top on this edge. THE FALLBACK IS THE ODD ONE OUT:
     hero keeps its own opener rhythm (--space-xl, 4rem) instead of the shared
     --pp-band-padding-adjacent-top (3.35rem here), matching .hero's own base rule.
     Safe to sit with its siblings at this breakpoint because the [data-pp-spacing]
     restatement below still out-orders it (issue 434); at desktop that restatement is
     ABOVE the per-component list, which is why hero's desktop rule lives up in the
     SHARED: Adjacent-Sibling Rhythm block instead.
     NO .hero--left TWIN IS NEEDED HERE: the left variant's own opener rhythm at this
     breakpoint is --space-xl, which is exactly what this rule already falls back to, so
     left/split are symmetric on mobile through this one rule. The desktop twin exists
     only because the two variants' rhythms DIVERGE there (--space-2xl vs --space-xl). */
  main > [data-pp-component] + .hero {
    padding-top: var(--hero-padding-top, var(--space-xl));
  }

  /* An explicit data-pp-spacing override (only hero emits it) must govern BOTH
     edges symmetrically at mobile, exactly as the desktop restatement does (the
     min-width:768px block by the base spacing rules). Without this restatement the
     generic adjacent rule above [0,2,1] out-orders the base .hero[data-pp-spacing]
     rules [0,2,0] (scoped to .hero in issue 578; the specificity is unchanged, which is
     what keeps this ordering argument true) and shaves ONLY the top edge of a spaced
     hero placed after another band,
     leaving top=band-rhythm / bottom=spacing value — a compact/spacious hero reads
     bottom-heavy instead of centered (issue 434). Same [0,2,1] specificity as the
     adjacent rule, placed after it so source order wins both edges. Values mirror the
     mobile BASE rules (compact --space-lg, spacious --space-2xl), NOT the larger
     desktop tier, so mobile spacing is unchanged except the top edge is restored. */
  main > .hero[data-pp-spacing="compact"] {
    padding-top: var(--space-lg);
    padding-bottom: var(--space-lg);
  }
  main > .hero[data-pp-spacing="spacious"] {
    padding-top: var(--space-2xl);
    padding-bottom: var(--space-2xl);
  }

  /* Own mobile padding, routed through the padding slots so a declared
     --*-padding-top/bottom takes effect at mobile too (issue 302). faq now routes
     through --faq-padding-top/bottom too (issue 304). Fallbacks route through the
     shared --pp-band-padding (3.35rem at this breakpoint). cta/stats/testimonials
     no longer need a per-component restatement here — their base rules already
     fall back to --pp-band-padding, so they join the same mobile tier (issue 431). */
  .section {
    padding-top: var(--section-padding-top, var(--pp-band-padding));
    padding-bottom: var(--section-padding-bottom, var(--pp-band-padding));
  }

  .grid {
    padding-top: var(--grid-padding-top, var(--pp-band-padding));
    padding-bottom: var(--grid-padding-bottom, var(--pp-band-padding));
  }

  .faq {
    padding-top: var(--faq-padding-top, var(--pp-band-padding));
    padding-bottom: var(--faq-padding-bottom, var(--pp-band-padding));
  }
}
