/* Theme base styles */

/* Tools
Any animations, or functions used throughout the project.
Note: _macros.css needs to be imported into each stylesheet where macros are used and not included here
*/

/* Settings
Design system tokens (CSS custom properties). Primitive -> Semantic; components bind to semantic tokens.
Source of truth: Figma "On The Stage - Website (2026)" Design System page (see design-system/figma-sections.json).
*/

/* Design system tokens: colors
   Source: Figma "On The Stage - Website (2026)" > Design System > Section / 02 Colors
   Verified against the Figma variable collections:
   "Primitive / Colors" (72 variables) and "Semantic / Brand" (39 variables).
   Architecture: Primitive -> Semantic (components bind to semantic tokens only).
   Owned by the Colors section agent. */

:root {
  /* --- Primitive / Crimson --- */
  --brand-crimson-50: #fceaee;
  --brand-crimson-100: #f8d0d8;
  --brand-crimson-200: #f2a3b4;
  --brand-crimson-300: #ef4466;
  --brand-crimson-400: #e72047;
  --brand-crimson-500: #c41033;
  --brand-crimson-600: #a60524;
  --brand-crimson-700: #8a0420;
  --brand-crimson-800: #660318;
  --brand-crimson-900: #470210;

  /* --- Primitive / Gold --- */
  --brand-gold-50: #fef7e6;
  --brand-gold-100: #fdefc9;
  --brand-gold-200: #fbdf93;
  --brand-gold-300: #fbcd58;
  --brand-gold-400: #f6c344;
  --brand-gold-500: #e0af32;
  --brand-gold-600: #cea337;
  --brand-gold-700: #a6822c;
  --brand-gold-800: #7d6221;
  --brand-gold-900: #574516;

  /* --- Primitive / Teal --- */
  --brand-teal-50: #e3f4f8;
  --brand-teal-100: #bfe6ef;
  --brand-teal-200: #8cd2e3;
  --brand-teal-300: #53bcd5;
  --brand-teal-400: #19a6c7;
  --brand-teal-500: #088dac;
  --brand-teal-600: #006981;
  --brand-teal-700: #005367;
  --brand-teal-800: #004352;
  --brand-teal-900: #003542;

  /* --- Primitive / Petrol --- */
  --brand-petrol-50: #eaeeee;
  --brand-petrol-100: #cfdadb;
  --brand-petrol-200: #9fb3b5;
  --brand-petrol-300: #6e8c8f;
  --brand-petrol-400: #436266;
  --brand-petrol-500: #2c3d41;
  --brand-petrol-600: #1f3337;
  --brand-petrol-700: #082329;
  --brand-petrol-800: #04191e;
  --brand-petrol-900: #001216;

  /* --- Primitive / Tan --- */
  --brand-tan-50: #fbf9f6;
  --brand-tan-100: #f7f3ec;
  --brand-tan-200: #ebe2d0;
  --brand-tan-300: #dccbac;
  --brand-tan-400: #cab081;
  --brand-tan-500: #b89456;
  --brand-tan-600: #97773f;
  --brand-tan-700: #735b30;
  --brand-tan-800: #4f3e21;
  --brand-tan-900: #2f2514;

  /* --- Primitive / Status Green --- */
  --status-green-50: #edf7f2;
  --status-green-100: #dbefe4;
  --status-green-200: #b7e0c9;
  --status-green-300: #8bcca8;
  --status-green-400: #55b57f;
  --status-green-500: #1f9d57;
  --status-green-600: #1a8449;
  --status-green-700: #156b3b;
  --status-green-800: #10522d;
  --status-green-900: #0c3c21;

  /* --- Primitive / Neutral ---
     Full gray 50-900 ramp confirmed from the "Primitive / Colors" collection. */
  --neutral-white: #ffffff;
  --neutral-black: #000000;
  --neutral-gray-50: #f9f8f5;
  --neutral-gray-100: #f5f3ef;
  --neutral-gray-200: #f7f3ec;
  --neutral-gray-300: #e4e1db;
  --neutral-gray-400: #c9c4bb;
  --neutral-gray-500: #9a958c;
  --neutral-gray-600: #5c5853;
  --neutral-gray-700: #3e3b37;
  --neutral-gray-800: #2a2825;
  --neutral-gray-900: #1a1a1a;

  /* --- Semantic / Brand --- */
  --color-brand-primary: var(--brand-crimson-500);
  --color-brand-primary-dark: var(--brand-crimson-700);
  --color-brand-secondary: var(--brand-gold-500);
  --color-brand-highlight: var(--brand-teal-500);
  --color-brand-surface: var(--neutral-gray-50);
  --color-brand-white: var(--neutral-white);

  /* --- Semantic / Background --- */
  --color-background-default: var(--neutral-white);
  --color-background-subtle: var(--neutral-gray-50);
  --color-background-hover: var(--neutral-gray-100);
  --color-background-inverse: var(--brand-petrol-700);

  /* --- Semantic / Text --- */
  --color-text-primary: var(--brand-petrol-700);
  --color-text-secondary: var(--neutral-gray-600);
  --color-text-on-brand: var(--neutral-white);
  --color-text-on-dark: var(--neutral-white);
  --color-text-disabled: var(--neutral-gray-400);

  /* --- Semantic / Border --- */
  --color-border-default: var(--neutral-gray-200);
  --color-border-strong: var(--neutral-gray-400);
  --color-border-focus-form: var(--color-brand-highlight);
  --color-border-focus-button: var(--brand-crimson-200);

  /* --- Semantic / Accent --- */
  --color-accent-gold: var(--brand-gold-500);
  --color-accent-teal: var(--brand-teal-500);

  /* --- Semantic / Photo filter ---
     Colors for the Image Filter Layers primitive (Recipe A / Recipe B). Every other
     value the recipes need already has a token: Recipe A layer 1 and Recipe B both
     bind to --color-text-primary (#082329) and --brand-petrol-900 (#001216), and the
     S3 accent bar binds to --brand-gold-500 (#e0af32).

     --photo-filter-warm is an intentional Image Filter Layers token: #ffb561 is an
     unbound raw fill in Figma (node 1715:15115), so it stays separate from the Gold
     ramp. Snapping it would visibly shift the hero and the S3 overlay away from the
     approved comp. Confirmed in issue #20. */
  --photo-filter-warm: #ffb561;

  /* --- Semantic / Status --- */
  --color-status-success: var(--status-green-500);
  --color-status-success-light: var(--status-green-100);
  --color-status-warning: var(--brand-gold-500);
  --color-status-warning-light: var(--brand-gold-100);
  --color-status-error: var(--brand-crimson-500);
  --color-status-error-light: var(--brand-crimson-100);
  --color-status-info: var(--brand-teal-500);
  --color-status-info-light: var(--brand-teal-100);

  /* --- Semantic / Link (light surfaces) --- */
  --color-link-light-default: var(--brand-crimson-500);
  --color-link-light-hover: var(--brand-crimson-700);
  --color-link-light-active: var(--brand-crimson-800);
  --color-link-light-visited: var(--neutral-gray-600);
  --color-link-light-disabled: var(--neutral-gray-400);

  /* --- Semantic / Link (dark surfaces) --- */
  --color-link-dark-default: var(--brand-teal-300);
  --color-link-dark-hover: var(--brand-teal-200);
  --color-link-dark-active: var(--brand-teal-100);
  --color-link-dark-visited: var(--neutral-gray-300);
  --color-link-dark-disabled: var(--neutral-gray-600);
}
/* Design system tokens: typography
   Source: Figma "On The Stage - Website (2026)" > Design System > Section / 03 Typography
   Variable collection "Typography" - 4 responsive modes, mobile-first here:
   SM >=320px (base), MD >=768px, LG >=1025px, XL >=1280px.
   Heading letter-spacing resolves in px per breakpoint (Figma platform constraint):
   0px SM, -1px MD, -1.5px LG, -2px XL. H3-H6 are flat 0px at all breakpoints.
   Body letter-spacing tokens (body-min/body-max) exist in Figma but are inert; do not wire them.
   Owned by the Typography section agent. */

:root {
  --font-family-brand: "Open Sans", sans-serif;

  --font-weight-regular: 400;
  --font-weight-semi-bold: 600;
  --font-weight-bold: 700;

  --font-line-height-heading-lg: 1.1; /* H1, H2 */
  --font-line-height-heading-md: 1.2; /* H3, H4 */
  --font-line-height-heading-sm: 1.3; /* H5, H6 */
  --font-line-height-body: 1.5;
  --font-line-height-label: 1.3;

  /* SM mode (>=320px) */
  --font-size-h1: 34px;
  --font-size-h2: 24px;
  --font-size-h3: 20px;
  --font-size-h4: 18px;
  --font-size-h5: 16px;
  --font-size-h6: 16px;
  --font-size-body-lg: 18px;
  --font-size-body: 16px;
  --font-size-sm: 14px;
  --font-size-xs: 12px;
  --font-size-xxs: 12px;
  --font-letter-spacing-heading: 0px;
}

@media (min-width: 768px) {
  /* MD mode */
  :root {
    --font-size-h1: 44px;
    --font-size-h2: 30px;
    --font-size-h3: 24px;
    --font-size-h4: 18px;
    --font-size-h5: 16px;
    --font-size-h6: 16px;
    --font-size-body-lg: 20px;
    --font-size-body: 16px;
    --font-size-sm: 14px;
    --font-size-xs: 12px;
    --font-size-xxs: 12px;
    --font-letter-spacing-heading: -1px;
  }
}

@media (min-width: 1025px) {
  /* LG mode */
  :root {
    --font-size-h1: 52px;
    --font-size-h2: 36px;
    --font-size-h3: 26px;
    --font-size-h4: 20px;
    --font-size-h5: 18px;
    --font-size-h6: 16px;
    --font-size-body-lg: 22px;
    --font-size-body: 18px;
    --font-size-sm: 16px;
    --font-size-xs: 14px;
    --font-size-xxs: 12px;
    --font-letter-spacing-heading: -1.5px;
  }
}

@media (min-width: 1280px) {
  /* XL mode */
  :root {
    --font-size-h1: 60px;
    --font-size-h2: 40px;
    --font-size-h3: 30px;
    --font-size-h4: 20px;
    --font-size-h5: 18px;
    --font-size-h6: 16px;
    --font-size-body-lg: 24px;
    --font-size-body: 20px;
    --font-size-sm: 16px;
    --font-size-xs: 14px;
    --font-size-xxs: 12px;
    --font-letter-spacing-heading: -2px;
  }
}
/* Design system tokens: spacing & layout
   Source: Figma "On The Stage - Website (2026)" > Design System > Section / 08 Spacing Scale
   Figma collection "Spacing / Layout" - all 30 variables, verified against the live
   Figma variable collection (single "Value" mode).
   Note: spacing/8 is 30px in Figma (not 32) - the scale is not a strict powers-of-4 ramp.
   Owned by the Spacing section agent. */

:root {
  /* Spacing scale - base unit 4px; each step is a multiple used for padding, gaps, and layout */
  --spacing-1: 4px;
  --spacing-2: 8px;
  --spacing-3: 12px;
  --spacing-4: 16px;
  --spacing-5: 20px;
  --spacing-6: 24px;
  --spacing-8: 30px;
  --spacing-10: 40px;
  --spacing-12: 48px;
  --spacing-16: 64px;
  --spacing-20: 80px;
  --spacing-24: 96px;
  --spacing-30: 120px;

  /* Section vertical rhythm: authored px values are desktop values; this
     scale shrinks them per breakpoint so 120px becomes 80px on tablet and
     64px on mobile (BugHerd #67, updated per design direction: mobile was
     48px/0.4, now 64px/8:15). Mobile-first: base is the mobile scale. */
  --section-pad-scale: calc(8 / 15); /* 64 / 120 */

  /* Layout: page margins per breakpoint */
  --layout-margin-sm: 20px;
  --layout-margin-md: 30px;
  --layout-margin-lg: 80px;
  --layout-margin-xl: 100px;

  /* Resolved page margin for the current breakpoint. theme-overrides.css sets
     .dnd-section padding with a single shorthand (it has to - the vertical half
     is theme-setting driven), so it cannot use the per-breakpoint tokens above
     without four media queries in generated CSS. Binding the shorthand to this
     one property keeps the DS margins alive at every breakpoint (BugHerd #68);
     before this it was a flat `1rem`, which tracked the root font size (16px on
     mobile, 20px on desktop) and overrode objects/_containers-dnd.css outright. */
  --layout-margin: var(--layout-margin-sm);

  /* Layout: grid gutters per breakpoint */
  --layout-gutter-sm: 20px;
  --layout-gutter-md: 30px;
  --layout-gutter-lg: 30px;
  --layout-gutter-xl: 30px;

  /* Layout: grid column counts per breakpoint (unitless) */
  --layout-columns-sm: 4;
  --layout-columns-md: 8;
  --layout-columns-lg: 12;
  --layout-columns-xl: 12;

  /* Breakpoints (reference only - custom properties cannot be used in media queries).
     SM >=320px, MD >=768px, LG >=1025px, XL >=1280px. */
  --layout-breakpoint-sm: 320px;
  --layout-breakpoint-md: 768px;
  --layout-breakpoint-lg: 1025px;
  --layout-breakpoint-xl: 1280px;

  /* Layout: content max width (XL cap) */
  --layout-max-width: 1280px;
}

@media (min-width: 768px) {
  :root {
    --layout-margin: var(--layout-margin-md);
    --section-pad-scale: calc(2 / 3); /* 80 / 120 */
  }
}

@media (min-width: 1025px) {
  :root {
    --layout-margin: var(--layout-margin-lg);
    --section-pad-scale: 1;
  }
}

@media (min-width: 1280px) {
  :root {
    --layout-margin: var(--layout-margin-xl);
  }
}
/* Design system tokens: border radius
   Source: Figma "On The Stage - Website (2026)" > Design System > Section / 09 Border Radius
   Full collection (9 variables) confirmed from the Figma variable dump.
   Owned by the Border Radius section agent. */

:root {
  --radius-none: 0;
  --radius-xs: 2px;
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-xl: 16px;
  --radius-2xl: 24px;
  --radius-3xl: 32px;
  --radius-full: 9999px;
}
/* Design system tokens: elevation & shadows
   Source: Figma "On The Stage - Website (2026)" > Design System > Section / 10 Elevation & Shadows
   Verified against Figma node 27:139 effect styles (Elevation/XS-XL) - exactly 5 levels,
   single drop shadow each, x=0, spread=0; alphas 0D/0F/14/1A/1F hex = 5/6/8/10/12%.
   "Elevation none" appears in the spec as a specimen but has no effect style, hence no token.
   Owned by the Elevation section agent. */

:root {
  --elevation-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
  --elevation-sm: 0 2px 4px 0 rgba(0, 0, 0, 0.06);
  --elevation-md: 0 4px 8px 0 rgba(0, 0, 0, 0.08);
  --elevation-lg: 0 8px 16px 0 rgba(0, 0, 0, 0.1);
  --elevation-xl: 0 16px 32px 0 rgba(0, 0, 0, 0.12);
}

/* Nav effect styles (Figma: Elevation/Nav + Elevation/Overlay, node 1066:456).
   Not part of the XS-XL ramp above: both are tinted (#0D0514 / #0D0814 base,
   not pure black) and Elevation/Nav carries a -10px spread.
   Appended by the header/nav build; token files are append-only. */

:root {
  --elevation-nav: 0 10px 20px -10px rgba(13, 5, 20, 0.1);
  --elevation-overlay: 0 12px 32px 0 rgba(13, 8, 20, 0.14);
}

/* Generic
This is where reset, normalize & box-sizing styles go.
*/

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

/* Full-bleed elements (100vw breakout trick, e.g. hero.module) measure against
   the viewport width including the scrollbar, which overshoots the real
   scrollable area by half the scrollbar's width on each side. This is the
   standard guard against that residual horizontal overflow. */

html {
  overflow-x: hidden;
}

/* Sitewide default background. Most sections on this site want the cream
   surface tone rather than stark white (confirmed: nothing else in this
   theme sets a page-level background, so this was previously just browser
   default white). Setting it once here means any section without its own
   explicit background - including future modules that don't have a Style-
   tab color field at all - automatically gets the cream tone for free,
   instead of every module needing to redeclare it. Modules that want a
   different color (or a full-bleed photo, e.g. hero.module) still set
   their own background explicitly and are unaffected by this default. */

body {
  background-color: var(--color-brand-surface);
}
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */

/* Document
   ========================================================================== */

/**
 * 1. Correct the line height in all browsers.
 * 2. Prevent adjustments of font size after orientation changes in iOS.
 */

html {
  line-height: 1.15; /* 1 */
  -webkit-text-size-adjust: 100%; /* 2 */
}

/* Sections
   ========================================================================== */

/**
 * Remove the margin in all browsers.
 */

body {
  margin: 0;
}

/**
 * Correct the font size and margin on `h1` elements within `section` and
 * `article` contexts in Chrome, Firefox, and Safari.
 */

h1 {
  font-size: 2em;
  margin: 0.67em 0;
}

/* Grouping content
   ========================================================================== */

/**
 * Add the correct box sizing in Firefox.
 */

hr {
  box-sizing: content-box;
  height: 0;
}

/**
 * 1. Correct the inheritance and scaling of font size in all browsers.
 * 2. Correct the odd `em` font sizing in all browsers.
 */

pre {
  font-family: monospace, monospace; /* 1 */
  font-size: 1em; /* 2 */
}

/* Text-level semantics
   ========================================================================== */

/**
 * 1. Remove the bottom border in Chrome 57-
 * 2. Add the correct text decoration in Chrome, Edge, Opera, and Safari.
 */

abbr[title] {
  border-bottom: none; /* 1 */
  text-decoration: underline; /* 2 */
  text-decoration: underline dotted; /* 2 */
}

/**
 * Add the correct font weight in Chrome, Edge, and Safari.
 */

b,
strong {
  font-weight: bolder;
}

/**
 * 1. Correct the inheritance and scaling of font size in all browsers.
 * 2. Correct the odd `em` font sizing in all browsers.
 */

code,
kbd,
samp {
  font-family: monospace, monospace; /* 1 */
  font-size: 1em; /* 2 */
}

/**
 * Add the correct font size in all browsers.
 */

small {
  font-size: 80%;
}

/**
 * Prevent `sub` and `sup` elements from affecting the line height in
 * all browsers.
 */

sub,
sup {
  font-size: 75%;
  line-height: 0;
  position: relative;
  vertical-align: baseline;
}

sub {
  bottom: -0.25em;
}

sup {
  top: -0.5em;
}

/* Forms
   ========================================================================== */

/**
 * 1. Change the font styles in all browsers.
 * 2. Remove the margin in Firefox and Safari.
 */

button,
input,
optgroup,
select,
textarea {
  font-family: inherit; /* 1 */
  font-size: 100%; /* 1 */
  line-height: 1.15; /* 1 */
  margin: 0; /* 2 */
}

/**
 * Remove the inheritance of text transform in Edge and Firefox.
 * 1. Remove the inheritance of text transform in Firefox.
 */

button,
select { /* 1 */
  text-transform: none;
}

/**
 * Correct the inability to style clickable types in iOS and Safari.
 */

button,
[type="button"],
[type="reset"],
[type="submit"] {
  -webkit-appearance: button;
}

/**
 * Remove the inner border and padding in Firefox.
 */

button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
  border-style: none;
  padding: 0;
}

/**
 * Restore the focus styles unset by the previous rule.
 */

button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
  outline: 1px dotted ButtonText;
}

/**
 * Correct the padding in Firefox.
 */

fieldset {
  padding: 0.35em 0.75em 0.625em;
}

/**
 * Remove the padding so developers are not caught out when they zero out `fieldset` elements in all browsers.
 */

legend {
  padding: 0;
}

/**
 * Add the correct vertical alignment in Chrome, Firefox, and Opera.
 */

progress {
  vertical-align: baseline;
}

/**
 * Correct the cursor style of increment and decrement buttons in Chrome.
 */

[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
  height: auto;
}

/**
 * 1. Correct the odd appearance in Chrome and Safari.
 * 2. Correct the outline style in Safari.
 */

[type="search"] {
  -webkit-appearance: textfield; /* 1 */
  outline-offset: -2px; /* 2 */
}

/**
 * Remove the inner padding in Chrome and Safari on macOS.
 */

[type="search"]::-webkit-search-decoration {
  -webkit-appearance: none;
}

/**
 * 1. Correct the inability to style clickable types in iOS and Safari.
 * 2. Change font properties to `inherit` in Safari.
 */

::-webkit-file-upload-button {
  -webkit-appearance: button; /* 1 */
  font: inherit; /* 2 */
}

/* Interactive
   ========================================================================== */

/*
 * Add the correct display in Edge and Firefox.
 */

details {
  display: block;
}

/*
 * Add the correct display in all browsers.
 */

summary {
  display: list-item;
}

/* Objects
Non-cosmetic design patterns including grid and layout classes)
*/



/* Grid layout
   Design system (Figma Section / 07 Breakpoints & Grid):
   gutters are 20px at SM (>=320px) and 30px from MD (>=768px) up.
   --grid-gutter derives from the layout gutter tokens; columns stack below 768px. */

:root {
  --grid-gutter: var(--layout-gutter-sm);
}

@media (min-width: 768px) {
  :root {
    --grid-gutter: var(--layout-gutter-md);
  }
}

/* Mobile layout (SM: columns stack full width) */

.row-fluid {
  display: flex;
  flex-wrap: wrap;
  /* Stacked columns take the gutter vertically, so a multi-column dnd row keeps
     the SM 20px rhythm once it collapses instead of butting together (BugHerd
     #68). Rows with a single span are unaffected. */
  row-gap: var(--grid-gutter);
  width: 100%;
}


  .row-fluid .span1,
  .row-fluid .span2,
  .row-fluid .span3,
  .row-fluid .span4,
  .row-fluid .span5,
  .row-fluid .span6,
  .row-fluid .span7,
  .row-fluid .span8,
  .row-fluid .span9,
  .row-fluid .span10,
  .row-fluid .span11,
  .row-fluid .span12{
  min-height: 1px;
  width: 100%;
}

/* Desktop layout (MD and up: 12-unit grid, token-driven gutters) */

@media (min-width: 768px) {
  .row-fluid {
    column-gap: var(--grid-gutter);
    flex-wrap: nowrap;
  }

  
    .row-fluid .span1 {
      width: calc((100% - 11 * var(--grid-gutter)) / 12 * 1 + 0 * var(--grid-gutter));
    }
  
    .row-fluid .span2 {
      width: calc((100% - 11 * var(--grid-gutter)) / 12 * 2 + 1 * var(--grid-gutter));
    }
  
    .row-fluid .span3 {
      width: calc((100% - 11 * var(--grid-gutter)) / 12 * 3 + 2 * var(--grid-gutter));
    }
  
    .row-fluid .span4 {
      width: calc((100% - 11 * var(--grid-gutter)) / 12 * 4 + 3 * var(--grid-gutter));
    }
  
    .row-fluid .span5 {
      width: calc((100% - 11 * var(--grid-gutter)) / 12 * 5 + 4 * var(--grid-gutter));
    }
  
    .row-fluid .span6 {
      width: calc((100% - 11 * var(--grid-gutter)) / 12 * 6 + 5 * var(--grid-gutter));
    }
  
    .row-fluid .span7 {
      width: calc((100% - 11 * var(--grid-gutter)) / 12 * 7 + 6 * var(--grid-gutter));
    }
  
    .row-fluid .span8 {
      width: calc((100% - 11 * var(--grid-gutter)) / 12 * 8 + 7 * var(--grid-gutter));
    }
  
    .row-fluid .span9 {
      width: calc((100% - 11 * var(--grid-gutter)) / 12 * 9 + 8 * var(--grid-gutter));
    }
  
    .row-fluid .span10 {
      width: calc((100% - 11 * var(--grid-gutter)) / 12 * 10 + 9 * var(--grid-gutter));
    }
  
    .row-fluid .span11 {
      width: calc((100% - 11 * var(--grid-gutter)) / 12 * 11 + 10 * var(--grid-gutter));
    }
  
}
/* Containers & DND sections
   Design system (Figma Section / 07 Breakpoints & Grid):
   SM >=320px   margins 20px  gutters 20px  4 cols (stacked)
   MD >=768px   margins 30px  gutters 30px  8 cols
   LG >=1025px  margins 80px  gutters 30px  12 cols
   XL >=1280px  margins 100px gutters 30px  12 cols, content cap 1280px
   Gutters live in objects/_layout.css (--grid-gutter on .row-fluid).
   Note: theme-overrides.css can override max-width/padding here via theme
   settings (container_width, dnd_section_padding); these are the DS defaults. */

.content-wrapper {
  margin: 0 auto;
  /* XL content cap + side margins (box-sizing: border-box) */
  max-width: calc(var(--layout-max-width) + 2 * var(--layout-margin-xl));
  padding: 0 var(--layout-margin-sm);
}

.dnd-section {
  padding-left: var(--layout-margin-sm);
  padding-right: var(--layout-margin-sm);
}

.dnd-section > .row-fluid {
  margin: 0 auto;
  max-width: var(--layout-max-width);
}

/* Column gutters come from .row-fluid column-gap (objects/_layout.css),
   so dnd columns carry no extra horizontal padding of their own. */
.dnd-section .dnd-column {
  padding: 0;
}

/* Full-bleed modules escape their section's horizontal padding themselves,
   but theme-overrides.css's `.dnd-section { padding: {vertical}px 1rem }`
   (loads after this file, wins on source order) still adds its own vertical
   padding above/below on top of whatever the module sets - :has() gives this
   enough specificity to win regardless of load order, and scopes it to only
   sections wrapping one of these full-bleed modules, so every other section
   keeps the theme's normal vertical rhythm untouched. Each of these modules
   owns its own Style-tab padding-top/padding-bottom fields instead, so
   there's exactly one place controlling their vertical spacing, not two
   stacked on top of each other.

   .article-block is here for the other half of the same problem. The three
   article body blocks are not full-bleed sections - they are prose blocks
   inside an 818px article column (css/components/_article-blocks.css) - but
   they are dropped into a dnd_area all the same, so each one lands in its own
   .dnd-section carrying the theme's 120px between-sections padding. That is
   the gap between two sections of a page, not the gap between a heading and
   the paragraph under it. Zeroed here, and _article-blocks.css owns the 16/40
   rhythm instead. Those three carry no Style-tab padding fields on purpose:
   an article's rhythm belongs to the article, not to each block in it.

   The four .blog-* entries are the blog landing's counterparts to the
   .resources-* four above (templates/blog-index.html) - same full-bleed
   breakout, same Style-tab padding fields, same reason for being here. There is
   no .blog-hero: that page reuses resources-hero.module verbatim, so it is
   already covered by the .resources-hero entry. */
.dnd-section:has(
  .hero,
  .audience-rows,
  .jump-nav,
  .product-snapshot,
  .media-copy-split,
  .stats-marquee,
  .hover-reveal-cards,
  .gallery-cards,
  .outcome-cards,
  .comparison-cards,
  .customer-story,
  .resource-cards,
  .resources-hero,
  .resources-featured,
  .resources-filters,
  .resources-grid,
  .resources-pagination,
  .blog-featured,
  .blog-filters,
  .blog-grid,
  .blog-pagination,
  .faq,
  .final-cta,
  .final-reassurance,
  .alternating-rows,
  .screenshot-cards,
  .process-steps,
  .section-header,
  .what-to-expect,
  .list-callout-section,
  .support,
  .copy-photo-carousel,
  .proof-panel,
  .article-block
) {
  padding-bottom: 0;
  padding-top: 0;
}

@media (min-width: 768px) {
  .content-wrapper {
    padding: 0 var(--layout-margin-md);
  }

  .dnd-section {
    padding-left: var(--layout-margin-md);
    padding-right: var(--layout-margin-md);
  }
}

@media (min-width: 1025px) {
  .content-wrapper {
    padding: 0 var(--layout-margin-lg);
  }

  .dnd-section {
    padding-left: var(--layout-margin-lg);
    padding-right: var(--layout-margin-lg);
  }
}

@media (min-width: 1280px) {
  .content-wrapper {
    padding: 0 var(--layout-margin-xl);
  }

  .dnd-section {
    padding-left: var(--layout-margin-xl);
    padding-right: var(--layout-margin-xl);
  }
}

/* Elements
Base HTML elements are styled in this section (<body>, <h1>, <a>, <p>, <button> etc.)
*/

/* Base typography - binds bare elements to the design system tokens.
   Source: Figma "On The Stage - Website (2026)" > Design System > Section / 03 Typography.
   Text styles: Heading/H1-H6, Body/Body LG, Body/Body, Body/Body Bold, Body/Small,
   Body/XSmall, Body/XXSmall, Label/Label SM.
   Font sizes and heading letter-spacing flex per breakpoint via generic/_tokens-typography.css.
   Open Sans 400/600/700 is loaded in layouts/base.html. */

/* The overflow-wrap is meant to prevent long/large words from breaking the mobile responsiveness of a page (e.g. horizontal scrolling). It is preferred to reduce font sizes on mobile to address this, with this CSS specifically helping with extreme scenarios where a reduction in font size is not possible. */

/* Cascade note: theme-overrides.css loads after main.css and restates
   html/body/p/a/h1-h6 from HubSpot theme settings using bare element selectors.
   The element bindings below intentionally carry one extra level of specificity
   (:root / body-prefixed) so the design system tokens always win that cascade
   and theme settings cannot silently detach elements from the token layer. */

:root {
  font-size: var(--font-size-body);
}

html body {
  color: var(--color-text-primary);
  font-family: var(--font-family-brand);
  font-size: var(--font-size-body);
  font-style: normal;
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
  overflow-wrap: break-word;
}

/* Handles word breaking for a few specific languages which handle breaks in words differently. If your content is not translated into these languages, you can safely remove this.  */

html[lang^="ja"] body,
html[lang^="zh"] body,
html[lang^="ko"] body {
  line-break: strict;
  overflow-wrap: normal;
  word-break: break-all;
}

/* Headings (Heading/H1-H6: brand family, Bold; H1-H2 carry the responsive
   heading letter-spacing token, H3-H6 are flat 0 at all breakpoints).
   The .h1-.h6 companion classes mirror theme-overrides.css so class-based
   headings stay on the token scale too. */

body h1,
body h2,
body h3,
body h4,
body h5,
body h6,
body .h1,
body .h2,
body .h3,
body .h4,
body .h5,
body .h6 {
  color: var(--color-text-primary);
  font-family: var(--font-family-brand);
  font-style: normal;
  font-weight: var(--font-weight-bold);
  margin: 0 0 var(--spacing-5);
  text-transform: none;
}

body h1,
body .h1 {
  font-size: var(--font-size-h1);
  letter-spacing: var(--font-letter-spacing-heading);
  line-height: var(--font-line-height-heading-lg);
}

body h2,
body .h2 {
  font-size: var(--font-size-h2);
  letter-spacing: var(--font-letter-spacing-heading);
  line-height: var(--font-line-height-heading-lg);
}

body h3,
body .h3 {
  font-size: var(--font-size-h3);
  letter-spacing: 0;
  line-height: var(--font-line-height-heading-md);
}

body h4,
body .h4 {
  font-size: var(--font-size-h4);
  letter-spacing: 0;
  line-height: var(--font-line-height-heading-md);
}

body h5,
body .h5 {
  font-size: var(--font-size-h5);
  letter-spacing: 0;
  line-height: var(--font-line-height-heading-sm);
}

body h6,
body .h6 {
  font-size: var(--font-size-h6);
  letter-spacing: 0;
  line-height: var(--font-line-height-heading-sm);
}

/* Paragraphs (Body/Body) */

body p {
  font-family: inherit;
  font-size: var(--font-size-body);
  line-height: var(--font-line-height-body);
  margin: 0 0 var(--spacing-5);
}

/* Anchors - semantic link tokens, light-surface set by default.
   Dark-surface contexts should re-point these to the --color-link-dark-* tokens. */

body a {
  color: var(--color-link-light-default);
  cursor: pointer;
  font-family: inherit;
  text-decoration: underline;
  text-underline-offset: 0.15em;
}

body a:hover,
body a:focus {
  color: var(--color-link-light-hover);
}

body a:focus-visible {
  outline: 2px solid var(--color-border-focus-form);
  outline-offset: 2px;
}

body a:active {
  color: var(--color-link-light-active);
}

body a[aria-disabled="true"],
body a.is-disabled {
  color: var(--color-link-light-disabled);
  pointer-events: none;
  text-decoration: none;
}

/* Bold / emphasis */

strong,
b {
  font-weight: var(--font-weight-bold);
}

small {
  font-size: var(--font-size-sm);
}

/* Lists */

ul,
ol {
  margin: 0 0 var(--spacing-5);
}

ul ul,
ol ul,
ul ol,
ol ol {
  margin: 0;
}

ul.no-list {
  list-style: none;
  margin: 0;
  padding-left: 0;
}

/* Code blocks */

pre {
  overflow: auto;
}

code {
  vertical-align: bottom;
}

/* Blockquotes */

body blockquote {
  border-left: 2px solid var(--color-brand-primary);
  margin: 0 0 var(--spacing-5);
  padding-left: var(--spacing-3);
}

/* Horizontal rules */

hr {
  border: none;
  border-bottom: 1px solid var(--color-border-default);
}

/* Image alt text */

img {
  font-size: var(--font-size-xxs);
  word-break: normal;
}

/* Text-style utilities - mirror the Figma named text styles documented in
   Section / 03 Typography (Body & UI Text). Sizes flex with the breakpoint modes. */

.text-body-lg {
  font-size: var(--font-size-body-lg);
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
}

.text-body {
  font-size: var(--font-size-body);
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
}

.text-body-bold {
  font-size: var(--font-size-body);
  font-weight: var(--font-weight-bold);
  line-height: var(--font-line-height-body);
}

.text-small {
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
}

.text-xsmall {
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
}

.text-xxsmall {
  font-size: var(--font-size-xxs);
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
}

/* Label/Label SM - SemiBold, label line-height. Figma specimens type labels in
   uppercase characters; no text-transform is baked into the style itself. */

.text-label {
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-semi-bold);
  line-height: var(--font-line-height-label);
}

/* Eyebrow - a distinct style from Label/Label SM: bold, uppercase brand-primary
   text with fixed 14px sizing and 0.84px tracking (not on the responsive type
   scale). Used above section headers site-wide. */

.text-eyebrow {
  color: var(--color-brand-primary);
  font-size: 14px;
  font-weight: var(--font-weight-bold);
  letter-spacing: 0.06em;
  line-height: var(--font-line-height-label);
  text-transform: uppercase;
}
/* Buttons
   Source: Figma "On The Stage - Website (2026)" > Design System > Section / 06 Buttons
   (node 26:104) and the Components page Button set.
   System: Theme (Light/Dark) x Style (Primary/Secondary) x Size (SM/MD/LG) x
   State (Default/Hover/Active/Focus/Disabled) = 60 variants.

   - Defaults: Primary style, Light theme, MD size.
     Modifiers: .button--secondary, .button--dark, .button--sm, .button--lg.
   - Primary is identical on Light and Dark themes; only Secondary changes per theme.
   - Strokes are drawn with inset box-shadow because Figma strokes are inside-aligned:
     state changes (1.5px ring -> 2px focus ring) never shift layout.
   - .is-hover / .is-active / .is-focus / .is-disabled mirror the real pseudo-classes
     so the design-system spec page can freeze each state.
   - Focus rings use --color-border-focus-button (2px, inside).

   Cascade note: theme-overrides.css (HubL-generated from theme.buttons.* settings)
   loads AFTER main.css and restyles `button, .button, .hs-button` (and their
   :hover/:focus/:active) with boilerplate values at up to class+pseudo specificity
   (0,2,0). This file owns buttons, so every rule below carries `:not(#specificity)`
   (an id that matches nothing) as a uniform id-level bump: each selector gains
   exactly (1,0,0), which outranks every theme-overrides button rule while keeping
   the internal cascade of this file unchanged. Do not remove the bump without
   also neutralizing the button block in theme-overrides.css. */

/* The bare `button` selector is SCOPED to .body-wrapper, and that scope is the
   editor fix - not a nicety.

   The rules below paint every `button` element as a brand crimson pill at
   id-level specificity. HubSpot's rich text editor loads the theme's stylesheets
   into the editing surface so authored content previews correctly, and in Focus
   Mode the TinyMCE toolbar lives in that same document - so every toolbar control
   rendered as a full crimson pill, overlapping its neighbours and hiding its own
   label. The (1,0,0) bump is what made it total: the editor styles its own
   controls with class selectors, which an id-level theme rule outranks outright.

   An earlier attempt excluded the editor by name - :not(:where([class*="tox-"],
   [class*="mce-"], ...)). That is guesswork about someone else's markup: it has
   to be right about which editor build HubSpot ships, and it silently stops
   working when they change it. It also cannot cover a control that is a bare
   <button> with no recognisable class.

   Scoping to .body-wrapper inverts it into something knowable from THIS side.
   layouts/base.html wraps the header, main and footer of every page in
   .body-wrapper, and every template in this theme extends that layout (checked:
   base.html is the only file under templates/ that does not extend it). So all
   site content is inside the wrapper and no editor chrome is, whatever it is
   built from. It also stops the theme bleeding into HubSpot's own injected UI -
   cookie banner, chat launcher, tools menu - which all render outside it.

   :where(.body-wrapper) rather than .body-wrapper is load-bearing. :where()
   always contributes zero specificity, so each selector keeps exactly the
   (1,0,1) it had. A plain .body-wrapper prefix would make it (1,1,1) - above the
   (1,1,0) that scope_css module rules resolve to - and would silently repaint
   the Resources hub's own pagination arrows, which are <button> elements that
   override this rule with `background: none`.

   `.button` and `.hs-button` keep no scope: they are authored classes that never
   appear on editor chrome, and .hs-button in particular is emitted by HubSpot
   forms that can render outside the wrapper. */

:where(.body-wrapper) button:not(#specificity),
.button:not(#specificity),
.hs-button:not(#specificity) {
  /* Component tokens. Figma binds the Active fills to primitives directly
     (no semantic token exists for pressed fills), so they are aliased once here. */
  --button-primary-active-bg: var(--brand-crimson-800);
  --button-secondary-active-bg: color-mix(in srgb, var(--brand-petrol-700) 8%, transparent);

  align-items: center;
  appearance: none;
  background-color: var(--color-brand-primary);
  border: 0;
  border-radius: var(--radius-md);
  color: var(--color-text-on-brand);
  cursor: pointer;
  display: inline-flex;
  font-family: var(--font-family-brand);
  font-size: var(--font-size-sm);
  font-style: normal;
  font-weight: var(--font-weight-semi-bold);
  gap: var(--spacing-2);
  justify-content: center;
  line-height: var(--font-line-height-label);
  padding: var(--spacing-3) var(--spacing-6);
  text-align: center;
  text-decoration: none;
  text-transform: none;
  transition: background-color 0.15s linear, box-shadow 0.15s linear, color 0.15s linear;
  white-space: normal;
}

/* --- Sizes (default = MD) --- */

.button--sm:not(#specificity) {
  font-size: var(--font-size-xs);
  padding: var(--spacing-2) var(--spacing-4);
}

.button--lg:not(#specificity) {
  padding: var(--spacing-4) var(--spacing-8);
}

/* --- Primary states (Light and Dark themes share these) --- */

:where(.body-wrapper) button:hover:not(#specificity),
.button:hover:not(#specificity),
.hs-button:hover:not(#specificity),
button.is-hover:not(#specificity),
.button.is-hover:not(#specificity),
.hs-button.is-hover:not(#specificity) {
  background-color: var(--color-brand-primary-dark);
  color: var(--color-text-on-brand);
}

:where(.body-wrapper) button:active:not(#specificity),
.button:active:not(#specificity),
.hs-button:active:not(#specificity),
button.is-active:not(#specificity),
.button.is-active:not(#specificity),
.hs-button.is-active:not(#specificity) {
  background-color: var(--button-primary-active-bg);
  color: var(--color-text-on-brand);
}

:where(.body-wrapper) button:focus-visible:not(#specificity),
.button:focus-visible:not(#specificity),
.hs-button:focus-visible:not(#specificity),
button.is-focus:not(#specificity),
.button.is-focus:not(#specificity),
.hs-button.is-focus:not(#specificity) {
  box-shadow: inset 0 0 0 2px var(--color-border-focus-button);
  outline: none;
}

/* --- Secondary style, Light theme (default theme) --- */

.button--secondary:not(#specificity) {
  background-color: transparent;
  box-shadow: inset 0 0 0 1.5px var(--color-brand-primary);
  color: var(--color-brand-primary);
}

.button--secondary:hover:not(#specificity),
.button--secondary.is-hover:not(#specificity) {
  /* Hover shifts text + stroke to the darker crimson (per designer QA). */
  background-color: var(--color-brand-surface);
  box-shadow: inset 0 0 0 1.5px var(--color-brand-primary-dark);
  color: var(--color-brand-primary-dark);
}

.button--secondary:active:not(#specificity),
.button--secondary.is-active:not(#specificity) {
  background-color: var(--button-secondary-active-bg);
  color: var(--color-brand-primary);
}

.button--secondary:focus-visible:not(#specificity),
.button--secondary.is-focus:not(#specificity) {
  background-color: transparent;
  box-shadow: inset 0 0 0 2px var(--color-border-focus-button);
}

/* --- Secondary style, Dark theme (for use on inverse backgrounds) --- */

.button--dark.button--secondary:not(#specificity) {
  box-shadow: inset 0 0 0 1.5px var(--color-brand-white);
  color: var(--color-brand-white);
}

.button--dark.button--secondary:hover:not(#specificity),
.button--dark.button--secondary.is-hover:not(#specificity) {
  background-color: color-mix(in srgb, var(--color-brand-white) 12%, transparent);
  color: var(--color-brand-white);
}

.button--dark.button--secondary:active:not(#specificity),
.button--dark.button--secondary.is-active:not(#specificity) {
  background-color: color-mix(in srgb, var(--color-brand-white) 20%, transparent);
  color: var(--color-brand-white);
}

.button--dark.button--secondary:focus-visible:not(#specificity),
.button--dark.button--secondary.is-focus:not(#specificity) {
  background-color: transparent;
  box-shadow: inset 0 0 0 2px var(--color-border-focus-button);
}

/* --- Disabled (all styles/themes: Default appearance at 40% opacity) --- */

:where(.body-wrapper) button:disabled:not(#specificity),
.button:disabled:not(#specificity),
.hs-button:disabled:not(#specificity),
button.is-disabled:not(#specificity),
.button.is-disabled:not(#specificity),
.hs-button.is-disabled:not(#specificity) {
  cursor: not-allowed;
  opacity: 0.4;
  pointer-events: none;
}

/* No button */

.no-button:not(#specificity),
.no-button:hover:not(#specificity),
.no-button:focus:not(#specificity),
.no-button:active:not(#specificity) {
  background: none;
  border: none;
  border-radius: 0;
  box-shadow: none;
  color: initial;
  font-family: inherit;
  font-size: inherit;
  font-style: inherit;
  font-weight: inherit;
  letter-spacing: inherit;
  line-height: inherit;
  margin-bottom: 0;
  padding: 0;
  text-align: left;
  text-decoration: none;
  transition: none;
}
/* Form & input elements
   Source: Figma "On The Stage - Website (2026)" > Design System > Section / 05 Form & Input (node 26:22)
   Components: Text Input, Textarea, Select, Checkbox, Radio, Toggle.
   States: Default, Focus, Filled, Error, Disabled - on Light and Dark themes.

   Everything binds to semantic tokens through the --ots-form-* component properties below.
   Dark theme: wrap any container in .theme-dark (or [data-theme="dark"]) and every control
   inside re-themes automatically.

   Selector coverage keeps HubSpot-generated form markup working
   (.hs-form-field, .hs-input, .hs-error-msg, .inputs-list, ...).

   Static state hooks (for spec pages / demos): .is-focus mirrors :focus,
   .is-error mirrors HubSpot's .invalid.error, :disabled is used directly. */

/* Theme scope ------------------------------------------------------------ */

:root {
  --ots-form-field-bg: var(--color-background-default);
  --ots-form-field-border: var(--color-border-default);
  --ots-form-field-text: var(--color-text-primary);
  --ots-form-field-placeholder: var(--color-text-secondary);
  --ots-form-control-border: var(--color-border-default);
  --ots-form-toggle-track: var(--neutral-gray-300); /* Figma binds the off track to this primitive */
  --ots-form-caret: var(--color-text-primary);
  --ots-form-label: var(--color-text-primary);
}

.theme-dark,
[data-theme="dark"] {
  --ots-form-field-bg: var(--color-background-inverse);
  --ots-form-field-border: var(--color-border-strong);
  --ots-form-field-text: var(--color-text-on-dark);
  --ots-form-field-placeholder: var(--color-text-disabled);
  --ots-form-control-border: var(--color-border-strong);
  --ots-form-toggle-track: var(--neutral-gray-600); /* Figma binds the dark off track to this primitive */
  --ots-form-caret: var(--color-brand-white);
  --ots-form-label: var(--color-text-on-dark);
}

/* Fields ------------------------------------------------------------------ */

.hs-form-field {
  margin-bottom: var(--spacing-6);
}

/* Form background - theme-overrides.css's "form, .submitted-message"
   pulls background-color from theme.forms.form.background.color, a
   portal-level theme setting (Design Manager > theme editor > Forms) that's
   currently set to brand-primary red rather than white - confirmed live on
   the Book a Demo form, where every gap between labels/inputs painted red
   with white .hs-input boxes floating on top. Same "body" prefix
   out-ranking trick already established below for .hs-input: body form
   (0,0,2) beats theme-overrides.css's bare form (0,0,1) regardless of load
   order. Fixing here, not per-module, since the theme setting affects any
   form on the site, not just this one. */

body form,
body .submitted-message {
  background-color: transparent;
}

/* Multi-column field groups - HubSpot's own classes for a form field group
   configured as 2 or 3 columns in the Forms editor (Marketing > Forms), e.g.
   First Name + Last Name side by side. Added for the Book a Demo form
   (demo-request-form.module) - the column grouping itself is a Forms-editor
   setting, not something this theme's CSS can impose, so this only makes
   whatever grouping is configured there lay out responsively. */

/* margin-bottom moves from the field to the row: the fields inside get
   margin-bottom: 0 below (so the two side-by-side fields don't add their
   own vertical gap against each other), but that left nothing providing
   the gap AFTER the row - confirmed live on the Book a Demo form, where
   grouped rows sat flush against whatever followed while single-field rows
   (whose .hs-form-field keeps its own default margin-bottom) kept a normal
   gap, reading as inconsistent spacing top to bottom. */

.hs-form .form-columns-2,
.hs-form .form-columns-3 {
  display: flex;
  gap: var(--spacing-3);
  margin-bottom: var(--spacing-6);
}

.hs-form .form-columns-2 .hs-form-field,
.hs-form .form-columns-3 .hs-form-field {
  flex: 1 0 0;
  margin-bottom: 0;
  min-width: 0;
}

@media (max-width: 479px) {
  .hs-form .form-columns-2,
  .hs-form .form-columns-3 {
    flex-direction: column;
  }
}

/* Labels ------------------------------------------------------------------ */

form label {
  color: var(--ots-form-label);
  display: block;
  font-family: var(--font-family-brand);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-semi-bold);
  line-height: var(--font-line-height-label);
  margin-bottom: var(--spacing-2);
}

/* Form Title */
.form-title {
  margin-bottom: 0;
}

/* Help text */

form legend {
  color: var(--ots-form-field-placeholder);
  font-size: var(--font-size-xs);
  line-height: var(--font-line-height-body);
}

/* Inputs - text-like (Text Input, Textarea, Select) ------------------------
   Figma: 45px tall = 21px line (14px / 1.5) + 12px vertical / 16px horizontal
   padding measured from the frame edge with the stroke drawn inside, so the
   CSS padding subtracts the border width to keep size and text position
   stable across the 1px default / 2px focus borders.

   The "body" prefix is load-bearing: theme-overrides.css loads AFTER main.css
   and its boilerplate "form input[type=text], ... select, textarea" block
   (0,1,2) re-declares border/background-color/color/border-radius from theme
   settings. "body form :is(...)" is (0,1,3), so the token-driven declarations
   below win. Do not drop the prefix.

   .ots-input is the standalone hook, for a Text Input that is genuinely not
   inside a <form> - the Resources Hub hero's search field (#82) is the first,
   since search behaviour is a later ticket and a form with no action would
   reload the page on Enter. Same opt-in shape as .ots-checkbox / .ots-radio /
   .ots-toggle below. It is not a second style: it joins the selector lists so
   there stays exactly one Text Input in this theme. */

body form :is(input[type="text"], input[type="search"], input[type="email"], input[type="password"], input[type="tel"], input[type="number"], input[type="date"], input[type="url"], select, textarea),
.hs-input:is(input:not([type="checkbox"], [type="radio"], [type="file"], [type="submit"], [type="button"], [type="image"]), select, textarea),
.ots-input {
  appearance: none;
  -webkit-appearance: none;
  background-color: var(--ots-form-field-bg);
  border: var(--ots-field-bw, 1px) solid var(--ots-field-bc, var(--ots-form-field-border));
  border-radius: var(--radius-md);
  box-sizing: border-box;
  color: var(--ots-form-field-text);
  display: inline-block;
  font-family: var(--font-family-brand);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
  padding: calc(var(--spacing-3) - var(--ots-field-bw, 1px)) calc(var(--spacing-4) - var(--ots-field-bw, 1px));
  transition: border-color 0.15s ease, background-color 0.15s ease;
  
  width: 100% !important;
}

form :is(input, textarea)::placeholder,
.hs-input::placeholder,
.ots-input::placeholder {
  color: var(--ots-form-field-placeholder);
  opacity: 1;
}

/* Focus (border-focus-form token, 2px) */
form :is(input[type="text"], input[type="search"], input[type="email"], input[type="password"], input[type="tel"], input[type="number"], input[type="date"], input[type="url"], select, textarea):is(:focus, .is-focus),
.ots-input:is(:focus, .is-focus) {
  --ots-field-bw: 2px;
  --ots-field-bc: var(--color-border-focus-form);
  outline: none;
}

/* Error (status-error token) */
form :is(input[type="text"], input[type="search"], input[type="email"], input[type="password"], input[type="tel"], input[type="number"], input[type="date"], input[type="url"], select, textarea):is(.is-error, .invalid.error),
.hs-input.invalid.error,
.ots-input:is(.is-error, .invalid.error) {
  --ots-field-bc: var(--color-status-error);
}

/* Disabled */
form :is(input[type="text"], input[type="search"], input[type="email"], input[type="password"], input[type="tel"], input[type="number"], input[type="date"], input[type="url"], select, textarea):disabled,
.ots-input:disabled {
  cursor: not-allowed;
  opacity: 0.4;
}

/* Textarea */

form textarea,
textarea.hs-input {
  min-height: 96px; /* Figma Textarea component height (no token exists) */
  resize: vertical;
}

form fieldset {
  max-width: 100% !important;
}

/* Select - caret drawn with two gradient halves so its color stays bound to
   --ots-form-caret (Figma glyph: 10x6 solid triangle).

   select.ots-input is the standalone hook, same opt-in as the text input
   above: the Resources Hub filter row (#84) is three selects with no <form>
   around them, because there is nothing to submit - the whole interaction is
   in-page against the inline index. Without this selector such a select gets
   the field box from the .ots-input rule but keeps appearance: none with no
   caret drawn, i.e. a box with no affordance that it opens. */

form select,
select.hs-input,
select.ots-input {
  background-image:
    linear-gradient(45deg, transparent 50%, var(--ots-form-caret) 50%),
    linear-gradient(135deg, var(--ots-form-caret) 50%, transparent 50%);
  background-origin: border-box;
  background-position:
    right calc(var(--spacing-4) + 5px) center,
    right var(--spacing-4) center;
  background-repeat: no-repeat;
  background-size: 5px 6px, 5px 6px;
  cursor: pointer;
  padding-right: calc(var(--spacing-10) - var(--ots-field-bw, 1px));
}

/* A select that still shows its placeholder option.
   Figma: select placeholder uses the same muted color as input placeholders
   (light: text-secondary, dark: text-disabled). Selectors match the (0,1,3) /
   (0,2,1) specificity of the base field rule above so this later rule wins
   the color tie, and out-rank theme-overrides.css "form select" (0,0,2). */
body form select.is-placeholder,
select.hs-input.is-placeholder,
select.ots-input.is-placeholder {
  color: var(--ots-form-field-placeholder);
}

/* Inputs - checkbox/radio --------------------------------------------------
   20px control (spacing-5), 1.5px border (Figma component value, no token),
   2px border on focus. Checked: brand primary fill (checkbox) / 10px dot (radio). */

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

form .inputs-list > li {
  display: block;
  margin: var(--spacing-2) 0;
}

form .inputs-list label {
  align-items: center;
  cursor: pointer;
  display: inline-flex;
  font-weight: var(--font-weight-regular);
  gap: var(--spacing-2);
  line-height: var(--font-line-height-body);
  margin-bottom: 0;
}

form input[type="checkbox"]:not(.ots-toggle),
form input[type="radio"],
input.ots-checkbox,
input.ots-radio {
  appearance: none;
  -webkit-appearance: none;
  background-color: var(--ots-form-field-bg);
  border: var(--ots-check-bw, 1.5px) solid var(--ots-check-bc, var(--ots-form-control-border));
  box-sizing: border-box;
  cursor: pointer;
  display: inline-grid;
  flex-shrink: 0;
  height: var(--spacing-5);
  margin: 0;
  padding: 0;
  place-content: center;
  transition: background-color 0.15s ease, border-color 0.15s ease;
  vertical-align: middle;
  width: var(--spacing-5);
}

form input[type="checkbox"]:not(.ots-toggle),
input.ots-checkbox {
  border-radius: var(--radius-sm);
}

form input[type="radio"],
input.ots-radio {
  border-radius: var(--radius-full);
}

/* Checkbox check glyph (color: brand white) */
form input[type="checkbox"]:not(.ots-toggle)::before,
input.ots-checkbox::before {
  background-color: var(--color-brand-white);
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
  content: "";
  height: 10px; /* Figma check glyph (no token exists) */
  transform: scale(0);
  transition: transform 0.15s ease;
  width: 12px;
}

form input[type="checkbox"]:not(.ots-toggle):checked,
input.ots-checkbox:checked {
  --ots-check-bc: var(--color-brand-primary);
  background-color: var(--color-brand-primary);
}

/* Radio dot (10px = half the 20px control) */
form input[type="radio"]::before,
input.ots-radio::before {
  background-color: var(--color-brand-primary);
  border-radius: var(--radius-full);
  content: "";
  height: calc(var(--spacing-5) / 2);
  transform: scale(0);
  transition: transform 0.15s ease;
  width: calc(var(--spacing-5) / 2);
}

form input[type="radio"]:checked,
input.ots-radio:checked {
  --ots-check-bc: var(--color-brand-primary);
}

form :is(input[type="checkbox"]:not(.ots-toggle), input[type="radio"]):checked::before,
:is(input.ots-checkbox, input.ots-radio):checked::before {
  transform: scale(1);
}

/* Checkbox/radio focus */
form :is(input[type="checkbox"]:not(.ots-toggle), input[type="radio"]):is(:focus-visible, .is-focus),
:is(input.ots-checkbox, input.ots-radio):is(:focus-visible, .is-focus) {
  --ots-check-bw: 2px;
  --ots-check-bc: var(--color-border-focus-form);
  outline: none;
}

/* Checkbox/radio error */
form :is(input[type="checkbox"]:not(.ots-toggle), input[type="radio"]):is(.is-error, .invalid.error),
:is(input.ots-checkbox, input.ots-radio):is(.is-error, .invalid.error) {
  --ots-check-bc: var(--color-status-error);
}

form :is(input[type="checkbox"], input[type="radio"]):disabled,
:is(input.ots-checkbox, input.ots-radio, input.ots-toggle):disabled {
  cursor: not-allowed;
}

/* Disabled dims the whole control + label pair (Figma applies 40% to the group) */
label:has(input[type="checkbox"]:disabled),
label:has(input[type="radio"]:disabled) {
  cursor: not-allowed;
  opacity: 0.4;
}

/* Toggle --------------------------------------------------------------------
   Custom control (HubSpot does not generate one): <input type="checkbox" class="ots-toggle">.
   Figma: 44x24 track (24px = spacing-6), 18px knob inset 3px; focus/error add a
   2px border, so the knob inset drops to 1px to keep its visual position. */

input.ots-toggle {
  appearance: none;
  -webkit-appearance: none;
  background-color: var(--ots-form-toggle-track);
  border: 0;
  border-radius: var(--radius-full);
  box-sizing: border-box;
  cursor: pointer;
  flex-shrink: 0;
  height: var(--spacing-6);
  margin: 0;
  padding: 0;
  position: relative;
  transition: background-color 0.15s ease;
  vertical-align: middle;
  width: 44px; /* Figma Toggle track width (no token exists) */
}

input.ots-toggle::before {
  background-color: var(--color-brand-white);
  border-radius: var(--radius-full);
  content: "";
  height: 18px; /* Figma Toggle knob (no token exists) */
  left: 3px;
  position: absolute;
  top: 3px;
  transition: left 0.15s ease;
  width: 18px;
}

input.ots-toggle:checked {
  background-color: var(--color-brand-primary);
}

input.ots-toggle:checked::before {
  left: 23px;
}

input.ots-toggle:is(:focus-visible, .is-focus) {
  border: 2px solid var(--color-border-focus-form);
  outline: none;
}

input.ots-toggle:is(.is-error, .invalid.error) {
  border: 2px solid var(--color-status-error);
}

input.ots-toggle:is(:focus-visible, .is-focus, .is-error, .invalid.error)::before {
  left: 1px;
  top: 1px;
}

input.ots-toggle:is(:focus-visible, .is-focus, .is-error, .invalid.error):checked::before {
  left: 21px;
}

label:has(input.ots-toggle:disabled) {
  cursor: not-allowed;
  opacity: 0.4;
}

/* Inputs - date picker ------------------------------------------------------ */

.hs-dateinput {
  position: relative;
}

.hs-dateinput:before {
  content: '\01F4C5';
  position: absolute;
  right: 10%;
  top: 50%;
  transform: translateY(-50%);
}

.fn-date-picker .pika-table thead th {
  color: var(--color-text-on-brand);
}

.fn-date-picker td.is-selected .pika-button {
  border-radius: var(--radius-none);
  box-shadow: none;
}

.fn-date-picker td .pika-button:hover,
.fn-date-picker td .pika-button:focus {
  border-radius: var(--radius-none) !important;
  color: var(--color-text-on-brand);
}

/* Inputs - file picker ------------------------------------------------------
   body prefix out-ranks the theme-overrides.css boilerplate block, which also
   covers input[type=file] (see the text-like inputs comment above). */

body form input[type="file"] {
  background-color: transparent;
  border: initial;
  color: var(--ots-form-field-text);
  font-family: var(--font-family-brand);
  font-size: var(--font-size-xs);
  padding: initial;
}

/* Headings and text ---------------------------------------------------------- */

form .hs-richtext,
form .hs-richtext p {
  color: var(--ots-form-label);
  font-size: var(--font-size-xs);
  line-height: var(--font-line-height-body);
  margin: 0 0 var(--spacing-6);
}

form .hs-richtext img {
  max-width: 100% !important;
}

/* GDPR ----------------------------------------------------------------------- */

.legal-consent-container .hs-form-booleancheckbox-display > span,
.legal-consent-container .hs-form-booleancheckbox-display > span p {
  margin-left: var(--spacing-4) !important;
}

/* Validation ------------------------------------------------------------------ */

.hs-form-required {
  color: var(--color-status-error);
}

.hs-error-msg,
.ots-form-error {
  color: var(--color-status-error);
  display: block;
  font-family: var(--font-family-brand);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
  margin: 0;
}

.hs-error-msg {
  margin-top: var(--spacing-1);
}

/* Submit button (visual style owned by elements/_buttons.css) ------------------ */

form input[type="submit"],
form .hs-button {
  cursor: pointer;
  display: inline-block;
  text-align: center;
  transition: all 0.15s linear;
  white-space: normal;
}

/* Captcha ----------------------------------------------------------------------- */

.grecaptcha-badge {
  margin: 0 auto;
}


/* Table */

table {
  border-collapse: collapse;
  margin-bottom: 1.4rem;
  overflow-wrap: break-word;
}

/* Table cells */

td,
th {
  vertical-align: top;
}

/* Table header */

thead th {
  vertical-align: bottom;
}

/* Components
Specific pieces of UI that are stylized. Typically used for global partial styling
*/

/* Header / main navigation
   Source: Figma "On The Stage - Website, 2026" - desktop node 1066:456 ("Navigation, v2"),
   tablet 959:20286+, mobile 959:20862+. Spec: docs/ots-nav-footer-dev-handoff.md.
   Markup: templates/partials/header.html. Behavior: js/nav.js.
   Breakpoints per spec: mobile <768px, tablet 768-1279px, desktop >=1280px.
   Mobile-first; the desktop pill/panel layout lives in the 1280px block at the end.
   Several values are hardcoded in Figma (not variable-bound) - each carries a comment.
   Button-element rules carry the :not(#specificity) id bump used across this theme
   (see elements/_buttons.css) so they outrank .no-button state rules and the
   theme-overrides.css button block that loads after main.css. */

/* --- Motion primitive: .nav-fade ---------------------------------------
   The single reusable dissolve for the whole nav system (handoff section 4):
   cross-dissolve, opacity only, 300ms, ease-out. No slide, no scale, no height
   animation. Used for desktop panel crossfades AND tablet/mobile level changes.
   Elements stay mounted; visibility is delayed on the way out so two panels can
   crossfade directly against each other with no flash of the closed state.
   Keep the 300ms in sync with FADE_MS in js/nav.js. */

.nav-fade {
  opacity: 0;
  pointer-events: none;
  transition: opacity 300ms ease-out, visibility 0s linear 300ms;
  visibility: hidden;
}

.nav-fade.is-visible {
  opacity: 1;
  pointer-events: auto;
  transition: opacity 300ms ease-out;
  visibility: visible;
}

@media (prefers-reduced-motion: reduce) {
  .nav-fade,
  .nav-fade.is-visible {
    transition: none;
  }
}

/* --- Header shell ------------------------------------------------------- */

/* Sticky nav: the announcement bar scrolls away, the pill pins. The offset
   equals the announcement height, measured by js/nav.js (content-driven, so
   no fixed px; the fallback approximates one unwrapped line). The pill keeps
   its own background + shadow, so no backdrop is added while stuck.

   On rendered pages HubSpot wraps the global partial in an unstyled div
   exactly the header's height, which gives a sticky .ots-header no room to
   pin - so the wrapper is the sticky element there. The .ots-header rule
   covers contexts without the wrapper (it is inert inside it: same height,
   no travel). */
div[data-global-resource-path$="partials/header.html"],
.ots-header {
  position: sticky;
  top: calc(-1 * var(--ots-announcement-h, 40px));
  z-index: 100; /* nav panels + scrim sit above page content */
}

/* Hero pages: the header overlays the hero photo instead of pushing it down.
   Scoped to pages that actually have a .hero, or a resources-gated (#73)
   full-bleed photo section, on them (via :has()), so every other page keeps
   the sticky/pin behavior above untouched - this is not a sitewide nav
   change. resources-gated.html does not use hero.module (it needs a DND copy
   column and a form column hero.module has no shape for), so it carries its
   own class here rather than being caught by the .hero selector.

   .resources-hero (#82) is the odd one out: it carries no photograph at all,
   so "overlays the photo" is not the reason it is here. The comp is - the
   Resources Hub frame draws Nav v2 (node 1318:16172, 1440x128) at y=0 on top
   of a hero that also starts at y=0, with the tag pill at y=217.5 inside the
   hero's own 180px top padding. In flow instead, the whole page would sit a
   full header band lower than every measurement in the comp. The nav pill
   carries its own white background and shadow, so it reads on the flat cream
   field exactly as it reads once any other page is scrolled. The module's
   padding-top is floored at the measured header height plus the
   between-sections increment, the same schedule hero.module uses, so the copy
   cannot land under the nav at a narrow width.

   .resources-thank-you (#77) is here for the original reason: its hero is a
   full-bleed photo band with the same dark-80 filter, drawn in the comp (node
   1491:35580) under a Nav v2 at y=0 with the copy at y=220 inside the hero's
   own top padding. Like resources-gated.html it does not use hero.module - a
   478px band cannot live inside that module's 600/640/760 min-heights - so it
   carries its own class here rather than being caught by the .hero selector.

   `position: fixed` (not absolute) so the header stays pinned through scroll
   like it does elsewhere. Trade-off: the announcement bar no longer scrolls
   away separately from the pill on hero pages - the whole header pins as one
   unit. Replicating "announcement scrolls away, pill re-pins" on top of an
   initially-transparent overlay would need a scroll-driven JS class toggle,
   not just CSS - flag if that nuance turns out to matter.

   `position: fixed` alone shrink-wraps to content width instead of spanning
   full width like it does in normal flow - explicit inset values are
   required to restore that. */
.body-wrapper:has(> main .hero) div[data-global-resource-path$="partials/header.html"],
.body-wrapper:has(> main .hero) .ots-header,
.body-wrapper:has(> main .resources-gated) div[data-global-resource-path$="partials/header.html"],
.body-wrapper:has(> main .resources-gated) .ots-header,
.body-wrapper:has(> main .resources-hero) div[data-global-resource-path$="partials/header.html"],
.body-wrapper:has(> main .resources-hero) .ots-header,
.body-wrapper:has(> main .resources-thank-you) div[data-global-resource-path$="partials/header.html"],
.body-wrapper:has(> main .resources-thank-you) .ots-header {
  left: 0;
  position: fixed;
  right: 0;
  top: 0;
}

.ots-header {
  font-family: var(--font-family-brand);
}

/* Body class toggled by js/nav.js while the tablet/mobile sheet is open */
.ots-nav-lock {
  overflow: hidden;
}

/* Navigation skipper (keyboard users; targets #main-content in base.html) */

.ots-header__skip {
  height: 1px;
  left: -1000px;
  overflow: hidden;
  position: absolute;
  text-align: left;
  top: -1000px;
  width: 1px;
}

.ots-header__skip:hover,
.ots-header__skip:focus,
.ots-header__skip:active {
  background-color: var(--color-background-default);
  height: auto;
  left: 0;
  overflow: visible;
  padding: var(--spacing-2) var(--spacing-4);
  top: 0;
  width: auto;
  z-index: 10;
}

/* --- Announcement bar ----------------------------------------------------
   Sits above the nav at every breakpoint; part of the header composition but
   not part of the pill. Static - it never animates (handoff section 4). */

.ots-announcement {
  background-color: var(--color-brand-primary);
  color: var(--color-text-on-brand);
}

.ots-announcement__inner {
  align-items: center;
  box-sizing: border-box;
  display: flex;
  font-size: var(--font-size-xs); /* Body/XSmall */
  gap: var(--spacing-3);
  justify-content: center;
  line-height: var(--font-line-height-body);
  margin: 0;
  min-height: 40px; /* fixed 40px bar in Figma (all frames); content centered */
  padding: 0 var(--layout-margin-sm);
}

.ots-announcement__badge {
  /* White at 20% over the brand bar, text stays white (Figma 1068:23 badge:
     fill rgba(255,255,255,0.2), text-on-brand). Was solid white w/ red text. */
  background-color: rgba(255, 255, 255, 0.2);
  border-radius: var(--radius-full);
  color: var(--color-text-on-brand);
  flex-shrink: 0;
  font-size: var(--font-size-xxs); /* Label/Label XS */
  font-weight: var(--font-weight-semi-bold);
  line-height: var(--font-line-height-label);
  padding: 3px var(--spacing-2); /* 3px hardcoded in Figma */
}

/* Text + dot + link cluster at Figma's inner 8px gap (badge keeps the 12px
   gap from __inner). */
.ots-announcement__group {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: var(--spacing-2);
  justify-content: center;
}

.ots-announcement__link {
  /* Body/XSmall, same color as the announcement text (Figma 959:20862 -
     "Read more" renders in text-on-brand/white, not the gold accent). */
  color: var(--color-text-on-brand);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
  text-decoration: none;
  transition: text-decoration-color 0.15s linear;
}

/* theme-overrides.css's global `a:hover` (element+pseudo-class) otherwise
   outranks the base rule's single-class color and re-tints this red. */
.ots-announcement__link:hover {
  color: var(--color-text-on-brand);
  text-decoration: underline;
}

.ots-announcement__link:focus-visible {
  border-radius: var(--radius-sm);
  outline: 2px solid var(--color-border-focus-button);
  outline-offset: 2px;
}

.ots-announcement__dot {
  color: var(--neutral-white);
  opacity: 0.5;
}

/* Mobile only (client-requested, not in the Figma handoff): the badge +
   text + dot + link cluster wraps to 2-3 lines on narrow phones, pushing
   the fixed nav (and the hero content below it) down further than
   intended. Dropping to the same XSmall label size already used for the
   badge buys back a line without needing to touch layout - height stays
   content-driven either way (js/nav.js measures whatever renders, per the
   comment above), so nothing downstream needs to change. */

@media (max-width: 767px) {
  .ots-announcement__inner,
  .ots-announcement__link {
    font-size: var(--font-size-xxs);
  }
}

/* Really small phones only (360px and under, client-requested): drop the
   "Read more" label and keep just the arrow, buying back more width for
   the announcement text itself before it wraps. Visually hidden rather
   than removed, so the link's accessible name is still "Read more" for
   screen readers - same technique as .show-for-sr in utilities/_helper.css,
   just scoped to this one narrower breakpoint instead of the sitewide
   768px tablet cutoff that class uses. */

@media (max-width: 360px) {
  .ots-announcement__link-text {
    border: 0;
    clip: rect(0, 0, 0, 0);
    height: 1px;
    overflow: hidden;
    padding: 0;
    position: absolute;
    white-space: nowrap;
    width: 1px;
  }

  /* The dot is already aria-hidden="true" (purely decorative, per
     header.html) - display: none costs nothing accessibility-wise here,
     unlike the link text above. */
  .ots-announcement__dot {
    display: none;
  }
}

/* --- Nav area (all breakpoints) ------------------------------------------ */

.ots-nav {
  /* Nav-area-to-panel gap and the bar top padding are hardcoded in Figma
     (not variable-bound) - see spec section 3. */
  --ots-nav-panel-gap: 10px;
  /* Figma "black overlay" layer: Background/Inverse at 90% layer opacity
     (verified on tablet 959:20499 + mobile 959:21090; the dev handoff's 60%
     is stale - confirm with design if it looks too heavy). */
  --ots-nav-scrim: color-mix(in srgb, var(--color-background-inverse) 90%, transparent);

  position: relative;
}

.ots-nav__bar {
  padding: 12px var(--layout-margin-sm) 0; /* 12px top hardcoded in Figma */
}

.ots-nav__bar-inner {
  position: relative;
}

/* Closed pill */

.ots-nav__pill {
  align-items: center;
  background-color: var(--color-background-default);
  border-radius: var(--radius-full);
  box-shadow: var(--elevation-nav);
  display: flex;
  gap: var(--spacing-4);
  justify-content: space-between;
  padding: var(--spacing-2) var(--spacing-5);
}

.ots-nav__logo {
  display: inline-flex;
  flex-shrink: 0;
}

.ots-nav__logo img {
  display: block;
  height: 30px; /* Figma mobile lockup 73.86x30 */
  width: auto;
}

/* Desktop-only groups, hidden until the 1280px block. The doubled class on
   the pill actions outranks the later .ots-nav__actions { display: flex }
   base rule, which otherwise wins the same-specificity tie. */

.ots-nav__links,
.ots-nav__actions--pill.ots-nav__actions--pill,
.ots-nav__panels {
  display: none;
}

.ots-nav__chevron {
  flex-shrink: 0;
  transition: transform 0.15s linear;
}

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

/* Menu trigger (hamburger) + sheet close: 44x32 hit areas per Figma */

.ots-nav__toggle:not(#specificity):not(#specificity),
.ots-nav__close:not(#specificity):not(#specificity) {
  align-items: center;
  color: var(--color-background-inverse); /* Figma style "Background/Inverse" on icon strokes */
  display: inline-flex;
  height: 32px;
  justify-content: center;
  width: 44px;
}

.ots-nav__toggle:focus-visible:not(#specificity):not(#specificity),
.ots-nav__close:focus-visible:not(#specificity):not(#specificity) {
  border-radius: var(--radius-sm);
  outline: 2px solid var(--color-border-focus-button);
  outline-offset: 2px;
}

/* Scrim behind the open sheet (tablet/mobile levels >=1) */

.ots-nav__scrim {
  background-color: var(--ots-nav-scrim);
  inset: 0;
  position: fixed;
  z-index: 1;
}

/* --- Tablet/mobile expanded pill ("sheet") -------------------------------
   Absolutely positioned over the closed pill so opening dissolves in place
   without pushing page content (the scrim covers the page behind it). */

.ots-nav__sheet {
  background-color: var(--color-background-default);
  border-radius: var(--radius-2xl); /* 24px expanded radius on mobile (hardcoded in Figma; matches --radius-2xl) */
  box-shadow: var(--elevation-nav);
  left: 0;
  max-height: calc(100vh - var(--spacing-6));
  overflow-y: hidden;
  padding: var(--spacing-2) var(--spacing-5);
  position: absolute;
  right: 0;
  top: 0;
  z-index: 2;
}

/* overflow-y only switches to scrollable while actually open - some WebKit
   builds ignore pointer-events:none on an element that also has active
   scroll overflow, letting a "closed" (opacity:0, pointer-events:none) sheet
   still intercept taps/hovers on the page content beneath it. */
.ots-nav__sheet.is-visible {
  overflow-y: auto;
}

.ots-nav__sheet-head {
  align-items: center;
  display: flex;
  justify-content: space-between;
}

.ots-nav__sheet-body {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-5);
  padding: var(--spacing-5) 0;
}

/* Level 1: stacked top-level rows */

.ots-nav__menu {
  display: flex;
  flex-direction: column;
  list-style: none;
  margin: 0;
  padding: 0;
}

/* Doubled :not(#) beats the .no-button reset's state variants (see the
   desktop .ots-nav__link note). */
.ots-nav__menu-link:not(#specificity):not(#specificity) {
  align-items: center;
  border-bottom: 1px solid var(--color-border-strong);
  color: var(--color-text-primary);
  display: flex;
  font-family: var(--font-family-brand);
  /* Label/Label per Figma (all 10 tablet/mobile frames bind --font-size-sm:
     14px below 1025px, 16px in the 1025-1279 token mode). */
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-semi-bold);
  justify-content: space-between;
  line-height: var(--font-line-height-label);
  padding: var(--spacing-5) 15px var(--spacing-5) 0; /* 15px right padding hardcoded in Figma */
  text-align: left;
  width: 100%;
}

.ots-nav__menu-link:focus-visible:not(#specificity):not(#specificity) {
  outline: 2px solid var(--color-border-focus-button);
  outline-offset: -2px;
}

/* Level 2: tapped item's panel injected inline below its row; chevron flips.
   Display toggling is safe here - the level swap happens behind the sheet-body
   dissolve driven by js/nav.js, so nothing animates height. */

.ots-nav__menu-link[aria-expanded="true"] .ots-nav__chevron {
  transform: rotate(180deg);
}

/* Open row drops its divider; the label stays Text/Primary and only the chevron
   turns red (Figma tablet 959:20530 + mobile 959:21124 both keep the parent
   label dark, not red). The panel card below carries its own border. */
.ots-nav__menu-item.is-expanded > .ots-nav__menu-link:not(#specificity):not(#specificity) {
  border-bottom-color: transparent;
}

.ots-nav__menu-item.is-expanded > .ots-nav__menu-link .ots-nav__chevron {
  color: var(--color-brand-primary);
}

.ots-nav__menu-panel {
  display: none;
}

.ots-nav__menu-item.is-expanded .ots-nav__menu-panel {
  background-color: var(--color-background-subtle);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  display: block;
  padding: var(--spacing-5);
}

/* Actions (Log In + Request Demo) */

.ots-nav__actions {
  align-items: center;
  display: flex;
  gap: var(--spacing-4);
}

.ots-nav__divider {
  background-color: var(--color-border-default);
  display: none; /* desktop-only (spec: hidden in tablet/mobile action rows) */
  height: 24px;
  width: 1px;
}

.ots-nav__login {
  color: var(--color-text-primary);
  font-size: var(--font-size-xs); /* Label/Label SM */
  font-weight: var(--font-weight-semi-bold);
  line-height: var(--font-line-height-label);
  text-decoration: none;
  transition: color 0.15s linear;
}

.ots-nav__login:hover {
  color: var(--color-link-light-hover);
}

/* --- Panel content (shared: desktop panels + inline sheet panels) -------- */

.ots-nav__mega {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-5);
}

.ots-nav__mega-grid {
  display: grid;
  gap: var(--spacing-6);
  grid-template-columns: 1fr; /* single column inside the sheet; desktop overrides below */
}

.ots-nav__mega-col {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-2);
}

.ots-nav__mega-heading {
  color: var(--color-brand-primary);
  font-size: var(--font-size-h5);
  font-weight: var(--font-weight-semi-bold);
  line-height: var(--font-line-height-heading-sm);
  margin: 0;
}

.ots-nav__mega-list,
.ots-nav__features-list,
.ots-nav__dropdown-list {
  display: flex;
  flex-direction: column;
  list-style: none;
  margin: 0;
  padding: 0;
}

.ots-nav__mega-list,
.ots-nav__features-list {
  gap: var(--spacing-2);
}

.ots-nav__dropdown-list {
  gap: var(--spacing-1);
}

.ots-nav__mega-link {
  display: block;
  text-decoration: none;
}

.ots-nav__mega-link-title {
  color: var(--color-text-primary);
  display: block;
  font-size: var(--font-size-xs); /* Label/Label SM */
  font-weight: var(--font-weight-semi-bold);
  line-height: var(--font-line-height-label);
  transition: color 0.15s linear;
}

.ots-nav__mega-link:hover .ots-nav__mega-link-title {
  color: var(--color-link-light-hover);
}

.ots-nav__mega-link-desc {
  color: var(--color-text-secondary);
  display: block;
  font-size: var(--font-size-xxs); /* Body/XXSmall */
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
}

.ots-nav__features {
  background-color: var(--color-brand-surface);
  border-radius: var(--radius-xl);
  display: flex;
  flex-direction: column;
  gap: var(--spacing-2);
  padding: var(--spacing-5);
}

.ots-nav__feature-link,
.ots-nav__dropdown-link {
  border-radius: var(--radius-md);
  color: var(--color-text-primary);
  display: block;
  font-size: var(--font-size-xs); /* Label/Label SM */
  font-weight: var(--font-weight-semi-bold);
  line-height: var(--font-line-height-label);
  text-decoration: none;
  transition: color 0.15s linear;
}

.ots-nav__dropdown-link {
  padding: var(--spacing-2) 0; /* 8px row padding hardcoded in Figma */
}

.ots-nav__feature-link:hover,
.ots-nav__dropdown-link:hover {
  color: var(--color-link-light-hover);
}

/* Shared focus style for panel links */

.ots-nav__mega-link:focus-visible,
.ots-nav__feature-link:focus-visible,
.ots-nav__dropdown-link:focus-visible,
.ots-nav__login:focus-visible,
.ots-nav__logo:focus-visible {
  border-radius: var(--radius-sm);
  outline: 2px solid var(--color-border-focus-button);
  outline-offset: 2px;
}

/* --- Sheet-scoped panel overrides (tablet/mobile inline panels only) ------
   The panel-content rules above are shared with the desktop hover panels;
   these overrides apply Figma's tablet/mobile treatment without touching
   desktop rendering. */

.ots-nav__sheet .ots-nav__mega {
  gap: 28px; /* grid-to-features gap matches the 28px column rhythm (Figma mobile L3) */
}

.ots-nav__sheet .ots-nav__mega-grid {
  gap: 28px; /* between stacked columns, hardcoded in Figma (no token) */
}

.ots-nav__sheet .ots-nav__mega-col,
.ots-nav__sheet .ots-nav__mega-list {
  gap: var(--spacing-3);
}

.ots-nav__sheet .ots-nav__mega-heading {
  font-size: var(--font-size-xs); /* Label/Label SM */
  line-height: var(--font-line-height-label);
}

.ots-nav__sheet .ots-nav__mega-link {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-1);
}

.ots-nav__sheet .ots-nav__mega-link-title {
  font-size: var(--font-size-xxs); /* Label/Label XS */
}

.ots-nav__sheet .ots-nav__dropdown-list {
  gap: var(--spacing-3);
}

.ots-nav__sheet .ots-nav__dropdown-link,
.ots-nav__sheet .ots-nav__feature-link {
  font-size: var(--font-size-xxs); /* Label/Label XS */
  padding: 0;
}

/* Features rail renders as a plain column inside the sheet (no card) */
.ots-nav__sheet .ots-nav__features {
  background: none;
  border-radius: 0;
  gap: var(--spacing-3);
  padding: 0;
}

.ots-nav__sheet .ots-nav__features-list {
  gap: var(--spacing-3);
}

/* --- Tablet (768-1279px) -------------------------------------------------- */

@media (min-width: 768px) {
  .ots-nav__bar {
    padding-inline: var(--layout-margin-md);
  }

  .ots-nav__pill {
    padding: var(--spacing-3) var(--spacing-8);
  }

  .ots-nav__logo img {
    height: 36px; /* Figma tablet lockup 88.63x36 */
  }

  .ots-nav__sheet {
    border-radius: var(--radius-3xl); /* tablet expanded radius, variable-bound in Figma */
    padding: var(--spacing-3) var(--spacing-8);
  }

  .ots-nav__sheet-body {
    gap: var(--spacing-10);
    padding: var(--spacing-10) 0;
  }

  /* Solutions inline panel (Figma tablet L3 1176:2939): category columns left,
     Features column right (180px text width), 40px between the two groups.
     Panels without a features rail keep the same markup and are unaffected. */
  .ots-nav__sheet .ots-nav__mega {
    flex-direction: row;
    gap: var(--spacing-10);
  }

  .ots-nav__sheet .ots-nav__mega-grid {
    flex: 1;
  }

  .ots-nav__sheet .ots-nav__features {
    flex-shrink: 0;
    width: 180px; /* Features column text width, hardcoded in Figma */
  }
}

/* --- Desktop (>=1280px) --------------------------------------------------- */

@media (min-width: 1280px) {
  .ots-nav__bar {
    padding-inline: var(--layout-margin-lg);
  }

  .ots-nav__bar-inner {
    margin-inline: auto;
    max-width: var(--layout-max-width); /* pill content width 1280 */
  }

  .ots-nav__pill {
    padding: var(--spacing-4) var(--spacing-8);
  }

  .ots-nav__logo img {
    height: 44px; /* Figma desktop lockup 108.32x44 */
  }

  /* Hover/level UI swaps */

  .ots-nav__toggle:not(#specificity):not(#specificity):not(#specificity),
  .ots-nav__sheet,
  .ots-nav__scrim {
    display: none;
  }

  .ots-nav__links {
    align-items: center;
    display: flex;
    gap: var(--spacing-1);
    list-style: none;
    /* Figma: logo left, links+actions as one right-aligned cluster (pill gap
       provides the 16px between links and actions). */
    margin: 0 0 0 auto;
    padding: 0;
  }

  .ots-nav__actions--pill.ots-nav__actions--pill {
    display: flex; /* doubled class matches the <1280px display:none rule */
  }

  .ots-nav__divider {
    display: block;
  }

  /* Top-level items */

  /* Doubled :not(#) beats the .no-button reset, whose :hover/:focus/:active
     variants otherwise win the tie and reset font/color/radius mid-hover. */
  .ots-nav__link:not(#specificity):not(#specificity) {
    align-items: center;
    border-radius: var(--radius-md);
    color: var(--color-text-primary);
    display: flex;
    font-family: var(--font-family-brand);
    font-size: var(--font-size-xs); /* Label/Label SM: 14px at the XL type mode */
    font-weight: var(--font-weight-semi-bold);
    gap: var(--spacing-2);
    line-height: var(--font-line-height-label);
    padding: 6px 14px; /* hardcoded in Figma (no token) */
    transition: background-color 0.15s linear;
  }

  .ots-nav__link:hover:not(#specificity):not(#specificity),
  .ots-nav__link.is-open:not(#specificity):not(#specificity) {
    background-color: var(--color-background-hover);
    color: var(--color-brand-primary); /* chevron follows via currentColor */
  }

  .ots-nav__link:focus-visible:not(#specificity):not(#specificity) {
    outline: 2px solid var(--color-border-focus-button);
    outline-offset: 2px;
  }

  .ots-nav__link:hover .ots-nav__chevron,
  .ots-nav__link.is-open .ots-nav__chevron {
    transform: rotate(180deg);
  }

  /* Panels: all mounted inside an absolutely positioned layer 10px below the
     pill. js/nav.js toggles .is-visible; .nav-fade handles the crossfade. */

  .ots-nav__panels {
    display: block;
    left: 0;
    position: absolute;
    right: 0;
    top: 100%;
    z-index: 3;
  }

  .ots-nav__panel {
    background-color: var(--color-background-default);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-xl);
    /* Elevation/Nav standardized across all panels. Figma has 0 10px 10px on
       Who We Serve + the dropdowns - flagged as a likely inconsistency
       (spec open question 7). */
    box-shadow: var(--elevation-nav);
    position: absolute;
    top: var(--ots-nav-panel-gap);
  }

  /* Invisible bridge across the 10px pill-to-panel gap so hover isn't lost
     while the pointer travels down into an open panel. */
  .ots-nav__panel::before {
    content: "";
    height: var(--ots-nav-panel-gap);
    left: 0;
    position: absolute;
    right: 0;
    top: calc(-1 * var(--ots-nav-panel-gap));
  }

  .ots-nav__panel--mega {
    left: 0;
    padding: var(--spacing-8);
    right: 0;
  }

  .ots-nav__panel--mega .ots-nav__mega-grid {
    gap: var(--spacing-8);
    grid-template-columns: repeat(4, 1fr); /* Who We Serve: 4 equal columns (2026-08 restructure) */
  }

  /* Solutions: 2x2 grid left + Features rail right */

  .ots-nav__panel--solutions {
    display: flex;
    padding: 0;
  }

  .ots-nav__panel--solutions .ots-nav__mega {
    flex-direction: row;
    gap: 0;
    width: 100%;
  }

  .ots-nav__panel--solutions .ots-nav__mega-grid {
    column-gap: var(--spacing-10);
    flex: 1;
    grid-template-columns: repeat(2, 1fr);
    padding: var(--spacing-8);
    row-gap: var(--spacing-4);
  }

  /* Figma (1067:120) models the 10px inset as a white stroke that doesn't push
     content, so the "Features" heading sits on the same line as the column
     headings and the tan fill's corner is small. A margin did neither: it shoved
     the heading 10px low and left the fill at the full 16px radius. A border
     reproduces both - content offsets 10px + 20px padding = 30px (the column
     line, matching the grid's --spacing-8 top pad), and the fill's inner radius
     resolves to 16 - 10 = 6px. Horizontal offset is unchanged (10 + --spacing-16
     either way). */
  .ots-nav__panel--solutions .ots-nav__features {
    border: 10px solid var(--color-background-default);
    flex-shrink: 0;
    margin: 0;
    padding: var(--spacing-5) var(--spacing-16);
  }

  /* Narrow dropdowns (Resources / About): 240px, right-aligned under their
     trigger. js/nav.js sets the inline `right` offset on open. */

  .ots-nav__panel--dropdown {
    padding: var(--spacing-8);
    right: 0;
    width: 240px;
  }
}

/* --- Legacy boilerplate header ---------------------------------------------
   templates/partials/header-no-navigation.html still uses the boilerplate
   .header classes. Minimal styles retained for that partial only; the main
   header above no longer uses any of these. */

.header .dnd-section {
  padding: 0;
}

.header__container {
  display: flex;
  justify-content: space-between;
}

.header__logo {
  align-items: center;
  display: flex;
  height: auto;
  margin-right: auto;
  max-width: 200px;
  overflow: hidden;
}

.header__logo img {
  max-width: 100%;
}
/* Footer global partial (templates/partials/footer.html)
   Source: Figma "On The Stage - Website (2026)" > Footer, v3
   Desktop 1117:879 (1440w) / tablet 891:17764 (768w) / mobile 891:18002 (393w).
   Static component: no animation at any breakpoint; layout only reflows.
   Breakpoints follow the token scale: MD >=768px, XL >=1280px.
   Figma-hardcoded values (no variable bound, flagged to design): divider color,
   14px link / 16px heading font sizes, 0.8 / 0.72 link opacities, 10px icon gap. */

/* font-family is set explicitly on text elements below where needed:
   theme-overrides.css styles p elements directly (theme-settings body font),
   which beats inheritance from .footer (same precedent as _01-logo.css). */

.footer {
  background-color: var(--color-background-inverse);
  font-family: var(--font-family-brand);
  /* Figma section padding: 64px top / 40px bottom (hardcoded in Figma; the
     spacing tokens resolve to the same values). Sides follow the page margin:
     20px at SM (BugHerd #68 - was a flat 30px), 30px at MD/LG, 80px at XL. */
  padding: var(--spacing-16) var(--layout-margin-sm) var(--spacing-10);
}

.footer__container {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-10);
  margin: 0 auto;
  max-width: var(--layout-max-width);
}

/* --- Logo + social icons row --- */

/* Wraps because the row does not fit at the design system's SM floor (320px):
   logo 110 + gap 16 + four 34px icons with 10px gaps (166) = 292, against a
   260px content box once the footer's own 30px side padding is taken off -
   34px over, which pushed the last icon 2px past the viewport. Nothing can be
   trimmed to close that: dropping the icons to 28px and the gap to 8px still
   leaves it short, and 34px is already under the 44px tap-target guideline.
   So the social row drops onto its own line below the logo instead. It fits
   on one line from roughly 360px up, which includes the 393px mobile comp, so
   this only changes what happens below the smallest width Figma drew. */

.footer__top {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: var(--spacing-4);
  justify-content: space-between;
}

.footer__logo {
  display: block;
  flex-shrink: 0;
}

.footer__logo img {
  display: block;
  height: auto;
  width: 110px; /* 110 x 44.68 in Figma */
}

.footer__social {
  display: flex;
  gap: 10px; /* Figma-hardcoded; not on the spacing scale (8 / 12) */
  list-style: none;
  margin: 0;
  padding: 0;
}

.footer__social-link {
  display: block;
  height: 34px; /* 33.95px in Figma, rounded per spec */
  width: 34px;
}

.footer__social-link svg {
  display: block;
  height: 100%;
  width: 100%;
}

/* Figma has no hover for the social icons (footer was spec'd static); BugHerd
   531475 asked for a fade, then a follow-up asked for the icon to read white
   on a red circle instead. background/glyph are two separate shapes (see
   footer.html) so each can carry its own fill and transition independently -
   a single evenodd cutout path can only ever be one color. The glyph's
   default fill matches the footer's own background so it still reads as a
   "knocked out" hole exactly like the old single-path version at rest. */
.footer__social-bg {
  fill: var(--color-text-on-dark);
  transition: fill 0.15s linear;
}

.footer__social-glyph {
  fill: var(--color-background-inverse);
  transition: fill 0.15s linear;
}

.footer__social-link:hover .footer__social-bg,
.footer__social-link:focus .footer__social-bg {
  fill: var(--color-brand-primary);
}

.footer__social-link:hover .footer__social-glyph,
.footer__social-link:focus .footer__social-glyph {
  fill: var(--color-text-on-dark);
}

/* --- Link columns --- */

/* Mobile (<768px): columns stack vertically, order preserved */
.footer__columns {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-8);
}

.footer__column {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-4);
}

.footer__column-heading {
  color: var(--color-text-on-dark);
  font-family: var(--font-family-brand);
  font-size: 16px; /* no font-size variable bound in Figma */
  font-weight: var(--font-weight-semi-bold);
  line-height: var(--font-line-height-label);
  margin: 0;
}

.footer__links {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-3);
  list-style: none;
  margin: 0;
  padding: 0;
}

.footer__link {
  color: var(--color-text-on-dark);
  font-size: 14px; /* no font-size variable bound in Figma */
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
  opacity: 0.8;
  text-decoration: none;
  /* Fade both properties the hover changes, at the theme's micro-interaction
     timing (BugHerd 531475; Figma spec'd the footer static - flagged in ticket). */
  transition: color 0.15s linear, opacity 0.15s linear;
}

.footer__link:hover,
.footer__link:focus {
  color: var(--color-link-dark-hover);
  opacity: 0.72;
}

/* --- Divider --- */

.footer__divider {
  /* Figma-hardcoded fill; no border/divider token bound (flagged to design) */
  background-color: rgba(255, 255, 255, 0.14);
  border: 0;
  height: 1px;
  margin: 0;
  width: 100%;
}

/* --- Bottom bar: copyright + legal links --- */

/* Mobile/tablet (<1280px): copyright stacks above the legal links row */
.footer__bottom {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-5);
}

.footer__copyright {
  color: var(--color-text-on-dark);
  font-family: var(--font-family-brand);
  font-size: var(--font-size-xxs);
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
  margin: 0;
  opacity: 0.8;
}

.footer__legal {
  display: flex;
  /* Wrap below MD instead of clipping (Figma mobile frame clips at 393px;
     flagged as a Figma bug in the spec - do not replicate) */
  flex-wrap: wrap;
  gap: var(--spacing-5);
  list-style: none;
  margin: 0;
  padding: 0;
}

.footer__legal-link {
  color: var(--color-text-on-dark);
  font-size: var(--font-size-xxs);
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
  opacity: 0.8;
  text-decoration: none;
  /* Same hover fade as the column links, so legal links don't snap next to them. */
  transition: color 0.15s linear, opacity 0.15s linear;
}

.footer__legal-link:hover,
.footer__legal-link:focus {
  color: var(--color-link-dark-hover);
  opacity: 0.72;
}

/* --- Tablet (768-1279px): 4-across columns, wrapping allowed --- */

@media (min-width: 768px) {
  .footer {
    padding-left: var(--layout-margin-md);
    padding-right: var(--layout-margin-md);
  }

  .footer__columns {
    flex-direction: row;
    flex-wrap: wrap;
    gap: var(--spacing-8);
  }

  .footer__column {
    flex: 1 0 0;
    min-width: 0; /* long labels wrap to two lines inside columns */
  }
}

/* --- Desktop (>=1280px): 80px side padding, single-row bottom bar --- */

@media (min-width: 1280px) {
  .footer {
    padding-left: var(--layout-margin-lg);
    padding-right: var(--layout-margin-lg);
  }

  .footer__columns {
    flex-wrap: nowrap;
  }

  .footer__bottom {
    align-items: center;
    flex-direction: row;
    justify-content: space-between;
  }
}
/* Image Filter Layers
   The shared photograph treatment: filter layers painted ABOVE the image at run
   time, never baked into the asset, so swapping the photo keeps the treatment
   (see docs/adr/0002-master-modules-are-shared-theme-primitives.md). Consumed
   through the photo_block macro in templates/partials/macros/photo-filter.html -
   modules should not hand-roll these layers.

   Two recipes, five variants:

   Recipe A - .photo-filter--photo
     Editorial photography (hero, audience cards, media/copy splits).
     1. --color-text-primary @ 40%, mix-blend-mode: screen     (lifts the shadows)
     2. --photo-filter-warm  @ 60%, mix-blend-mode: soft-light (warms the highlights)
     Confirmed byte-for-byte against Figma node 1715:15124 - rgba(8,35,41,0.4)
     screen over rgba(255,181,97,0.6) soft-light, which is exactly the two tokens
     above at those opacities.

   Recipe B - .photo-filter--dark-40 | --dark-80 | --dark-85 | --dark-90
     A full-bleed photograph sitting behind a dark section, where section copy has
     to stay readable over any photo.
     1. --brand-petrol-900, mix-blend-mode: color (recolors the photo to petrol)
     2. --brand-petrol-900, normal blend, opacity per section
     The dark steps the design calls for are 40% (S4), 85% (S11) and 90% (S7).
     80% is a client-requested step down from S11's 85%, so the Final CTA photo
     reads through the overlay (BugHerd #41); --dark-85 stays available because
     it is still the Figma value and a selectable module field option.

   .photo-filter--none
     No treatment - the macro renders the photo with no layer element at all.
     Product Screenshot Container photography uses this: real product UI never
     gets a filter.

   Layer mechanics: both layers live on one aria-hidden child rather than on
   .photo-filter itself, mirroring Figma's own structure. That keeps the block's
   own ::before/::after free for consumers - the hero's radial vignette is a
   third, Hero-only layer, and specifically a full-bleed-only one: it holds
   contrast for white copy sitting on the photograph, and the Who We Serve
   split-panel hero has no such layer in Figma because its copy sits beside the
   photo rather than on it (issue #20 item 6). The mechanism stays the point -
   a consumer can add a third layer without touching this file. It also keeps
   the layers at the exact bounds of the photo since
   the child is inset:0 inside the same box the image fills. The static <img>
   paints below both layers regardless of source order - only positioned layers
   take part in that paint tier. */

.photo-filter {
  overflow: hidden;
  position: relative;
}

.photo-filter__image {
  display: block;
  height: 100%;
  object-fit: cover;
  object-position: bottom;
  width: 100%;
}

.photo-filter__layers {
  inset: 0;
  pointer-events: none;
  position: absolute;
}

.photo-filter__layers::before,
.photo-filter__layers::after {
  content: "";
  inset: 0;
  position: absolute;
}

/* --- Recipe A --- */

.photo-filter--photo .photo-filter__layers::before {
  background-color: color-mix(in srgb, var(--color-text-primary) 40%, transparent);
  mix-blend-mode: screen;
}

.photo-filter--photo .photo-filter__layers::after {
  background-color: color-mix(in srgb, var(--photo-filter-warm) 60%, transparent);
  mix-blend-mode: soft-light;
}

/* --- Recipe B ---
   Layer 1 is identical across the steps; only layer 2's opacity changes, so the
   step modifiers set nothing but that one value. */

.photo-filter--dark-40 .photo-filter__layers::before,
.photo-filter--dark-80 .photo-filter__layers::before,
.photo-filter--dark-85 .photo-filter__layers::before,
.photo-filter--dark-90 .photo-filter__layers::before {
  background-color: var(--brand-petrol-900);
  mix-blend-mode: color;
}

.photo-filter--dark-40 .photo-filter__layers::after,
.photo-filter--dark-80 .photo-filter__layers::after,
.photo-filter--dark-85 .photo-filter__layers::after,
.photo-filter--dark-90 .photo-filter__layers::after {
  background-color: var(--brand-petrol-900);
  opacity: var(--photo-filter-dark-opacity);
}

.photo-filter--dark-40 {
  --photo-filter-dark-opacity: 0.4;
}

.photo-filter--dark-80 {
  --photo-filter-dark-opacity: 0.8;
}

.photo-filter--dark-85 {
  --photo-filter-dark-opacity: 0.85;
}

.photo-filter--dark-90 {
  --photo-filter-dark-opacity: 0.9;
}

/* --- The same recipes over a dnd_section's own background ---

   Everything above treats an <img> inside .photo-filter. A background chosen
   on a section in the page editor is not an image element at all - HubSpot
   paints it as CSS on the section - so those rules have nothing to wrap.
   section-photo-filter.module renders the box below and reuses the recipe
   classes verbatim, so there is exactly one definition of each treatment.

   These live here rather than in that module's own scoped style block on
   purpose: a module's scoped CSS is confined to its subtree, and the first
   selector deliberately reaches UP to the section that contains it, which a
   scoped rule cannot do.

   The :has() hook means only a section actually containing the module becomes
   a positioning context - every other section on every other page is
   untouched.

   Stacking, which took two wrong turns to get right. The editor paints a
   section background as a plain background-image on the section ELEMENT
   (.dnd_area-row-N-background-layers), not on a pseudo-element or a separate
   layer node. So the treatment has to sit above that background and below the
   modules, and it must be able to blend with it.

   `z-index: -1` on the layer does neither. A negative-z-index child paints
   below every in-flow descendant box, not just below the text, and setting
   any z-index other than auto makes the layer its own stacking context, which
   seals mix-blend-mode off from the section background it is supposed to
   recolor - so the Recipe B colour layer silently does nothing.

   Instead the layer keeps z-index: auto (no stacking context, so blending
   reaches the section background) and the CONTENT is lifted above it. The
   lift excludes the row holding the filter itself, so the result does not
   depend on where in the section the module is dropped - without that
   exclusion the filter rides up with its own row and lands on top of the
   copy whenever it is dropped last. */

.dnd-section:has(.section-photo-filter) {
  position: relative;
}

.section-photo-filter {
  inset: 0;
  pointer-events: none;
  position: absolute;
}

.dnd-section:has(.section-photo-filter)
  > .row-fluid
  > .dnd-column
  > .row-fluid-wrapper:not(:has(.section-photo-filter)) {
  position: relative;
  z-index: 1;
}
/* Product Screenshot Container
   The one way this theme frames real product UI: a fixed-aspect, rounded, clipping
   container whose own background shows through wherever the screenshot does not
   reach (see docs/adr/0002-master-modules-are-shared-theme-primitives.md). Consumed
   through the screenshot_block macro in
   templates/partials/macros/product-screenshot.html - modules should not hand-roll
   this frame.

   Three rules the design is explicit about:

   1. The upload is unretouched and every effect is CSS (BugHerd #42, #50). The
      client uploads a plain capture; the crop, the radius, the white ring and the
      shadow are all applied here. Nothing is ever baked into the asset.
   2. The screenshot bleeds the full stage width from the top edge and keeps its
      natural aspect (height:auto, never stretched). Whatever runs past the
      container's aspect box is CROPPED at the bottom edge - that is the design's
      framing, not an accident - and a screenshot shorter than the box leaves the
      container background visible below it.
   3. No Image Filter Layers. The photograph recipes in _photo-filter.css are for
      photography only and never touch product UI, so this component shares no CSS
      with them.

   Parameters, all set by the macro as modifier classes:

     aspect      --4-3 (§1, §3, §4, §5a, §5b) | --3-2 | --16-9 | --1-1
     background  --bg-inverse (§5a) | --bg-crimson (§3) | --bg-white (§1, §4) | none
     frame       (base = plain, §5a) | --raised (§1, §4, §5b) | --bordered (§3)

   Geometry is expressed in percentages of the container so every instance scales
   with its column and no breakpoint overrides are needed. The full-bleed stage is
   Figma 1460:34047 (§1: a 523x544 capture in a 520x390 frame) and 1460:34276 (§4:
   a 500x730 capture in a 500x375 frame) - both put the capture at the frame's top
   left at full width and let the frame cut the rest. The inset stage is node
   1715:15591 (604x453), which composes rather than crops. */

.product-screenshot {
  --product-screenshot-aspect: 4 / 3;
  --product-screenshot-stage-top: 0;
  --product-screenshot-stage-left: 0;
  --product-screenshot-stage-width: 100%;

  aspect-ratio: var(--product-screenshot-aspect);
  border-radius: var(--radius-md);
  overflow: hidden;
  position: relative;
  width: 100%;
}

/* --- Aspect ratio --- */

.product-screenshot--4-3 {
  --product-screenshot-aspect: 4 / 3;
}

.product-screenshot--3-2 {
  --product-screenshot-aspect: 3 / 2;
}

.product-screenshot--16-9 {
  --product-screenshot-aspect: 16 / 9;
}

.product-screenshot--1-1 {
  --product-screenshot-aspect: 1 / 1;
}

/* --- Background ---
   The container's own fill, visible wherever the screenshot does not reach. */

.product-screenshot--bg-inverse {
  background-color: var(--color-background-inverse);
}

.product-screenshot--bg-crimson {
  background-color: var(--brand-crimson-900);
}

.product-screenshot--bg-white {
  background-color: var(--neutral-white);
}

/* --- Frame ---
   Base is §5a: radius-md, no border, no shadow.

   --raised is the sitewide treatment the client asked for in BugHerd #42/#50: a
   thin white ring, the base radius, and a soft shadow, over a cropped capture.
   Both comps land on radius-md rather than the --radius-xl this modifier used to
   carry - §1's frame rect (1460:34197) is rx 8.468 and §4's mask rect (1460:34278)
   is rx 10.516 on a path expanded by half its 4.854 outside stroke, i.e. 8.09 -
   so the radius comes off the base and only the ring and shadow are declared here.

   The 3px white border is Figma's own value at §1 and there is no border-width
   token collection to bind it to. §4 draws the same ring at 4.854; the two comps
   disagree, and 3px is the value the theme already carries at --bordered.

   Because the box is border-box, the ring eats into the aspect box rather than
   growing it, and overflow:hidden clips the capture at the padding edge - so the
   ring reads as an unbroken white outline around a cropped screenshot. */

.product-screenshot--raised {
  border: 3px solid var(--neutral-white);
  box-shadow: var(--elevation-md);
}

/* §3 is the one frame that keeps radius-xl: it is a composed instance (crimson
   field, gold accent bar, inset secondary), not a cropped capture. */

.product-screenshot--bordered {
  border: 3px solid var(--neutral-white);
  border-radius: var(--radius-xl);
  box-shadow: var(--elevation-md);
}

/* --- Primary screenshot --- */

.product-screenshot__stage {
  left: var(--product-screenshot-stage-left);
  position: absolute;
  top: var(--product-screenshot-stage-top);
  width: var(--product-screenshot-stage-width);
}

/* No radius on the image itself in the full-bleed case: the container already
   clips it, and a second, smaller radius inside that clip cuts a notch out of
   each corner. §3's inset stage floats clear of the container edges and does
   round its own corners - see --with-inset below. */

.product-screenshot__image {
  display: block;
  height: auto;
  width: 100%;
}

/* --- Inset secondary screenshot (§3) ---
   Positioned against the container rather than the primary, because it overhangs
   the primary's right edge. Its presence also narrows and re-offsets the primary,
   which is why the two travel together as one modifier. */

.product-screenshot--with-inset {
  --product-screenshot-stage-top: 15.13%;
  --product-screenshot-stage-left: 13.24%;
  --product-screenshot-stage-width: 66.66%;
}

.product-screenshot--with-inset .product-screenshot__image {
  border-radius: var(--radius-sm);
}

.product-screenshot__inset {
  border: 2px solid var(--neutral-white);
  border-radius: var(--radius-sm);
  box-shadow: var(--elevation-md);
  left: 70%;
  overflow: hidden;
  position: absolute;
  top: 28.7%;
  width: 18%;
}

.product-screenshot__inset-image {
  display: block;
  height: auto;
  width: 100%;
}

/* --- Gold accent bar (§3) ---
   Sits on the container's bottom edge, runs off the left side (clipped by the
   container) and stops short of the right. The 30px corner is Figma's value and is
   not a step on the radius ramp, so it stays literal rather than snapping to
   --radius-3xl (32px) and shifting away from the comp. */

.product-screenshot__accent {
  background-color: var(--brand-gold-500);
  border-top-right-radius: 30px;
  bottom: 0;
  height: 14px;
  left: -24%;
  position: absolute;
  right: 12.75%;
}
/* Carousel
   The shared multi-item component: one implementation of the slide stack, the
   cross-dissolve, the dots, and the auto-rotate/drag behavior, consumed by every
   section that shows more than one of something
   (see docs/adr/0002-master-modules-are-shared-theme-primitives.md). Markup comes
   from the macros in templates/partials/macros/carousel.html; behavior from
   js/carousel.js. Modules should not hand-roll a second carousel.

   Structure the consumer builds (see the macro header for the full example):

     .carousel[data-carousel]
       [role=radiogroup]           from carousel_radios()
         .carousel__radio          one per slide
       .carousel__slides
         .carousel__slide          the consumer's own per-slide markup
           .carousel__dots         from carousel_dots(), placed wherever the
             .carousel__dot        design puts the nav for that section

   State lives in the radio group, not in a class: CSS alone drives which slide is
   visible, so a slide is never stuck invisible if js/carousel.js fails to load. The
   JS adds auto-rotation, drag, and - since BugHerd #110/#111 turned the dots from
   <label for> into <button> - the dot clicks. Without the script the first slide
   still renders and the radio group's arrow keys still change slides; only pointer
   clicks on the dots go dead.

   Cross-dissolve: 300ms ease-out, opacity only - the same dissolve the nav and
   footer use (see .nav-fade in components/_header.css). The duration is
   --carousel-duration so it is stated once; keep it in sync with FADE_MS in
   js/carousel.js.

   Geometry confirmed against Figma node 1715:17790 (§8 Customer Story), measured on
   node 1715:17796: dots are 12px circles at x=0/24/48, i.e. a 24px pitch, which is
   12px plus a --spacing-3 gap. Active --color-brand-primary over inactive
   --brand-crimson-100.

   SECOND DELIBERATE CHANGE TO LIVE RENDERING. The old inline carousel in
   customer-story.module drew 8px dots on a --spacing-2 (8px) gap - a 16px pitch. That
   was a build error against this same Figma node, and moving to the shared component
   corrects it, so §8's dots grow by 4px each. #3's acceptance criterion allows for one
   rendering change (the auto-rotate behavior); this is a second one, called out here
   and on the ticket rather than slipped in, because a reviewer diffing the module
   alone cannot see where 12px came from. */

.carousel {
  --carousel-duration: 300ms;
  --carousel-ease: ease-out;

  position: relative;
}

/* Visually hidden but still focusable and tabbable, so keyboard users reach the
   group and get native radio arrow-key navigation across every slide. The visible
   dot <label for="...">s are the pointer targets, which is why the input itself
   takes no pointer events. Not display:none or visibility:hidden - either would
   drop the group out of the tab order and out of the accessibility tree. */

.carousel__radio {
  height: 1px;
  opacity: 0;
  pointer-events: none;
  position: absolute;
  width: 1px;
}

/* Every slide stacks in the same grid cell, so the grid auto-sizes to the tallest
   slide (plain absolute positioning would need an explicit height) and opacity can
   cross-dissolve between them. display:none cannot be transitioned at all, which is
   why this is not built that way.

   touch-action: pan-y lets js/carousel.js read a horizontal drag as a slide step
   while a vertical swipe still scrolls the page. */

.carousel__slides {
  display: grid;
  touch-action: pan-y;
}

.carousel__slide {
  grid-column: 1;
  grid-row: 1;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--carousel-duration) var(--carousel-ease),
    visibility 0s linear var(--carousel-duration);
  visibility: hidden;
}

/* Single-slide degradation: with one item the macros emit no radios and no dots, so
   there is no :checked to reveal anything. An only child is always the visible
   slide - a static single item, which is what §3 and §8 ship as today. */

.carousel__slide:only-child {
  opacity: 1;
  pointer-events: auto;
  transition: none;
  visibility: visible;
}

/* Only the slide matching the checked radio is visible and interactive; every other
   slide, and its own embedded copy of the dots, dissolves out. Generated for a fixed
   range rather than a per-instance slide count - the same technique
   product-snapshot.module uses for its tab-image crossfade. Five is the ceiling the
   macros document and enforce.

   Paired through :has() on the root rather than a sibling combinator so the radios
   can sit inside their role="radiogroup" wrapper and still reach the stack. */

.carousel:has(.carousel__radio:nth-of-type(1):checked) .carousel__slide:nth-of-type(1),
.carousel:has(.carousel__radio:nth-of-type(2):checked) .carousel__slide:nth-of-type(2),
.carousel:has(.carousel__radio:nth-of-type(3):checked) .carousel__slide:nth-of-type(3),
.carousel:has(.carousel__radio:nth-of-type(4):checked) .carousel__slide:nth-of-type(4),
.carousel:has(.carousel__radio:nth-of-type(5):checked) .carousel__slide:nth-of-type(5) {
  opacity: 1;
  pointer-events: auto;
  transition: opacity var(--carousel-duration) var(--carousel-ease);
  visibility: visible;
}

/* Dots

   Each slide carries its own dots row, because the design puts the nav inside the
   slide's own content column (Figma node 1715:17796 sits under §8's CTA) and only
   the active slide is ever visible. The nth-of-type pair below is what marks the
   current dot: within slide N, dot N is the active one. */

.carousel__dots {
  display: flex;
  gap: var(--spacing-3);
}

/* The 12px diameter is a literal because the design system has no size ramp to bind
   to - the spacing tokens are for padding, gaps and layout, and --spacing-3 happening
   to also be 12px would be a coincidence dressed up as a relationship. The gap beside
   it IS spacing, so that one is tokenised. */

/* A <button> since BugHerd #110/#111 - see the macro for why it stopped being a
   <label for> and what that costs.

   The :not(#specificity) bump is load-bearing, not decoration. elements/_buttons.css
   styles every `button` at (1,0,1) using that same id-level hack (see its cascade
   note), which outranks a plain .carousel__dot class. Without the bump here the dots
   came out as brand-primary buttons: 48x24 instead of 12x12, an 8px radius instead of
   a circle, and - the part that actually broke the component - every dot painted the
   ACTIVE crimson, because the button rule's background-color also beat the
   current-dot pair below at (0,4,0). The indicator disappeared entirely.

   So every dot selector in this file carries the bump. The :nth-of-type pairs still
   work: all dots in a row are the same element type, which is all nth-of-type counts. */

.carousel__dot:not(#specificity) {
  appearance: none;
  background-color: var(--brand-crimson-100);
  border: 0;
  border-radius: var(--radius-full);
  cursor: pointer;
  display: block;
  height: 12px;
  padding: 0;
  width: 12px;
}

.carousel__slide:nth-of-type(1) .carousel__dot:nth-of-type(1):not(#specificity),
.carousel__slide:nth-of-type(2) .carousel__dot:nth-of-type(2):not(#specificity),
.carousel__slide:nth-of-type(3) .carousel__dot:nth-of-type(3):not(#specificity),
.carousel__slide:nth-of-type(4) .carousel__dot:nth-of-type(4):not(#specificity),
.carousel__slide:nth-of-type(5) .carousel__dot:nth-of-type(5):not(#specificity) {
  background-color: var(--color-brand-primary);
}

/* The radio carries the focus ring, but it is a 1px transparent box in the corner,
   so the ring has to be drawn on the dot the focused radio controls. Without this
   keyboard focus in the group is invisible. */

.carousel:has(.carousel__radio:nth-of-type(1):focus-visible) .carousel__slide .carousel__dot:nth-of-type(1),
.carousel:has(.carousel__radio:nth-of-type(2):focus-visible) .carousel__slide .carousel__dot:nth-of-type(2),
.carousel:has(.carousel__radio:nth-of-type(3):focus-visible) .carousel__slide .carousel__dot:nth-of-type(3),
.carousel:has(.carousel__radio:nth-of-type(4):focus-visible) .carousel__slide .carousel__dot:nth-of-type(4),
.carousel:has(.carousel__radio:nth-of-type(5):focus-visible) .carousel__slide .carousel__dot:nth-of-type(5) {
  outline: 2px solid var(--color-border-focus-form);
  outline-offset: 2px;
}

/* While a drag is in progress the pointer is captured, so the dots would otherwise
   swallow the release as a click on whichever dot it happened to land on. */

.carousel.is-dragging .carousel__dot {
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .carousel {
    --carousel-duration: 0s;
  }
}
/* Hover-Reveal Card
   The shared photo card whose body copy and CTA are hidden until the visitor hovers,
   focuses, or taps it (see
   docs/adr/0002-master-modules-are-shared-theme-primitives.md). Consumed through the
   hover_reveal_card macro in templates/partials/macros/hover-reveal-card.html;
   tap-to-reveal comes from js/hover-reveal-card.js. Modules should not hand-roll a
   second one.

   Confirmed against Figma node 1715:17920 (hover state) versus 1715:17935 and
   1715:17947 (rest state), all inside node 1715:17801:

   Rest    photo fills the card with Recipe A applied; the category tag is a pill on
           the photo at --color-background-inverse 40%; the dark scrim covers the
           lower third of the card at 80%; only the title is visible.
   Hover   the card is darkened over its full height at 90%; body copy and CTA fade in
           below the title; the tag flips to --neutral-white 40%.

   Three places the comp is internally inconsistent or silent, resolved deliberately:

   1. Recipe A is applied unevenly across the three cards. Node 1715:17938 (the middle
      card's filter) carries only the screen layer and is mispositioned well off the
      card. Every card here gets the full recipe, through photo_block - the filter is
      never rebuilt locally.
   2. The revealed CTA is a filled brand-primary button in this file, while the Master
      Modules doc describes CTA Button as a text-link-with-arrow. Figma wins, so the
      CTA is the theme's own .button. Raised on the decisions issue (#20).
   3. The comp draws both end states and says nothing about the path between them.
      Growing the rest scrim to full height reads as a wipe; BugHerd #13 ruled for the
      crossfade the homepage already shipped - the rest band fades out while a
      full-card layer fades in - so the two pages animate the same way. Hence two
      scrim elements whose opacities are mirror images, and 350ms ease rather than the
      200ms ease-out this file started with.

   Scrim geometry is no longer a share of the card. It was 33.4% (Figma's 167px over
   its 500px card), which scaled the band with the column and so had it standing 133 /
   160 / 167px up the ramp - a height faking padding. Measured at LG, that left 95px of
   dead space above a two-line title against 25px above a five-line one, with 24px
   under both (design QA). The band now hangs off the resting title's own box instead,
   which is what it is there to back; see .hover-reveal-card__content::after.

   Height is --hover-reveal-card-height, so a consuming section can set its own ramp
   without touching this file. The default ramp lands on Figma's 500px from LG up.

   --- How the two states are expressed ---

   One inherited number, --hrc-on (0 at rest, 1 revealed), drives every animated
   value through calc(); three discrete companions cover what arithmetic cannot
   express (an fr row, a visibility keyword, the tag's base color). Those four
   property NAMES are the single definition of what the two states mean: the geometry
   - the two scrims' opacities, the gap, the row, the 40% tag wash - is written once,
   further down, on the elements that own it. An entry condition below only flips the
   four-line block; it never restates a height or an opacity, so the hover path and the
   tap path cannot drift apart on any actual value.

   The four-line block itself does appear three times, because two of the three entry
   conditions are media-query-scoped and CSS cannot put a media query and a class into
   one selector list. Three copies of four declarations is the price of that; three
   copies of the geometry would have been the alternative.

   Nothing transitions the custom properties themselves - custom properties animate
   discretely. The transitions are declared on the real properties (opacity, gap,
   grid-template-rows, background-color), which interpolate normally when the calc()
   they contain resolves to a new value.

   Entry conditions, and why there are four:
     :hover           the design's intent on a mouse
     :focus-visible   keyboard - the card itself is the tab stop (see the macro
                      header for why it carries tabindex)
     :focus-within    keeps the card open once focus moves on into the CTA
     <=1024 or touch  the tablet/mobile breakpoint, plus any no-hover pointer above
                      it - permanently revealed, no tap required (see below;
                      .is-revealed/--tap are dead selectors kept only in case
                      tap-to-reveal js is reinstated)

   Accessibility: at rest the hidden half is visibility:hidden, so it is genuinely
   absent from the accessibility tree and its CTA is not focusable - a screen reader
   at rest gets the tag and the title, which is exactly what the comp shows. It is
   never both hidden and announced. Reaching it is the card's own tab stop, which
   reveals the content and makes the CTA the next tab stop.

   Tablet and mobile are deliberately NOT gated behind a tap:
   js/hover-reveal-card.js is no longer loaded (see templates/layouts/base.html), so
   .hover-reveal-card--tap is never added and the card stays permanently in its
   revealed state below LG. This is the same no-JS fallback the card always had - it
   is just the permanent state for those breakpoints now, not a stopgap before a
   script runs. */

.hover-reveal-card {
  --hover-reveal-card-height: 400px;
  --hover-reveal-card-duration: 350ms;
  --hover-reveal-card-ease: ease;

  /* Rest. The four-line state block; every rule below reads it. */
  --hrc-on: 0;
  --hrc-extra-row: 0fr;
  --hrc-extra-visibility: hidden;
  --hrc-tag-base: var(--color-background-inverse);

  /* The card's own fill, which the photograph covers. Figma paints
     rgb(228,225,219) behind every card in node 1715:17801, which is
     --neutral-gray-300; it has no semantic alias, so this binds to the primitive. It
     shows through only before a section's photography exists, which is the state §9
     is being built in (#16). */
  background-color: var(--neutral-gray-300);
  border-radius: var(--radius-md);
  display: flex;
  flex-direction: column;
  height: var(--hover-reveal-card-height);
  justify-content: flex-end;
  overflow: hidden;
  padding: var(--spacing-6);
  position: relative;
}

/* Tablet and mobile: permanently revealed, no tap required. The
   :not(.hover-reveal-card--tap) qualifier is harmless dead weight kept only so a
   reinstated js/hover-reveal-card.js (which would add that class) could collapse the
   card again without a second CSS change.

   The gate is the BREAKPOINT first and the pointer second, for the reason spelled
   out at the same block in components/_reveal-card.css (BugHerd #64): the client
   reviews these pages in a desktop browser at 1024, where (hover: none) is false,
   so a capability-only gate left the revealed content behind hover on exactly the
   viewport the rule was written for. 1024px is --layout-breakpoint-lg minus 1px,
   written out because custom properties cannot appear in media queries. */

@media (max-width: 1024px), (hover: none) {
  .hover-reveal-card:not(.hover-reveal-card--tap) {
    --hrc-on: 1;
    --hrc-extra-row: 1fr;
    --hrc-extra-visibility: visible;
    --hrc-tag-base: var(--neutral-white);
  }
}

/* Revealed. :hover is scoped to hover-capable pointers so a touch device's sticky
   hover cannot hold a card open after js/hover-reveal-card.js has closed it. */

@media (hover: hover) {
  .hover-reveal-card:hover {
    --hrc-on: 1;
    --hrc-extra-row: 1fr;
    --hrc-extra-visibility: visible;
    --hrc-tag-base: var(--neutral-white);
  }
}

.hover-reveal-card:focus-visible,
.hover-reveal-card:focus-within,
.hover-reveal-card.is-revealed {
  --hrc-on: 1;
  --hrc-extra-row: 1fr;
  --hrc-extra-visibility: visible;
  --hrc-tag-base: var(--neutral-white);
}

.hover-reveal-card:focus-visible {
  outline: 2px solid var(--color-border-focus-form);
  outline-offset: 2px;
}

/* The photo is photo_block's own .photo-filter block, given the card's bounds. Recipe
   A rides along with it; nothing here re-declares a filter layer. */

.hover-reveal-card__photo {
  inset: 0;
  position: absolute;
}

/* The reveal is a crossfade between two fixed layers, not one growing layer (#13, and
   the header's third resolved conflict). The rest band keeps the lower third at 80%
   and fades to nothing; the full-card layer starts at nothing and arrives at 90%. Both
   opacities are the same --hrc-on read in opposite directions, so neither state can be
   re-typed on an entry condition, and the pair cannot end up both visible at rest or
   both gone when revealed.

   Why the rest band has to leave rather than sit underneath: at 80% under a 90% layer
   its top edge stays readable as a seam across the card. That seam is what #13 opened
   on, so fading it out is the fix and not a flourish. */

/* The rest band is __content's own ::after, not a sibling with a height: an absolutely
   positioned sibling cannot be sized by a box it is not inside, and the band has to
   track the resting title to keep even padding around it. inset:-24px gives the same
   --spacing-6 on all four sides by construction; left, right and bottom land exactly
   on the card's edges because __content is already inset by the card's own padding, so
   the band's padding cannot drift from the card's.

   Matching the band across a row is the one part CSS cannot do here, because it needs
   the card to be a subgrid of the section's grid and a row-axis subgrid takes its size
   from the parent's tracks, so the card's own height stops applying (measured: a 500px
   card collapses to 195px). js/hover-reveal-card-band.js measures the row's tallest
   title instead and publishes --hrc-title-floor, which the title's min-height reads.
   Without the script the band still hugs each card's own title, two-line floor and
   all - see that rule below. */

.hover-reveal-card__content::after {
  background-color: var(--color-background-inverse);
  content: "";
  inset: calc(-1 * var(--spacing-6));
  opacity: calc(0.8 * (1 - var(--hrc-on)));
  pointer-events: none;
  position: absolute;
  transition: opacity var(--hover-reveal-card-duration) var(--hover-reveal-card-ease);

  /* Behind the title but still over the photograph: __content's own z-index opens a
     stacking context, so this negative layer is trapped inside it rather than dropping
     behind .hover-reveal-card__photo. */
  z-index: -1;
}

.hover-reveal-card__scrim-full {
  background-color: var(--color-background-inverse);
  inset: 0;
  opacity: calc(0.9 * var(--hrc-on));
  pointer-events: none;
  position: absolute;
  transition: opacity var(--hover-reveal-card-duration) var(--hover-reveal-card-ease);
}

/* rgba(8,35,41,0.4) / rgba(255,255,255,0.4) in Figma - the same 40% wash over a base
   that flips with the state, which is why only the base is a state property.

   The 6px vertical padding is Figma's own value (node 1715:17933) with no token to
   bind to: the spacing ramp steps 4px to 8px straight past it. */

.hover-reveal-card__tag {
  background-color: color-mix(in srgb, var(--hrc-tag-base) 40%, transparent);
  border-radius: var(--radius-full);
  left: var(--spacing-6);
  padding: 6px var(--spacing-3);
  position: absolute;
  top: var(--spacing-6);
  transition: background-color var(--hover-reveal-card-duration) var(--hover-reveal-card-ease);
  width: fit-content;
}

.hover-reveal-card__tag-text {
  color: var(--color-text-on-dark);
  margin: 0;
}

/* A two-row grid rather than a flex column, so the hidden row can collapse to 0fr and
   the title drops back to the bottom edge of the card. The gap has to collapse with
   it - a zero-height row still gets its gap - which is why the gap is scaled by the
   same --hrc-on rather than being a constant. This is what avoids a max-height magic
   number that would clip long copy. */

.hover-reveal-card__content {
  color: var(--color-text-on-dark);
  display: grid;
  gap: calc(var(--spacing-4) * var(--hrc-on));
  grid-template-rows: auto var(--hrc-extra-row);
  position: relative;
  transition: gap var(--hover-reveal-card-duration) var(--hover-reveal-card-ease),
    grid-template-rows var(--hover-reveal-card-duration) var(--hover-reveal-card-ease);

  /* Opens the stacking context the ::after rest band relies on, and keeps the trio
     over the photograph and the full-card scrim.

     Nothing here may touch align-content. It defaults to stretch, which is what lets
     the --hrc-extra-row 1fr track grow to its content; pinning it to end collapses
     that track to 0 and, because __extra clips, silently hides the revealed body and
     CTA at every breakpoint below LG. */
  z-index: 1;
}

/* Double-classed on purpose. The title carries .h5 for the type scale, and
   `body .h5` in elements/_typography.css sets a 20px bottom margin at specificity
   (0,1,1) - a bare .hover-reveal-card__title loses to it and the title floats 20px
   above the card's own --spacing-6 padding instead of sitting on it. Measured: 44px
   from the title's baseline box to the card edge instead of 24px. */

/* The floor is on the TITLE, not on __content: it raises the band without the content
   grid having to pack its rows, which would collapse the reveal (see the note on
   z-index above). The flex pair keeps the words on the card's bottom padding when the
   floor is taller than the text, so the slack shows up above them and the 24px below
   never moves.

   --hrc-title-floor is written per card by js/hover-reveal-card-band.js: the tallest
   resting title on that visual row, so cards side by side match even when one headline
   wraps further than the others. CSS cannot ask for that itself - it needs the card to
   be a subgrid of the section grid, and a row-axis subgrid takes its size from the
   parent's tracks, which drops the card's own fixed height.

   The fallback is what renders with the script absent or before it runs: two lines of
   the .h5 the title carries, expressed as the type scale rather than a pixel. That
   keeps a one-line headline on the same band as a two-line one unaided, so the script
   is a refinement on top of a sane no-JS result, not a requirement for one. */

.hover-reveal-card .hover-reveal-card__title {
  align-items: flex-end;
  color: inherit;
  display: flex;
  margin: 0;
  min-height: var(
    --hrc-title-floor,
    calc(2 * var(--font-size-h5) * var(--font-line-height-heading-sm))
  );
}

/* visibility is what makes the rest state genuinely inaccessible rather than merely
   transparent. Its 0s transition is delayed by the full duration while hiding and by
   nothing while revealing, so the fade-out is visible instead of being cut off - the
   delay falls out of --hrc-on rather than needing a second rule. */

.hover-reveal-card__extra {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-4);
  opacity: var(--hrc-on);
  overflow: hidden;
  pointer-events: auto;
  transition: opacity var(--hover-reveal-card-duration) var(--hover-reveal-card-ease),
    visibility 0s linear calc(var(--hover-reveal-card-duration) * (1 - var(--hrc-on)));
  visibility: var(--hrc-extra-visibility);
}

.hover-reveal-card__body {
  color: inherit;
  font-size: var(--font-size-sm);
  line-height: var(--font-line-height-body);
  margin: 0;
}

.hover-reveal-card__cta {
  width: fit-content;
}

@media (prefers-reduced-motion: reduce) {
  .hover-reveal-card {
    --hover-reveal-card-duration: 0s;
  }
}

/* MD (tablet, >=768px) and LG (small desktop, >=1025px). Figma's 500px is the XL
   size; LG up adopts it, and the two smaller steps keep a card that is still taller
   than it is wide in a single-column stack. A consuming section that needs a
   different ramp sets --hover-reveal-card-height rather than editing this. */

@media (min-width: 768px) {
  .hover-reveal-card {
    --hover-reveal-card-height: 480px;
  }
}

@media (min-width: 1025px) {
  .hover-reveal-card {
    --hover-reveal-card-height: 500px;
  }
}
/* Reveal Card
   The shared bordered surface card whose reveal state is the comp's 3px crimson
   stroke: white(ish) fill, 1px --color-border-strong border and no ring at rest;
   --color-brand-primary border plus a 2px outward box-shadow ring and the reveal
   fill when revealed. Consumed by outcome-cards.module and resource-cards.module
   (and mirrors the portal-only gallery-cards.module); modules should not hand-roll
   a second one - the same rule as components/_hover-reveal-card.css, whose state
   machinery this file copies.

   The ring is a box-shadow, not a wider border - box-shadow never affects box
   size/layout, so the card gains a thicker-looking outline with zero jumpiness.
   The 1px border plus the 2px ring is the comp's 3px stroke (Figma 1460:34232,
   border-3 --color-brand-primary). Figma aligns that stroke inside the frame, so
   the ring here sits 2px further out than the comp draws it; an inside 3px border
   would shift the card's content by 2px on reveal.

   --- How the two states are expressed ---

   Two inherited numbers split the reveal in half. --rc-on (0 at rest, 1
   revealed) drives the card's chrome - fill, border color and ring - through
   calc()/color-mix(). --rc-content (same 0/1) drives anything a consumer hides
   at rest, such as a CTA's opacity, with --rc-vis as its discrete companion for
   the visibility keyword. Entry conditions only flip those numbers - they never
   restate a color - so the hover, focus and featured paths cannot drift apart
   on any actual value. Consumers read them for their own reveal extras, with a
   rest-state fallback (var(--rc-on, 0), var(--rc-content, 1)) so the properties
   are inert outside a .reveal-card.

   The halves are separate because tablet/mobile reveals one without the other:
   below LG the content sits revealed at rest while the chrome does not (see the
   breakpoint block below).

   Entry conditions:
     :hover            the design's intent on a mouse, scoped to (hover: hover)
                       so a touch device's sticky hover cannot hold a card open
     :focus            keyboard, when the card itself is the tab stop
     :focus-within     keyboard, when links inside the card take focus
     --featured        the reveal made permanent, authored per card

   --static pins the card at rest through every condition (outcome-cards'
   cta_visibility="always" instances, where the only hover state left is the
   link's own). It sits last in the file so its selectors win the order tie
   against the entry conditions above.

   Fills: --reveal-card-bg is the rest fill (a consumer's HubL-authored tint),
   --reveal-card-bg-reveal the revealed fill. Both default to white, so a card
   that authors no tint reveals white-on-white - a no-op - and only a tinted
   card actually lifts (the comp draws the revealed card as #ffffff on the same
   #f9f8f5-card row, BugHerd #47). */

.reveal-card {
  --reveal-card-bg: var(--neutral-white);
  --reveal-card-bg-reveal: var(--neutral-white);
  --reveal-card-duration: 0.3s;

  /* Rest. The state block; every rule below reads it. */
  --rc-on: 0;
  --rc-content: 0;
  --rc-vis: hidden;

  background-color: color-mix(in srgb, var(--reveal-card-bg-reveal) calc(var(--rc-on) * 100%), var(--reveal-card-bg));
  border: 1px solid color-mix(in srgb, var(--color-brand-primary) calc(var(--rc-on) * 100%), var(--color-border-strong));
  border-radius: var(--radius-xl);
  box-shadow: 0 0 0 calc(var(--rc-on) * 2px) var(--color-brand-primary);
  padding: var(--spacing-5);
  transition: background-color var(--reveal-card-duration) ease,
    border-color var(--reveal-card-duration) ease,
    box-shadow var(--reveal-card-duration) ease;
}

/* Tablet and mobile: the content half is revealed at rest, so a card's CTA is
   there to be tapped with no tap-to-reveal step in front of it - the rule the
   client set for every interactive module on these breakpoints, which
   css/components/_hover-reveal-card.css already follows. Only the content pair
   flips: the chrome stays at rest, since five cards all wearing the crimson
   ring at once is the reveal state, not a resting row ("we don't need the red
   border shown by default"). That also retires the tap toggle js/reveal-card.js
   used to run (BugHerd #64), which would now swallow the first tap on a visible
   CTA.

   The gate is the BREAKPOINT first and the pointer second. It was (hover: none)
   alone, which is the honest test for "no tap step is possible" but not for what
   the client asked for: they review these pages in a desktop browser resized to
   1024, where (hover: none) is false, so the CTAs stayed hidden behind hover and
   #64 reopened twice on a fix that was already live. --layout-breakpoint-lg
   minus 1px (custom properties cannot appear in media queries, so it is written
   out) is the same LG floor the rest of the theme steps at. (hover: none) stays
   as the second half of the comma so a touch device wider than that - iPad Pro
   landscape - still gets a tappable CTA. */

@media (max-width: 1024px), (hover: none) {
  .reveal-card:not(.reveal-card--static) {
    --rc-content: 1;
    --rc-vis: visible;
  }
}

@media (hover: hover) {
  .reveal-card:hover {
    --rc-on: 1;
    --rc-content: 1;
    --rc-vis: visible;
  }
}

.reveal-card:focus,
.reveal-card:focus-within,
.reveal-card--featured {
  --rc-on: 1;
  --rc-content: 1;
  --rc-vis: visible;
}

.reveal-card:focus-visible {
  outline: 2px solid var(--color-border-focus-form);
  outline-offset: 2px;
}

.reveal-card--static,
.reveal-card--static:hover,
.reveal-card--static:focus,
.reveal-card--static:focus-within {
  --rc-on: 0;
  --rc-content: 0;
  --rc-vis: hidden;
}

@media (prefers-reduced-motion: reduce) {
  .reveal-card {
    --reveal-card-duration: 0s;
  }
}
/* Resource card
   The shared card for the Resources hub grid, the ungated related strip, and the
   thank-you strip (issue #71). Consumed through the resource_card macro in
   templates/partials/macros/resource-card.html - modules should not hand-roll a
   second one. Confirmed byte-for-byte against Figma node 1318:16357 via Dev Mode.

   Not built on the shared .reveal-card primitive (components/_reveal-card.css):
   that primitive's hover state adds a 2px outward ring and crossfades the fill,
   and issue #71 is explicit that hover changes only the border and title color -
   "nothing else changes". Composing it here would mean fighting its ring off on
   every state instead of not having one.

   The card height is a custom property, not a hardcoded value, mirroring
   --hover-reveal-card-height: a grid that needs a different ramp sets
   --resource-card-height (and --resource-card-width) on its own selector rather
   than overriding this rule. 393x546 is the comp's own value. Fixing the height
   (rather than hugging content, the fluid default elsewhere in this theme) is
   deliberate here: the rule and type label are bottom-pinned regardless of title
   length, which only means something if the card height itself does not move with
   the title. The width default exists so the card has a sane standalone size
   (module preview, this QA page) - a real grid overrides it per column via
   --resource-card-width: 100% (or auto) on its own row/grid-item selector, the
   same way .resource-cards__row .resource-card (resource-cards.module, issue #72)
   and every other grid card in this theme sizes
   from its container rather than from a fixed px value. */

.resource-card {
  background-color: var(--color-background-default);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  box-shadow: var(--elevation-xs);
  color: inherit;
  display: flex;
  flex-direction: column;
  gap: var(--spacing-8);
  height: var(--resource-card-height, 546px);
  padding: var(--spacing-8);
  text-decoration: none;
  transition:
    border-color var(--reveal-card-duration, 0.3s) ease,
    box-shadow var(--reveal-card-duration, 0.3s) ease;
  width: var(--resource-card-width, 393px);
}

a.resource-card {
  cursor: pointer;
}

/* Card image - Recipe A (Image Filter Layers) plus a third layer the comp adds on
   top of it: a radial vignette pooled at the bottom of the photo. Figma's own
   gradient (node 1318:16361) is an ellipse centered at the image's bottom edge,
   201.7% of the image's width and 100% of its height, rgba(8,35,41,0.6) at its 50%
   stop fading to transparent by 75% - reproduced below as a plain radial-gradient
   since the axes are not rotated relative to the image, only swapped, which a CSS
   ellipse expresses directly. */

.resource-card__media {
  aspect-ratio: 333 / 250;
  border-radius: var(--radius-md);
  flex-shrink: 0;
  width: 100%;
}

.resource-card__media::after {
  background-image: radial-gradient(
    ellipse 200% 100% at 50% 100%,
    color-mix(in srgb, var(--color-text-primary) 60%, transparent) 50%,
    transparent 75%
  );
  content: "";
  inset: 0;
  pointer-events: none;
  position: absolute;
}

/* Content column - text block at the top, foot block (rule + type label) pushed to
   the card's bottom edge by margin-top: auto regardless of how many lines the
   title takes. Only holds while .resource-card itself has a fixed height. */

.resource-card__content {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: var(--spacing-4);
  min-height: 0;
}

.resource-card__text {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-2);
}

.resource-card__title {
  transition: color var(--reveal-card-duration, 0.3s) ease;
}

.resource-card__excerpt {
  color: var(--color-text-primary);
}

.resource-card__foot {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-4);
  margin-top: auto;
}

.resource-card__rule {
  background-color: var(--color-border-default);
  flex-shrink: 0;
  height: 1px;
  width: 100%;
}

.resource-card__type {
  color: var(--color-link-light-default);
  font-size: var(--font-size-sm);
  line-height: var(--font-line-height-body);
  margin: 0;
}

/* Hover / focus - border and title only, per issue #71 ("nothing else changes").
   :focus-within rather than :focus so the whole-card anchor picks up the same state
   a mouse hover gives it; there is nothing else inside the card to focus.

   The border goes 1px --color-border-strong -> 3px --color-brand-primary. Measured off
   the comps at 1:1: node 1318:16357 (rest) renders a 1px #c9c4bb stroke, node
   1318:16208 (hover) a 3px #c41033 one, on all four edges.

   The extra 2px is an inset ring rather than border-width: 3px because Figma draws the
   stroke *over* the padding - card-image sits 30px from the frame's outer edge in both
   comps, so the thicker hover stroke moves nothing. A border-width change would, since
   this card is border-box: the content box would lose 4px per axis and every child
   would shift 2px on mouseover. The ring abuts the 1px border in the same colour, so
   the two read as one 3px band, and the shadow is re-stated because box-shadow is a
   single property - elevation-xs is unchanged from the rest state. */

.resource-card:hover,
.resource-card:focus-within {
  border-color: var(--color-brand-primary);
  box-shadow:
    var(--elevation-xs),
    inset 0 0 0 2px var(--color-brand-primary);
}

.resource-card:hover .resource-card__title,
.resource-card:focus-within .resource-card__title {
  color: var(--color-link-light-hover);
}

.resource-card:focus-visible {
  outline: 2px solid var(--color-border-focus-form);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .resource-card,
  .resource-card__title {
    transition: none;
  }
}
/* List Callout
   Shared primitive: an eyebrow above a bordered/glass card holding an
   optional title+body and a disc bullet list. Covers Book a Demo's
   "GOOD FIT FOR" (light, in Why Teams Choose OTS) and "WE'LL WALK THROUGH"
   (dark/glass, in What to Expect) - same shape, confirmed against Figma dev
   mode (node 996:11791 and 996:11828), only the card treatment differs.
   Consumed through the list_callout macro in
   templates/partials/macros/list-callout.html - modules should not hand-roll
   this markup (see docs/adr/0002-master-modules-are-shared-theme-primitives.md). */

/* No max-width here - this is a shared primitive dropped into whatever row
   its caller already caps (740px on Book a Demo via book-a-demo.css, its
   own row elsewhere), matching how every other card/row-filling element in
   this theme behaves. The flat 710px cap this used to carry was correct for
   the one Figma frame it was first built against, but that's an artifact of
   this being the first page to use it, not something the primitive itself
   should impose on every future caller. */

.list-callout {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-4);
}

.list-callout__eyebrow {
  margin: 0;
}

.list-callout__card {
  border-radius: var(--radius-xl);
  display: flex;
  flex-direction: column;
  gap: var(--spacing-2);
}

.list-callout__card-text {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-2);
}

.list-callout__title {
  margin: 0;
}

.list-callout__description {
  font-size: var(--font-size-sm);
  line-height: var(--font-line-height-body);
  margin: 0;
}

.list-callout__list {
  font-size: var(--font-size-sm);
  line-height: var(--font-line-height-body);
  list-style: disc;
  margin: 0;
  padding-left: var(--spacing-5);
}

.list-callout__list li + li {
  margin-top: var(--spacing-2);
}

/* Light theme - bordered white card on a light section background
   (Why Teams Choose OTS "GOOD FIT FOR"). */

.list-callout--light .list-callout__eyebrow {
  color: var(--color-text-primary);
}

.list-callout--light .list-callout__card {
  background-color: var(--neutral-white);
  border: 1px solid var(--color-border-strong);
  padding: var(--spacing-5);
}

.list-callout--light .list-callout__title {
  color: var(--color-text-primary);
}

.list-callout--light .list-callout__description,
.list-callout--light .list-callout__list {
  color: var(--color-text-secondary);
}

/* Dark/glass theme - same treatment as hero.module's trust callouts
   (What to Expect "WE'LL WALK THROUGH"). */

.list-callout--dark .list-callout__eyebrow,
.list-callout--dark .list-callout__title,
.list-callout--dark .list-callout__description,
.list-callout--dark .list-callout__list {
  color: var(--neutral-white);
}

.list-callout--dark .list-callout__card {
  backdrop-filter: blur(10px);
  background-color: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.1);
  padding: var(--spacing-8);
}
/* Jump Nav (Master Module) - numbered in-page jump links with scroll-spy.
   Markup: modules/jump-nav.module. Behavior: js/jump-nav.js.
   Figma node 1424:20894; plan docs/plans/solution-template.md §3.

   Below 1025px the list is a dropdown panel under a toggle button showing the
   current section; at >=1025px the toggle disappears and the list renders as
   the comp's white rounded card. Sticky positioning belongs to the host
   template (templates/solution.html pins the aside wrapper), not here. */

.jump-nav {
  position: relative;
}

/* ---- dropdown toggle (hidden at >=1025px) ----
   elements/_buttons.css restyles every <button> behind an id-level
   :not(#specificity) bump; the double bump here outranks it, same pattern as
   .ots-nav__toggle in _header.css. */

.jump-nav__toggle:not(#specificity):not(#specificity) {
  align-items: center;
  background: var(--neutral-white);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  color: var(--color-text-primary);
  cursor: pointer;
  display: flex;
  font-family: var(--font-family-brand);
  font-size: var(--font-size-h6);
  font-weight: var(--font-weight-bold);
  gap: var(--spacing-2);
  justify-content: space-between;
  line-height: 1.3;
  padding: var(--spacing-4) var(--spacing-5);
  text-align: left;
  width: 100%;
}

.jump-nav__chevron {
  border-bottom: 2px solid currentColor;
  border-right: 2px solid currentColor;
  flex-shrink: 0;
  height: 9px;
  margin-top: -4px;
  transform: rotate(45deg);
  transition: transform 0.2s ease-out;
  width: 9px;
}

.jump-nav__toggle[aria-expanded="true"] .jump-nav__chevron {
  margin-top: 4px;
  transform: rotate(225deg);
}

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

/* ---- the list: dropdown panel below 1025px, card at >=1025px ---- */

.jump-nav__list {
  background: var(--neutral-white);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  display: none;
  left: 0;
  list-style: none;
  margin: 0;
  padding: var(--spacing-2) var(--spacing-5);
  position: absolute;
  right: 0;
  top: calc(100% + var(--spacing-2));
  z-index: 20;
}

.jump-nav__list.is-open {
  display: block;
}

.jump-nav__item + .jump-nav__item {
  /* Hairline divider between rows per comp; lighter than the card border. */
  border-top: 1px solid var(--neutral-gray-300);
}

/* Sticky nav v2 (comp 2415:18811, the current draw of node 1424:20894).
   State is one color swap on the whole row and nothing else: off rows are
   Text/Primary, the active row is Brand/Primary. No teal, no opacity, and
   number and label at one size - 16px - rather than the number a step up.

   This is the third pass at the contrast error and the one that clears it
   with room to spare. On the white card: off rows 16.37:1, active row
   6.05:1. What it replaced, for anyone re-opening this: the original tan
   (--color-text-disabled, #c9c4bb) was 1.74:1; teal number + 50% row was
   2.28:1; the same at 85% passed at 4.57:1 but left the two states nearly
   indistinguishable, which is what sent it back to design. */

.jump-nav__link {
  color: var(--color-text-primary);
  display: flex;
  font-family: var(--font-family-brand);
  font-weight: var(--font-weight-bold);
  gap: var(--spacing-2);
  line-height: 1.3;
  padding: var(--spacing-3) 0;
  text-decoration: none;
}

/* Hover previews the state the click is about to produce. Not drawn in the
   comp, which has no hover frame - but these are links, and with the off
   state now at full strength there is no other affordance left on the row. */
.jump-nav__link:hover,
.jump-nav__link:focus-visible,
.jump-nav__link.is-active {
  color: var(--color-brand-primary);
}

.jump-nav__number,
.jump-nav__label {
  font-size: var(--font-size-h6);
}

@media (min-width: 1025px) {
  .jump-nav__toggle:not(#specificity):not(#specificity) {
    display: none;
  }

  .jump-nav__list {
    display: block;
    padding: var(--spacing-8);
    position: static;
  }

  /* Comp rhythm: 18px from row to hairline on both sides (1424:20894).
     Literal on purpose - the spacing scale has no 18px step (16/20). */
  .jump-nav__link {
    padding: 18px 0;
  }

  .jump-nav__item:first-child .jump-nav__link {
    padding-top: 0;
  }

  .jump-nav__item:last-child .jump-nav__link {
    padding-bottom: 0;
  }
}
/* Speakers Row - issue #74, docs/adr/0003, Figma node 1491:35500
   ("speakers-row", file tWBYRmdXsCgjEqz2N4Xj5u).
   Markup: modules/speakers-row.module.

   DARK-ON-PHOTO ONLY. Every gated-page module needs a dark variant because
   the whole page is white copy over an 80% dark filter (ADR-0003), and the
   comp draws only that one treatment. There is no light variant here rather
   than an invented one; add it when a comp asks for it.

   A stylesheet rather than the module's own scoped style block, which is the
   convention for every other module here: this one needs a container query,
   and scope_css does not descend into @container. See the trap list in
   docs/agents/dev-loop.md for what that does and how it was found;
   jump-nav.module is the precedent for a module whose CSS lives in
   components/ instead.

   The container query is not a stylistic preference. This module's width is
   not a function of the viewport, and not even a monotonic one: the gated
   copy column measures 693px at a 768px viewport, then NARROWS to 322px at
   1025px when the 138px gutter and 390px form column appear beside it,
   reaching 712px again only around 1440px. A viewport breakpoint at any usual
   value would put two 148px cards side by side somewhere in that range.

   SPECIFICITY: the three paragraph rules below are compound (two classes)
   rather than plain BEM. templates/resources-gated.css sets
   `.resources-gated__copy-area p { font-size / line-height / margin }` at
   (0,1,1) to type the rich-text blocks this module sits between, which beats
   a single class. Nothing else here needs the bump. */

.speakers-row {
  /* Makes this element the query container for the rule below. It has to sit
     on an ANCESTOR of the element being sized - a container cannot query its
     own width - which is why the card row is a separate child. */
  container-type: inline-size;
}

/* No type metrics here: the heading's level is authorable (h2-h6, see the
   module's fields.json), so font-family/size/weight/letter-spacing/line-height
   all come from the tag and its companion class in elements/_typography.css.
   This rule used to carry Heading/H5's values to re-point a hardcoded <h2>,
   and because it is a plain two-class selector (0,2,0) rather than a scope_css
   ID selector, it outranked `body .h2`-`body .h6` (0,1,1) and would have
   pinned every level at h5's metrics. The removed declarations were h5's
   exactly, so the h5 default renders as it did before.

   color and margin stay: this is the dark-on-photo-only module, so it needs
   --color-text-on-dark over the shared heading rule's --color-text-primary,
   and spacing-10 below the heading over that rule's spacing-5. Both still win
   at (0,2,0). */

.speakers-row .speakers-row__heading {
  color: var(--color-text-on-dark);
  margin: 0 0 var(--spacing-10);
}

/* 40px row and column gap, both from the comp (speaker-avatars, gap 40). A
   grid rather than a wrapping flex row so a 1- or 3-speaker repeater keeps
   the two-up rhythm instead of stretching the odd card across the column.

   The tracks are 1fr, not the comp's flat 336px: "nothing is built to a fixed
   width" (docs/agents/section-modules.md) - 336 is what 1fr resolves to at
   the 1440px canvas the comp was drawn on, and it does, exactly. */

.speakers-row__list {
  display: grid;
  gap: var(--spacing-10);
  grid-template-columns: minmax(0, 1fr);
  list-style: none;
  margin: 0;
  padding: 0;
}

/* 640px: two comp-width cards (336) plus the 40px gap, less the slack a card
   can give up before its avatar stops fitting beside the name. Below it the
   cards stack full width.

   Measured consequence, stated because it is surprising: inside the gated
   template the row is two-up at 768px and again from ~1368px, and one-up in
   between - that is the non-monotonic column width above, not a bug here.
   Dropping the threshold to reach 1280 would mean 248px cards, which was
   rendered and rejected: "Director of Education Partnerships" wraps to four
   lines and outgrows the avatar beside it. If a reviewer wants the comp's
   two-up at 1280 anyway, this one number is the knob. */

@container (min-width: 640px) {
  .speakers-row__list {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/* The card. Equal heights come from the grid's default stretch, which is what
   puts the LinkedIn icon on a shared baseline across a row of cards whose
   bios differ in length.

   The glass treatment is restated here rather than shared, which is the
   theme's existing habit for it - hero.module's trust callouts, what-to-expect
   and .list-callout--dark each carry their own copy, and no two agree (this
   comp fills at 0.1 and rounds to radius-xl, list-callout fills at 0.05 and
   does not round). Extracting one primitive would mean re-deciding those
   comps, which is nobody's ticket yet. The -webkit- prefix is here because
   Safari still needs it for backdrop-filter; .list-callout--dark is the only
   other blurred card in the theme and it goes without. */

.speakers-row__card {
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  background-color: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: var(--radius-xl);
  color: var(--color-text-on-dark);
  display: flex;
  flex-direction: column;
  gap: var(--spacing-8);
  height: 100%;
  overflow: hidden;
  padding: var(--spacing-8);
}

/* Avatar row: 66px circle, 30px gap, name/title block. */

.speakers-row__identity {
  align-items: center;
  display: flex;
  gap: var(--spacing-8);
}

/* photo_block's own .photo-filter__image carries object-position: bottom,
   right for an editorial crop and wrong for a headshot - a face sits in the
   top half of the frame. */

.speakers-row__avatar {
  border-radius: var(--radius-full);
  flex-shrink: 0;
  height: 66px;
  width: 66px;
}

.speakers-row__avatar .photo-filter__image {
  object-position: center;
}

/* min-width: 0 is what lets a long job title wrap inside a narrow card
   instead of pushing the flex row wider than the card. The comp sets this
   block to nowrap, which only holds at the comp's own 336px card. */

.speakers-row__name-block {
  min-width: 0;
}

/* Label/Label SM for both lines - one 14px semibold text node in the comp,
   split into two fields here (issue #74) and styled to read as that one
   block. */

.speakers-row__card .speakers-row__name,
.speakers-row__card .speakers-row__title {
  font-family: var(--font-family-brand);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-semi-bold);
  line-height: var(--font-line-height-label);
  margin: 0;
  overflow-wrap: break-word;
}

/* Body/Small. No width of any kind: the comp's bio is 284px inside a 276px
   wrapper, which is a comp bug, not a spec (issue #74). */

.speakers-row__card .speakers-row__bio {
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-regular);
  line-height: var(--font-line-height-body);
  margin: 0;
  overflow-wrap: break-word;
}

/* margin-top: auto pins the icon to the bottom of the card; the card's own
   30px gap keeps it that far clear of the bio in the tallest card of a row,
   where "auto" resolves to nothing. */

/* flex-shrink: 0 is not what margin-top: auto already does. The auto margin
   absorbs FREE space; shrinking is what happens when there is none, which is
   the case a card whose content outgrows its stretched row height hits. */

.speakers-row__linkedin {
  display: block;
  flex-shrink: 0;
  height: 34px;
  margin-top: auto;
  text-decoration: none;
  width: 34px;
}

/* The comp's LinkedIn asset is a single path: a filled disc with the glyph
   knocked out of it, so the backdrop shows through the letters. Used as a
   mask (background-color paints the disc, the SVG supplies only the shape)
   rather than an <img>, the same technique outcome-cards.module uses - an SVG
   referenced through <img src> cannot take its color from CSS. */

.speakers-row__linkedin-icon {
  -webkit-mask-image: var(--icon-url);
  -webkit-mask-position: center;
  -webkit-mask-repeat: no-repeat;
  -webkit-mask-size: contain;
  background-color: var(--color-text-on-dark);
  display: block;
  height: 100%;
  mask-image: var(--icon-url);
  mask-position: center;
  mask-repeat: no-repeat;
  mask-size: contain;
  transition: opacity 0.2s ease;
  width: 100%;
}

/* The comp draws no hover state, and none is inherited: elements/_typography
   .css gives `body a` an underline and a colour change on hover, and this
   anchor's content is a masked shape with no text to underline and no colour
   to change - so without this it is the only link in the theme that answers
   the pointer with nothing at all. Opacity, because it is the one property
   that reads on a knocked-out disc over an arbitrary photograph. */

.speakers-row__linkedin:hover .speakers-row__linkedin-icon,
.speakers-row__linkedin:focus-visible .speakers-row__linkedin-icon {
  opacity: 0.7;
}

@media (prefers-reduced-motion: reduce) {
  .speakers-row__linkedin-icon {
    transition: none;
  }
}
/* Benefits List - issue #76, docs/adr/0003, Figma node 1491:35546
   ("bullets-row", file tWBYRmdXsCgjEqz2N4Xj5u).
   Markup: modules/benefits-list.module.

   DARK-ON-PHOTO ONLY, for the same reason css/components/_speakers-row.css
   is: the whole gated page is white copy over an 80% dark filter (ADR-0003)
   and the comp draws only that one treatment. No light variant is invented
   here; add one when a comp asks for it.

   A stylesheet rather than the module's own scoped style block. This module
   needs a container query and scope_css does not descend into @container -
   the rules come out with bare selectors, leak to the whole page, and then
   lose to their own unconditional base rule on specificity, so the layout
   silently never changes. speakers-row and jump-nav are the precedents;
   docs/agents/dev-loop.md carries the full trap.

   The container query is not a preference. The gated copy column's width is
   not a function of the viewport and not even monotonic in it: it measures
   708px at a 768px viewport, NARROWS to 411px at 1025 when the gutter and the
   390px form column appear beside it, and reaches 712 again around 1440
   (numbers from css/templates/resources-gated.css). A viewport breakpoint at
   any usual value would put three 100px bullet columns side by side somewhere
   in that range. */

/* The card. Every number is the comp's (1491:35547): fill 0.05, 1px border at
   0.1, blur 10, radius 16, padding 30.

   The glass treatment is restated here rather than reached through the
   list_callout primitive, which ADR-0002 and _list-callout.css's own header
   would otherwise ask for. The full argument is in the module's docstring:
   short version, the card is the same object but the LIST is not, and that
   primitive cannot render a multi-column one without growing a second,
   incompatible list mode. Restating is also this theme's existing habit for
   the treatment - hero.module's trust callouts, what-to-expect,
   .list-callout--dark and .speakers-row__card each carry their own copy and no
   two agree (this comp and list-callout fill at 0.05, speakers-row and
   what-to-expect at 0.1). The -webkit- prefix is here because Safari still
   needs it for backdrop-filter.

   container-type: inline-size makes this element the query container for the
   grid below - a container cannot query itself, which is why the grid is a
   separate child. Queries resolve against the CONTENT box, so the thresholds
   below are card width minus the 60px of padding, not column width. */

.benefits-list {
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  background-color: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: var(--radius-xl);
  color: var(--color-text-on-dark);
  container-type: inline-size;
  padding: var(--spacing-8);
}

/* 24px row and column gap, both the comp's: its three columns sit at x 0 /
   224 / 448 at 200 wide (200 + 24), and its second row starts at y 96 under a
   42-tall first row (72 + 24).

   A grid rather than a wrapping flex row so a 4- or 5-bullet list keeps the
   comp's column rhythm instead of stretching the odd item across the card.
   The tracks are 1fr, not the comp's flat 200px - "nothing is built to a
   fixed width" (docs/agents/section-modules.md) - and 1fr resolves to exactly
   200 at the comp's own 648px content box. */

.benefits-list__items {
  display: grid;
  gap: var(--spacing-6);
  grid-template-columns: minmax(0, 1fr);
  list-style: none;
  margin: 0;
  padding: 0;
}

/* Two thresholds, both drawn from a 180px minimum column rather than from the
   comp's own 200: two-up at 2 x 180 + 24 = 384, three-up at 3 x 180 + 48 =
   588. 180 is where the comp's longest bullet ("Strengthen audience
   experiences") still wraps to two lines rather than three, which is what it
   already does at the comp's 200.

   Thresholds set AT the comp's 200px column were measured and rejected. The
   copy column resolves to 712px at a 1440px viewport, so the card's content
   box is 652 and three-up needs 648 - 4px of slack. A classic scrollbar
   spends all of it: rendered in a browser with one, the same 1440 viewport
   gives a 697px column, a 635px content box, and the comp's own width falls
   back to two-up. Measured, not reasoned about.

   Measured consequence, stated because it is surprising: inside the gated
   template the grid is three-up at 768, one-up at 1025, two-up at 1280 and
   three-up again from ~1310. That is the non-monotonic column above, not a
   bug here - .speakers-row__list does the same thing for the same reason. */

@container (min-width: 384px) {
  .benefits-list__items {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@container (min-width: 588px) {
  .benefits-list__items {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

/* Body/XSmall - 14/1.5 at LG and XL, stepping to 12 at MD and SM with the
   rest of the scale. The comp's own type style.

   Each bullet is its own two-column grid rather than a list marker, because
   the marker has to sit INSIDE the grid cell. An `outside` marker is placed
   beyond the li's border box regardless of any padding on the li, so on a
   grid list the markers of columns two and three would paint into the gap
   beside the previous column; `inside` puts them in the text flow and loses
   the comp's hanging indent on a wrapped bullet. Drawing the disc as a
   pseudo-element is the only shape that keeps both. The <ul>/<li> markup is
   untouched and the module puts role="list" back on the <ul>, which is what
   list-style: none costs in Safari.

   The indent is three steps off the spacing scale rather than the comp's exact
   pixels: --spacing-2 of padding, a --spacing-1 disc, --spacing-3 of gap, so
   the text starts 24px inside the column where the comp's own list indent puts
   it at 22. Two pixels is not worth three literals
   (docs/agents/section-modules.md: design-system custom properties over
   literals). */

.benefits-list__item {
  display: grid;
  font-family: var(--font-family-brand);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-regular);
  gap: var(--spacing-3);
  grid-template-columns: var(--spacing-1) minmax(0, 1fr);
  line-height: var(--font-line-height-body);
  overflow-wrap: break-word;
  padding-left: var(--spacing-2);
}

/* currentColor, so the disc follows the card's white without a second colour
   declaration to keep in sync. The margin centres it on the FIRST line rather
   than the whole cell, which is what keeps a one-line and a two-line bullet
   reading off the same baseline: half of a 1.5 line box less half the disc,
   i.e. 0.75em - 2px. Written against em rather than the lh unit so it holds
   on browsers without lh support, and it is exact because the line-height
   above is a unitless 1.5. Both halves are written against --spacing-1 so the
   disc and the track it sits in cannot drift apart. */

.benefits-list__item::before {
  background-color: currentColor;
  border-radius: var(--radius-full);
  content: "";
  height: var(--spacing-1);
  margin-top: calc(0.75em - var(--spacing-1) / 2);
  width: var(--spacing-1);
}
/* Gated Rich Text - issue #76, docs/adr/0003, Figma node 1491:35486
   ("Resources gated, v2", file tWBYRmdXsCgjEqz2N4Xj5u).
   Markup: modules/gated-rich-text.module.

   The dark-on-photo prose palette for the gated resource page's copy column,
   covering everything a marketer or a migrated WordPress body can put in a
   richtext field. The light-surface twin is css/components/_article-blocks.css
   (the ungated article). The two are separate files, and the list rules here
   are that file's with a colour and a margin added - deliberate, because the
   palette and the block rhythm both differ (40px there, the theme's own
   dnd_section padding here) and the shared part is two declarations. Worth
   folding together only if a third dark-surface prose block appears.

   WHY EVERY SELECTOR DOUBLES ITS ROOT CLASS. Three files type this markup and
   they load in this order: elements/_typography.css and then this file (both
   inside main.css), then css/templates/resources-gated.css (the template's own
   sheet), then theme-overrides.css.

   That middle file is the problem. It types `.resources-gated__copy-area h1`,
   `p` and `a` at (0,1,1) to keep #73's placeholder copy readable, and it loads
   AFTER main.css - so a plain `.gated-rich-text p` ties it on specificity and
   loses on source order. Written that way, this module's paragraph and link
   rules would be dead on the one page it was built for while its list,
   heading and blockquote rules applied, and the two files would have to be
   kept byte-identical by hand to hide the difference.

   `.gated-rich-text.gated-rich-text` is (0,2,0) on one element, so every rule
   here reaches (0,2,x) and the module is authoritative wherever it is dropped.
   css/components/_speakers-row.css does the same thing against the same file
   for the same reason, with a parent/child pair rather than a doubled class -
   this module's children come out of a richtext field and carry no classes to
   pair with, so the root doubles instead.

   The template's own rules stay where they are and are still worth having:
   they are the fallback for anything else in that column that types nothing of
   its own, a native @hubspot/rich_text most of all. They no longer have to
   agree with these.

   Against the other two files nothing special is needed. This file ties
   elements/_typography.css and wins on source order, which is what re-points
   `body a`'s crimson and `body blockquote`'s brand rule. theme-overrides.css's
   `h1`-`h6` are (0,0,1) and lose to anything here. Heading SIZES are
   deliberately not set: `body h2` already beats that same (0,0,1) rule and
   already carries the design system's per-breakpoint scale. */

.gated-rich-text.gated-rich-text {
  color: var(--color-text-on-dark);
}

/* Blocks own the space below them and none above, so two adjacent blocks
   cannot collapse into a surprise - and the last one gives its margin back so
   the module measures exactly as tall as its copy. */

.gated-rich-text.gated-rich-text > :last-child {
  margin-bottom: 0;
}

/* color: inherit rather than a literal white: an ancestor's inherited value
   never overrides an explicit rule on the element itself, however specific
   that ancestor rule is, so every level needs its own declaration to beat
   theme-overrides.css. This is the same note css/templates/resources-gated.css
   carries for its own h1. */

.gated-rich-text.gated-rich-text h1,
.gated-rich-text.gated-rich-text h2,
.gated-rich-text.gated-rich-text h3,
.gated-rich-text.gated-rich-text h4,
.gated-rich-text.gated-rich-text h5,
.gated-rich-text.gated-rich-text h6 {
  color: inherit;
}

/* Body/Body - 20/1.5 at XL, stepping down with the scale. 24px below a
   paragraph rather than elements/_typography.css's 20, matching what
   resources-gated.css already sets for the copy column. */

.gated-rich-text.gated-rich-text p {
  color: inherit;
  font-size: var(--font-size-body);
  line-height: var(--font-line-height-body);
  margin: 0 0 var(--spacing-6);
}

/* Lists. The comp has none in this column, but the migrated corpus does and
   the field accepts them, so they are typed rather than left to fall back to
   the light-surface defaults. ol is styled with ul so a list reads at one
   size whichever marker it uses.

   AT BODY SIZE, not a step under it. This rule used to bind --font-size-sm,
   copying the relationship _article-blocks.css draws on the UNGATED template
   - and that file earns its 16-inside-20 against a specific node
   (use-case-text's body, 1495:40893, which types its lead paragraph at 20 and
   every bullet at 16). Nothing in the gated comp says the same. Its only list
   content is the bullets-row card (1491:35546) at --font-size-xs inside a
   translucent panel, which is benefits-list.module - a different object with
   its own ground, not prose in this column.

   So the step down here was an analogy to another template's comp, and on a
   real migrated page it reads as an unexplained shrink mid-prose: the 2025
   Trends Report page runs 20px paragraph, 16px bullets, 20px paragraph, with
   nothing in the content marking the middle block as subordinate. Reported
   from the live page. A rich-text list in this column is prose continuation
   and now sizes with the prose around it.

   The ungated template is deliberately NOT changed to match: its 16px is a
   comp value, this one never was. The two files were already free to disagree
   - see this file's header on why every selector here doubles its root class
   - and this is the first place they actually do. */

.gated-rich-text.gated-rich-text ul,
.gated-rich-text.gated-rich-text ol {
  color: inherit;
  font-size: var(--font-size-body);
  line-height: var(--font-line-height-body);
  margin: 0 0 var(--spacing-6);
  padding-left: var(--spacing-5);
}

.gated-rich-text.gated-rich-text li + li {
  margin-top: var(--spacing-3);
}

/* Links. The design system already answers this - _tokens-colors.css ships a
   --color-link-dark-* set for exactly this case, and elements/_typography.css
   says in as many words that dark-surface contexts re-point onto it. Teal-300
   on the dark-80 filter, not the inherited white: a white link under white
   copy is distinguishable only by its underline, which is not enough on a
   photograph whose local contrast moves under the text.

   :focus alongside :hover, not :focus-visible, because that is the pair
   elements/_typography.css itself uses for `body a` - this rule replaces that
   one and inherits its shape. text-underline-offset repeats its value for the
   same reason. */

.gated-rich-text.gated-rich-text a {
  color: var(--color-link-dark-default);
  text-decoration: underline;
  text-underline-offset: 0.15em;
}

.gated-rich-text.gated-rich-text a:hover,
.gated-rich-text.gated-rich-text a:focus {
  color: var(--color-link-dark-hover);
}

.gated-rich-text.gated-rich-text a:active {
  color: var(--color-link-dark-active);
}

/* elements/_typography.css draws the quote rule in --color-brand-primary,
   which is a dark petrol and effectively invisible on this page. */

.gated-rich-text.gated-rich-text blockquote {
  border-left-color: var(--color-text-on-dark);
  margin: 0 0 var(--spacing-6);
}
/* Featured card
   The wide dark card that opens a listing page - Figma node 1318:16173, drawn on
   the Resources landing comp and reused verbatim on the blog landing (the two
   comps are the same frame; see docs/adr/0003).

   A DIFFERENT card from the grid card, not a variant of it: dark
   --color-background-inverse field, a tag pill where the grid card has a plain
   crimson type label, H3 where the grid card has H5, a horizontal media/copy
   split, and a "Learn More" button the grid cards do not carry. Only the
   photograph is shared, and it is shared literally - the media block wears
   .resource-card__media so Recipe A plus the comp's radial vignette
   (components/_resource-card.css) is written once. That block is 333x250 at
   radius-md on both cards, which is why reusing the class costs nothing but the
   geometry it already wants.

   Shared rather than scoped into a module because it has two consumers with
   different data behind the same drawing:

     resources-featured.module  a HubDB row from the resources taxonomy index,
                                picked by the `featured` flag
     blog-featured.module       contents[0] from HubSpot's own blog listing

   Only the CARD is here. Each module keeps its own section chrome - the
   full-bleed breakout, the padding schedule, the 1028px inner cap and the
   eyebrow - because those are module fields, not card properties.

   resources-featured.module still carries its own scoped copy of these styles
   and is NOT migrated onto this file, for the same reason
   components/_pagination-pill.css records: that module is shipped and measured
   against the comp, and re-pointing its selectors is a refactor with nothing to
   gain today. Converging the two is filed separately; until then this file is
   the one to edit for the blog and that one for the hub, and the two are
   expected to agree.

   Geometry from the node, at the 1440px canvas:
     card        1028x310, --spacing-8 padding and gap, --radius-xl,
                 --elevation-xs
     card-image  333x250 at --radius-md
     card-content  605x250, text at the top and the CTA pinned to the bottom by
                 justify-content: space-between
     tag         67x30 - Label/Label SM on rgba(255,255,255,.1), pill
     title       Heading/H3, then --spacing-2 to the excerpt (Body/Small)
     CTA         138x45 - the shared .button, Primary/MD, unmodified

   Source order is copy THEN media, with row-reverse putting the media back on
   the left at LG - the sitewide "split layouts stack copy first, image second"
   rule (BugHerd #66, docs/agents/section-modules.md). The comp draws no
   breakpoint below 1440; LG is where the copy column still clears a readable
   measure beside a 333px photo (805 - 333 - 30 = 442). */

/* position: relative anchors the CTA's own ::after, which is stretched over the
   whole card so the card is one hit area with exactly one interactive element in
   it. Nesting an <a> inside a card-wide <a> - the grid card's shape - is not
   available here, because the comp's card carries a button. */
.featured-card {
  background-color: var(--color-background-inverse);
  border-radius: var(--radius-xl);
  box-shadow: var(--elevation-xs);
  display: flex;
  flex-direction: column;
  gap: var(--spacing-8);
  padding: var(--spacing-8);
  position: relative;
}

/* Stacked, the photograph takes the card's full width; the 333px basis arrives
   with the row at LG. Everything else about the block comes from
   .resource-card__media. */
.featured-card__media {
  width: 100%;
}

.featured-card__content {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-8);
}

.featured-card__text {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-4);
}

/* 10% white on the dark field, which is the comp's own fill and the inverse of
   the hero's tag pill (.resources-hero__tag), one theme up. */
.featured-card__tag {
  background-color: color-mix(in srgb, var(--neutral-white) 10%, transparent);
  border-radius: var(--radius-full);
  padding: 6px var(--spacing-3);
  width: fit-content;
}

/* uppercase is a display transform, not the label's own casing: the blog's tags
   are authored lowercase ("ticketing", "school theatre") and the resources
   vocabulary is title case, and the comp types this pill in capitals either
   way. Label/Label SM itself bakes in no transform (see .text-label in
   elements/_typography.css). */
.featured-card__tag-text {
  color: var(--color-text-on-dark);
  margin: 0;
  text-transform: uppercase;
}

.featured-card__copy {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-2);
}

/* The `body` prefix is load-bearing, and this is the one rule that breaks if it
   is "simplified" away.

   The title is a <p class="h3 featured-card__title">, so it is matched by
   elements/_typography.css's `body h1, ..., body .h3, ...` rule, which sets
   --color-text-primary. That selector is (0,1,1); a bare .featured-card__title
   is (0,1,0), so it LOSES - and --color-text-primary is #082329, the exact
   colour of this card's own background. The title does not look wrong, it
   disappears completely: rendered white-on-navy in the comp, navy-on-navy here.
   Measured on the blog landing, where the Featured card came out with a tag
   pill, an excerpt, a button, and a blank space where the headline goes.

   The excerpt was unaffected, because it carries no .h3 - which is exactly what
   made this look like missing content rather than a colour bug.

   resources-featured.module carries the identical declaration at the identical
   (0,1,0) and does NOT have this problem, because it lives inside a
   scope_css block: HubSpot prefixes every scoped rule with the module wrapper's
   id, which is (1,1,0) and beats the typography rule outright. Extracting the
   card into this shared, unscoped component is what lost that free specificity.
   Anything else pulled out of such a block and into components/ inherits
   the same hazard - check it against `body .h1`-`.h6` before believing it.

   `body .featured-card__title` is (0,1,1), tying the typography rule, and wins
   on source order: main.css includes elements/ before components/. That is the
   same fix and the same reasoning as `body .article-heading` in
   components/_article-blocks.css, which documents the trap from the other
   direction. It also clears theme-overrides.css's own `.h3, h3` rule, which
   restates headings from the theme settings - crimson, in this portal - at only
   (0,1,0). */
body .featured-card__title,
body .featured-card__excerpt {
  color: var(--color-text-on-dark);
  margin: 0;
}

.featured-card__cta {
  width: fit-content;
}

/* The card-wide hit area. z-index is load-bearing, not tidiness: the photo block
   carries its own `position: relative` (components/_photo-filter.css) and is a
   LATER sibling than the copy column, so with both at `z-index: auto` the
   photograph paints over this pseudo-element and swallows every click on it - a
   third of the card at LG, the bottom half stacked. */
.featured-card__cta::after {
  content: "";
  inset: 0;
  position: absolute;
  z-index: 1;
}

/* row-reverse rather than an order swap or a source-order swap, so the DOM still
   reads copy before media and keyboard focus reaches the title before the
   photograph. */
@media (min-width: 1025px) {
  .featured-card {
    align-items: flex-start;
    flex-direction: row-reverse;
  }

  .featured-card__media {
    flex: 0 1 333px;
    width: auto;
  }

  /* flex: 1 1 0, not flex: 1. `flex: 1` leaves the basis at auto, i.e. the
     content's max-content width - a long title measures wider than the track,
     the media becomes the only shrinkable item, and the photo comes out at
     whatever is left (219px against a two-line title, measured on the hub).
     Basis 0 makes the copy the only GROWING item instead, so the media keeps its
     333px basis and the copy takes the remainder. */
  .featured-card__content {
    align-self: stretch;
    flex: 1 1 0;
    justify-content: space-between;
    min-width: 0;
  }
}
/* Pagination pill
   The centred pill that pages a card grid - Figma node 1318:17462, drawn on the
   Resources landing comp and reused verbatim on the blog landing (the two comps
   are the same frame; see docs/adr/0003).

   Shared rather than scoped into a module because it now has TWO consumers with
   different mechanics behind the same drawing:

     resources-pagination.module  client-side, over the inline index
                                  resources-grid.module emits. Buttons, no hrefs,
                                  ships `hidden` and is filled by
                                  js/resources-hub.js.
     blog-pagination.module       server-side, over HubSpot's own blog listing
                                  paging. Real <a href> links from
                                  blog_page_link(), rendered by HubL, works with
                                  JS off.

   The mechanics cannot be shared - one is a script writing buttons, the other is
   HubL writing links - but every pixel of the drawing can, so the drawing lives
   here and each module contributes only its markup and its own section chrome
   (the full-bleed breakout and the padding schedule, which are module fields).

   Written as an element-agnostic block: every rule below works on a <button> and
   on an <a>. That is why the reset at the top of .pagination-pill__page zeroes
   both a button's border and a link's text-decoration, and why the active state
   is keyed on [aria-current="page"] rather than on the tag - HubL marks the
   current page with aria-current on an <a>, the script marks it on a <button>,
   and neither needs a different rule.

   resources-pagination.module still carries its own scoped copy of these styles
   and is NOT migrated onto this file. That module is shipped, measured against
   the comp at seven widths and four corpus sizes, and re-pointing its selectors
   is a refactor with nothing to gain today. Converging the two is filed
   separately; until then this file is the one to edit for the blog and that one
   for the hub, and the two are expected to agree.

   Geometry, from the node at the 1440 canvas - identical to the figures
   resources-pagination.module records, since it is the same node:
     pill    394x94, white, 1px --color-border-strong, fully rounded,
             padding 20 vertical / 30 horizontal, 12 gap
     number  28x54 - a glyph in 8px/12px padding, Body/Body Bold
     active  46x46, --color-brand-surface, 1px ring, crimson label
     dots    34x54 - the same padding round an 18px ellipsis
     chevron 6x10 solid triangle - the design system's select caret
             (elements/_forms.css draws it 10x6) turned a quarter turn

   The 46 is written as the sum that produces it rather than as 46, so it tracks
   --font-size-body down the breakpoints (16/16/18/20) and stays a circle at
   every width instead of pinning the comp's XL value. Same call the resources
   module records at length. */

/* --- The pill ---------------------------------------------------------------
   width: fit-content plus auto margins centres it in whatever track the
   consumer puts it in. max-width guards the one case fit-content cannot: a
   viewport narrower than the row. */
.pagination-pill {
  align-items: center;
  background-color: var(--color-brand-white);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-full);
  display: flex;
  gap: var(--spacing-3);
  justify-content: center;
  margin: 0 auto;
  max-width: 100%;
  padding: var(--spacing-5) var(--spacing-8);
  width: fit-content;
}

/* --- Prev / next -----------------------------------------------------------
   One rule set for both the enabled and the disabled end. Server-side, "prev on
   page 1" has no href to point at, so blog-pagination.module renders it as a
   <span> carrying .is-disabled rather than as a dead link - a link with no href
   is not focusable and announces nothing, and a <button disabled> would be a
   control that cannot work without JS. .is-disabled therefore has to grey the
   arrow the same way :disabled does for the button consumer. */
.pagination-pill__arrow {
  background: none;
  border: 0;
  color: var(--color-brand-primary);
  cursor: pointer;
  display: block;
  flex-shrink: 0;
  line-height: 0;
  padding: 0;
  position: relative;
  text-decoration: none;
}

.pagination-pill__arrow:disabled,
.pagination-pill__arrow.is-disabled {
  color: var(--color-text-disabled);
  cursor: default;
}

/* Colour-only hover. The circle treatment belongs to the page numbers - a 46px
   disc around a 6x10 chevron reads as a stray UI element - so the chevron just
   deepens to the token the button system darkens to. `background` is restated
   because the global button hover paints buttons dark crimson, and without it
   the 6x10 box shows as a dark blob behind the triangle. */
.pagination-pill__arrow:hover:not(:disabled):not(.is-disabled) {
  background: none;
  color: var(--color-brand-primary-dark);
}

.pagination-pill__arrow:focus-visible {
  border-radius: var(--radius-xs);
  outline: 2px solid var(--color-border-focus-form);
  outline-offset: 4px;
}

/* The touch target, invisible and asymmetric. 6x10 of ink is nowhere near a
   tappable target, so each arrow reaches 24px OUTWARD into the pill's own
   padding and 6px inward - half the gap, so it can never swallow a click meant
   for page one. The focus ring stays on the 6x10 box, where the ink is.

   These four insets are the only bare pixels in the file and they are not comp
   values: they come from the 44px target guideline and from half of
   --spacing-3. */
.pagination-pill__arrow::after {
  bottom: -17px;
  content: "";
  position: absolute;
  top: -17px;
}

.pagination-pill__arrow--prev::after {
  left: -24px;
  right: -6px;
}

.pagination-pill__arrow--next::after {
  left: -6px;
  right: -24px;
}

/* 6x10 solid triangle out of one border edge. currentColor, so the enabled,
   hover and disabled states are one declaration each on the arrow. */
.pagination-pill__chevron {
  border-color: transparent;
  border-style: solid;
  display: block;
  height: 0;
  width: 0;
}

.pagination-pill__arrow--prev .pagination-pill__chevron {
  border-right-color: currentColor;
  border-width: 5px 6px 5px 0;
}

.pagination-pill__arrow--next .pagination-pill__chevron {
  border-left-color: currentColor;
  border-width: 5px 0 5px 6px;
}

/* --- Page numbers ----------------------------------------------------------
   Hidden below MD, where the status line takes over. Both are rendered at every
   width and swapped in CSS, so nothing listens for a resize. */
.pagination-pill__pages {
  display: none;
}

@media (min-width: 768px) {
  .pagination-pill__pages {
    align-items: center;
    display: flex;
    gap: var(--spacing-3);
    list-style: none;
    margin: 0;
    padding: 0;
  }
}

.pagination-pill__page {
  background: none;
  border: 0;
  border-radius: var(--radius-full);
  color: var(--color-text-primary);
  cursor: pointer;
  display: block;
  font-family: var(--font-family-brand);
  font-size: var(--font-size-body);
  font-weight: var(--font-weight-bold);
  isolation: isolate;
  line-height: var(--font-line-height-body);
  padding: var(--spacing-3) var(--spacing-2);
  position: relative;
  text-align: center;
  text-decoration: none;
}

/* Hover takes the ACTIVE treatment, not a tint - the comp's note on the "1"
   reads "active/hover state", one style for both. But it must not take the
   active GEOMETRY: restyling the element itself to the 46 floor widens it under
   the pointer and shifts every neighbour. So the circle is a ::before painted
   behind the label - same sum for its size, same surface, ring and radius -
   while the element's own 28x54 layout box never changes. isolation: isolate
   keeps the pseudo's z-index: -1 inside it; without it the circle stacks behind
   the pill's own white background and vanishes.

   :not([aria-current="page"]) rather than [aria-current="false"]: HubL renders
   the non-current pages with no aria-current attribute at all, where the
   script writes an explicit "false" on every one. The negation covers both. */
.pagination-pill__page:hover:not([aria-current="page"]) {
  background: none;
  color: var(--color-brand-primary);
}

.pagination-pill__page:hover:not([aria-current="page"])::before {
  background-color: var(--color-brand-surface);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-full);
  content: "";
  height: calc(
    var(--font-size-body) * var(--font-line-height-body) + 2 * var(--spacing-2)
  );
  left: 50%;
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%);
  width: max(
    calc(var(--font-size-body) * var(--font-line-height-body) + 2 * var(--spacing-2)),
    100%
  );
  z-index: -1;
}

.pagination-pill__page:focus-visible {
  outline: 2px solid var(--color-border-focus-form);
  outline-offset: 2px;
}

/* The active pill. 8px round the 30px line box is 46 tall at XL, which is the
   comp's 46x46 - the vertical padding gives its border pixel back so the 1px
   ring does not push the box to 48. The min-width is the same sum rather than a
   literal 46, so the box is a circle at every breakpoint and a two-digit page
   widens it past that floor instead of bursting it. */
.pagination-pill__page[aria-current="page"] {
  background-color: var(--color-brand-surface);
  border: 1px solid var(--color-border-strong);
  color: var(--color-brand-primary);
  cursor: default;
  min-width: calc(
    var(--font-size-body) * var(--font-line-height-body) + 2 * var(--spacing-2)
  );
  padding-bottom: calc(var(--spacing-2) - 1px);
  padding-top: calc(var(--spacing-2) - 1px);
}

.pagination-pill__ellipsis {
  color: var(--color-text-primary);
  display: block;
  font-family: var(--font-family-brand);
  font-size: var(--font-size-body);
  font-weight: var(--font-weight-bold);
  line-height: var(--font-line-height-body);
  padding: var(--spacing-3) var(--spacing-2);
}

/* --- Mobile status ---------------------------------------------------------
   "Page N of M" between the two arrows below MD. No comp covers it; ADR-0003
   names it as the interim behaviour until the mobile comp arrives. It carries
   the same type as a page number so the pill's height does not change across
   the breakpoint. */
.pagination-pill__status {
  color: var(--color-text-primary);
  font-family: var(--font-family-brand);
  font-size: var(--font-size-body);
  font-weight: var(--font-weight-bold);
  line-height: var(--font-line-height-body);
  margin: 0;
  padding: var(--spacing-3) var(--spacing-2);
  text-align: center;
}

/* Above MD it stops being VISIBLE, not present. display: none would take it out
   of the accessibility tree, and for the client-side consumer it is the
   paginator's only live region. It is clipped instead - .show-for-sr from
   utilities/_helper.css, inlined rather than toggled as a class because the
   visible mobile style is this same element and a class swap would need JS
   watching a breakpoint. position: absolute takes it out of the flex row, so
   the pill measures exactly as it did.

   The server-side consumer does not need the live region (a real navigation
   happens, and the new page is announced as a page load), but it costs nothing
   and keeps one rule for both. */
@media (min-width: 768px) {
  .pagination-pill__status {
    border: 0;
    clip: rect(0, 0, 0, 0);
    height: 1px;
    overflow: hidden;
    padding: 0;
    position: absolute;
    white-space: nowrap;
    width: 1px;
  }
}
/* Article body blocks
   The prose palette the resource article's drag-and-drop body is built from -
   issues #80, #81 and #121, docs/adr/0003, Figma node 1495:40867 ("Resources
   ungated, v2", file tWBYRmdXsCgjEqz2N4Xj5u). Eight modules share this file:
   article-heading, article-rich-text and article-image (#80), then
   article-stat-cards, article-numbered-list, article-callout and
   article-pull-quote (#81), in the order the comp stacks them, then
   article-cta (#121).

   article-cta is the one with no comp behind it - it exists because the
   WordPress theme renders an after-content CTA on 22 of the 74 migrated
   resources and neither comp has a slot for one. It is drawn from the card
   recipe the ungated template already uses for its share rail rather than
   invented; see its own section at the foot of this file. The four #81 blocks
   have a comp but no content; see below.

   Shared rather than scoped into each module for two reasons. The seven
   blocks' vertical rhythm is one system, not seven independent decisions -
   the gap below a heading is 16 precisely because the copy under it belongs
   to it, and every other gap is 40 - and templates/resources-ungated.html
   needs to reach the last block in the body area to stop it double-spacing
   against the hairline that closes the article. A module's own scoped style
   block would put an id selector in front of every rule and make that reach
   impossible without !important.

   (Naming that scoping tag in full here would be a mistake: a .css file in
   this theme is compiled as HubL, so a HubL tag written inside a CSS comment
   is still parsed as a tag - the first version of this file said it by name
   and everything below the comment silently vanished from main.css.)

   The four #81 blocks carry no migrated content at all - the resource corpus
   has zero <ol>, nothing that maps to a callout or a stat card, and five
   <blockquote> none of which is a pull quote, and the blog's own lists and
   quotes travel as content.post_body and never touch a module. They are
   built for future authoring, per an ADR decision re-confirmed after the blog
   came into scope. Each module's own docstring says so again.

   Why the body is modules at all rather than one rich-text field: ADR-0003,
   "Three templates". The marketer composes the article out of blocks, so
   these carry no section chrome - no full-bleed breakout, no layout margins,
   no Style-tab padding. They sit inside an 818px column that already owns its
   own margins (css/templates/resources-ungated.css) and they only have to
   stack correctly inside it.

   The native blog does NOT use these. blog-post.html is templateType:
   blog_post, which rejects dnd_area, so a post's body stays content.post_body
   and gets a prose stylesheet of its own that matches what these render.
   ADR-0003, "The blog".

   MEASURED (#81), in the portal, on templates/resources-ungated.html with all
   seven blocks seeded, at 320 / 768 / 1025 / 1280 / 1440, with a scrollbar:

     Article column   265 / 693 / 810 / 681 / 801
     Stat row         one card fills the column at every width; two and three
                      go side by side from 1025 up (465.2 / 313.1 / 385.5 and
                      247 for three at 1440); at 768 three wrap 2 + 1 with the
                      last row filling from the left; at 320 all counts stack.
     Pull quote       250.2 tall at 1440 against the comp's 248, padding
                      64/40 from 768 up and 40/24 below it.
     Block rhythm     40px between every pair of adjacent blocks, from
                      .article-block - stat cards end 1924.4 and the numbered
                      list starts 1964.4, the list ends 2306.4 and the callout
                      starts 2346.4.
     Empty states     all four collapse to height 0 with their content
                      removed, so an unconfigured block costs no space.
     Numeral track    at six items the ::before is content-sized and the text
                      starts at 121.7; at twelve, all twelve text columns
                      share one left edge (132) on the two-digit track.

   `scrollWidth - clientWidth` is 0 at 320 and 7 from 768 up. The 7 is the
   related strip's and Final CTA's own 100vw breakout measuring against the
   scrollbar, not these blocks - it is the same figure #74 and #76 recorded,
   and no .article-* element appears in the overflow probe at any width,
   including with a 68-character unbroken word as a list item. */

/* --- Shared spacing ---------------------------------------------------------
   Every block owns the space BELOW it and none above, so the rhythm is
   readable in one direction and two adjacent blocks can never collapse into a
   surprise. margin is set on all four sides rather than margin-bottom alone
   because .article-image is a <figure>, which carries a UA margin of
   1em 40px that would otherwise indent it out of the column.

   40px is the comp's gap between every top-level body group (intro-text ends
   at 995 and photo-collage starts at 1035; photo-collage ends at 1971.79 and
   use-case-text starts at 2011.79; and so on down the column).

   The theme's own .dnd-section vertical padding is zeroed for sections
   holding these - see the :has() list in objects/_containers-dnd.css - so
   this is the only thing spacing article blocks. Left alone, the theme
   setting would put 120px between every paragraph and its heading. */

.article-block {
  margin: 0 0 var(--spacing-10);
}

/* --- Heading ----------------------------------------------------------------
   16px, not 40: in the comp a heading and the copy under it are one auto-
   layout group with gap 16 (intro-text 1495:40884, use-case-text 1495:40891,
   get-started-text 1495:40941 all read gap-[16px]), and it is the group that
   sits 40 from its neighbours. Splitting that group into two draggable
   modules keeps the same two numbers: a heading is always followed by its own
   copy, so it always carries the tighter gap.

   Declared after .article-block, which it ties with on specificity and beats
   on source order. The body prefix is not decoration: article-heading.module
   emits the matching .h2/.h3 class alongside the tag, so the rule this has to
   beat is elements/_typography.css's `body .h2`, and a bare .article-heading
   loses to it on specificity - measured as a 20px gap where the comp has 16.
   components/ is included after elements/ in main.css, so at equal
   specificity this wins on source order.

   No font-size, line-height or letter-spacing here. The tag itself carries
   them through elements/_typography.css's `body h2` / `body h3` rules, which
   are already the comp's H2 (40/1.1/-2px at XL) and H3 (30/1.2/0) and already
   step down per breakpoint. Same call section-header.module makes. That is
   also why the module emits both the tag and its matching .h2/.h3 class -
   the class-based half of that rule pair is what survives theme-overrides.css
   restating h1-h6 from theme settings. */

body .article-heading {
  color: var(--color-text-primary);
  margin-bottom: var(--spacing-4);
}

/* --- Rich text --------------------------------------------------------------
   The migrated body: paragraphs and bulleted lists, nothing else. Across all
   74 WordPress resources the body is a heading, some paragraphs and a
   bulleted list (ADR-0003, "The corpus, measured": zero <ol>, zero <table>,
   zero <iframe> in the resource corpus).

   Paragraph size is not set here - `body p` is already --font-size-body
   (20px at XL) at line-height 1.5, which is the comp's Body/Body. Only the
   list deviates. */

.article-rich-text > :last-child {
  margin-bottom: 0;
}

/* Bullets render at --font-size-sm (16px at XL) inside 20px body copy. That
   is a real difference in the comp, not a rounding error: use-case-text's
   body (1495:40893) types its lead paragraph at 20 and every bullet at 16,
   and the same 16 shows up again in the numbered list further down
   (1495:40905). Bound to --font-size-sm rather than a literal 16px so it
   steps down with the rest of the scale (16 / 16 / 14 / 14 across XL-SM).

   ol is styled with ul even though the resource corpus has none, because a
   list inside body copy should read at one size whichever marker it uses,
   and a marketer can type either into this field. The numbered-list module
   the ADR schedules separately is a different object - a hanging-indent
   layout with its own markers - not this. */

.article-rich-text ul,
.article-rich-text ol {
  font-size: var(--font-size-sm);
  line-height: var(--font-line-height-body);
  padding-left: var(--spacing-5);
}

/* The comp separates its bullets with a blank line of their own size (a
   24px empty line between each 24px bullet line, which is how the gap gets
   typed inside a single Figma text block). A blank line is not a thing real
   list markup has, so it lands here as half of one - 12px - which reads as
   deliberate separation without the airiness a full 24px leaves between
   one-line bullets. */

.article-rich-text li + li {
  margin-top: var(--spacing-3);
}

/* --- Image with caption -----------------------------------------------------
   603px inside the 818px column, left aligned, at the image's natural aspect
   (photo-collage, 1495:40887). The cap is a comp pixel and stays one: it is a
   measure decision like hero.module's 640px text column, not a layout width -
   below 603 the column caps it and it goes fluid.

   flex + gap rather than a margin under the image is what satisfies "handles
   a missing caption without leaving empty space": with no <figcaption> the
   figure has one child and gap has nothing to apply to.

   align-items: flex-start is the "up to" half of "up to 603px". The default
   stretch would run every child the full width of the figure, and with
   width: 100% on the image that turns 603 from a cap into a target - a 400px
   inline image out of a migrated post would be blown up to 603 and lose
   sharpness, which is the opposite of what the field promises. */

.article-image {
  align-items: flex-start;
  display: flex;
  flex-direction: column;
  gap: var(--spacing-8);
  max-width: 603px;
}

/* The module emits its <figure> even with no image picked, so that the
   dnd_section around it still matches the .article-block entry in
   objects/_containers-dnd.css's :has() list and loses the theme's 120px
   section padding - see article-image.module/module.html. This is what then
   takes the block itself out of the flow, so an unconfigured image costs no
   space at all rather than the 40px its own margin would leave.

   Tested on the image rather than with :empty. The figure is never empty in
   the CSS sense: HubL leaves the newlines and indentation from around its
   conditional behind as text nodes, so :empty silently never matches and the
   40px margin stays - measured. :has() is already the mechanism
   objects/_containers-dnd.css relies on, so this needs no support that the
   theme is not already assuming. */

.article-image:not(:has(.article-image__img)) {
  display: none;
}

/* The comp's frame: 3px white ring, --radius-md, --elevation-md. Those are
   the same three values css/components/_product-screenshot.css gives its
   --raised modifier, and deliberately not reached through that component's
   screenshot_block macro. That container crops its image to a fixed aspect
   box, which is right for product UI and wrong here - these are arbitrary
   images out of 74 migrated WordPress posts, and cropping one to 4:3 would
   cut content out of it. The comp's own instance is a stretched
   "product, 4:3" (603 x 882.79, i.e. 1.46:1) precisely because the designer
   overrode that crop too.

   box-sizing is border-box theme-wide (generic/_reset.css), so the ring eats
   into the 603 rather than growing past it.

   Known tension with ADR-0002, recorded rather than resolved: these three
   declarations are now a second copy of what --raised already says, and that
   ADR asks modules to compose theme-level partials instead. Deduplicating
   means giving the Product Screenshot Container a crop-free mode, which
   changes a primitive six other modules render through - a change worth
   making on its own, not inside the ticket that noticed it.

   width: auto with max-width: 100%, not width: 100%: 603 is a ceiling, not a
   target. A 400px inline image out of a migrated post keeps its own size and
   sits left; only images wider than the box are scaled down to it. */

.article-image__img {
  border: 3px solid var(--neutral-white);
  border-radius: var(--radius-md);
  box-shadow: var(--elevation-md);
  display: block;
  height: auto;
  max-width: 100%;
  width: auto;
}

/* Body/Small - 16px at 1.5, primary text color, 30px under the image
   (1495:40887 reads gap-[30px]; the image frame ends at 882.79 and the
   caption sits at 912.79). Issue #80's prose says 20px; the comp's own gap
   property says 30, and the comp wins. */

.article-image__caption {
  color: var(--color-text-primary);
  font-size: var(--font-size-sm);
  line-height: var(--font-line-height-body);
  margin: 0;
}

/* --- Stat cards -------------------------------------------------------------
   stats-row (1495:40894): bordered white cards, each a large teal value over
   a short grey label, in a row 30px apart. The comp draws two at 394px each
   inside the 818px column.

   NOT a two-column grid. #81 asks for one, two and three to all work, so the
   row auto-fits: each column is at least 240px, so 818px takes three, the
   768px breakpoint's 708px column takes two and the third wraps under, and a
   phone's 280px column takes one. minmax(min(240px, 100%), 1fr) rather than
   minmax(240px, 1fr) - the bare form makes the track 240px wide even when the
   container is narrower than that, which pushes a card past a 280px column.
   The last row fills from the left, the same behaviour benefits-list.module's
   own grid has.

   align-items is left at the grid default (stretch), against the comp's own
   `center`. Two hugging cards centred against each other is what Figma
   records because its cards hug their content; two bordered cards of
   different heights, floating at different offsets, is not a design anybody
   drew - it only looks right in the comp because both labels happen to run to
   two lines. Stretch keeps the row reading as one object at any label length. */

.article-stat-cards {
  display: grid;
  gap: var(--spacing-8);
  grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
}

/* Emitted empty by the module so the wrapping dnd_section still matches the
   .article-block entry in objects/_containers-dnd.css's :has() list - see
   article-image.module/module.html for why that matters and why this is
   tested on the card rather than with :empty. */

.article-stat-cards:not(:has(.article-stat-cards__card)) {
  display: none;
}

/* The comp gives the card 30px of vertical padding and none horizontal,
   holding its label to a flat 220px instead. 220px is kept below as the
   label's measure, but the padding is applied on all four sides: at the
   narrowest column the card is 280px wide, so 30px a side is exactly what
   the 220px label needs anyway, and without it a longer value than the
   comp's "$180M+" would touch the border.

   justify-content: center is what the stretched track above needs - the
   shorter card's content sits in the middle of the taller card's height
   rather than at its top. */

.article-stat-cards__card {
  align-items: center;
  background-color: var(--color-background-default);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  display: flex;
  flex-direction: column;
  gap: var(--spacing-1);
  justify-content: center;
  overflow-wrap: break-word;
  padding: var(--spacing-8);
  text-align: center;
}

/* Heading/H2 in the comp - 40px bold at XL, tracking -2px - but a <p>, not a
   heading: "50M+" is a figure, not a section of the article, and putting it
   in the document outline would break the outline the article's own H2s
   build. Hence the ramp is stated here rather than inherited from a tag.

   --color-accent-teal (Teal/500) is 3.88:1 on the card's white. This line is
   40px bold at XL and 24px bold at SM, both comfortably over WCAG's
   large-text threshold (18.66px bold), which owes 3:1 - so it passes at every
   breakpoint. That is NOT true of the numbered list's numerals below, which
   are 16px; see the note there. */

.article-stat-cards__value {
  color: var(--color-accent-teal);
  font-size: var(--font-size-h2);
  font-weight: var(--font-weight-bold);
  letter-spacing: var(--font-letter-spacing-heading);
  line-height: var(--font-line-height-heading-lg);
  margin: 0;
}

/* Body/Small at --color-text-secondary. The 220px is the comp's own flat
   width for this text node, kept as a max-width: it is a measure decision
   like article-image's 603px, so a short label stays short and centred
   rather than being stretched to it. */

.article-stat-cards__label {
  color: var(--color-text-secondary);
  font-size: var(--font-size-sm);
  line-height: var(--font-line-height-body);
  margin: 0;
  max-width: 220px;
}

/* --- Numbered list ----------------------------------------------------------
   feature-list (1495:40905): items 30px apart, each a bold teal numeral 8px
   left of its text, with wrapped lines hanging under the text rather than
   under the numeral.

   A real <ol> whose markers are drawn by hand. list-style: none plus a
   counter on ::before, not ::marker: the comp states an 8px gap between
   numeral and text, and an `outside` marker sits wherever the list's own
   padding leaves it - there is no property that says "8px from the text".
   Flex says it exactly, and the hanging indent falls out of it for free
   because the text is a flex item with its own box.

   padding-left resets the UA's own 40px list indent; the comp's list starts
   flush with the column, and the indent it does have is the numeral itself.
   The bottom margin comes from .article-block. */

.article-numbered-list {
  counter-reset: article-numbered-list;
  display: flex;
  flex-direction: column;
  gap: var(--spacing-8);
  list-style: none;
  padding-left: 0;
}

.article-numbered-list:not(:has(li)) {
  display: none;
}

.article-numbered-list__item {
  align-items: flex-start;
  color: var(--color-text-primary);
  counter-increment: article-numbered-list;
  display: flex;
  font-size: var(--font-size-sm);
  gap: var(--spacing-2);
  line-height: var(--font-line-height-body);
}

/* Heading/H6 (a flat 16px at every breakpoint), at the item's OWN
   --font-line-height-body rather than the label 1.3 this rule used to carry.

   The 1.3 was described here as putting the numeral "a shade above the first
   line of its text, which is what the comp's top-aligned row draws". Measured on
   a rendered article, that shade is 1.6px: a 16px numeral in a 20.8px line box
   beside 16px text in a 24px box, sharing a top edge, puts the numeral's
   baseline (24 - 20.8) / 2 higher. It reads as misalignment rather than as
   intent, and it was reported as such. Matching the line box makes the two
   baselines coincide; nothing else about the numeral changes.

   Kept in step with the same declaration in css/templates/blog-post.css, which
   reproduces this block for raw <ol> in a blog post body. If one changes, so
   does the other - that pairing is the acceptance criterion for that file.

   Teal/600, NOT the Teal/500 the comp and #81 both name. This is 16px bold,
   which is under WCAG's large-text threshold (18.66px bold), so it owes 4.5:1
   rather than 3:1 - and Teal/500 is 3.88:1 on this page's ground, over the
   large-text bar and under the small-text one. Teal/600 is 6.29:1. Same
   substitution, same measurement and the same client ruling as
   customer-story.module's attribution line: "use the next darker color value
   there (for small text), which is Teal/600" (BugHerd #112, Figma node 1-51).
   The stat card's value above is left on Teal/500 precisely because it is
   large text and does not owe the higher ratio.

   Bound to the primitive because the teal ramp has no semantic alias at 600 -
   the same reason customer-story and outcome-cards bind theirs. */

.article-numbered-list__item::before {
  color: var(--brand-teal-600);
  content: counter(article-numbered-list) ".";
  flex: 0 0 auto;
  font-size: var(--font-size-h6);
  font-weight: var(--font-weight-bold);
  line-height: var(--font-line-height-body);
}

/* Past nine items the numerals gain a digit, and a content-sized track makes
   every item from 10 down start its text ~9px right of items 1-9 - the list
   reads ragged down its left edge. The repeater takes up to 20, so give the
   numeral a two-digit track and right-align inside it, which is how a
   numbered list has always handled mixed digit counts.

   Scoped to lists that actually reach ten rather than applied to all of them:
   the comp's list has six items, and a flat two-digit track would push its
   text ~10px right of where the comp puts it for the sake of a case that list
   does not have. :has() is already the mechanism this file and
   objects/_containers-dnd.css rely on, so this needs no support the theme is
   not already assuming.

   1.5em, not a px figure, so it tracks the numeral's own --font-size-h6. */

.article-numbered-list:has(li:nth-child(10)) .article-numbered-list__item::before {
  min-width: 1.5em;
  text-align: right;
}

/* min-width: 0 is the whole reason this span exists rather than a bare text
   node - see the module's docstring. Without it a long unbroken word sets the
   flex item's floor at its own width and pushes the item past the column. */

.article-numbered-list__text {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: break-word;
}

/* --- Callout ----------------------------------------------------------------
   good-fit-group (1495:40924). The card itself is the shared list_callout
   primitive (templates/partials/macros/list-callout.html,
   css/components/_list-callout.css), composed by article-callout.module - the
   comp's card here IS Book a Demo's "GOOD FIT FOR" card, down to the copy.
   Nothing about the card treatment is restated.

   Three type values differ between the two instances, and they are corrected
   here rather than in the primitive, because changing the primitive would
   change Book a Demo, which is live and drawn from its own comp:

     eyebrow size    12px flat here (Body/XSmall, 1495:40925) against the
                     .text-eyebrow utility's literal 14px.
     eyebrow track   0.48px on 12px = 0.04em, against the utility's 0.06em.
     list size       --font-size-xs (14px at XL) against the primitive's
                     --font-size-sm (16px), and --color-text-primary against
                     its --color-text-secondary. Both are the comp's, and both
                     read as an article aside stepping down from the 20px body
                     copy around it rather than competing with it.

   Two comp values are deliberately NOT chased. The eyebrow-to-card gap is 14px
   in the comp and stays the primitive's --spacing-4 (16px); 14 is not on the
   spacing scale and the 2px is invisible. And the comp's bullets have no gap
   between them, because the whole list is one Figma text node - the primitive's
   8px stays, for the same reason .article-rich-text gives its own list items
   room. */

.article-callout:not(:has(.list-callout)) {
  display: none;
}

.article-callout .list-callout__eyebrow {
  font-size: var(--font-size-xxs);
  letter-spacing: 0.04em;
}

.article-callout .list-callout__list {
  color: var(--color-text-primary);
  font-size: var(--font-size-xs);
}

/* --- Pull quote -------------------------------------------------------------
   Customer Story (1495:40933): the article's widest single object - a
   bordered white card, 40px of horizontal and 64px of vertical padding, with
   Body/Body LG copy 30px above a semi-bold attribution.

   The 64/40 padding is the desktop figure and steps down to 40/24 below the
   MD breakpoint rather than being held flat: at a phone's 280px column, 40px
   a side would leave 200px of measure for 18px copy, and 64px above and below
   would put nearly half a viewport of empty card around three lines of text.
   This is the one article block whose padding is big enough for that to
   matter - the callout's 20px is the same at every width.

   Card colours are stated here rather than reached through the list_callout
   primitive: this shares that card's fill, border and radius but nothing else
   - no eyebrow, no list, and its own padding - so composing it would mean
   suppressing more of it than it contributes. */

.article-pull-quote {
  background-color: var(--color-background-default);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-xl);
  display: flex;
  flex-direction: column;
  gap: var(--spacing-8);
  padding: var(--spacing-10) var(--spacing-6);
}

/* Emitted with no quote set by the module, and hidden here - see
   article-image.module/module.html for the full reasoning, and note the same
   trap: HubL leaves whitespace text nodes behind, so :empty never matches. */

.article-pull-quote:not(:has(.article-pull-quote__quote)) {
  display: none;
}

/* The theme dresses a bare <blockquote> as a pulled-out aside - a 2px crimson
   rule down its left edge and 12px of padding off it (`body blockquote` in
   elements/_typography.css, recoloured again by theme-overrides.css from the
   secondary_color theme setting). That is the right default for a quotation
   dropped into flowing copy, and the wrong one here: this quote is already
   pulled out, by the card around it, and the comp draws no rule. Reset rather
   than left alone - measured on this template, the rule paints inside the card
   and indents the first line 14px past the attribution under it.

   A class beats `body blockquote` on specificity (0,1,0 against 0,0,2), so
   neither the element rule nor the theme setting that recolours it needs
   !important to overrule. */

.article-pull-quote__blockquote {
  border-left: 0;
  margin: 0;
  padding-left: 0;
}

/* Body/Body LG - 24px regular at XL, stepping down with the ramp. Regular
   weight, not the semi-bold a pull quote often carries: the comp's is
   Open Sans Regular, and the size is what separates it from the 20px copy
   around it. Matches customer-story.module's own quote treatment. */

.article-pull-quote__quote {
  color: var(--color-text-primary);
  font-size: var(--font-size-body-lg);
  line-height: var(--font-line-height-body);
  margin: 0;
}

/* Label/Label SM at --color-text-secondary, which is 7.06:1 on the card's
   white - this line is 14px semi-bold, so it owes 4.5:1 and clears it. The
   comp's own colour, and no substitution is needed here, unlike the numbered
   list's teal numerals above. */

.article-pull-quote__attribution {
  color: var(--color-text-secondary);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-semi-bold);
  line-height: var(--font-line-height-label);
}

@media (min-width: 768px) {
  .article-pull-quote {
    padding: var(--spacing-16) var(--spacing-10);
  }
}

/* --- After-content CTA ------------------------------------------------------
   Issue #121. The one block here with no comp to build from: the WordPress
   theme draws it (nylon/assets/scss/pages/_blog-single.scss, .post-content-cta
   - a tinted panel at 10px radius, 32/24 padding, an outlined secondary
   button) and both resource comps omit it entirely.

   So it is composed from what this page already has rather than invented. The
   card is the same recipe as the share rail 288px to its right - white on the
   cream ground, 1px --color-border-strong, --radius-lg, --spacing-8 padding
   (css/templates/resources-ungated.css, ".resources-ungated__share") - which
   also lands within two pixels of WordPress's own 10px radius and 32px
   padding. Two cards on one page reading as the same object is the point; a
   third treatment invented here would not be.

   No max-width. Unlike .article-image's 603px, this is a panel rather than a
   measure - it spans the column the way the hairlines above and below the
   article do.

   Inherits .article-block's 40px bottom margin, so it sits in the article's
   rhythm rather than owning a gap of its own. The migrated CTA is always the
   last block in the body area, where resources-ungated.css's own
   :last-child rule zeroes that margin against the closing hairline. */

.article-cta {
  background-color: var(--color-background-default);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-lg);
  padding: var(--spacing-8);
}

/* An unconfigured card costs no space at all. The module emits its wrapper
   unconditionally so the .article-block entry in objects/_containers-dnd.css's
   :has() list still strips the theme's section padding (same reason
   article-image emits its <figure>), and this takes the empty result back out
   of the flow.

   Tested on the three children rather than with :empty, for the reason the
   .article-image rule above records: HubL leaves the newlines around its own
   conditionals behind as text nodes, so :empty silently never matches. */

.article-cta:not(:has(.article-cta__title, .article-cta__body, .article-cta__button)) {
  display: none;
}

/* Card label, not an article heading. The tag is an H2 so the block lands in
   the document outline, but --font-size-h4 is the scale step that reads as a
   panel's title beside 20px body copy - the article's own H2 is 40px and
   would make the CTA louder than the section it follows.

   The module deliberately does not emit a matching .h2 class (see its
   docstring), so this class alone (0,1,0) outranks theme-overrides.css
   restating a bare h2 (0,0,1) and no `body` prefix is needed here - unlike
   .article-heading above, which does carry .h2 and therefore has to beat
   `body .h2`. */

.article-cta__title {
  color: var(--color-text-primary);
  font-family: var(--font-family-brand);
  font-size: var(--font-size-h4);
  font-weight: var(--font-weight-bold);
  letter-spacing: normal;
  line-height: var(--font-line-height-label);
  margin: 0 0 var(--spacing-4);
}

/* 24px between the copy and the button, against WordPress's own 3rem. The
   card here is tighter than WordPress's (30px padding against its 42/50), and
   48px inside it would leave the button floating away from the copy it
   belongs to. */

.article-cta__body {
  margin-bottom: var(--spacing-6);
}

.article-cta__body > :last-child {
  margin-bottom: 0;
}

/* A card with a title and a button but no copy still needs 24 between them,
   and .article-cta__body is absent in that case rather than empty.

   8, not 24: the title already carries 16 below it, and the button is
   inline-flex (elements/_buttons.css), so the two margins do not collapse -
   they add. A second full --spacing-6 here would make this gap 40 while the
   copy-to-button case beside it stays 24. */

.article-cta__title + .article-cta__button {
  margin-top: var(--spacing-2);
}

/* One of the three author-bio uses carries a headshot inside the copy. It is
   an arbitrary WordPress upload, so it is capped rather than sized, the same
   call .article-image__img makes. */

.article-cta__body img {
  border-radius: var(--radius-md);
  display: block;
  height: auto;
  margin-bottom: var(--spacing-4);
  max-width: 100%;
  width: auto;
}
/* Menu and simple menu */

.hs-menu-wrapper ul {
  display: flex;
  flex-wrap: wrap;
  list-style: none;
  margin: 0;
  padding-left: 0;
}

/* Horizontal menu */

.hs-menu-wrapper.hs-menu-flow-horizontal .hs-menu-children-wrapper {
  flex-direction: column;
}

@media (max-width: 767px) {
  .hs-menu-wrapper.hs-menu-flow-horizontal ul {
    flex-direction: column;
  }
}

/* Vertical menu */

.hs-menu-wrapper.hs-menu-flow-vertical ul {
  flex-direction: column;
}

/* Flyouts */

.hs-menu-wrapper.hs-menu-flow-vertical.flyouts ul {
  display: inline-flex;
}

@media (max-width: 767px) {
  .hs-menu-wrapper.hs-menu-flow-vertical ul {
    display: flex;
  }
}

.hs-menu-wrapper.flyouts .hs-item-has-children {
  position: relative;
}

.hs-menu-wrapper.flyouts .hs-menu-children-wrapper {
  left: -9999px;
  opacity: 0;
  position: absolute;
}

.hs-menu-wrapper.flyouts .hs-menu-children-wrapper a {
  display: block;
  white-space: nowrap;
}

.hs-menu-wrapper.hs-menu-flow-horizontal.flyouts .hs-item-has-children:hover > .hs-menu-children-wrapper {
  left: 0;
  opacity: 1;
  top: 100%;
}

.hs-menu-wrapper.hs-menu-flow-vertical.flyouts .hs-item-has-children:hover > .hs-menu-children-wrapper {
  left: 100%;
  opacity: 1;
  top: 0;
}

@media (max-width: 767px) {
  .hs-menu-wrapper.flyouts .hs-menu-children-wrapper,
  .hs-menu-wrapper.hs-menu-flow-horizontal.flyouts .hs-item-has-children:hover > .hs-menu-children-wrapper,
  .hs-menu-wrapper.hs-menu-flow-vertical.flyouts .hs-item-has-children:hover > .hs-menu-children-wrapper {
    left: 0;
    opacity: 1;
    position: relative;
    top: auto;
  }
}

/* CTA, logo, and rich text images */

.hs_cos_wrapper_type_cta img,
.hs_cos_wrapper_type_logo img,
.hs_cos_wrapper_type_rich_text img {
  height: auto;
  max-width: 100%;
}

/* Utilities
Helper classes with ability to override anything that comes before it
*/

/* For content that needs to be visually hidden but stay visible for screenreaders */

.show-for-sr {
  border: 0 !important;
  clip: rect(0, 0, 0, 0) !important;
  height: 1px !important;
  overflow: hidden !important;
  padding: 0 !important;
  position: absolute !important;
  white-space: nowrap !important;
  width: 1px !important;
}

@media (max-width: 767px) {
  .show-for-sr--mobile {
    border: 0 !important;
    clip: rect(0, 0, 0, 0) !important;
    height: 1px !important;
    overflow: hidden !important;
    padding: 0 !important;
    position: absolute !important;
    white-space: nowrap !important;
    width: 1px !important;
  }
}