@layer dx-component-base {
/* ==========================================================================
   Core Content Block
   Shared typography (eyebrow, heading, text) used by all components that
   include the core partial. The button element itself lives in button.css
   — `.dx-core__buttons` here is just the row that holds them inside core.

   Colors use semantic tokens (--dx-heading, --dx-text, --dx-primary, etc.)
   set by section mode classes (.dx-mode-1, .dx-mode-2, …). Each mode
   class is data-driven from the site's configured mode list; the role
   tokens are mode-aware and resolve through the Dynamic colour layer.
   When no mode is set, falls back to inherited color values (root vars
   point at mode 1's resolution).
   ========================================================================== */

.dx-core__eyebrow {
    font-size: var(--dx-eyebrow-size);
    font-weight: var(--dx-eyebrow-weight);
    /* Empty token = `inherit` via the var() fallback, which picks up
       the body's `--dx-global-line-height` through normal inheritance.
       Non-empty token overrides site-wide. Same pattern as Rich Text's
       `--dx-text-line-height`. */
    line-height: var(--dx-eyebrow-line-height, inherit);
    text-transform: uppercase;
    letter-spacing: var(--dx-eyebrow-letter-spacing);
    /* Vertical rhythm between Core children is driven by the parent
       flex `gap` (set on .dx-core / .dx-core__content below) so a
       single per-instance `gap` field controls every child-to-child
       spacing uniformly. Older versions used per-child margin-bottom
       (1rem / 1rem / 2rem) — replaced to make the rhythm controllable. */
    /* Color comes from the mode-aware --dx-eyebrow token, emitted
       per `.dx-mode-N` rule by dx_build_dynamic_emission_css(). The
       Eyebrow panel exposes one colour-ref per configured mode; the
       emission writes each mode's chosen colour into --dx-eyebrow
       scoped to the matching mode class. */
    color: hsl(var(--dx-eyebrow));
}

.dx-core__heading {
    /* Typography (size/weight/line-height/letter-spacing) comes from the
       tag-based rules in the dx-tokens inline stylesheet — so h1 gets
       `var(--dx-size-h1)`, h2 gets `var(--dx-size-h2)`, etc. (the per-tag
       rules in dx_output_token_styles read straight from the type-size
       scale tokens). Don't hardcode font-size here; it would override
       every tag at equal-or-higher specificity than the bare `h1`/`h2`
       rules. */
    margin: 0; /* parent flex `gap` carries the vertical rhythm */
    /* Colour comes from the mode-aware semantic token. --dx-heading is
       set per-mode by the .dx-mode-* rules in dx-defaults.css, with an
       optional override emitted by tokens.php from the
       heading_color_light / heading_color_dark color-refs picked on the
       Heading panel. */
    color: hsl(var(--dx-heading));
}


.dx-core__text {
    font-size: var(--dx-size-text-m);
    /* --dx-text-weight and --dx-text-line-height are conditionally emitted
       by tokens.php — only present when Rich Text → Weight / Line Height
       have been explicitly set. When empty, the var is undefined and
       `inherit` kicks in, picking up the body's global value. Same pattern
       as --dx-link-color etc. */
    font-weight: var(--dx-text-weight, inherit);
    line-height: var(--dx-text-line-height, inherit);
    margin: 0; /* parent flex `gap` carries the vertical rhythm */
    max-width: 640px;
    /* Colour comes from the mode-aware semantic token, optionally
       overridden by the text_color_light / text_color_dark color-refs
       on the Rich Text panel (see Heading rule above for the pattern). */
    color: hsl(var(--dx-text));
}

.dx-core__text a {
    /* --dx-link-color resolves to an HSL TRIPLET — it role-aliases to
       --dx-color-dynamic-link (mode-aware, set in the Variable Manager),
       the same triplet convention as --dx-text / --dx-eyebrow. So it MUST
       be wrapped in hsl() exactly like .dx-core__text above; consuming it
       bare makes `color` an invalid value and the link silently inherits
       the body text colour. Fallback (var unset) is --dx-primary, also a
       triplet. */
    color: hsl(var(--dx-link-color, var(--dx-primary)));
    /* --dx-link-decoration is set from Rich Text → Link Decoration.
       Sanitizer guarantees `underline` | `none`. */
    text-decoration: var(--dx-link-decoration);
    text-underline-offset: 0.15em;
    transition: color var(--dx-effect-transition-duration) ease;
}

.dx-core__text a:hover {
    /* --dx-link-hover-color is an HSL TRIPLET (role-aliases to
       --dx-color-dynamic-link-hover) — wrapped in hsl() like the base
       link rule above. Fallback (var unset) is --dx-color-primary-dark,
       also a triplet; matches the solid-button convention and stays
       readable whether primary is saturated or pastel. */
    color: hsl(var(--dx-link-hover-color, var(--dx-color-primary-dark)));
}

/* Lists inside rich text. The global reset (`* { padding: 0 }`) strips the
   browser's default `padding-inline-start: 40px` on ul/ol, which leaves bullet
   markers flush against the container edge (and clipped if `overflow: hidden`).
   Restore a sensible inline indent so markers sit inside the text column. */
.dx-core__text ul,
.dx-core__text ol {
    padding-inline-start: 1.5em;
}

.dx-core__text--xsmall {
    font-size: var(--dx-size-text-xs);
}

.dx-core__text--small {
    font-size: var(--dx-size-text-s);
}

.dx-core__text--large {
    font-size: var(--dx-size-text-l);
    max-width: 720px;
}

.dx-core__text--xlarge {
    font-size: var(--dx-size-text-xl);
    max-width: 760px;
}

/* ── Code slot — passthrough HTML / shortcode wrapper ───────────────────
   No declarations — the wrapper exists purely as a structural hook
   in the Core's flex column so parent `gap` drives the spacing above
   and below (CLAUDE.md "Spacing — use gap, not margin"). Whatever
   the user pastes inside (iframe, form, custom markup) styles
   itself via its own attributes or the section's Custom CSS:
       %root% .dx-core__code iframe { ... }
   Adding any rule here would impose chrome on user-pasted content,
   which is exactly the thing this component exists to NOT do.
   ─────────────────────────────────────────────────────────────────── */
.dx-core__code {
    /* Intentionally empty. Class is the documented stable hook. */
}

.dx-core__buttons {
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
    align-items: center;
}

/* ── Bottom-anchored buttons (`buttons_anchor: 'bottom'`) ──────────────
   When enabled, the buttons row expands to consume any leftover
   vertical space in the Core and anchors its button children to the
   bottom edge of that stretched cell. The visual win is in card grids
   where sibling Cores have unequal content heights — without this,
   buttons float at arbitrary positions per card; with it, they share
   a baseline at the row's bottom.

   ── Why BOTH `flex-grow: 1` AND `align-items: end` ──
     - `flex-grow: 1` stretches `.dx-core__buttons` vertically to
       consume leftover space in its parent flex-column.
     - `align-items: end` then anchors the button children to the
       BOTTOM of the now-stretched cell (cross-axis end for a flex
       row). Without it, the children sit at the row's natural
       baseline — which is the TOP of the stretched cell, since the
       cell expanded downward.
   The two compose to "row fills column, contents stick to its
   bottom edge." Either alone is insufficient.

   ── Where the cell's parent grants the flex-column context ──
   `.dx-core__buttons` is always a direct child of a flex-column:
     - No media → `.dx-core` (flex-column).
     - Media top / bottom → `.dx-core__content` (flex-column).
     - Media left / right → `.dx-core__content` (flex-column, sits
       inside `.dx-core`'s 2-col grid).
   In every case `flex-grow: 1` works against a flex-column parent,
   so this single rule covers every Core composition.

   ── No-op behaviour for non-grid contexts ──
   When the Core's height equals its natural content height (single-
   Core sections, or sibling Cores that happen to have equal natural
   heights), there's no leftover vertical space for `flex-grow: 1` to
   consume. The rule applies but has no visible effect. Safe to
   enable at the Core level even when the Core sometimes renders in
   non-grid contexts; visual impact appears only when there's space
   to fill. */
.dx-core--buttons-bottom .dx-core__buttons {
    flex-grow: 1;
    align-items: end;
    /* `align-content: end` is the multi-line analogue of `align-items:
       end`. It only takes effect when the buttons row has WRAPPED
       (more than one line) — i.e. when combined with the buttons-full
       modifier (which gives each button `flex-basis: 100%`, forcing
       each to its own line). Without this, the default
       `align-content: normal` (stretch-like) distributes the wrapped
       lines evenly across the stretched cross-axis, leaving the
       buttons visually floating in the middle of the row instead of
       anchored to the bottom. `align-items: end` alone governs items
       within each line, not the lines as a block.
       For single-line (auto-width) buttons, this declaration has no
       effect — `align-content` only applies to multi-line flex
       containers. Safe to set unconditionally. */
    align-content: end;
}

/* ── Buttons-bottom × layout composition ──────────────────────────────
   The `flex-grow: 1` on `.dx-core__buttons` only consumes leftover
   vertical space when its DIRECT PARENT already fills the `.dx-core`
   height. Since `.dx-core__content` is always present (per the
   refactor that made the wrapper unconditional), one rule covers
   every flex-column layout — text-only, media-top, media-bottom.

   ── Flex-column layouts (text-only / media-top / media-bottom) ──
   `.dx-core` is `flex-direction: column`. `.dx-core__content` is a
   child that doesn't `flex-grow` by default; when the grid stretches
   `.dx-core` to match the tallest sibling card, the extra space sits
   BELOW `.dx-core__content`, and the buttons inside can't reach the
   bottom of the card. Giving `.dx-core__content` `flex-grow: 1`
   makes it fill the available vertical space; the buttons inside
   (also `flex-grow: 1` via the rule above) push to its bottom edge.

   ── Side-by-side layouts (media-left / media-right) ──
   `.dx-core` is a 2-col grid; `.dx-core__content` lives in one
   column and grid auto-stretches it to the row's height. The
   default `justify-content: center` on the content column (set
   further down in this file) centers ALL inner children vertically
   — which fights against the buttons trying to anchor to the
   bottom. Override to `flex-start` so the children stack at the top
   and `.dx-core__buttons` is free to grow into the bottom space.
   `flex-grow: 1` on `.dx-core__content` has no effect in the grid
   case (grid items don't honour flex-grow); the rule applying to
   both paths is harmless. */
.dx-core--buttons-bottom .dx-core__content {
    flex-grow: 1;
}
.dx-core--buttons-bottom.dx-core--media-left .dx-core__content,
.dx-core--buttons-bottom.dx-core--media-right .dx-core__content {
    justify-content: flex-start;
}

/* ── Full-width buttons (`buttons_width: 'full'`) ──────────────────────
   Two declarations, both load-bearing:

   1. `.dx-core__buttons { width: 100% }` — stretches the BUTTONS
      ROW to the full width of its column. Required because
      `.dx-core--align-left` (the default) sets
      `align-items: flex-start` on `.dx-core`, which means flex
      children do NOT stretch across the cross-axis (horizontal in
      a column). Without this rule, the buttons row is content-
      natural width — narrower than the column. Then point 2's
      "each button = 100% of row" still leaves the buttons
      visually narrower than the heading / text above them, which
      is the bug the rule chases.

   2. `.dx-core__buttons > * { flex-basis: 100% }` — stretches each
      BUTTON to the full width of the row. With the row's existing
      `flex-wrap: wrap`, each button at flex-basis 100% wraps to
      its own line. Buttons keep their own `justify-content: center`
      from button.css, so text + arrow stays centred inside each
      stretched button — the conventional "full-width CTA" look.

   ── Why both, separately ──
   `width: 100%` on the parent and `flex-basis: 100%` on the
   children solve different problems on different axes. The parent
   stretch fixes "the box is too narrow"; the child stretch fixes
   "the children don't fill the box." Either alone leaves a visible
   gap somewhere. */
.dx-core--buttons-full .dx-core__buttons {
    width: 100%;
}
.dx-core--buttons-full .dx-core__buttons > * {
    flex-basis: 100%;
}

/* ──────────────────────────────────────────────────────────────────────
   Alignment modifiers — applied by core.php from the resolved `align`
   value (instance → Core's saved setting → 'left'). Same class drives
   both per-instance overrides and Core's site-wide default; no separate
   CSS emission is needed.

   Strategy: `align-items` lives on `.dx-core__content` (the flex-column
   holding eyebrow / heading / text / buttons); `text-align` lives on
   `.dx-core` so it inherits through to every text node regardless of
   nesting. Same outcome as the old "flex-column on .dx-core" approach
   but with the alignment hooks on the actual content stack — which
   makes Custom CSS like
       `%root% .dx-core:nth-child(2) .dx-core__content { align-items: center }`
   work uniformly in every Core composition (text-only, media+content,
   side-by-side). Pre-refactor, the same author had to write different
   selectors for text-only vs media-bearing Cores because the slot stack
   lived in different places.

   text-align matters even with align-items because a long heading that
   wraps to multiple lines needs its lines aligned within the heading
   box (which becomes content-width but still has a width, e.g. the
   longest line). Without text-align the shorter wrapped lines would
   hug the wrong edge.

   `.dx-core__text` still carries `max-width: 640px` for readability;
   under align-items: flex-end, that box hits 640px max and is pushed
   to the right edge by the column flex — no margin-auto trick needed.

   `.dx-core__buttons` also needs its OWN `justify-content` per align
   value — an earlier refactor dropped this on the assumption that the
   row shrinks to its content width and the parent's `align-items`
   places the whole block. That's only true on a single line. Once the
   row wraps (narrow viewports, long button labels), each wrapped line
   widens to the longest button and the inner buttons keep their
   default `justify-content: flex-start` — so center-aligned cores
   end up with wrapped buttons stacked along the left edge. Setting
   the inner justify-content matches the outer align so wrap stays
   visually consistent with the rest of the core.
   ────────────────────────────────────────────────────────────────────── */

/* ──────────────────────────────────────────────────────────────────────
   Outer axis container — arranges `.dx-core__media` (when present) and
   `.dx-core__content` (when present) along the media-vs-content axis.
   The INNER slot stack rhythm (eyebrow / heading / text / buttons)
   lives one level down on `.dx-core__content`, which is always present
   when the Core has any content slots (per core.php's `$has_any_content`
   gate). Single source of truth for the stack gap is the
   `--dx-core-gap` custom property, consumed by `.dx-core__content`
   below.

   `flex-direction: column` is the default axis; the media-position
   classes override it (column-reverse / row / row-reverse for top /
   left / right). The media-left/right classes further down switch
   `.dx-core` to `display: grid` for the side-by-side layouts.
   ────────────────────────────────────────────────────────────────────── */

/* ── Core Link overlay — whole-card clickable pattern ─────────────────
   Heydon Pickering's "block link" / "card link" pattern. The
   `.dx-core__link` anchor sits as the first child of `.dx-core` with
   `position: absolute; inset: 0;` covering the whole card. Inner
   interactive descendants (buttons, inline anchors inside rich text)
   sit on a higher z-index so clicks on THEM hit the descendant, not
   the overlay — buttons keep working as their own actions while the
   surrounding card surface follows the Core Link.

   Why this pattern (not wrapping the <li> in <a>):
   - Wrapping would put <a> inside <a> (buttons, rich-text links) —
     invalid HTML per the spec, breaks assistive tech.
   - Overlay keeps the markup valid AND keeps both whole-card link +
     inner buttons clickable.

   Accessibility:
   - `aria-labelledby` on `.dx-core__link` points at the heading so
     screen readers announce "<heading text>, link". Fallback aria-
     label "Open link" when no heading is present.
   - `:focus-visible` on the overlay surfaces a ring around the
     whole card via `:has()` on the parent — keyboard users see the
     card highlighted as the focus target. Graceful fallback: older
     browsers without `:has` still get an outline on the (invisible)
     overlay element itself. */
.dx-core--has-link {
    position: relative;
    cursor: pointer;
    /* Hover affordances on clickable Cores are user-opt-in only —
       Elegance ships with no hover effect by default. `cursor: pointer`
       is the universal "this is clickable" signal; that's enough.
       Authors who want a hover lift, shadow, scale, or border-color
       swap turn it on explicitly:
         • Border-color swap: the `border_hover_enabled` toggle on
           Container Core (gated on `border = on`). Renders via the
           `.dx-core--border-hover:hover` rule (see border-color block
           further down).
         • Anything else: Custom CSS on the container or per-Core,
           scoped via `%root%:hover { … }`.
       No translate / box-shadow / brightness defaults. */
}

.dx-core__link {
    position: absolute;
    inset: 0;
    z-index: 1;
    /* Border-radius inherits so the overlay's hit area matches a
       rounded card's silhouette. */
    border-radius: inherit;
    /* No visible content — the anchor is purely a hit target. The
       accessible name comes from aria-labelledby / aria-label. */
    text-decoration: none;
    color: transparent;
}

/* Interactive descendants ride above the overlay so they remain
   independently clickable. The selectors target the canonical
   interactive elements inside a Core (buttons + their wrapper, any
   inline anchor inside rich text, the menu slot, the code slot).
   Each gets `position: relative` to participate in z-index stacking. */
.dx-core--has-link .dx-core__buttons,
.dx-core--has-link .dx-core__text a,
.dx-core--has-link .dx-core__menu,
.dx-core--has-link .dx-core__code {
    position: relative;
    z-index: 2;
}

/* Keyboard focus — when the overlay <a> receives focus, paint a
   ring around the whole card (via :has on the modern path) or
   around the anchor itself (legacy fallback). The card is the
   visible target; outlining the invisible overlay alone would
   leave the user without a clear focus cue. */
.dx-core--has-link:has(.dx-core__link:focus-visible) {
    outline: 2px solid hsl(var(--dx-primary));
    outline-offset: 2px;
}
.dx-core__link:focus-visible {
    /* Fallback for browsers without :has — at least show SOMETHING. */
    outline: 2px solid hsl(var(--dx-primary));
    outline-offset: 0;
}

.dx-core {
    display: flex;
    flex-direction: column;

    /* ── Variable-consumer pattern (paired with core.php emission) ────
       Each property below is set via a `--dx-core-*` variable that
       core.php writes into the inline `style="…"` attribute when the
       per-instance cascade resolves a non-empty value. When the value
       is empty / no-fill / no-border, the variable is unset and the
       `var()` falls back to the no-op default (0 / transparent).

       The reason these live in the stylesheet (and not as raw inline
       `padding: …` / `background-color: …` declarations the way they
       used to) is to give user Custom CSS a fair shot at the cascade.
       A rule like `%root% .dx-core { background-color: red }` from
       the Section's Custom CSS field has ID-level specificity, and
       with these properties in the stylesheet it beats this rule on
       normal cascade terms — no `!important` required. With the old
       inline emission the user's rule lost to the inline declaration
       no matter what selector specificity they used.

       The variable's VALUE is still set inline, so the cascade-
       resolved per-instance value still wins by default — only the
       PROPERTY ASSIGNMENT has moved from inline to stylesheet. See
       core.php's "Variable-emission pattern" comment block for the
       matching half of this contract. */
    /* Per-axis padding — split from the legacy single `--dx-core-
       padding` for the spacing-pair widget (Stage 2). Each axis
       reads its own variable; either or both can be unset, and the
       fallback is 0. The two long-hands compose into the same
       `padding` shorthand outcome but let the user pick a different
       step on each axis (e.g. 0 block + L inline for a horizontal-
       only inset card). */
    padding-block:    var(--dx-core-padding-block,  0);
    padding-inline:   var(--dx-core-padding-inline, 0);
    background-color: var(--dx-core-background, transparent);
    border:           var(--dx-core-border,     0);
    border-radius:    var(--dx-core-radius,     0);

    /* Border-color hover swap animates smoothly via this transition.
       Only `border-color` is transitioned (not the shorthand) — width
       and style stay fixed on hover, so animating them would be
       wasted work. Mirrors the `--dx-effect-transition-duration` the
       buttons / link / menu hover use, so every hover affordance on
       the site shares one timing curve. The :hover override is
       below; it swaps just the color longhand and the shorthand-set
       width/style survive. */
    transition: border-color var(--dx-effect-transition-duration) ease;
}

/* Hover border-color — sourced from the mode-aware Dynamic alias so
   per-mode hover colours come for free. By default
   `--dx-color-dynamic-border-hover` resolves to the same value as
   `--dx-color-dynamic-border` per mode (see `$dx_alias_defaults` in
   inc/tokens.php). Users opt into a distinct hover by repointing the
   alias in the Variable Manager's Dynamic tab — same shape as
   `dynamic-menu` → `dynamic-menu-hover` for nav links.

   Gated on the per-Core `.dx-core--border-hover` modifier so the swap
   activates only on Cores whose `border_hover_enabled` toggle is on
   (see partials/core.php). Without the gate, the moment a user picks
   a non-matching hover colour in the VM every bordered Core on the
   site would start hover-swapping; the per-Core opt-in lets one card
   in a grid have the effect while its siblings stay static.

   No `:has(.dx-core__link)` gate — decorative hover effects on
   non-clickable cards are a legitimate design choice (subtle tint
   for visual rhythm in a card grid, etc.). The transition above is
   shared so the swap animates either way. */
.dx-core--border-hover:hover {
    border-color: hsl(var(--dx-color-dynamic-border-hover));
}

/* ──────────────────────────────────────────────────────────────────────
   Inner slot stack — eyebrow / heading / text / buttons.

   Always present when the Core has any content slot (symmetric with
   `.dx-core__media`, which is always present when media is set). One
   reliable hook for "the content column" regardless of media state,
   so Custom CSS like `%root% .dx-core:nth-child(2) .dx-core__content
   { align-items: center }` works uniformly across text-only Cores,
   media+content Cores, and side-by-side layouts.

   `--dx-core-gap` is set inline on `.dx-core` by core.php (per-
   instance `gap` field on the Core); the custom property inherits
   into `.dx-core__content` so the same dropdown controls the slot-
   stack rhythm in every Core composition.
   ────────────────────────────────────────────────────────────────────── */
.dx-core__content {
    display: flex;
    flex-direction: column;
    gap: var(--dx-core-gap, var(--dx-space-m));
    /* Inner padding on the content column — per-axis after the Stage
       3 spacing-pair split. Each axis reads its own variable; either
       or both can be unset, fallback is 0. Driven by the
       `content_padding_block` / `content_padding_inline` fields on
       Container Core settings; emitted by partials/core.php as
       `--dx-core-content-padding-block` / `--dx-core-content-
       padding-inline` inline styles. Variable-emission pattern lets
       Custom CSS authors override via `%root% .dx-core__content
       { padding: …; }` (id-level vs inline-style specificity — see
       partials/core.php's Variable-emission docblock for the
       contract). */
    padding-block:  var(--dx-core-content-padding-block,  0);
    padding-inline: var(--dx-core-content-padding-inline, 0);
}

/* Alignment modifiers — `text-align` stays on `.dx-core` (inherits down
   to every text node inside `.dx-core__content`); `align-items` moves
   to `.dx-core__content` (the actual flex container holding the slots).
   See the long comment block above the alignment section for the
   strategy. The `.dx-core__buttons` justify-content rules stay as-is —
   buttons is a descendant in both text-only and media+content cases. */

.dx-core--align-left {
    text-align: left;
}
.dx-core--align-left .dx-core__content {
    align-items: flex-start;
}
.dx-core--align-left .dx-core__buttons {
    justify-content: flex-start;
}

.dx-core--align-center {
    text-align: center;
}
.dx-core--align-center .dx-core__content {
    align-items: center;
}
.dx-core--align-center .dx-core__buttons {
    justify-content: center;
}

.dx-core--align-right {
    text-align: right;
}
.dx-core--align-right .dx-core__content {
    align-items: flex-end;
}
.dx-core--align-right .dx-core__buttons {
    justify-content: flex-end;
}

/* Vertical alignment within the Core's grid cell.

   Two layouts need this to work, and they require different consumer
   rules — same source, one variable bridges them:

     - Text-only Cores (and stacked media: media-top / media-bottom)
       have `.dx-core` itself as `display: flex; flex-direction: column`.
       `justify-content` on `.dx-core` directly positions the inner
       stack along the main (vertical) axis.

     - Side-by-side media Cores (media-left / media-right) flip
       `.dx-core` to `display: grid` (see the .dx-core--has-media.
       --media-left/right rule below). `justify-content` on a grid
       container controls the inline (horizontal) axis, NOT vertical
       — so the rule above is a no-op in this layout. The vertical
       stack the user actually wants to align lives one level down on
       `.dx-core__content` (the flex-column that holds eyebrow /
       heading / text / buttons inside the content cell).

   To express both with one declaration: each `.dx-core--vertical-X`
   class sets the `--dx-core-vertical-justify` custom property AND
   sets `justify-content` on `.dx-core` directly. Custom properties
   inherit, so `.dx-core__content` reads the same value via
   `justify-content: var(--dx-core-vertical-justify, …)`. One source
   of truth, two consumer rules — flex-column case and side-by-side-
   media case both honour the same pick.

   Typical use case: a header row where Core 1 holds a tall logo
   image, Core 2 holds a single line of menu items, Core 3 holds a
   button. Without these, the menu items and button sit at the TOP
   of their cells (aligned with the logo's top edge, looking
   off-center). With `--vertical-center`, all three cores' content
   share the same vertical centerline. Same shape works for a
   single-Core section with side-by-side image — "Bottom" pushes
   the text stack to the bottom of the image's row height.

   Composes orthogonally with `--align-{left,center,right}`. */
.dx-core--vertical-top {
    --dx-core-vertical-justify: flex-start;
    justify-content: flex-start;
}
.dx-core--vertical-center {
    --dx-core-vertical-justify: center;
    justify-content: center;
}
.dx-core--vertical-bottom {
    --dx-core-vertical-justify: flex-end;
    justify-content: flex-end;
}

/* Drop the text block's max-width when the core is left/right aligned.

   Centered prose keeps `.dx-core__text { max-width: 640px }` for
   readability — long centered lines genuinely fatigue the eye because
   the eye has to track both edges. Left/right aligned prose tracks a
   single edge, so wider lines stay readable, and the visual win of
   matching the text block's width to the heading (so they share both
   left and right edges, not just the anchored side) outweighs the
   per-line length concern.

   Without this, the heading box is content-width (~800px for a long
   line) while the text box stops at 640px — they share the right edge
   under flex-end but the text sits visibly inset from the heading's
   left edge, which reads as misalignment rather than intentional
   right-anchored stacking. */
.dx-core--align-left .dx-core__text,
.dx-core--align-right .dx-core__text {
    max-width: none;
}

/* ── Media slot ─────────────────────────────────────────────────────────────

   `.dx-core__media` is rendered iff media (image or video) is set —
   symmetric with `.dx-core__content` (rendered iff any content slot is
   set). The four media-position variants drive flex-direction on
   `.dx-core` to lay the two slot-wrappers out:

     - media-top:    column,         media first
     - media-bottom: column-reverse, media last visually
     - media-left:   row,            media first (overridden to `grid`
                                       further down for the side-by-side
                                       2-col layout)
     - media-right:  row-reverse,    media last visually (same grid
                                       override applies)

   Side-by-side variants (left/right) are intentionally not yet
   container-query responsive — narrow-width collapse to stacked is a
   Phase 2 (Repeater Grid stabilization) concern. Until then, side-by-side
   stays side-by-side at all widths.

   Text-only Core has no `.dx-core__media`, just `.dx-core__content`.
   Media-only Core has no `.dx-core__content`, just `.dx-core__media`
   (and the `.dx-core--media-only` modifier collapses the would-be
   2-col grid back to single-column so the media spans full width).
   ========================================================================== */

.dx-core--has-media {
    display: flex;
    align-items: stretch;
    /* OUTER gap — between the media slot and the content stack. The
       per-instance `media_gap` field writes `--dx-core-media-gap` via
       inline style on .dx-core; this rule consumes it with a
       space-l fallback so the default behaviour is unchanged when no
       value is set. The INNER content rhythm (between
       eyebrow / heading / text / buttons) is a separate gap, applied
       on .dx-core / .dx-core__content via `--dx-core-gap`. */
    gap: var(--dx-core-media-gap, var(--dx-space-l));
}

/* Media-position rules — co-required with `.dx-core--has-media` so
   they only fire when media is actually present. Without the
   `--has-media` qualifier, the live-preview class-swap directive
   (which unconditionally writes `dx-core--media-{value}` to `.dx-core`
   whenever the Media Position dropdown changes) would apply these
   rules even when the Media slot is toggled OFF — and `dx-core--media-
   left/right` switches `.dx-core` to a 2-col grid that breaks the
   text-only layout, dropping eyebrow / heading / text / buttons into
   side-by-side cells. Gating on `--has-media` keeps the directive
   harmless when media is off; with media on, both classes are emitted
   together by core.php so the rule fires normally. */
.dx-core--has-media.dx-core--media-top    { flex-direction: column; }
.dx-core--has-media.dx-core--media-bottom { flex-direction: column-reverse; }
.dx-core--has-media.dx-core--media-left   { flex-direction: row; }
.dx-core--has-media.dx-core--media-right  { flex-direction: row-reverse; }

.dx-core__media {
    flex-shrink: 0;
    /* Media width cap — `--dx-media-width` is set inline on .dx-core by
       the Core's `media_width` field (a named px preset or Custom).
       Default (Full) emits no var, so this falls back to 100% = current
       behaviour. A capped slot shrinks and the inline SVG / image
       (width: 100%) fills it; horizontal placement follows the Core's
       Align Content (align-items), so a small icon lands top-left or
       centered to match the card. */
    max-width: var(--dx-media-width, 100%);
}

.dx-core__media .dx-image,
.dx-core__media .dx-core__video {
    width: 100%;
    height: auto;
    display: block;
}

/* ──────────────────────────────────────────────────────────────────────
   Media aspect-ratio override — uniform card grids.

   When the user picks a ratio on the Container Core's `media_aspect_
   ratio` field, partials/core.php emits a `.dx-core--media-aspect-{key}`
   modifier class. The rules below set `aspect-ratio` on the media
   wrapper + force the image to fill via `object-fit: cover` (cropping
   to the shape rather than letter-boxing).

   Five named ratios cover the common cases:
     square    → 1 / 1   (e.g. profile cards)
     landscape → 4 / 3   (classic photo)
     wide      → 16 / 9  (modern hero / card)
     portrait  → 4 / 5   (Instagram-style)

   The image itself is constrained to the wrapper via width:100% +
   height:100% (the height:auto from the base rule above is overridden
   by height:100% here when the modifier is active). object-fit: cover
   handles cropping; the image scales up or down to fully cover the
   aspect-bounded slot.

   When the modifier class isn't emitted (default Auto), none of this
   applies — image renders at its natural size, same as before. */
/* Force the wrapper to honor its aspect-ratio even when the image's
   intrinsic dimensions would push it taller. Three things working
   together:

   1. `min-height: 0` — flex/grid items default to `min-height: auto`
      which is "don't shrink below content's intrinsic min size." The
      image's natural height (e.g. 412px for a portrait at 330 wide)
      becomes the wrapper's min-height, overriding aspect-ratio. The
      0 override lets the wrapper match aspect-ratio's preferred size.
      Same fix already in use further down for the Match-Content rule
      (`min-height: 0` on the absolutely-positioned media slot).

   2. `overflow: hidden` — defensive belt-and-suspenders. If any future
      change makes the inner image briefly exceed the aspect-bounded
      box (load-time intrinsic flash, plugin injection, etc.), it
      gets clipped instead of expanding the wrapper.

   3. The .dx-image / .dx-core__video rules below force `height: 100%`
      so the image fits the aspect-bounded box rather than taking its
      natural height. `object-fit: cover` crops to fill. */
.dx-core[class*="dx-core--media-aspect-"] .dx-core__media {
    min-height: 0;
    overflow: hidden;
}
.dx-core[class*="dx-core--media-aspect-"] .dx-core__media .dx-image,
.dx-core[class*="dx-core--media-aspect-"] .dx-core__media .dx-core__video {
    width: 100%;
    height: 100%;
    object-fit: cover;
}
.dx-core--media-aspect-square    .dx-core__media { aspect-ratio: 1 / 1;  }
.dx-core--media-aspect-landscape .dx-core__media { aspect-ratio: 4 / 3;  }
.dx-core--media-aspect-wide      .dx-core__media { aspect-ratio: 16 / 9; }
.dx-core--media-aspect-portrait  .dx-core__media { aspect-ratio: 4 / 5;  }

/* SVG uploads are rendered as inline `<svg>…</svg>` markup by
   `dx_render_image_html()` (see `inc/component-registry.php`) — they
   land in the DOM with the same `.dx-image` class as raster images
   and inherit the rule above. No separate `--svg` modifier; the
   only difference between a JPG and an SVG at render time is the
   element type, and the helper handles that. Authors who need
   per-instance SVG tweaks (e.g. `fill: currentColor` overrides on
   inner paths) use Custom CSS scoped to `%root% .dx-image svg ...`. */

/* Side-by-side layouts (media-left / media-right) — CQ-driven 1-col /
   2-col toggle, with Match Content's absolute-positioning trick gated
   by the SAME CQ further down. Mobile-first: stacked by default,
   side-by-side once the SECTION container reaches the threshold.

   ── Why both rules query the same `dx-section` named container ──
   `@container` queries the nearest ancestor container. If the layout
   rule (which targets `.dx-core`) and the Match Content rule (which
   targets `.dx-core__media`) each rely on a bare `@container (...)`
   query, they end up querying DIFFERENT ancestors — the layout rule
   walks up to `.dx-section`, but if `.dx-core` itself sets
   `container-type`, the Match Content rule queries `.dx-core` instead.
   That mismatch creates a "dead band" of viewport widths where the
   grid is 2-col but Match Content doesn't fire — exactly the bug this
   block used to ship. Naming both queries `dx-section` forces them to
   query the same container, so they fire/un-fire together. (We
   intentionally do NOT set `container-type` on `.dx-core` for the same
   reason — any future `@container` inside this file would query
   `.dx-core` and de-sync from the layout rule.)

   Why this is a grid override of the parent .dx-core--has-media flex
   rules: flex can't express "two equal tracks that collapse to one on
   narrow widths" cleanly. The flex parent stays in place for the
   stacked (media-top / media-bottom) layouts which still benefit from
   flex's simple column / column-reverse swap.

   Trade-off: the user-editable `core_stack_threshold` token no longer
   dynamically shifts the collapse point — collapse is fixed at the
   threshold below. The token still acts as the minimum *column width*
   in 2-col mode (each column's `minmax(min(50%, …), 1fr)` floor), so
   wide-threshold customisations still get wider columns; they just
   don't change the 1-col↔2-col boundary. */
.dx-core--has-media.dx-core--media-left,
.dx-core--has-media.dx-core--media-right {
    display: grid;
    /* Default: single column (stacked). */
    grid-template-columns: 1fr;
    /* `gap` is real CSS gap — both column-gap (between content and
       media in side-by-side) and row-gap (between stacked items when
       the grid collapses to one column). For a clean 50/50 split with
       no air between, set media_gap to None on the panel → emits
       --dx-core-media-gap: 0. The grid then puts the column boundary
       at the exact container midpoint and the two halves meet flush.
       For visible gap, the columns each lose half-of-gap to the
       middle — that's how grid `gap` works, and it's how users expect
       a setting named "gap" to behave. */
    gap: var(--dx-core-media-gap, var(--dx-space-l));
    /* NB: NO `container-type` here. Both this rule and the Match
       Content rule below query the `dx-section` named container — see
       the long comment above. Adding container-type here would shadow
       the section container for descendant queries and break the
       sync. */
}

/* Promote to 2-col side-by-side once the SECTION is wide enough.
   Each column gets a floor of `min(half-of-(container − gap), threshold)`
   and a `1fr` ceiling so columns share leftover space equally.

   ── Why the floor is `(100% − gap) / 2`, NOT a plain `50%` ──
   Previously the formula was `min(50%, threshold)`. That looks right
   in isolation — neither column exceeds half the container — but the
   row is `col + gap + col`. Two `50%` columns + a non-zero gap sum
   to MORE than 100%, so the inner grid couldn't fit inside its own
   container and overflowed the parent cell.
   That bug manifested as Core blocks bleeding into the adjacent
   outer-grid column (Duo template, 2 outer items at section width
   ≥ 46rem: each outer cell ~530px, inner columns floored at 265px
   each, plus a ~32px inner gap → 562px minimum demand vs. 530px
   available → overflow ate the OUTER gap, making the two outer
   items look glued together).
   Subtracting one gap from 100% before halving ensures
   `col + gap + col = 100%` exactly when the floor is in effect.
   The `min(…, threshold)` clamp still kicks in when the container is
   wide enough that the threshold is the tighter constraint, so the
   user-editable `--dx-core-stack-threshold` token still acts
   as the minimum column width in wide-container cases. */
@container dx-section (min-width: 46rem) {
    .dx-core--has-media.dx-core--media-left,
    .dx-core--has-media.dx-core--media-right {
        grid-template-columns:
            minmax(min(calc((100% - var(--dx-core-media-gap, var(--dx-space-l))) / 2), var(--dx-core-stack-threshold, 20rem)), 1fr)
            minmax(min(calc((100% - var(--dx-core-media-gap, var(--dx-space-l))) / 2), var(--dx-core-stack-threshold, 20rem)), 1fr);
    }
}

/* Media-only Core — no content slots, just the media.
   The partial (`components/partials/core.php`) skips the
   `.dx-core__content` wrapper when `$has_any_content` is false, so
   the Core has a single child. Without this override the parent grid
   still reserves a second 1fr column, leaving the media rendered at
   half-width with an empty cell beside it. Collapsing
   `grid-template-columns` back to a single column lets the media slot
   span the full container, which is the natural read for "this Core
   is media-only." Applies in both the stacked (narrow) and side-by-
   side (wide) cases — the side-by-side rule above is the one that
   needed overriding, but emitting the override at base level
   short-circuits the @container block too. */
.dx-core--has-media.dx-core--media-only.dx-core--media-left,
.dx-core--has-media.dx-core--media-only.dx-core--media-right {
    grid-template-columns: 1fr;
}
@container dx-section (min-width: 46rem) {
    .dx-core--has-media.dx-core--media-only.dx-core--media-left,
    .dx-core--has-media.dx-core--media-only.dx-core--media-right {
        grid-template-columns: 1fr;
    }
}

/* Media-width cap (side-by-side) — when the user caps Media Width
   (≠ Full), the media column shrinks to its content (`auto`) so the
   capped image/icon takes only what it needs and the CONTENT column
   (`1fr`) fills the rest. Without this the media column held its ~50%
   track from the base 2-col rule, leaving a gap beside a small icon —
   the same empty-column problem the media-only rule above solves, here
   for "small media + real content."

   Only in 2-col mode — in the stacked (narrow) layout the slot's
   `max-width` already does the right thing in the single column.
   `:not(.dx-core--media-only)` so a capped media-only Core keeps the
   media-only `1fr` collapse (no empty content column to leave behind).
   Column order follows the reading-order rule below: media-left puts
   media in col 1, media-right puts it in col 2. */
@container dx-section (min-width: 46rem) {
    .dx-core--has-media.dx-core--media-capped.dx-core--media-left:not(.dx-core--media-only) {
        grid-template-columns: auto 1fr;
    }
    .dx-core--has-media.dx-core--media-capped.dx-core--media-right:not(.dx-core--media-only) {
        grid-template-columns: 1fr auto;
    }
}

/* Reading order:
   - media-left  : media in col 1, content in col 2  (visual + DOM-stacked)
   - media-right : content in col 1, media in col 2  (visual + DOM-stacked)
   `order` works the same in both grid states. In single-column (stacked)
   mode, items follow the same order top-to-bottom, which mirrors the
   side-by-side reading order. */
.dx-core--media-left  .dx-core__media   { order: 0; }
.dx-core--media-left  .dx-core__content { order: 1; }
.dx-core--media-right .dx-core__content { order: 0; }
.dx-core--media-right .dx-core__media   { order: 1; }

/* Side-by-side vertical alignment — `.dx-core` is `display: grid` in
   media-left/right layouts, so `justify-content` on `.dx-core` controls
   the inline axis (no-op for vertical). The vertical position the user
   actually wants to control lives one level down on `.dx-core__content`.
   The `.dx-core--vertical-*` classes set `--dx-core-vertical-justify`
   on `.dx-core`; this rule consumes it via inheritance, falling back to
   `center` to preserve the historical default. See the comment block on
   `.dx-core--vertical-*` for the two-consumer-rules rationale.

   Note: `display`, `flex-direction`, and `gap` are inherited from the
   unconditional `.dx-core__content` rule above — no need to redeclare. */
.dx-core--media-left .dx-core__content,
.dx-core--media-right .dx-core__content {
    justify-content: var(--dx-core-vertical-justify, center);
}

/* ── Height driver ──────────────────────────────────────────────────────
   `media_height` field (Container tab) declares who decides section
   height when media sits beside content. Three values: auto (each
   natural — current default), content (text drives, image stretches),
   media (image drives, text column stretches to match).

   The rules only fire on side-by-side layouts (media-left / media-right);
   stacked layouts (media-top / media-bottom) ignore the hint because the
   two are in a flex column where image height = its own and text height
   = its own, no shared cross-axis to align.

   No new tokens — values are pure layout: align-items + object-fit.
   ────────────────────────────────────────────────────────────────────── */

/* Match content: text drives the section's height. The image must NOT
   contribute its natural height to the grid's cross-axis sizing —
   otherwise a tall image would inflate the section past the content's
   height and the mode would effectively act like Match media. The fix:
   take the image out of the grid track flow by absolutely positioning
   it inside its (stretched) media slot, so the slot's visual size =
   content column's natural height, and the image fills that area via
   object-fit cover.

   Scoped to the 2-column state via the SAME `@container dx-section
   (min-width: 46rem)` rule that promotes the grid to 2-col above —
   both query the named `dx-section` container at the same threshold,
   so they fire together. Without the matching named query, the two
   `@container` blocks would walk up to different ancestors and create
   a dead band of viewport widths where the grid is 2-col but Match
   Content silently doesn't apply. See the long comment above the
   layout rules. */
@container dx-section (min-width: 46rem) {
    .dx-core--media-left.dx-core--height-content .dx-core__media,
    .dx-core--media-right.dx-core--height-content .dx-core__media {
        position: relative;
        align-self: stretch;
        /* min-height: 0 lets the grid item shrink below its natural
           content height — defensive against browsers that compute the
           slot's intrinsic min-height from the image inside it. */
        min-height: 0;
    }
    .dx-core--media-left.dx-core--height-content .dx-core__media .dx-image,
    .dx-core--media-right.dx-core--height-content .dx-core__media .dx-image,
    .dx-core--media-left.dx-core--height-content .dx-core__media .dx-core__video,
    .dx-core--media-right.dx-core--height-content .dx-core__media .dx-core__video {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        object-fit: cover;
    }
}

/* Match media: the image renders at its natural aspect, the content
   column stretches to match the image's height. The existing
   .dx-core--align-{top,center,bottom} classes already control vertical
   placement of eyebrow / heading / text / buttons inside that column
   via justify-content, so no extra alignment plumbing here.
   Grid auto-handles cross-axis stretch by default (align-items defaults
   to stretch on grid containers), so no explicit `align-items` needed —
   `align-self: stretch` on the content is enough to be explicit about
   intent. */
.dx-core--media-left.dx-core--height-media .dx-core__content,
.dx-core--media-right.dx-core--height-media .dx-core__content {
    align-self: stretch;
}

/* (Media bleed CSS lived here. Removed — bleed was a cross-cutting
   concern between Container Width and Media Position. If reintroduced,
   the simpler shape would fold it into the Container Width option list
   rather than a standalone toggle.) */

}

/* ── Heading visual-size override — UNLAYERED ──────────────────────────
   Lives OUTSIDE the `@layer dx-component-base` block above because the
   per-tag font-size rules emitted by inc/tokens.php (`h2 { font-size:
   var(--dx-size-h2) }` etc.) are inlined as UNLAYERED `<style>` in
   <head>. CSS cascade layers give unlayered author styles priority
   over EVERY explicit layer, regardless of selector specificity. A
   layered `.dx-core__heading--size-h4` rule (specificity 0,1,0) would
   normally beat an unlayered `h2` rule (0,0,1), but unlayered ALWAYS
   wins over layered — so we have to break out of the layer here to
   actually take precedence over the per-tag rule.

   Set by the Core's `heading_size` cascade field (registry default ''
   = "match tag"; explicit h1..h6 overrides). An H2 element carrying
   `.dx-core__heading--size-h4` renders at H4's font-size while keeping
   the H2 markup for semantics + screen readers. Shared line-height /
   weight / letter-spacing / colour stay on the h1..h6 group rule
   emitted by tokens.php — only the size is overridden. */
.dx-core__heading--size-h1 { font-size: var(--dx-size-h1); }
.dx-core__heading--size-h2 { font-size: var(--dx-size-h2); }
.dx-core__heading--size-h3 { font-size: var(--dx-size-h3); }
.dx-core__heading--size-h4 { font-size: var(--dx-size-h4); }
.dx-core__heading--size-h5 { font-size: var(--dx-size-h5); }
.dx-core__heading--size-h6 { font-size: var(--dx-size-h6); }
