/* block-variants.css — shared block engine for bespoke block-aware themes.
   Written to static/cms/block-variants.css on every build (writeBlockVariantsCSS)
   and linked AFTER each theme's main.css so it wins on equal-specificity
   overrides. Consumes the --block-* vars that block-knobs.css derives from
   data-tone/-width/-space. Keyed only on the theme-neutral hooks
   [data-cms-block][data-variant="…"] and [data-cms-block][data-tone="…"] plus
   the shared block skeleton classes every bundled theme uses. kreutzer-grand
   does NOT link this — it keeps its own bespoke per-variant CSS.
   Tone rules and per-block variant layouts are appended below task-by-task. */

/* ---- Tone: apply + legibility. Only light/dark/accent match; auto/absent
   never do, so an untouched block is never repainted. soft/faint are generic
   weakenings of --block-fg (no theme palette needed). ---- */
[data-cms-block][data-tone="light"],
[data-cms-block][data-tone="dark"],
[data-cms-block][data-tone="accent"] {
  background: var(--block-bg);
  color: var(--block-fg);
  --block-fg-soft: color-mix(in srgb, var(--block-fg) 78%, transparent);
  --block-fg-faint: color-mix(in srgb, var(--block-fg) 60%, transparent);
}
/* Primary text — headings, quote bodies, and the rich_text primary body
   COMMONLY hardcode their own color in a theme's main.css (nordic's
   .hero-title does), so they do NOT inherit the section `color` set above and
   would stay their light-ground ink on a dark/accent tone (dark-on-dark →
   ~1:1, verified). Recolor them to --block-fg so an explicit tone stays
   legible. The class-scoped soft/faint tiers below are more specific and still
   win for secondary/tertiary copy. */
[data-cms-block][data-tone] :is(h1, h2, h3, h4),
[data-cms-block][data-tone] .hero-title,
[data-cms-block][data-tone] .hero-title .composer,
[data-cms-block][data-tone] blockquote,
[data-cms-block][data-tone] .pull,
[data-cms-block][data-tone] .intro-body,
[data-cms-block][data-tone] .measure,
[data-cms-block][data-tone] .card-tile-text,
[data-cms-block][data-tone] .card-title { color: var(--block-fg); }
/* Secondary copy — subtitles/intros/aside body. rich_text's PRIMARY body
   (.intro-body) is intentionally NOT here: it inherits the section's
   --block-fg at full strength (matches kreutzer-grand's tone treatment); only
   the ASIDE body (.intro-aside-body) is dimmed. Class names are the theme
   vocabulary per Task 1's audit, NOT the overlay's .rich-text-body. */
[data-cms-block][data-tone] .lead,
[data-cms-block][data-tone] .hero-sub,
[data-cms-block][data-tone] .cta-body,
[data-cms-block][data-tone] .intro-aside-body {
  color: var(--block-fg-soft);
}
/* Tertiary / meta copy. */
[data-cms-block][data-tone] .hero-detail,
[data-cms-block][data-tone] .hero-detail-note,
[data-cms-block][data-tone] .gallery-caption {
  color: var(--block-fg-faint);
}
/* Eyebrow accent role: dark uses the tone accent (reads on a dark ground);
   light/accent fall back to fg (their accent fails contrast on light/gold). */
[data-cms-block][data-tone="dark"] .eyebrow { color: var(--block-accent); }
[data-cms-block][data-tone="light"] .eyebrow,
[data-cms-block][data-tone="accent"] .eyebrow { color: var(--block-fg); }
/* Buttons: outline in a tone-safe color, fill on hover, text → block bg. */
[data-cms-block][data-tone="dark"] .btn { border-color: var(--block-accent); color: var(--block-accent); }
[data-cms-block][data-tone="light"] .btn,
[data-cms-block][data-tone="accent"] .btn { border-color: var(--block-fg); color: var(--block-fg); }
[data-cms-block][data-tone] .btn:hover { background: var(--block-fg); color: var(--block-bg); }

/* ---- Hero variants (shared skeleton: .hero-grid > .hero-copy + .hero-portrait) ----
   Ported from kreutzer-grand's main.css ("Hero — layout variants", ~L342) per
   Reference R2: grand's `.hero--{v}` hook becomes the theme-neutral
   `[data-cms-block][data-variant="{v}"]`; grand's palette vars (--navy/--gold/
   --ivory/...) become --block-* custom properties or a neutral literal; the
   shared skeleton classes (.hero-grid/.hero-copy/.hero-portrait/.portrait-frame)
   are kept exactly as nordic (and every bundled theme) names them.

   image-bottom is the documented default. Unlike grand, whose own base
   .hero-grid rule is already a single centered column so its variants only
   ever add overrides, nordic's un-scoped base .hero-grid (main.css) is a
   permanent 1.05fr/.95fr split with no per-variant hook at all — every
   variant below sets its own grid-template-columns explicitly instead of
   relying on that base, and the old always-side-by-side look now lives
   behind the explicit split-left/split-right variants. Same story for
   .hero-portrait: nordic caps its width only inside its own
   "@media (max-width: 920px)" block, relying on the base 2-column fr-ratio
   to bound it above that width. image-bottom/image-top reuse that same
   360px cap so the portrait plate (aspect-ratio 4/5, no intrinsic width of
   its own) does not stretch to the full .wrap measure now that it can be
   the grid's only (or first) column at desktop widths too. ---- */
[data-cms-block][data-variant="image-bottom"] .hero-grid { display: grid; grid-template-columns: 1fr; }
[data-cms-block][data-variant="image-top"] .hero-grid { display: grid; grid-template-columns: 1fr; }
[data-cms-block][data-variant="image-top"] .hero-portrait { order: -1; }
[data-cms-block][data-variant="image-bottom"] .hero-portrait,
[data-cms-block][data-variant="image-top"] .hero-portrait { max-width: 360px; margin-inline: auto; }

/* split-left / split-right — a co-equal two-column layout on wide viewports
   (portrait beside the copy; copy left-aligned since it is no longer the
   sole centered column). split-right mirrors split-left by pushing the
   portrait first via `order` — split-left needs no override since source
   order already puts the copy first/left. Both collapse back to a single
   column at/under 820px (the breakpoint nordic's own base hero already
   uses), portrait capped the same way image-bottom/image-top cap it above. */
[data-cms-block][data-variant="split-left"] .hero-grid,
[data-cms-block][data-variant="split-right"] .hero-grid { display: grid; grid-template-columns: 1fr 1fr; align-items: center; gap: clamp(2rem, 5vw, 4rem); }
[data-cms-block][data-variant="split-right"] .hero-portrait { order: -1; }
[data-cms-block][data-variant="split-left"] .hero-copy,
[data-cms-block][data-variant="split-right"] .hero-copy { text-align: left; }
[data-cms-block][data-variant="split-left"] .hero-sub,
[data-cms-block][data-variant="split-right"] .hero-sub { margin-inline: 0; }
[data-cms-block][data-variant="split-left"] .hero-detail,
[data-cms-block][data-variant="split-right"] .hero-detail { justify-content: flex-start; }
@media (max-width: 820px) {
  [data-cms-block][data-variant="split-left"] .hero-grid,
  [data-cms-block][data-variant="split-right"] .hero-grid { grid-template-columns: 1fr; }
  [data-cms-block][data-variant="split-left"] .hero-portrait,
  [data-cms-block][data-variant="split-right"] .hero-portrait { max-width: 360px; margin-inline: auto; }
}

/* background / banner — the image becomes the section's full-bleed backdrop
   (hero.html renders it as .hero-bg, a sibling of .wrap, only for these two
   variants, and never calls the portrait partial for either) under a dark
   scrim for legibility. Both force a single centered column since nordic's
   base grid has no per-variant fallback (see the note above). background
   keeps nordic's own hero height (already tall/immersive); banner caps it
   to a shorter, page-header-sized band so the two read as opposites, not
   siblings — mirroring grand's own background/banner distinction. Because
   the backdrop turns one section of an otherwise light, airy theme dark,
   the text roles nordic hardcodes to its light-mode ink tokens (heading,
   eyebrow, sub, detail) are re-pointed to white/near-white here — the same
   "local dark mode" job the tone=dark rules above do via --block-fg, just
   applied to nordic's own class names since this backdrop is a variant
   concern, not a tone one. noimage is a fallback for a background/banner
   block with no image set at all (variant chosen, no photo yet). */
[data-cms-block].block-hero[data-variant="background"],
[data-cms-block].block-hero[data-variant="banner"] { position: relative; isolation: isolate; color: #fff; }
[data-cms-block].block-hero[data-variant="banner"] { min-height: clamp(20rem, 46vh, 30rem); }
[data-cms-block][data-variant="background"] .hero-grid { grid-template-columns: 1fr; }
[data-cms-block][data-variant="banner"] .hero-grid { grid-template-columns: 1fr; min-height: 42vh; display: grid; align-content: center; }
[data-cms-block][data-variant="background"] .hero-bg,
[data-cms-block][data-variant="banner"] .hero-bg { position: absolute; inset: 0; z-index: -1; overflow: hidden; }
[data-cms-block][data-variant="background"] .hero-bg img,
[data-cms-block][data-variant="banner"] .hero-bg img { width: 100%; height: 100%; object-fit: cover; }
[data-cms-block][data-variant="background"] .hero-bg::after,
[data-cms-block][data-variant="banner"] .hero-bg::after { content: ""; position: absolute; inset: 0; background: rgba(10, 15, 25, .55); }
[data-cms-block][data-variant="background"].block-hero--noimage,
[data-cms-block][data-variant="banner"].block-hero--noimage { background: #0a0f19; }

/* ---- Hero image framing (data-image-position / data-image-height) ----
   Two editor knobs that decide WHICH PART of a photo survives the crop and
   HOW TALL the cropped area is. Both are delivered by the shared
   blocks/_knobs.html partial every bundled theme already calls, and both map
   to a custom property in block-knobs.css — so these rules are the single
   place either knob is actually applied, for all 14 themes at once.

   Position targets every hero image container the themes share: the portrait
   card (split-*, plus the themes that keep a card for image-top/bottom), the
   full-bleed backdrop (background/banner), and nordic's full-bleed band.
   Each of those sets object-fit: cover, which is precisely what makes
   object-position meaningful. The 50% 50% fallback is the browser's own
   default, so a block with no position chosen is byte-for-byte unaffected. */
[data-cms-block] .hero-portrait img,
[data-cms-block] .portrait-frame img,
[data-cms-block] .hero-bg img,
[data-cms-block] .hero-band img,
[data-cms-block] .img-wrap img { object-position: var(--block-image-position, 50% 50%); }

/* Height only bites when the attribute is present (neutral "auto" emits
   nothing), and each container needs a different lever:
     .hero-band     — a full-bleed band; height is its own property.
     .portrait-frame — a card sized by aspect-ratio, which must be released
                       before an explicit height can take effect.
     background/banner — the image is absolutely positioned to fill the
                       section, so the SECTION's min-height governs. */
[data-cms-block][data-image-height] .hero-band { height: var(--block-image-height); }
[data-cms-block][data-image-height] .portrait-frame { aspect-ratio: auto; height: var(--block-image-height); }
[data-cms-block][data-image-height].block-hero[data-variant="background"],
[data-cms-block][data-image-height].block-hero[data-variant="banner"] { min-height: var(--block-image-height); }
[data-cms-block][data-variant="background"] .hero-copy,
[data-cms-block][data-variant="banner"] .hero-copy { max-width: 46rem; margin-inline: auto; text-align: center; }
[data-cms-block][data-variant="background"] .hero-title,
[data-cms-block][data-variant="banner"] .hero-title,
[data-cms-block][data-variant="background"] .hero-title .composer,
[data-cms-block][data-variant="banner"] .hero-title .composer,
[data-cms-block].block-hero[data-variant="background"] .eyebrow,
[data-cms-block].block-hero[data-variant="banner"] .eyebrow,
[data-cms-block][data-variant="background"] .hero-sub,
[data-cms-block][data-variant="banner"] .hero-sub,
[data-cms-block][data-variant="background"] .hero-detail-text,
[data-cms-block][data-variant="banner"] .hero-detail-text { color: #fff; }
[data-cms-block][data-variant="background"] .hero-detail,
[data-cms-block][data-variant="banner"] .hero-detail { color: rgba(255, 255, 255, .7); }

/* centered / minimal — no image at all (hero.html never renders the
   portrait partial for either); the full centered copy composition alone.
   minimal strips further to a quieter treatment with the detail/colophon
   row dropped entirely — nordic has no rule-flank glyphs to strip
   individually the way grand does, so hiding the whole row reads the same
   as grand's "rule-flanks dropped". Both MUST reset nordic's base 2-column
   .hero-grid to a single column — with no portrait rendered, the leftover
   base grid would otherwise trap the centered copy in the left half and leave
   an empty right column (verified via computed grid-template-columns). */
[data-cms-block][data-variant="centered"] .hero-grid,
[data-cms-block][data-variant="minimal"] .hero-grid { grid-template-columns: 1fr; }
[data-cms-block][data-variant="centered"] .hero-copy { max-width: 46rem; margin-inline: auto; text-align: center; }
[data-cms-block][data-variant="minimal"] .hero-copy { max-width: 40rem; margin-inline: auto; text-align: center; }
[data-cms-block][data-variant="minimal"] .hero-detail { display: none; }

/* ---- Media + text variants (shared skeleton: .mediatext-grid >
   .mediatext-media > .mediatext-frame, + .mediatext-copy) ----
   Ported from kreutzer-grand's main.css ("Media + text — layout
   variants", ~L695) per Reference R2: grand's `.mediatext--{v}` hook
   becomes the theme-neutral `[data-cms-block][data-variant="{v}"]`;
   grand's palette vars (--navy/--ivory-2/...) become an engine-neutral
   literal (this block has no `--block-*` involvement at all — like hero,
   every variant rule here is a LAYOUT concern, not a Tone one); the
   shared skeleton classes (.mediatext-grid/.mediatext-media/
   .mediatext-frame/.mediatext-copy) are kept exactly as nordic (and,
   per the vocabulary audit's §2.5, every bundled theme) names them.

   image-left is the documented default and needs NO rule of its own:
   media_text.html's DOM order already emits the frame before the copy,
   and nordic's own un-scoped base .mediatext-grid (main.css) already
   lays the two out side by side left-to-right — that base rule already
   IS "image-left".

   ⚠️ Unlike hero's .hero-grid (a real CSS Grid, `display: grid`),
   nordic's (and grand's) base .mediatext-grid is `display: flex;
   flex-wrap: wrap;` with each child `flex: 1 1 320px` — unconditionally,
   with no per-variant fallback. unlike hero: at ANY viewport wide enough
   to fit both 320px bases + the gap (nearly every desktop width), two
   children sit side by side purely because flex has room for both on one
   line — there is no grid-template-columns to reset. The R2 lesson still
   applies, just via the flex-equivalent mechanism: a variant that must
   render single-column while STILL emitting both children (image-top)
   has to force `flex-direction: column` itself — relying on flex-wrap's
   narrow-viewport reflow alone would leave it 2-column at desktop widths.
   A variant that only ever emits ONE child in the row (background, whose
   image becomes the full-bleed backdrop instead of an inline frame — see
   media_text.html) doesn't need flex-direction changed at all: a lone
   flex-grow:1 child already claims the full row on its own; the analogous
   "empty gap" risk there is `.mediatext-copy`'s own `max-width` clamp
   leaving leftover space in the row once it stops growing — solved with
   `justify-content: center` on the grid (ported from grand) rather than
   a grid-template-columns reset. Both mechanisms are verified against
   grand's own shipped CSS, not assumed. */

/* image-right — a plain flex `order` flip; unlike hero's split-left/
   split-right this is NOT reset back on mobile — "right" stays reversed
   even once the row wraps to a single column, so the arrangement's
   left/right intent still reads after it stacks (matches grand). */
[data-cms-block][data-variant="image-right"] .mediatext-media { order: 2; }

/* image-top — single, centred column regardless of viewport width; the
   frame shrinks to a capped width instead of sharing the row with copy.
   Cross-axis centering for both children comes for free from nordic's
   (and grand's) own base `.mediatext-grid { align-items: center; }`,
   which applies to the cross axis regardless of flex-direction — no
   extra margin-inline:auto needed here (verified against grand, which
   has none either). */
[data-cms-block][data-variant="image-top"] .mediatext-grid { flex-direction: column; }
[data-cms-block][data-variant="image-top"] .mediatext-media { flex: 0 1 auto; width: min(100%, 28rem); }
[data-cms-block][data-variant="image-top"] .mediatext-copy { flex: 1 1 auto; max-width: 46rem; text-align: center; }

/* background — the image becomes the section's full-bleed backdrop
   (media_text.html renders it as .mediatext-bg, a sibling of .wrap, only
   for this variant, and never renders .mediatext-media/-frame at all —
   no inline plate beside an already-full-bleed photo). The section-level
   position/isolation/color:#fff is set explicitly here, scoped to
   .block-media_text — NOT borrowed from hero's section. Variant values
   like "background"/"banner" collide across block types (hero, media_text,
   cta, quote all reuse them), so EVERY section-level variant rule is
   scoped by its block-type class; only child-scoped rules
   (`… .mediatext-grid`) may omit it, since the child class is already
   type-unique. See Reference R2's collision note. No image → a plain dark
   panel (media_text.html's $hasImage guard skips .mediatext-bg entirely;
   the noimage literal below supplies the ground). */
[data-cms-block].block-media_text[data-variant="background"] { position: relative; isolation: isolate; color: #fff; }
[data-cms-block][data-variant="background"] .mediatext-grid { justify-content: center; }
[data-cms-block][data-variant="background"] .mediatext-bg { position: absolute; inset: 0; z-index: -1; overflow: hidden; }
[data-cms-block][data-variant="background"] .mediatext-bg img { width: 100%; height: 100%; object-fit: cover; display: block; }
[data-cms-block][data-variant="background"] .mediatext-bg::after { content: ""; position: absolute; inset: 0; background: rgba(10, 15, 25, .55); }
[data-cms-block][data-variant="background"].block-media_text--noimage { background: #0a0f19; }
[data-cms-block][data-variant="background"] .mediatext-copy { max-width: 44rem; margin-inline: auto; text-align: center; }
[data-cms-block][data-variant="background"] .mediatext-copy h2,
[data-cms-block][data-variant="background"] .mediatext-copy .measure { color: #fff; }

/* overlap — an editorial two-column layout: the framed image is pulled
   over the copy panel's leading edge via a negative margin (paired with
   a higher z-index on the media column), so the two visibly overlap
   instead of sitting in separate gutters. Desktop-only (min-width gate,
   mirroring hero's split-left/split-right) — under that width the base
   flex-wrap already gives a clean stacked fallback with no overlap math
   to unwind. The copy panel's fill is an engine-neutral light literal
   (grand's own `--ivory-2` is a theme palette var, not portable); the
   frame keeps grand's own lift-shadow. */
[data-cms-block][data-variant="overlap"] .mediatext-grid { gap: 0; }
@media (min-width: 760px) {
  [data-cms-block][data-variant="overlap"] .mediatext-media {
    position: relative; z-index: 2; flex: 0 1 46%;
    margin-right: clamp(-5rem, -8vw, -3rem);
  }
  [data-cms-block][data-variant="overlap"] .mediatext-frame { box-shadow: 0 24px 48px -22px rgba(0, 0, 0, .4); }
  [data-cms-block][data-variant="overlap"] .mediatext-copy {
    position: relative; z-index: 1; flex: 1 1 54%;
    /* tone-aware: under an explicit tone the panel takes the tone ground so
       the (tone-recolored) heading/body contrast against it; else the light
       editorial panel. */
    background: var(--block-bg, rgba(255, 255, 255, .96));
    padding: clamp(2.5rem, 6vw, 4rem) clamp(2.5rem, 6vw, 4rem) clamp(2.5rem, 6vw, 4rem) clamp(5rem, 11vw, 7rem);
  }
}

/* No image — image-left/image-right/image-top/overlap all fall back to
   the SAME plain, centred, full-width copy (no empty image slot/
   placeholder is ever rendered; see media_text.html's $hasImage guard).
   Higher specificity than any single variant-specific rule above (an
   extra class selector), so it wins regardless of source order and
   cleanly cancels overlap's asymmetric panel padding too (verified: 4
   attribute-or-class selectors vs. overlap's 3). background has its own
   no-image fallback (a plain dark panel) above, so it's excluded here. */
[data-cms-block].block-media_text--noimage:not([data-variant="background"]) .mediatext-copy {
  flex: 1 1 100%;
  max-width: 46rem;
  margin-inline: auto;
  text-align: center;
}

/* ---- CTA variants (shared skeleton: .wrap > .rule? + .invite-copy +
   .actions; boxed additionally wraps those three in .invite-panel) ----
   Ported from kreutzer-grand's main.css ("Invitation / CTA — layout
   variants", ~L1152) per Reference R2: grand's `.invite--{v}` hook becomes
   the theme-neutral `[data-cms-block][data-variant="{v}"]`; grand's
   palette vars (--navy/--gold/--surface/...) become --block-* custom
   properties or an engine-neutral literal; the canonical shared skeleton
   (vocabulary audit §2.2 — fully canonical, no per-theme normalization
   required) — `.invite`/`.block-cta` > `.wrap` > `.eyebrow`/h2/
   `.cta-body`/`.actions` > `.btn` — is kept exactly as nordic (and every
   bundled theme) names it.

   Every cta selector below additionally carries `.block-cta`
   ([data-cms-block][data-variant="{v}"].block-cta), unlike hero/
   media_text's bare [data-cms-block][data-variant="{v}"]. Reason: two of
   cta's four variant VALUES are exact strings other blocks also use for
   their OWN, differently-shaped variants — "banner" also exists on hero's
   (and, from Task 8, quote's) enum, and "centered" also exists on hero's.
   cta's own banner needs a real `background` on the bare section (a flat
   accent BAND, not an image backdrop like hero's), which an unscoped
   selector would leak onto any hero/quote block that happens to carry the
   same `data-variant="banner"` attribute — the shared child classes
   (.eyebrow/.actions/.btn/.rule) make the risk concrete, not just
   theoretical. "split"/"boxed" have no colliding block today but are
   scoped the same way for one consistent, defensively-correct pattern
   across this whole section.

   centered is the documented default and needs no rule of its own — the
   base `.invite`/`.wrap` rules (nordic's own main.css) already render it
   centered/stacked, mirroring grand's identical "centered needs no rules
   of its own" comment. nordic's `.wrap` is a plain block container with
   no grid/flex of its own (unlike hero's `.hero-grid`/media_text's
   `.mediatext-grid`), so — unlike those two blocks — there is no stray
   multi-column base for banner/boxed to inherit and reset; only `split`
   below ever turns `.wrap` into a row, and it does so entirely under its
   own variant-scoped selector, never the bare shared class. ---- */

/* split — a horizontal bar: copy left, actions right, vertically centered.
   cta.html gives `.wrap` exactly two children for this variant (the
   `.rule` ornament is dropped — see cta.html's own comment); `.wrap`
   becomes that two-item flex row explicitly (nordic's base has no display
   rule for `.wrap` at all, so there is nothing to reset here — only to
   set). Collapses back to a single stacked column at/under 820px, the
   same breakpoint hero's split-left/split-right and grand's own
   invite--split use. */
[data-cms-block][data-variant="split"].block-cta .wrap {
  display: flex; align-items: center; justify-content: space-between;
  gap: clamp(2rem, 5vw, 4rem); text-align: left;
}
[data-cms-block][data-variant="split"].block-cta .invite-copy { flex: 1 1 auto; max-width: 42rem; }
[data-cms-block][data-variant="split"].block-cta .actions { flex: 0 0 auto; margin-top: 0; justify-content: flex-end; }
@media (max-width: 820px) {
  [data-cms-block][data-variant="split"].block-cta .wrap {
    flex-direction: column; align-items: stretch; text-align: center;
    gap: clamp(1.5rem, 4vw, 2.2rem);
  }
  [data-cms-block][data-variant="split"].block-cta .invite-copy { max-width: none; }
  [data-cms-block][data-variant="split"].block-cta .actions { justify-content: center; }
}

/* banner — a full-width accent band. Grand's own banner is a hardcoded
   gold/navy flip (its own --tone-accent-* pairing, a theme palette
   choice); the portable equivalent is --block-accent for the band itself
   (falling back to a neutral mid-tone slate, never a gold literal) with a
   plain white foreground — the same literal-white treatment hero's own
   background/banner variant already uses for its scrim text, so a
   white-on-accent band reads as the same family. An explicit tone
   (light/dark/accent) COMPOSES with banner: the band uses --block-bg /
   --block-fg (the guaranteed-contrast pair block-knobs.css derives from
   data-tone), falling back to a fixed slate/white when no tone is set.
   This pairing is REQUIRED — an earlier version used --block-accent for the
   band background, but --block-accent is a light DETAIL colour under
   dark/accent tones, so banner+tone=accent rendered white-on-white
   (invisible). Children follow the band via currentColor, so they stay
   legible whichever tone (or none) is set. */
[data-cms-block][data-variant="banner"].block-cta {
  background: var(--block-bg, #3a4553);
  color: var(--block-fg, #fff);
}
/* the heading hardcodes its own colour in most themes → force it onto the
   band colour (currentColor), so it's legible even with NO tone set (where
   the tone heading-recolor rule doesn't fire). */
[data-cms-block][data-variant="banner"].block-cta h2,
[data-cms-block][data-variant="banner"].block-cta .composer { color: currentColor; }
[data-cms-block][data-variant="banner"].block-cta .eyebrow,
[data-cms-block][data-variant="banner"].block-cta .cta-body { color: color-mix(in srgb, currentColor 80%, transparent); }
[data-cms-block][data-variant="banner"].block-cta .rule { background: currentColor; }
[data-cms-block][data-variant="banner"].block-cta .btn { border-color: currentColor; color: currentColor; }
[data-cms-block][data-variant="banner"].block-cta .btn::before { background: currentColor; }
[data-cms-block][data-variant="banner"].block-cta .btn:hover { color: var(--block-bg, #3a4553); }

/* boxed — the same centered composition, inset in a bordered panel on a
   faintly raised ground. Grand's own boxed panel is a gold-bordered,
   corner-bracketed plate (theme-specific ornament — not ported, per
   Reference R2's "do not import decorative visuals" rule); the portable
   equivalent is a currentColor-derived border + tint, which is always
   legible against whatever `color` the block currently computes to
   (nordic's own ink by default, or --block-fg once an explicit tone
   applies) without needing a --block-* var of its own that would be
   unset under tone="auto". */
[data-cms-block][data-variant="boxed"].block-cta .invite-panel {
  max-width: 46rem; margin-inline: auto;
  padding: clamp(2.5rem, 6vw, 4rem);
  border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
  background: color-mix(in srgb, currentColor 6%, transparent);
}

/* ---- Quote variants (shared skeleton: .wrap > .rule? + blockquote.pull +
   .lead; with-portrait additionally nests a .quote-grid > .quote-portrait +
   .quote-copy pair inside that same .wrap) ----
   Ported from kreutzer-grand's main.css ("Quote — layout variants", ~L561)
   per Reference R2: grand's `.quote--{v}` hook becomes the theme-neutral
   `[data-cms-block][data-variant="{v}"]`; grand's palette vars (--navy/
   --gold/--surface/...) become --block-* custom properties or an
   engine-neutral literal; the canonical shared skeleton (vocabulary audit
   §2.3 — fully canonical for engine purposes: two cosmetic naming families
   exist for the section/wrap/attribution classes across the 13 themes, but
   the classes the engine actually needs — .wrap, blockquote.pull, .lead,
   .rule — are universal in both) is kept exactly as nordic (and every
   bundled theme) names them.

   Collision note: "banner" is also a hero AND a cta variant value (see
   those two sections' own identical warning above). Every rule below that
   styles the section itself, or a cross-block-shared child (.wrap/.rule/
   .pull/.lead — none of which are quote-specific), therefore carries the
   `.block-quote` scope on all five variant values — not just "banner",
   which is the only one that actually collides with another block's enum
   today. "pull"/"bordered"/"with-portrait" are scoped the same way anyway,
   for one consistent, defensively-correct pattern across this whole
   section — mirroring cta's own "split"/"boxed" precedent. Only the
   with-portrait rules keyed on the new, block-specific .quote-grid/
   .quote-portrait/.quote-copy children (net-new markup — 0/13 themes
   rendered quote's `image` field before this task) omit the extra class,
   since those child classes are already type-unique.

   plain is the documented default and needs no rule of its own — nordic's
   base "Quote — a quiet centred statement band" rules (main.css) already
   render it; like cta's own "centered", quote's base .wrap/.statement is a
   plain block container with no grid/flex at all, so there is no stray
   multi-column base left for any variant to reset either. ---- */

/* pull — the same centred band, blown up to a monumental display size: the
   "mark"/typographic-emphasis treatment the Layout picker's own label
   describes. Only the blockquote's font-size changes (grand's exact
   clamp, ported as-is — a type scale, not a palette value); nordic's own
   ch-based max-width on it (`.statement blockquote.pull`, main.css) scales
   up automatically alongside the bigger font-size, so the line measure
   keeps reading as ~34 characters instead of ballooning edge to edge,
   exactly as grand's own identical comment documents — no separate
   max-width override needed here. */
[data-cms-block].block-quote[data-variant="pull"] .pull {
  font-size: clamp(2.4rem, 1.4rem + 4.4vw, 5.5rem);
}

/* bordered — the quote inset in a simple bordered, tinted panel. Grand's
   own bordered panel is a gold-bordered, corner-bracketed plate (a
   theme-specific ornament — corner crop-marks are explicitly on Reference
   R2's do-not-import list); the portable equivalent mirrors cta's own
   `boxed` treatment exactly: a currentColor-derived border + tint, legible
   against whatever `color` the block currently computes to (nordic's own
   ink by default, or --block-fg once an explicit tone applies) without
   needing a --block-* var of its own that would be unset under
   tone="auto". Applied to `.wrap` since that is the one element every
   variant already wraps its content in. */
[data-cms-block].block-quote[data-variant="bordered"] .wrap {
  max-width: 42rem; margin-inline: auto;
  padding: clamp(2.5rem, 6vw, 4rem);
  border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
  background: color-mix(in srgb, currentColor 6%, transparent);
}

/* banner — the band uses --block-bg / --block-fg (the guaranteed-contrast
   pair block-knobs.css derives from data-tone), falling back to a fixed
   slate/white when no tone is set. NOT --block-accent for the background:
   that is a light DETAIL colour under dark/accent tones and rendered
   white-on-white (invisible) once Tone=accent was also set. `.pull`,
   `.lead` and `.rule` carry their OWN explicit color/background in a
   theme's base CSS, so an inherited section color wouldn't reach them —
   each follows the band via currentColor below. */
[data-cms-block].block-quote[data-variant="banner"] {
  background: var(--block-bg, #3a4553);
  color: var(--block-fg, #fff);
}
[data-cms-block].block-quote[data-variant="banner"] .pull,
[data-cms-block].block-quote[data-variant="banner"] .lead { color: currentColor; }
[data-cms-block].block-quote[data-variant="banner"] .pull::before,
[data-cms-block].block-quote[data-variant="banner"] .pull::after { color: currentColor; }
[data-cms-block].block-quote[data-variant="banner"] .rule { background: currentColor; }

/* with-portrait — a two-column testimonial: a framed portrait beside the
   quote+attribution, stacking to one column on mobile via the same
   flex-wrap formula as .mediatext-grid/.hero-grid's own split variants —
   set explicitly here since .quote-grid has no base rule anywhere else to
   reset (net-new markup). text-align flips to left (overriding the
   inherited center from nordic's own base `.statement` rule) and the
   pull-quote/attribution/rule's own auto-centering margins are zeroed so
   they sit flush with the portrait instead of centering inside their own
   column. No image → quote.html never renders .quote-grid at all (falls
   back to the exact same flat markup plain/pull/bordered/banner render),
   so no `:not(...)` guard is needed the way grand's own CSS needs one —
   these selectors simply never match anything in that case. */
[data-cms-block][data-variant="with-portrait"] .quote-grid {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: clamp(2rem, 5vw, 4rem); text-align: left;
}
[data-cms-block][data-variant="with-portrait"] .quote-portrait,
[data-cms-block][data-variant="with-portrait"] .quote-copy {
  flex: 1 1 320px; min-width: 0;
}
[data-cms-block][data-variant="with-portrait"] .quote-copy .rule {
  margin-inline: 0;
}
[data-cms-block][data-variant="with-portrait"] .quote-copy .pull,
[data-cms-block][data-variant="with-portrait"] .quote-copy .lead {
  margin-inline: 0;
}

/* ---- Rich text variants (shared skeleton: .intro-grid > .intro-body +
   .intro-aside; .intro-aside → .intro-pull + .intro-aside-body) ----
   Ported from kreutzer-grand's main.css ("Rich text — layout variants",
   ~L479) per Reference R2: grand's `.intro--{v}` hook becomes the
   theme-neutral `[data-cms-block][data-variant="{v}"]`; the canonical
   shared skeleton (vocabulary audit) — .intro-grid/.intro-body/
   .intro-aside/.intro-aside-body/.intro-pull — is kept exactly as nordic
   (and every bundled theme) names it, per Task 1's audit — NOT
   .rich-text-body/.rich-text-aside (overlay/default-only names, never
   used by a bundled theme; see this file's Task 4 tone-section comment
   for the identical warning).

   Collision note: rich_text's four enum values (with-aside, single,
   two-column, lead-in) do NOT collide with any other block's enum (unlike
   hero/cta/quote's shared "banner"/"centered" — see those sections'
   collision warnings above), and every rule below is scoped to a
   block-specific CHILD class (.intro-grid/.intro-body — never the bare
   section, never a cross-block-shared class like .wrap/.lead), so none
   needs the extra `.block-rich_text` scope R2's collision rule requires
   elsewhere in this file — there is no collision to guard against here,
   and no section-level (background/color) rule that would need it either.

   with-aside is the documented default and needs no rule of its own,
   mirroring cta's "centered"/media_text's "image-left" precedent:
   rich_text.html renders `.intro-aside` (the pull-quote/aside_body card)
   ONLY for this variant, and nordic's own base `.intro-grid` (main.css) —
   a real CSS Grid, `display: grid; grid-template-columns: 1.3fr 1fr` — is
   ALREADY that exact two-column body+aside split, with its own
   `:has(> :only-child)` fallback to one column already covering the case
   where with-aside is chosen but neither `pull` nor `aside_body` is set.
   That base rule already IS "with-aside".

   single / two-column / lead-in never render `.intro-aside` at all
   (rich_text.html) — body-only, per Reference R1 step 5's markup DOF for
   this block ("aside slot present only for with-aside"). `.intro-grid`
   still wraps the lone `.intro-body` for all three (kept, per "keep
   existing classes" — no new wrapper element introduced), so each MUST
   explicitly reset `.intro-grid`'s `grid-template-columns` to a single
   track (Reference R2's base-container lesson): relying on nordic's own
   `:has(> :only-child)` fallback above would be theme-specific luck — this
   engine CSS is shared by all 13 non-grand themes, and a future theme's
   `.intro-grid` may have no such fallback at all, only the fixed
   2-column base. Confirmed mechanism (checked nordic's main.css directly,
   not assumed): `.intro-grid` is CSS Grid, not flex (unlike
   .mediatext-grid), so the reset is `grid-template-columns: 1fr` — the
   grid-native equivalent of media_text's `flex-direction: column`. */
[data-cms-block][data-variant="single"] .intro-grid,
[data-cms-block][data-variant="two-column"] .intro-grid,
[data-cms-block][data-variant="lead-in"] .intro-grid {
  grid-template-columns: 1fr;
}

/* two-column — a DIFFERENT mechanism than the container reset above: the
   body TEXT itself flows in two balanced CSS columns (`columns` on
   `.intro-body`), not the `.intro-grid` container splitting into two
   tracks (that stays reset to a single track above — the two text-columns
   live inside it). Widened past `.measure`'s own `max-width: var(--col)`
   (nordic's normal single-column reading width, still applied to
   `.intro-body` via its own `measure` class) since two ~28ch columns read
   comfortably at a noticeably wider overall measure; collapses back to a
   single column on narrow viewports, where two columns would squeeze
   every line unreadably thin. */
[data-cms-block][data-variant="two-column"] .intro-body {
  max-width: 60rem;
  columns: 2;
  column-gap: clamp(2rem, 4vw, 3.5rem);
}
@media (max-width: 680px) {
  [data-cms-block][data-variant="two-column"] .intro-body { columns: 1; }
}

/* lead-in — the single reading column from the container reset above,
   with a genuinely oversized opening paragraph ahead of the rest of the
   body, which then drops back to `.intro-body`'s normal size — the
   emphasis the Layout picker's own "lead-in" label describes. No
   drop-cap treatment (unlike kreutzer-grand's own lead-in, which pairs
   its lead paragraph with a `::first-letter` drop cap): nordic's
   rich_text is explicitly documented as "no drop cap — this theme keeps
   its type quiet" (see rich_text.html's own header comment), and the
   engine supplies layout structure, not a per-theme typographic
   decoration nordic has deliberately opted out of. */
[data-cms-block][data-variant="lead-in"] .intro-body > p:first-of-type {
  font-size: clamp(1.25rem, 1rem + 1vw, 1.6rem);
  font-weight: 500;
  line-height: 1.5;
  margin-bottom: 1.2rem;
}

/* ---- Gallery variants (shared skeleton: .gallery-grid > img, direct
   children — no per-image wrapper element at all; .gallery-caption is a
   sibling of .gallery-grid, both direct children of the section) ----
   Ported from kreutzer-grand's main.css ("Gallery — layout variants",
   ~L806) per Reference R2: grand's `.gallery--{v}` hook becomes the
   theme-neutral `[data-cms-block][data-variant="{v}"]`; grand's palette
   vars (--gold/--ivory/--ivory-3/--ink-faint) and its gold-corner-bracket
   frame decoration are deliberately NOT ported (R2: "do not import
   grand's decorative visuals that depend on theme-specific assets" — the
   corner brackets are exactly that; the engine supplies layout structure
   only). The shared skeleton class is `.gallery-grid` — nordic's own
   name, inherited byte-for-byte from the CMS-default partial (Task 1's
   audit §2.7 confirms all 13 non-grand themes shared this identically,
   having no bespoke gallery.html of their own before this task) — NOT
   grand's own bespoke `.gallery-track`/`.gallery-item`, which belong only
   to grand's separate, un-converted file.

   Markup DOF: none (Tasks 5–11 table) — gallery.html (this task) emits
   the IDENTICAL DOM for every variant, a bare `<img>` per image with no
   wrapper element at all; all four mechanisms below are pure CSS on
   `.gallery-grid` / `.gallery-grid img`, never a structural change gated
   on `$v`. This is the same "CSS-only, no markup branch" shape cta's
   split/banner/boxed and quote's plain/pull/bordered/banner already
   demonstrate for their own non-portrait variants (Tasks 7/8).

   Collision note: `grid`/`masonry`/`carousel` are also 3 of cards' 5
   enum values (cards' own Task 11 adds `data-variant="masonry"` etc. to
   ITS OWN section too) — but every rule below keys on the CHILD class
   `.gallery-grid`, which is type-unique (cards' shared child class is
   `.cards-grid`, a different string, confirmed against Task 1's audit),
   so per R2's own carve-out ("Rules scoped by a block-specific CHILD
   class … are already type-unique and may omit [the block-type class]")
   none of grid/masonry/carousel need the extra `.block-gallery` compound
   scope. `full-bleed` is the one exception below: it is a SECTION-LEVEL
   rule (it cancels the section's own gutter — see next paragraph), so per
   R2's mandatory rule for any selector that styles the section element
   itself, it carries the `.block-gallery` scope explicitly — even though
   no other block's enum happens to also use the literal string
   "full-bleed" today; R2 requires the scope defensively, not reactively.

   Nordic's own skeleton has no `.wrap` at all on this block — unlike
   every other converted block (hero/media_text/cta/quote/rich_text all
   gutter their content via an inner `.wrap`/`.mediatext-grid`/`.intro-
   grid`/etc. div, leaving the outer `<section>` itself unconstrained).
   Gallery is the opposite: nordic's base `.block` class (main.css, under
   its own "Default block partials" comment — the shared fallback style
   every theme-default block type gets) puts `max-width`/`padding-inline`/
   `margin-inline` DIRECTLY on the `<section class="block block-gallery
   …">` itself, with `.gallery-caption` and `.gallery-grid` both direct
   children of that already-guttered section — confirmed by reading
   nordic's main.css directly, not assumed. `full-bleed`'s edge-to-edge
   effect therefore has to cancel `.block`'s gutter at the SECTION level
   (there is no inner wrap to cancel it on instead, unlike grand's own
   `.wrap.gallery-track` combo-class, where the cancellation targets the
   track alone) — then restore a matching inset just on `.gallery-caption`
   so the caption alone stays readable while the grid alone goes
   edge-to-edge, porting grand's stated intent ("wrap gutter cancelled for
   the image track only, caption keeps its own") onto this flatter,
   wrap-less skeleton rather than grand's literal mechanism. ---- */

/* grid (default) — a uniform responsive grid, fixed 4:3 crop so the rows
   read tidy and even. Restated explicitly here rather than left to
   nordic's own already-matching base `.gallery-grid` rule (main.css)
   because this engine file is shared by the other 12 rollout themes too
   (Tasks 13–24) — each may have a differently-shaped (or absent) own
   fallback for a block type none of them has a bespoke partial for yet;
   an explicit rule makes "grid" render identically everywhere
   block-variants.css is linked, not a per-theme accident. */
[data-cms-block][data-variant="grid"] .gallery-grid {
  display: grid;
  gap: clamp(1rem, 2.5vw, 1.75rem);
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
}
[data-cms-block][data-variant="grid"] .gallery-grid img {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}

/* masonry — a staggered CSS multi-column flow. `columns: <count> <width>`
   picks its own column count responsively, the same way grid's
   `auto-fit` does. Each image keeps its OWN natural aspect ratio (no
   crop, no fixed height — overriding grid's 4:3 above) — that is what
   actually produces the staggered look; `break-inside: avoid` keeps one
   image from ever splitting across a column break, and `margin-bottom`
   supplies the row gap `columns` itself never does (column-gap is
   column-to-column only). `display: block` MUST be restated, not just
   `columns` — nordic's base `.gallery-grid` (like the explicit "grid"
   rule above) sets `display: grid`, and CSS multicol has no effect at
   all on a grid container (checked against spec, not assumed) — this is
   the same base-container-reset lesson R2 documents for hero's/
   media_text's/rich_text's single-column variants (Tasks 5/6/9), applied
   here to a display-MODE reset instead of a column-count one. */
[data-cms-block][data-variant="masonry"] .gallery-grid {
  display: block;
  columns: 3 16rem;
  column-gap: clamp(1.2rem, 2.5vw, 2rem);
}
[data-cms-block][data-variant="masonry"] .gallery-grid img {
  display: block;
  width: 100%;
  aspect-ratio: auto;
  height: auto;
  break-inside: avoid;
  margin-bottom: clamp(1.2rem, 2.5vw, 2rem);
}

/* carousel — a single horizontal row, native CSS scroll-snap, no JS.
   `display: flex` MUST be restated for the same reason masonry restates
   `display: block` above — the base/"grid" rule's `display: grid` would
   otherwise persist (a higher-specificity rule that never sets a given
   property does not clear an earlier rule's value for it), and
   `overflow-x`/scroll-snap on a grid container would not produce a
   scrollable single row. Each image gets a fixed flex-basis
   (`flex: 0 0 …`) plus an explicit `width: auto` instead of grid's/the
   base's own `width: 100%` (which would otherwise stretch every image to
   the full row width) so a fixed number of cards sit in view with the
   rest reachable by scrolling; a trailing mask-image fade is the "scroll
   for more" cue, narrower still on small viewports (kreutzer-grand's own
   breakpoint, ported as-is — a structural threshold, not a palette
   value). */
[data-cms-block][data-variant="carousel"] .gallery-grid {
  display: flex;
  gap: clamp(1rem, 2vw, 1.6rem);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: clamp(1.5rem, 3vw, 3rem);
  padding-bottom: .75rem;
  -webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 56px), transparent 100%);
          mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 56px), transparent 100%);
}
[data-cms-block][data-variant="carousel"] .gallery-grid img {
  display: block;
  flex: 0 0 clamp(14rem, 32vw, 22rem);
  width: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  scroll-snap-align: start;
}
@media (max-width: 680px) {
  [data-cms-block][data-variant="carousel"] .gallery-grid img { flex-basis: 78vw; }
}

/* full-bleed — edge-to-edge: cancels nordic's base `.block` gutter (see
   this section's header comment above — gallery has no inner `.wrap` the
   way every other block does, so the cancellation is section-level here,
   not on a child) for the image grid; `.gallery-caption` gets its own
   inset restored so it stays a readable measure instead of also going
   edge-to-edge. Tiles grow and the gap shrinks to a hairline; every 5th
   image spans 2 grid columns AND widens its own crop to 2:1 (2-column
   width ÷ 2:1 ratio = 1-column height, so rows stay a uniform height with
   no grid-row spanning involved) — ported as-is from grand, a purely
   structural mosaic rhythm, not a palette choice; applied directly to the
   `img` itself (grand applies it to a `.gallery-item` wrapper + its inner
   img separately, but nordic's skeleton has no such wrapper — the img IS
   the grid item here, so both declarations land on the same selector).
   Section-level rule, so — unlike grid/masonry/carousel above — this one
   carries the `.block-gallery` scope per R2's mandatory rule for any rule
   styling the section itself. */
[data-cms-block].block-gallery[data-variant="full-bleed"] {
  max-width: none;
  margin-inline: 0;
  padding-inline: 0;
}
[data-cms-block][data-variant="full-bleed"] .gallery-caption {
  padding-inline: clamp(1.75rem, 6vw, 4.5rem);
}
[data-cms-block][data-variant="full-bleed"] .gallery-grid {
  display: grid;
  gap: .35rem;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
[data-cms-block][data-variant="full-bleed"] .gallery-grid img {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
}
@media (min-width: 760px) {
  [data-cms-block][data-variant="full-bleed"] .gallery-grid img:nth-child(5n+1) {
    grid-column: span 2;
    aspect-ratio: 2 / 1;
  }
}

/* ---- Cards variants (shared skeleton: .block-cards > .wrap > an optional
   .section-head, then the variant-specific body; "list"/"grid" keep their
   OWN pre-existing, bespoke, UNCHANGED body markup and CSS — nothing below
   ever styles them) ----
   Cards is the one block where the rollout ADDS branches to an
   already-branching partial rather than converting one (Task 1's audit
   §2.6, and the plan's own "cards is additive, not repurposed" callout):
   every one of the 13 non-grand themes' cards.html already dispatched on
   `variant` with the OLD sentinel values "list"/"grid" before Phase 2, and
   kreutzer-grand's own cards.html proves the fix is structural, not a
   per-theme rename — its "list" and "grid" branches emit NEITHER
   `data-variant` NOR a `block-cards--{v}` class (grep-confirmed against
   grand's own file), so every selector below, keyed on
   `[data-cms-block][data-variant="masonry|carousel|feature"]`, can never
   match a theme's own "list"/"grid" section at all — those two stay
   exactly as they always have, with ONLY `_knobs.html` added (tone/align/
   width/space; nordic's own cards.html header comment has the full
   rationale). masonry/carousel/feature are the net-new branches; ported
   from kreutzer-grand's main.css ("Cards — layout variants", ~L966) per
   Reference R2.

   Shared per-card partial: masonry/carousel/feature all render through ONE
   new sub-partial (_cards_item.html, mirroring kreutzer-grand's own) using
   grand's canonical field-to-class mapping — `.card-tile` > `.img-wrap`
   (image/placeholder) + `.card-tile-body` (h3/.role/.instrument/
   .card-tile-text) — per the audit's explicit recommendation to keep
   grand's names as the new canonical set rather than reusing a theme's own
   divergent "grid"-branch names (nordic's own grid branch calls the same
   two fields `.detail`/`.card-tile-bio`).

   ⚠️ Collision (flagged by Task 1's audit for exactly nordic, momentum and
   brut): those three themes' own EXISTING, bespoke "grid" branch already
   uses a bare, gate-less `.card-tile` class (nordic's own main.css:
   `.card-tile img { aspect-ratio: 3/4; object-fit: cover; }`,
   `.card-tile .role`, `.card-tile h3`, …) — reused deliberately by
   _cards_item.html above (same framed-card look, zero new CSS needed for
   the frame/role/heading), so EVERY rule below that reaches `.card-tile`
   (or a descendant of it) is scoped under
   `[data-cms-block][data-variant="masonry|carousel|feature"]` — never a
   bare `.card-tile` selector — so it can only ever match the NEW
   sub-partial's markup, never leak onto (or be leaked onto by) a theme's
   own gate-less grid tiles. masonry additionally MUST override
   `aspect-ratio` explicitly (nordic's own `.card-tile img` rule already
   fixes a 3/4 crop, unlike grand, whose own `.card-tile` is exclusive to
   this new sub-partial with no competing base rule at all) — the same
   base-container-reset lesson R2 documents elsewhere in this file, applied
   to an image-crop property instead of a layout one.

   `.cards-feature-grid` is deliberately NOT grand's literal
   `.feature-grid`: nordic (and, grep-confirmed, all 12 other rollout
   themes) already carries an ORPHANED `.feature-grid` rule left over from
   media_text's deleted OLD "feature" branch (a two-column numeral plaque —
   see media_text.html's own header comment) — reusing the literal name
   would silently leak that unrelated rule's own `align-items`/`gap`/
   `grid-template-columns` into this one. Renamed once, here, so every
   future rollout theme's own "feature" sub-partial can key on the same
   collision-free name.

   masonry/carousel do not participate in cards' own per_row field the way
   "grid" does, mirroring grand exactly: masonry's column COUNT is
   responsive (CSS `columns`, not a fixed track count) and carousel is a
   single row — neither markup branch (cards.html) ever emits
   `data-per-row` for them. Only "feature"'s trailing grid re-uses the
   mechanism (`.cards-feature-grid[data-per-row]`, below), sized 1–4 the
   same way "grid" is; per_row=1 spans the full row (`minmax(0, 1fr)`, one
   wide track), mirroring "grid"'s own per_row=1 fix (each theme's own
   main.css) and kreutzer-grand's identical
   `.artists-grid[data-per-row="1"]`. "grid" keeps its own pre-existing
   per_row handling entirely in each theme's own main.css — a bespoke,
   kept-as-is concern, same as the rest of "grid"'s look, never here. ---- */

/* masonry — a staggered CSS multi-column card wall, mirroring gallery's own
   masonry mechanism exactly (`columns: <count> <width>`, natural per-image
   aspect ratio, `break-inside: avoid`). `aspect-ratio: auto` MUST be
   restated (see the collision note above) — a theme's own `.card-tile img`
   rule may already fix a crop ratio, and an unset property never "clears"
   a lower-specificity declaration for that SAME property, regardless of
   this rule's higher specificity on the properties it DOES set. */
[data-cms-block][data-variant="masonry"] .cardwall {
  columns: 3 18rem;
  column-gap: clamp(1.6rem, 3vw, 2.6rem);
}
[data-cms-block][data-variant="masonry"] .cardwall .card-tile {
  display: block;
  width: 100%;
  break-inside: avoid;
  margin-bottom: clamp(1.6rem, 3vw, 2.6rem);
}
[data-cms-block][data-variant="masonry"] .cardwall .card-tile img {
  width: 100%;
  height: auto;
  aspect-ratio: auto;
  display: block;
}

/* carousel — a single horizontal row, native CSS scroll-snap, no JS —
   mirrors gallery's own carousel exactly (same tile/gap clamps, trailing
   mask-fade "scroll for more" cue). `.wrap.cardrow-track` keeps the site's
   own gutter on the row (cards.html renders it as a second `.wrap`
   sibling, after the section-head's own `.wrap` — mirrors grand);
   `aspect-ratio: 4/3` is restated (a wider, landscape crop reads better in
   a horizontal strip) — overriding a theme's own portrait-oriented default
   the same way masonry overrides its own crop above, just to a different
   ratio. */
[data-cms-block][data-variant="carousel"] .cardrow-track {
  display: flex;
  gap: clamp(1rem, 2vw, 1.6rem);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: clamp(1.5rem, 3vw, 3rem);
  padding-bottom: .75rem;
  -webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 56px), transparent 100%);
          mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 56px), transparent 100%);
}
[data-cms-block][data-variant="carousel"] .cardrow-track .card-tile {
  flex: 0 0 clamp(14rem, 32vw, 22rem);
  scroll-snap-align: start;
}
[data-cms-block][data-variant="carousel"] .cardrow-track .card-tile img {
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
@media (max-width: 680px) {
  [data-cms-block][data-variant="carousel"] .cardrow-track .card-tile { flex-basis: 78vw; }
}

/* feature — the first card renders large/prominent in its own, standalone
   `.feature-lead` wrapper (not a `grid-column: span` on a shared track;
   the "rest" grid is a SEPARATE, later element — grand's own two-part
   structure, ported as-is), the rest in a smaller responsive grid beneath,
   sized by the SAME per_row field "grid" uses (`.cards-feature-grid
   [data-per-row]` below). */
[data-cms-block][data-variant="feature"] .feature-lead {
  margin-bottom: clamp(2.6rem, 5vw, 4.2rem);
}
[data-cms-block][data-variant="feature"] .feature-lead .card-tile {
  display: grid;
  grid-template-columns: 1.15fr 1fr;
  gap: clamp(1.8rem, 4vw, 3.4rem);
  align-items: center;
}
[data-cms-block][data-variant="feature"] .feature-lead .card-tile img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
[data-cms-block][data-variant="feature"] .feature-lead .card-tile-body {
  padding-top: 0;
  text-align: left;
}
[data-cms-block][data-variant="feature"] .feature-lead h3 {
  font-size: clamp(2rem, 1.6rem + 1.8vw, 2.9rem);
}
[data-cms-block][data-variant="feature"] .feature-lead .card-tile-text {
  margin-top: 1.1rem;
  max-width: 42ch;
}
@media (max-width: 760px) {
  [data-cms-block][data-variant="feature"] .feature-lead .card-tile { grid-template-columns: 1fr; }
  [data-cms-block][data-variant="feature"] .feature-lead .card-tile-body { text-align: center; }
  [data-cms-block][data-variant="feature"] .feature-lead .card-tile-text { margin-inline: auto; }
}
[data-cms-block][data-variant="feature"] .cards-feature-grid {
  display: grid;
  gap: clamp(1.8rem, 3vw, 2.6rem);
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
}
[data-cms-block][data-variant="feature"] .cards-feature-grid[data-per-row="1"] { grid-template-columns: minmax(0, 1fr); }
[data-cms-block][data-variant="feature"] .cards-feature-grid[data-per-row="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
[data-cms-block][data-variant="feature"] .cards-feature-grid[data-per-row="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
[data-cms-block][data-variant="feature"] .cards-feature-grid[data-per-row="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 680px) {
  [data-cms-block][data-variant="feature"] .cards-feature-grid,
  [data-cms-block][data-variant="feature"] .cards-feature-grid[data-per-row="2"],
  [data-cms-block][data-variant="feature"] .cards-feature-grid[data-per-row="3"],
  [data-cms-block][data-variant="feature"] .cards-feature-grid[data-per-row="4"] { grid-template-columns: 1fr; }
}

/* Shared new-markup spacing for masonry/carousel/feature's per-card body
   (_cards_item.html's `.card-tile-body`) — layout-only, no color (each
   theme's own main.css supplies `.instrument`/`.card-tile-text`'s color,
   alongside its existing `.detail`/`.card-tile-bio`), so this is safe
   regardless of which theme links this file. feature's own lead card
   overrides it to `padding-top: 0` above (the image sits BESIDE the copy
   there, not above it) — that rule carries the extra `.feature-lead`
   ancestor class, making it unambiguously MORE specific than this shared
   one regardless of source order. */
[data-cms-block][data-variant="masonry"] .card-tile-body,
[data-cms-block][data-variant="carousel"] .card-tile-body,
[data-cms-block][data-variant="feature"] .card-tile-body {
  padding-top: 1rem;
}

/* ---- Image sizing fix: neutralize the width/height attributes ----
   cms/image.html emits width="…" height="…" on every <img> (they prevent
   layout shift). Those attributes are PRESENTATIONAL HINTS, and per CSS a
   specified height defeats `aspect-ratio` entirely — so every theme rule of
   the form `<container> img { width: 100%; aspect-ratio: 3/4 }` was silently
   ignored and the image rendered at its full source pixel height. Two photos
   of different sizes therefore produced two different frame heights: ragged
   columns and images overflowing the space meant for them.

   Restoring `height: auto` hands sizing back to width + aspect-ratio, which
   is what makes every tile in a row identical. Deliberately UNGATED by any
   knob — the defect is present with no knob set, so this must hold for all
   existing content.

   The container list is what matters here, and it is wider than it looks:
   11 of the 14 bundled themes style their default grid cards as
   `.artist img`, only three as `.card-tile img`, and every theme frames
   gallery tiles as `.gallery-grid img` (grand: `.gallery-item img`). A fix
   aimed at one of those class names silently leaves the rest broken, which is
   why TestBundledThemes_AspectRatioImagesAreNeutralized reads this rule's
   selectors and checks every theme's aspect-ratio image rule against them.

   Frames that legitimately pin a height (hero band/backdrop, portrait and
   media_text frames, all of which say `height: 100%`) are deliberately NOT in
   this list. Nothing else declares a height on these containers — the two
   rules that do (grand's own card and masonry-gallery images) already say
   `auto` — so this conflicts with nothing. */
[data-cms-block] .img-wrap img,
[data-cms-block] .card-tile img,
[data-cms-block] .artist img,
[data-cms-block] .gallery-grid img,
[data-cms-block] .gallery-item img { height: auto; }

/* ---- Cards image framing (data-image-ratio / data-image-fit) ----
   The editor's frame-shape and fill-mode knobs. Both are BLOCK-level, which
   is what guarantees every column keeps an identical frame — the whole point
   of the feature. The matching per-CARD crop needs no rule here: it arrives
   as data-image-position on the individual <img> (emitted by cms/image.html),
   which redefines --block-image-position nearer the element, and the shared
   object-position rule up in the hero-framing section already consumes it for
   `.img-wrap img`.

   THIS BLOCK MUST STAY LAST IN THE FILE. Three variant rules above restate
   aspect-ratio/object-fit on the very same card image at the SAME specificity
   as these selectors — masonry (`aspect-ratio: auto`), carousel (4/3) and
   feature's lead card — so the tie is broken by source order alone. Moved
   earlier, the knob would silently stop working in exactly those three
   layouts while still working in "grid". A test asserts this ordering.

   `.placeholder` is included so a card with no image yet keeps the same frame
   as its neighbours instead of breaking the row. height:auto lets the
   aspect-ratio drive the box even where a theme pinned a height. */
[data-cms-block][data-image-ratio] .card-tile .img-wrap img,
[data-cms-block][data-image-ratio] .card-tile .img-wrap .placeholder {
  aspect-ratio: var(--block-image-ratio);
  width: 100%;
  height: auto;
}
[data-cms-block][data-image-fit] .card-tile .img-wrap img {
  object-fit: var(--block-image-fit, cover);
}
