/* ============================================================================
   Monarch Celebrations — redesign token layer
   Generated against DESIGN.md (operator-approved 2026-08-18, option B).
   Every value here traces to DESIGN.md. Do not introduce a token not in that file.
   ========================================================================== */

/* --- Fonts: self-hosted, no Google Fonts runtime dependency (DESIGN.md) ----- */
@font-face {
  font-family: 'Fraunces';
  src: url('fonts/Fraunces-web.woff2') format('woff2-variations');
  /* DESIGN.md approves Fraunces at 400 only. The FILE stays variable (opsz is the
     point of the face); the exposed WEIGHT range is pinned so no unapproved weight
     can be reached by accident. */
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Fraunces';
  src: url('fonts/Fraunces-Italic-web.woff2') format('woff2-variations');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Plus Jakarta Sans';
  src: url('fonts/PlusJakartaSans-web.woff2') format('woff2-variations');
  /* 400 500 600 are all approved, so the contiguous 400-600 range is exactly the spec.
     Verified 2026-08-23, not assumed: I widened this to 700 for the carousel caption on the theory
     that 700 was being synthesised. A width probe at 40px says otherwise — "Handgloves 12345"
     measures 352.52px at BOTH 600 and 700, i.e. this file's weight axis tops out at 600 and the
     browser clamps. The widening bought nothing, so it is reverted rather than left in place
     looking like a fix. Mark 6's "bolder" is carried by size and scrim instead. */
  font-weight: 400 600;
  font-style: normal;
  font-display: swap;
}

:root {
  /* Palette — measured from base.css; a11y remediations are load-bearing */
  --canvas:        #FAF6EE;
  --surface-1:     #FFFFFF;
  --surface-2:     #EBDDC5;
  --surface-dark:  #1F2540;
  --text-primary:  #1F2540;
  --text-secondary:#3D4D60;
  --text-muted:    #65737C;   /* darkened to 4.5:1 — do NOT lighten */
  --hairline:      #D4C5A8;
  --accent:        #E89A7A;   /* PRIMARY ACTION ONLY */
  --accent-hover:  #C97A5C;
  /* QA 2026-08-19: #97644F cleared 4.6:1 on CREAM but only 3.69:1 on --surface-2 blush,
     which the split-band eyebrows and .mark sit on. Darkened one step along the same hue
     so it clears 4.5 on EVERY ground it is used on, with margin rather than by a hair:
     blush 5.26 · cream 6.53 · white 7.04. Measured, not eyeballed. */
  --accent-text:   #7A4E3C;
  --forest:        #2A5560;
  /* CURRENTLY UNUSED (mc-dkxu, 2026-09-05). It existed for exactly one rule — .btn-portal:hover —
     and the operator measured that hover as "not enough contrast": 1.55:1 against --forest. The
     Portal button now inverts to --accent / --text-primary instead. The token is kept, not deleted:
     it is a legitimate lighter partner to --forest if a future dark-ground surface needs one, and
     deleting a named colour is a bigger decision than retiring one rule. */
  --forest-bright: #3C7385;   /* LIGHTER than --forest, never darker */
  --gold:          #B59556;   /* DARK GROUNDS ONLY — ~3:1 on light */
  /* NEW 2026-08-23, walk mark 14 (mc-fjyq) — "highlight in red". The palette had no error colour,
     only the amber --warn-* advisory pair, and amber is not red. 6.4:1 on white / 5.9:1 on canvas,
     so it is legible as TEXT and not merely as a border. PENDING doctrine sign-off. */
  --danger:        #B3261E;
  --warn-bg:       #FFF4E5;   /* under-review banner, legal pages only */
  --warn-line:     #E8A87C;
  --warn-text:     #8B4A2B;

  /* Type */
  --font-display: 'Fraunces', 'Charter', 'Iowan Old Style', Georgia, serif;
  --font-body:    'Plus Jakarta Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;

  /* Spacing — 8px base */
  --s1: 4px;  --s2: 8px;  --s3: 16px; --s4: 24px; --s5: 32px;
  --s6: 48px; --s7: 64px; --s8: 96px; --s9: 128px;
  --section-y: var(--s8);
  --container: 1240px;
  --gutter: var(--s4);
  /* mc-jvug, operator verbatim: "This row should be the full width of the page up until a
     1920-pixel-width viewport and anything bigger than that, then it centers it. The same goes
     for all of the rows above it."
     A two-column row is NOT a --container: the container is the 1240px centred reading column,
     and these rows are meant to run edge to edge. So this is a second, much wider ceiling that
     only the full-bleed rows use, written ONCE here rather than as a bare 1920 sprinkled into a
     rule. Named for what it is — the widest a full-bleed row is allowed to get. */
  --row-max: 1920px;

  /* Shape + elevation */
  --radius: 4px;
  --radius-lg: 12px;
  --shadow-sm: 0 1px 2px rgba(31,37,64,.06);
  --shadow-md: 0 4px 12px rgba(31,37,64,.08);
  --shadow-lg: 0 12px 32px rgba(31,37,64,.12);

  /* Text on DARK grounds. These were four unnamed literals (.92 / .86 / .82 / .8) scattered
     across the hero, footer and carousel — the exact thing this file's header forbids, and a
     false pass in the token-fidelity check, which compared RGB only and read them all as
     plain white. Two named steps replace all four. */
  --on-dark:       rgba(255,255,255,.92);
  --on-dark-muted: rgba(255,255,255,.82);
  --on-dark-line:  rgba(255,255,255,.55);   /* borders only, never text */

  --nav-h: 76px;
}

/* Skip link — the nav is position:fixed, so a keyboard user otherwise tabs through the
   whole navigation on every page before reaching content. Visible ONLY on focus. */
.skip-link {
  position: fixed; inset-inline-start: var(--gutter); inset-block-start: -100px;
  z-index: 100; background: var(--surface-1); color: var(--text-primary);
  padding: 12px 20px; border-radius: var(--radius); border: 1px solid var(--hairline);
  font-family: var(--font-body); font-size: .95rem; font-weight: 600; text-decoration: none;
  transition: inset-block-start .15s ease;
}
.skip-link:focus { inset-block-start: var(--s3); }
/* The skip link moves focus to #main. Suppressing the ring outright leaves a keyboard user
   with no idea where they landed, so give the landing target a visible, deliberate
   treatment instead of `outline: none`. */
main:focus-visible { outline: 2px solid var(--accent-hover); outline-offset: 4px; }

*, *::before, *::after { box-sizing: border-box; }
html { scroll-padding-top: 90px; -webkit-text-size-adjust: 100%; }
body {
  margin: 0;
  background: var(--canvas);
  color: var(--text-primary);
  font-family: var(--font-body);
  font-size: 1rem;
  line-height: 1.65;
  font-weight: 400;
}

/* --- Type scale ------------------------------------------------------------ */
h1, h2 {
  font-family: var(--font-display);
  font-weight: 400;
  font-optical-sizing: auto;   /* the point of Fraunces */
  margin: 0;
}
h1 { font-size: clamp(2.6rem, 5.5vw, 4.25rem); line-height: 1.05; letter-spacing: -0.02em; text-wrap: balance; }
h2 { font-size: clamp(1.9rem, 3.6vw, 2.75rem); line-height: 1.15; letter-spacing: -0.01em; text-wrap: balance; }
h3 { font-family: var(--font-body); font-size: clamp(1.25rem, 2.4vw, 1.6rem); font-weight: 600; line-height: 1.25; margin: 0; }
p  { margin: 0 0 var(--s3); }
.lede { font-size: 1.15rem; line-height: 1.6; color: var(--text-secondary); max-width: 60ch; }
small, .small { font-size: .875rem; line-height: 1.5; }

/* The italic accent word — now a real italic, not weight-faked */
h1 em, h2 em { font-style: italic; color: var(--accent-text); }
/* On the dark hero --accent-text measures only ~3:1. Large text technically clears 3:1,
   but the site's a11y posture is "compute it, do not squeak by" — use the same warm
   light tone the hero eyebrow uses (11.23:1). */
.hero h1 em, .hero h2 em { color: var(--surface-2); }

.eyebrow {
  font-family: var(--font-body);
  font-size: .75rem; font-weight: 600; line-height: 1.2;
  letter-spacing: .15em; text-transform: uppercase;
  color: var(--accent-text);
  margin: 0 0 var(--s2);
}

/* Inline emphasis inside running copy (adopted from Rafanelli, at 4.6:1) */
.mark { color: var(--accent-text); font-weight: 500; }

.container { max-width: var(--container); margin: 0 auto; padding-inline: var(--gutter); }
.section { padding-block: var(--section-y); }
.prose { max-width: 68ch; }
.prose p { color: var(--text-secondary); }

/* --- Section divider: the thin vertical rule (adopted from Rafanelli) ------- */
.rule-v { width: 1px; height: 32px; background: var(--hairline); margin: var(--s4) auto; border: 0; }
.section-header { margin-bottom: var(--s7); }
.section-header.center { text-align: center; }
.section-header.center .lede { margin-inline: auto; }

/* THE NAV AND THE FOOTER MOVED TO assets/chrome.css (mc-g629, 2026-09-05).
   The old pages -- the giveaway, the giveaway rules, the client portal, the services
   agreement, vendor-partnership and the ten blog posts -- now show the same bar and the
   same footer as these nineteen, and they load assets/css/base.css, which cannot coexist
   with this file: the two disagree on body, h1, h2, h3, p, .btn, .eyebrow, .prose,
   .container and .nav, so whichever loads second wins outright. So the chrome lives in its
   own sheet that both shells load, and it is NOT duplicated here -- includes/head.php
   links chrome.css immediately before this file. What moved: the whole nav block, the
   Events disclosure group, the two nav-scoped button rules, .btn-portal, the footer, and
   the mobile-nav half of the 1150px media query below. */
/* --- Buttons -------------------------------------------------------------- */
/* ROOT CAUSE of three separate #0000EE sightings: there was no base link rule at all. Every
   link was styled by class (.btn, .post, .nav-links a, .footer a), so a link written without
   one fell through to the browser default — legible, and therefore invisible to a contrast
   check, but not a colour anyone chose.
   Underlined as well as coloured: WCAG 1.4.1 means a link in running text must not be
   distinguished by colour alone. Specificity is (0,1,2), below every class-based link rule. */
main a:not([class]) {
  color: var(--accent-text);
  text-decoration: underline;
  text-underline-offset: 2px;
}
main a:not([class]):hover { color: var(--text-primary); }

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--s2);
  font-family: var(--font-body); font-size: .95rem; font-weight: 500; letter-spacing: .02em;
  padding: 12px 24px; border-radius: var(--radius);
  text-decoration: none; cursor: pointer; border: 1px solid transparent;
  transition: background .18s ease, border-color .18s ease, color .18s ease;
}
.btn-primary { background: var(--accent); color: var(--text-primary); }  /* 6.7:1 */
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: transparent; color: var(--forest); border-color: var(--forest); }
.btn-secondary:hover { background: var(--forest); color: var(--surface-1); }
/* Account glyph. Stroked, currentColor, sized to the cap height so it reads as part of the
   label rather than an attached badge. aria-hidden in the markup — the link text is the name. */
.btn-ico { width: 1.05em; height: 1.05em; flex: none; fill: none; stroke: currentColor;
           stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }

.btn-ghost { background: transparent; color: var(--surface-1); border-color: var(--on-dark-line); }
.btn-ghost:hover { background: rgba(255,255,255,.12); }

:where(a, button, input, select, textarea, summary):focus-visible {
  outline: 2px solid var(--accent-hover); outline-offset: 2px; border-radius: var(--radius);
}

/* --- Hero: leads with the portal, NOT a wordless photograph ---------------- */
.hero {
  /* DESIGN.md: page hero 160px top / 64px bottom. The nav is fixed and overlays the
     hero, so the 160px is measured from the nav's underside, not the viewport top. */
  position: relative; padding-block: calc(var(--nav-h) + 160px) 64px;
  background: var(--surface-dark); color: var(--surface-1); overflow: hidden;
}
.hero::after { /* readability scrim over any background media */
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(31,37,64,.72) 0%, rgba(31,37,64,.90) 100%);
  z-index: 0;
}
.hero > .container { position: relative; z-index: 1; }
.hero h1 { color: var(--surface-1); max-width: 18ch; }
.hero .lede { color: var(--on-dark); margin-block: var(--s4) var(--s6); max-width: 56ch; }
.hero .eyebrow { color: var(--surface-2); }
.hero-actions { display: flex; gap: var(--s3); flex-wrap: wrap; }

/* --- Split band: colour field beside image, alternating (from Brilliant) --- */
.split { display: grid; grid-template-columns: 1fr 1fr; align-items: stretch; }
/* BAND GROUNDS — operator ruling 2026-08-23, closing walk marks 1 / 5 / 6.
   Rows alternate #FAF6EE (--canvas) and white, and each row is ONE colour across BOTH columns.
   Two things that mattered in the prototypes:
   • UNIFORM PER ROW. An earlier mock lightened only .split-field, so the copy column went pale while
     the art column stayed tan and the band visibly split into two panels. "I like A because the
     color is uniform on the bar" — so both halves move together, always.
   • The tan (--surface-2) is gone from the bands. It passed contrast (6.46:1) but read heavy under a
     block of copy. On canvas the body sits at 8.02:1 and --text-muted reaches 4.53:1, so every
     colour used on a band now clears AA — including the caption colour that failed at 3.65:1. */
.split-field { background: var(--canvas); padding: clamp(32px, 5vw, 72px); display: flex; flex-direction: column; justify-content: center; }
/* The `font-size: 1rem` that used to sit here shrank every split lede from the design's 1.15rem
   (18.4px) to 16px — a 13% demotion of the emphasis size to body size, with nothing in this file
   explaining why splits should differ. The operator flagged it three separate times in one walk
   ("the copy is too little" · "very small" · "very stint"), and MEASURING found this one rule
   behind all three. Contrast was never the problem: #3D4D60 on --surface-2 measures 6.46:1 against
   a 4.5 requirement. Small text at weight 400 on a mid-tone ground simply READS as low contrast.
   Colour is still pinned here because .split-field sets its own ground. */
.split-field .lede { color: var(--text-secondary); }
.split-media { min-height: 420px; background-size: cover; background-position: center; }
.split.is-flipped .split-field { order: 2; }
/* The alternate row: white, again across BOTH columns. */
.split.is-alt .split-field,
.split.is-alt .split-media.is-art { background: var(--surface-1); }
/* At --canvas the band is the same value as the page, so it no longer separates itself by colour.
   A hairline does that job — without it the bands stop reading as bands at all. */
.split { border-block-start: 1px solid var(--hairline); }
.split + .split { border-block-start: 0; }

/* mc-jvug — THE HOMEPAGE ROWS STOP AT --row-max AND CENTRE PAST IT.
   Operator verbatim: "This row should be the full width of the page up until a 1920-pixel-width
   viewport and anything bigger than that, then it centers it. The same goes for all of the rows
   above it." Measured before this rule: at 1280 the row was 1280 wide, at 1920 it was 1920, at
   2400 it was 2400 — no cap and no centring anywhere.
   SCOPED TO THE HOMEPAGE, deliberately. `.split` is the site-wide two-column row pattern and it is
   used on the weddings, corporate and quinceañera pages too. The operator's words were "all of the
   rows above it", which reads as the page she was on, so this names her three homepage sections —
   #portal, #events and #testimonials, which are the only three ids of those names anywhere in the
   site (checked: index.php only). Whether every page should follow is a separate ruling and is not
   assumed here.
   VISIBLE SIDE EFFECT PAST 1920, and it is intended: the hairline that opens each row (the rule
   directly above) now stops with the row instead of running the full width of a very wide screen,
   and the white alternate rows become a 1920-wide band centred on the page ground. */
#portal .split,
#events .split,
#testimonials .split {
  max-width: var(--row-max);
  margin-inline: auto;
}

/* --- Cards: hairline, not shadow ------------------------------------------ */
.card { background: var(--surface-1); border: 1px solid var(--hairline); border-radius: var(--radius-lg); padding: var(--s4); }
.card h3 { margin-bottom: var(--s2); }
.card p { color: var(--text-secondary); margin-bottom: 0; }
.grid { display: grid; gap: var(--s4); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }

/* mc-iopq, 2026-09-05 — the about page's four story blocks, in ONE column.
   Operator verbatim: "The two-column system isn't working. Make it a single column centered in the
   body, same width as the div container for the 'The Truth About Being New' section. One headline
   per row, one-column row." THIS REVERSES the `.grid-2` rows the same bead asked for on the walk
   before it; about.php carries the earlier ruling quoted in full beside the markup.
   NO WIDTH IS WRITTEN HERE, deliberately. The width the operator named is the `.container` that the
   Truth About Being New section already sits in, and these blocks sit in the same `.container` — so
   the column reaches that width by RELEASING `.prose`'s 68ch reading cap, not by naming a number
   that would then have to be kept in step with `--container` by hand. Centred in the body is
   `.container`'s own `margin: 0 auto`, unchanged.
   The gap is the row rhythm, not a column gutter: with one column, `--s4` (24px) let two long
   stories touch, so these rows take the section-sized `--s7` between headings instead. */
.about-column { grid-template-columns: minmax(0, 1fr); gap: var(--s7); }
.about-column > .prose { max-width: none; }

/* --- Portal proof strip: the thing no competitor has ---------------------- */
.portal-proof { background: var(--surface-1); border-block: 1px solid var(--hairline); }
.proof-item .eyebrow { color: var(--text-muted); }

/* mc-3gpg + mc-z3qn, 2026-09-05 — THIS REVERSES the 88px icon that used to be written here, and
   the old note is quoted rather than deleted: "One icon per card (bead mc-z3qn): same
   subordinate-to-copy sizing as .card-badge, just larger." The operator measured the result and
   ruled against it twice: "The images are not big enough. I want the images to scale up so they're
   the width of the card that they pertain to."
   So the picture is now the card's own width, edge to edge, and a square box makes all four the
   same size regardless of what the source file is — three of these art files are 480x480 and the
   pricing one is 1200x896, which is precisely why the row read as "not the same size". `cover` on a
   1:1 box crops the wide one to a square instead of letter-boxing it. */
.proof-media {
  width: 100%; height: auto;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  display: block;
  image-rendering: pixelated;
}
/* The card surrenders its own padding so the picture controls its own inset, and `.proof-body`
   takes that padding back for the words. overflow:hidden keeps the square picture inside the
   card's 12px corner radius. */
.portal-proof .proof-item.card { padding: 0; overflow: hidden; }
.proof-item.card > .proof-media { border-radius: 0; }

/* mc-3gpg + mc-z3qn, 2026-09-05 — operator verbatim: "The images need to scale down 10%."
   THIS TRIMS the edge-to-edge result ruled two marks ago; the old note above is left standing
   because the ruling it records is still what set the picture's size — this takes ten percent off
   THAT, it does not go back to the 88px icon.
   90% of the card's own width, so all four shrink by the same amount and the four cards stay
   identical: the box is still `aspect-ratio: 1/1` with `object-fit: cover`, so every picture is
   still the same cropped square and the row keeps its shape. The card's border is untouched.
   The top inset is written as `5%` — a percentage margin resolves against the containing block's
   WIDTH, so it is exactly the same gap as the 5% left over on each side. One number, three sides,
   no second value to keep in step with the first. */
.portal-proof .proof-item.card > .proof-media {
  width: 90%;
  margin: 5% auto 0;
}
.proof-body { padding: var(--s4); }

/* RETIRED 2026-09-05 by mc-3gpg + mc-z3qn. The subgrid block that stood here aligned the
   eyebrow / heading / body rows of a THREE-column `.portal-proof .grid-3`, and that markup no
   longer exists — the strip has been a `.grid-4` since the cut-over, and the grid-4 escape hatch
   written beside it already dropped those items back to block flow. Kept as the record of what the
   alignment gate used to do here, not as live styling:
     .portal-proof .grid-3 { gap: var(--s3) var(--s6); grid-template-rows: auto auto auto auto; }
     .portal-proof .proof-item { display:grid; grid-row: span 4; grid-template-rows: subgrid; row-gap: var(--s2); }
     @supports not (grid-template-rows: subgrid) { .portal-proof .proof-item h3 { min-height: 2.5em } }
   The alignment gate is still met, by a simpler mechanism: the four items are grid children that
   stretch to the row's height, each one a bordered card with the same square picture on top, so
   the four cards are identical in width and height and the pictures share one baseline. */
.portal-proof .grid-4 { grid-template-rows: none; }
.portal-proof .grid-4 .proof-item { display: block; grid-row: auto; }

/* --- Instagram FACADE (approved): no third-party contact until user acts --- */
/* KEPT: the facade class is still used by v1 pages. v2 now renders the real thumbnail
   rail below, which preserves the same privacy property by different means — the
   thumbnails are OUR files, so page load contacts Meta zero times, and the only request
   that ever reaches them is the iframe a visitor opens by clicking. */
.ig-facade {
  position: relative; display: grid; place-items: center;
  min-height: 320px; background: var(--surface-2);
  border: 1px solid var(--hairline); border-radius: var(--radius-lg); text-align: center; padding: var(--s5);
}
.ig-facade p { color: var(--text-secondary); max-width: 46ch; }

/* --- Instagram FEED — thumbnail rail + click-to-open embed ----------------- */
.ig-empty {
  border: 1px solid var(--hairline); border-radius: var(--radius-lg);
  background: var(--surface-2); padding: var(--s5); text-align: center;
}
.ig-empty p { color: var(--text-secondary); max-width: 52ch; margin-inline: auto; }

/* Native overflow scrolling with scroll-snap rather than a JS carousel: keyboard
   scrolling, trackpads and touch swipes all work for free, and with JS off it stays a
   plain scrollable row instead of a dead widget. */
.ig-rail {
  display: flex; gap: var(--s3);
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  padding-bottom: var(--s3);            /* room for the scrollbar, not the thumbnails */
  -webkit-overflow-scrolling: touch;
}
.ig-item { flex: 0 0 auto; scroll-snap-align: start; }

.ig-thumb {
  position: relative; display: block; padding: 0; cursor: pointer;
  width: 220px; height: 220px;
  border: 1px solid var(--hairline); border-radius: var(--radius-lg);
  background: var(--surface-2); overflow: hidden;
  transition: transform .18s ease, box-shadow .18s ease;
}
.ig-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ig-thumb:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(31,37,64,.14); }
.ig-thumb:focus-visible { outline: 3px solid var(--forest); outline-offset: 3px; }

/* Play badge on video/reel thumbnails. Sits on a scrim so it stays legible over a bright
   frame — a white glyph alone disappears on half the photos in a feed. */
.ig-play {
  position: absolute; right: var(--s2); bottom: var(--s2);
  display: grid; place-items: center; width: 32px; height: 32px;
  border-radius: 50%; background: rgba(31,37,64,.72); color: #fff;
}

/* Modal. The iframe is injected on open and removed on close (see site.js) — a pool of
   pre-built iframes would mean every post connecting to Meta on page load. */
.ig-modal[hidden] { display: none; }
.ig-modal {
  position: fixed; inset: 0; z-index: 1000;
  display: grid; place-items: center; padding: var(--s4);
}
.ig-modal-backdrop { position: absolute; inset: 0; background: rgba(31,37,64,.6); }
.ig-modal-panel {
  position: relative; z-index: 1; width: min(420px, 100%);
  background: var(--surface-1); border-radius: var(--radius-lg);
  padding: var(--s4); max-height: 90vh; overflow: auto;
}
.ig-modal-body { min-height: 320px; }
.ig-modal-body iframe { width: 100%; border: 0; display: block; }
.ig-modal-close {
  position: absolute; top: var(--s2); right: var(--s2);
  width: 32px; height: 32px;                 /* WCAG 2.5.8 target size */
  font-size: 24px; line-height: 1; cursor: pointer;
  background: transparent; border: 0; color: var(--text-secondary);
}
.ig-modal-close:focus-visible { outline: 3px solid var(--forest); outline-offset: 2px; }
.ig-modal-foot { margin: var(--s3) 0 0; text-align: center; }

@media (max-width: 600px) {
  .ig-thumb { width: 150px; height: 150px; }
}
@media (prefers-reduced-motion: reduce) {
  .ig-thumb { transition: none; }
  .ig-thumb:hover { transform: none; }
}

/* --- Under-review banner (legal pages) ------------------------------------ */
.review-banner {
  background: var(--warn-bg); border: 1px solid var(--warn-line);
  border-left: 4px solid var(--warn-text);
  border-radius: var(--radius); padding: 18px 22px; margin-bottom: var(--s5); font-size: .95rem;
}
/* Only the FIRST strong is the banner's title and wants to be a block. The rule used to match
   EVERY strong in the banner, so the emphasised phrases inside the sentence ("not yet been reviewed
   by a Florida-licensed attorney", "may change", "the signed document controls") each became their
   own block and shattered the paragraph across seven lines. Pre-existing on privacy.php and
   sla.php; found while adding the same banner to terms.php and accessibility.php, which is how a
   two-page defect became visible. Inline emphasis now stays inline and still takes the warn colour. */
.review-banner > strong:first-child { display: block; margin-bottom: 4px; }
.review-banner strong { color: var(--warn-text); }

/* --- Responsive ----------------------------------------------------------- */
@media (max-width: 1150px) {
  .grid-3 { grid-template-columns: 1fr; }
  .grid-2 { grid-template-columns: 1fr; }
  .grid-4 { grid-template-columns: 1fr; }
  /* mc-1u0d: "especially when this gets to mobile, where each card is going to fill one row."
     One column means the flow runs DOWNWARD, so the chevron moves from the right edge to the
     bottom edge and turns with it. Same element, same gap, quarter-turn. */
  .step-card::after {
    inset-block-start: auto;
    inset-block-end: calc(var(--s4) / -2);
    inset-inline-end: 50%;
    transform: translate(50%, 50%) rotate(45deg);
  }
  .split { grid-template-columns: 1fr; }
  .split.is-flipped .split-field { order: 0; }
  .split-media { min-height: 260px; }

  .portal-proof .proof-item { display: block; grid-row: auto; }
  /* mc-3gpg: the proof pictures are now square and the full width of their card, so ONE column
     under 1150px would render a ~1100px-tall picture on a tablet. Two columns keep the picture at
     a sane size; the single column waits for the phone breakpoint below. */
  .portal-proof .grid-4 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 420px) {
  :root { --section-y: var(--s7); }
}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}

/* Wide content must scroll in its own container, never the body */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: .92rem; }
th { background: var(--surface-2); text-align: left; padding: 10px 12px; font-weight: 600; border-bottom: 1px solid var(--hairline); }
td { padding: 10px 12px; border-bottom: 1px solid var(--hairline); color: var(--text-secondary); vertical-align: top; }

/* --- Pixel-art walkthrough carousel (top of the homepage) ------------------
   Operator-approved assets from plans/homepage-pixel-journey. GIF-style: muted
   autoplay loops, no play button, no sound; advances on an 8s timer. It sits ABOVE
   the hero, so it is the first thing on the page. */
.carousel-wrap { background: var(--surface-dark); padding-block: calc(var(--nav-h) + var(--s5)) var(--s3); }
/* Walk mark 7 (mc-m89x): "a little too much space here between the animated reel and the section
   below it. Reduce this by 50%." Measured, not eyeballed: the gap was the reel's 32px bottom padding
   PLUS the next section's 96px top padding = 128px. Halving one side alone would land at 112px and
   read as "you didn't do it", so both are halved — 16 here + 48 on the hero = 64px, exactly 50%.
   The hero half lives with the existing `.carousel-wrap + .hero` rule further down this file. */
/* Operator 2026-08-19: the carousel fills the container width — it is the masthead,
   so it should not sit narrower than the content beneath it. */
.carousel { width: 100%; }
.carousel-stage {
  position: relative; aspect-ratio: 16 / 9; background: #11141f;
  border: 1px solid rgba(255,255,255,.12); border-radius: var(--radius-lg);
  overflow: hidden; display: grid; place-items: center;
}
.carousel-stage video {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; opacity: 0; transition: opacity .45s ease;
  image-rendering: pixelated;          /* the art is pixel art — do not smooth it */
}
.carousel-stage video.is-current { opacity: 1; }
.carousel-fallback { color: var(--on-dark-muted); font-size: .95rem; max-width: 44ch; text-align: center; padding: var(--s4); }
/* Operator walk, mark 4. Stacking order is now: CAPTION, clip, dots, controls — the caption
   reads before the thing it describes, and both jump-targets sit directly under the clip.
   The caption animates in on each advance rather than swapping text under a static frame. */
/* Caption OVER the art (operator ruling 2026-08-21, overriding ART-DIRECTION §5's
   "fixed band directly below the media box").
   The burn-in prohibition still holds and is what makes this safe: the caption is still
   HTML rendered from SCENES.json, never lettering inside a frame, so EN/ES still work and
   nothing has to be re-generated per language.

   Operator constraint: it must NOT obfuscate the art. So it is pinned to the BOTTOM edge,
   sized to its content rather than a reserved 3-line block, and backed by a gradient that
   fades to fully transparent upward — a flat translucent bar would cut a hard horizontal
   line across the frame, which is the thing that reads as "covering" the art. */
.carousel-frame { position: relative; }
.carousel-caption {
  position: absolute; left: 0; right: 0; bottom: 0;
  margin: 0;
  padding: var(--s5) var(--s4) var(--s3);
  color: var(--on-dark);
  /* "Bold font, maybe Jakarta, something more squarish" — Jakarta IS the body face, so
     this is weight + tracking rather than a new family. No new font to load. */
  font-family: var(--font-body);
  /* Walk mark 6 (mc-q1ex): "the font is too small here and it needs to be bolder to contrast
     against the animation behind it."
     600, not 700 — measured. This asked for 700, but Plus Jakarta Sans' weight axis in this file
     tops out at 600 (a width probe reads identical at both), so 700 was only ever a request the
     font could not honour. Asking for a weight that does not exist is how you get a caption that
     "isn't bold enough" and a stylesheet that looks like it already tried. The contrast the mark
     actually wants comes from the three levers that DO move: a real size step, a heavier scrim,
     and a stronger shadow. Size scales with the viewport because the video behind it gets busier
     as it gets wider. */
  font-weight: 600;
  font-size: clamp(1.15rem, 1.5vw, 1.4rem); line-height: 1.4;
  letter-spacing: .005em;
  text-align: center;
  text-wrap: balance;
  /* Transparent at the top, solid enough at the bottom to hold 4.5:1 on any frame. Deepened for
     mark 6: with 700 unavailable, the scrim does the work the missing weight cannot. */
  background: linear-gradient(to top,
              rgba(20, 24, 42, .94) 0%,
              rgba(20, 24, 42, .82) 45%,
              rgba(20, 24, 42, 0)  100%);
  /* Belt for a bright frame: the scrim carries most of it, the shadow covers the rest. */
  text-shadow: 0 1px 4px rgba(0, 0, 0, .7);
  border-bottom-left-radius: inherit;
  border-bottom-right-radius: inherit;
  pointer-events: none;                /* never intercept a click meant for the carousel */
}
/* Slide in from the left + fade (operator walk mark b1, mc-vd2p: the previous vertical
   rise "blipped into existence" — this reads as a deliberate entrance instead). */
.carousel-caption.is-entering {
  animation: caption-in .5s cubic-bezier(.2, .7, .3, 1) both;
}
@keyframes caption-in {
  from { opacity: 0; transform: translateX(-32px); }
  to   { opacity: 1; transform: none; }
}
/* An entrance animation on text that changes every 8s is exactly what this media query is
   for. Reduced motion keeps the fade (still a real transition) but drops the slide, since
   the horizontal motion is the part a "reduce" preference is asking to avoid. */
@media (prefers-reduced-motion: reduce) {
  .carousel-caption.is-entering { animation: caption-in-fade .5s ease both; }
}
@keyframes caption-in-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}
.carousel-controls {
  display: flex; gap: var(--s2); justify-content: center; margin-top: var(--s3);
}
.carousel-btn {
  width: 44px; height: 44px; display: grid; place-items: center;
  background: transparent; color: var(--surface-1);
  border: 1px solid rgba(255,255,255,.45); border-radius: var(--radius);
  font-size: 1rem; line-height: 1; cursor: pointer;
}
.carousel-btn:hover { background: rgba(255,255,255,.12); }
.carousel-dots { display: flex; flex-wrap: wrap; gap: var(--s2); margin-top: var(--s3); justify-content: center; }
/* These are real controls, so they need a real target. WCAG 2.5.8 sets the minimum at 24x24
   CSS px; a 34x6 pill fails it outright and is genuinely hard to hit on a phone. The pill
   stays 6px tall visually — it is drawn with ::before — while the button itself is 24px tall
   and transparent, so the hit area is compliant without changing the design. */
.carousel-dots button {
  height: 24px; width: 34px; padding: 0; border: 0; cursor: pointer;
  background: transparent; display: grid; place-items: center;
}
.carousel-dots button::before {
  content: ''; display: block; height: 6px; width: 34px; border-radius: 3px;
  background: rgba(255,255,255,.28);
}
.carousel-dots button[aria-current="true"]::before { background: var(--accent); }
.carousel-dots button:focus-visible { outline: 2px solid var(--accent-hover); outline-offset: 3px; }

/* The hero follows the carousel, so it no longer clears the fixed nav itself.
   Walk mark 7 (mc-m89x): top was --s8 (96px), which with the reel's own 32px bottom padding made a
   128px trough. "Reduce this by 50%" → 16 + 48 = 64px. The reel side is halved at .carousel-wrap;
   this is the other half. Edited HERE rather than adding a new rule: an earlier
   `.carousel-wrap + .hero` I wrote was silently dead, outranked by this one at equal specificity
   but later in the file — two rules with the same selector is how spacing bugs get reintroduced. */
.carousel-wrap + .hero { padding-block: var(--s6) var(--s7); }

@media (max-width: 700px) {
  /* MOBILE DROPS THE OVERLAY AND SITS BELOW THE ART — deliberate, and a judgment call
     worth stating rather than burying.
     The ruling was "over the art, but do not obfuscate the art". At 375px the media box
     is only ~165px tall, so a two-line Spanish caption covers roughly a third of the
     frame and lands squarely on the characters. Position was the mechanism; not covering
     the art was the point. Where they conflict, the point wins — so below the frame at
     mobile, where it covers nothing, with the same slide-and-fade entrance.
     Overrule this and I will pin it to the frame at every width. */
  .carousel-caption {
    position: static;
    padding: var(--s3) 0 0;
    background: none;
    text-shadow: none;
    color: var(--on-dark);
    font-size: .95rem; line-height: 1.45;
    pointer-events: auto;
  }
}
@media (prefers-reduced-motion: reduce) {
  /* Do not auto-advance or autoplay for a reader who asked for stillness. */
  .carousel-stage video { animation: none; }
}

/* --- About: portrait beside copy ------------------------------------------ */
.split-portrait { display: grid; grid-template-columns: minmax(240px, 340px) 1fr; gap: var(--s6); align-items: start; }
.split-portrait img { width: 100%; height: auto; border-radius: var(--radius-lg); border: 1px solid var(--hairline); display: block; }
@media (max-width: 800px) { .split-portrait { grid-template-columns: 1fr; gap: var(--s4); } }

/* --- Blog list ------------------------------------------------------------ */
.post-list { display: grid; gap: var(--s3); }
/* `color` was missing here, so the card inherited the UA link colour and every post title
   rendered #0000EE. It survived six QA rounds because blue on white is 8.6:1 — it PASSES
   contrast. A contrast check cannot tell you a colour is wrong, only that it is legible;
   that is why the harness now also checks token fidelity. Anything that sets
   text-decoration:none on an <a> must set color too. */
/* mc-j1da (walk mark 29): cards carry the post's own lead image as a thumbnail. Grid rather than
   flex so a card with NO image keeps its text in the same column as every other card — with flex
   the text would slide left and the list would look ragged. `grid-template-columns` is set on the
   cards that HAVE an image only, via :has(). */
.post { display: block; padding: var(--s4); background: var(--surface-1); border: 1px solid var(--hairline); border-radius: var(--radius-lg); text-decoration: none; color: var(--text-primary); }
.post:has(.post-thumb) {
  display: grid; grid-template-columns: 140px 1fr; gap: var(--s4); align-items: start;
}
/* The card's text is wrapped in .post-body so the grid holds exactly TWO items. Two earlier
   attempts failed on this: with four loose children the browser flowed them across both columns
   and the h3 landed in the 140px image column (headline wrapping one word per line); pinning the
   image to grid-row 1/-1 fixed the columns but then the image's height was shared across the text
   rows, spacing the eyebrow, headline and blurb far apart. One wrapper removes both problems. */
.post-body { display: block; }
.post-body > :last-child { margin-bottom: 0; }
.post-thumb {
  width: 140px; aspect-ratio: 4 / 3; object-fit: cover;
  border-radius: var(--radius); display: block;
  /* The photos are licensed stock at full article width; at 140px they are decorative, so they get
     the same hairline as the card to sit in the system rather than float on it. */
  border: 1px solid var(--hairline);
}
/* Below the card's comfortable two-column width the thumbnail goes full-bleed above the text
   instead of squeezing the headline into a sliver. 560px is where a 140px image plus a readable
   measure stops fitting, measured rather than guessed. */
@media (max-width: 560px) {
  .post:has(.post-thumb) { grid-template-columns: 1fr; }
  .post-thumb { width: 100%; aspect-ratio: 16 / 9; }
}
.post:hover { border-color: var(--accent); }
.post h3 { margin-bottom: var(--s2); }
.post p { color: var(--text-secondary); margin: 0; }

/* ── Testimonials ─────────────────────────────────────────────────────────
   Used by v2/includes/testimonials.php. Lives here rather than in forms.css
   because index.php does NOT set $PAGE['needs_forms'], so forms.css never
   loads on the homepage — putting these there would silently do nothing.
   ------------------------------------------------------------------------ */
.testimonial { margin: 0; display: flex; flex-direction: column; gap: var(--s3); }
.testimonial blockquote {
  margin: 0;
  font-family: var(--font-display, Fraunces), Georgia, serif;
  font-size: 1.05rem;
  line-height: 1.6;
  color: var(--text-primary);
}
.testimonial blockquote::before { content: "\201C"; }
.testimonial blockquote::after  { content: "\201D"; }
.testimonial figcaption { display: flex; flex-direction: column; gap: 2px; margin-top: auto; }
.testimonial .t-name { font-weight: 600; color: var(--text-primary); }
.testimonial .t-loc  { color: var(--text-secondary); font-size: 0.92em; }

/* DEMO badge — the same honesty device portfolio.php uses. A fabricated quote
   must never be able to sit on the page looking like a real client's words, so
   this is deliberately loud rather than tasteful. */
.testimonial .demo-badge {
  align-self: flex-start;
  margin: 0;
  padding: 2px 8px;
  border: 1px solid var(--accent);
  border-radius: 999px;
  /* --font-body, not --font-ui: v2 defines exactly two font tokens,
     --font-display and --font-body. --font-ui does not exist and would have
     fallen through to the literal fallback, quietly bypassing the token system.
     Matches .eyebrow, which is the same kind of label. */
  font-family: var(--font-body);
  font-size: 0.68rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--accent);
}

/* .grid-1 — tokens.css defines grid-2 and grid-3 but not grid-1, and a single
   testimonial in a 3-up grid sits in the left third with two empty columns
   beside it. At launch stage one quote is the normal case. Constrain and centre
   it so it reads as a deliberate single pull-quote. */
.grid-1 { grid-template-columns: 1fr; }
.testimonial-grid.grid-1 { max-width: 60ch; margin-inline: auto; }
.testimonial-grid.grid-2 { max-width: 90ch; margin-inline: auto; }

/* --- Card art (walk marks 4, 15, 16; then mc-wu5n / mc-1u0d) --------------- */
/* The crucible's ruling on the card grids: art must be SUBORDINATE to the step
   mechanics. These are one-sentence cards in a numbered strip — a full-width
   image above each would add ~180-220px per card and turn "four quick steps"
   into four illustrated panels. So the badge sits BESIDE the number, and the
   card stays text-first: the reader still scans "1 Apply", not "picture, then
   title".
   UPDATED 2026-09-05 (pins 17 and 18): the badge now sits beside the TITLE, not
   above it and not beside a bare number. Operator on the commitment cards: "I
   want the images for each of these cards to float to the left of the title
   copy… and the text should float to the right of that… and then the
   description of that card would be unchanged." Operator on the step cards: "I
   want the image to float to the left of the card header and the text and the
   description text." The ruling above still holds — the art is still small,
   still 56px, still subordinate. What changed is which way it is subordinate:
   beside the words instead of stacked over them.

   Fixed 56px box with object-fit:contain, because the source crops came out
   between 1103px and 1873px square — at a uniform width alone, the tighter
   crops would render visibly larger than the looser ones and the grid would
   read as sloppy. Normalising here is cheaper than re-cropping to a common
   subject scale. */
.card-badge {
  width: 56px; height: 56px;
  object-fit: contain;
  display: block;
  flex: 0 0 auto;
  image-rendering: pixelated;      /* never smooth pixel art on upscale */
}
/* Step cards: number and badge share a row above the heading. RETIRED by mc-1u0d — the markup
   that used it is gone. Kept as the record of what pin 18 replaced, not as live styling. */
.step-head {
  display: flex; align-items: center; gap: var(--s3);
  margin-bottom: var(--s2);
}
.step-head .eyebrow { margin: 0; }

/* mc-wu5n (pin 17) — the six commitment cards. Badge left, title right, on one row; the
   description below spans the whole card, exactly as the operator asked ("the description of that
   card would be unchanged"). align-items:center rather than flex-start because these titles wrap
   to two lines about half the time, and a badge pinned to the top of a two-line title reads as
   misaligned beside a one-line neighbour. */
.card-head {
  display: flex; align-items: center; gap: var(--s3);
  margin-bottom: var(--s3);
}
.card-head h3 { margin-bottom: 0; }

/* mc-1u0d (pin 18) — the four partnership steps. The operator gave an either/or and then asked for
   more than either: "I want the image to float to the left of the card header and the text and the
   description text. Or I want the image larger so it fills the card and we can use thinner cards.
   The width can be smaller for the card, so maybe a 4-column row. The number should be more
   prominent and it should be at the top left of the card, or it should be like 1. (parentheses)…
   Maybe we can even have arrows that point from one step to the next to show a flow, especially
   when this gets to mobile… Visually I would just like this to show a flow."
   THE PATH TAKEN MEETS EVERY SENTENCE rather than picking one: the badge floats left of the title
   AND the description (one flex row, the words in a column beside the art); the row is four
   columns instead of two, so the cards are thinner; the number leads the title as "1." in the
   warm brown; and a chevron sits in the gap between one card and the next, so the four read as a
   sequence and not as four unrelated boxes. The chevron turns to point DOWNWARD in the stacked
   layout, which is the case the operator singled out ("especially when this gets to mobile, where
   each card is going to fill one row").

   AND THEN THE OPERATOR MEASURED IT (mc-1u0d, pin 16, 2026-09-05): "Like the screenshot implies, I
   would like the description text to be in alignment with the pixel art image (left alignment)."
   The flex row put the description in the column BESIDE the art, 72px in from the card's left
   edge, while the art started at 0. The card now uses `.card-head` — the commitment-card pattern
   already on this page — so the art and the title share one row and the description spans the
   card beneath them, starting on the art's own left edge. `.step-card` keeps only
   `position:relative`, which the chevron below is positioned against. */
.step-card {
  position: relative;
}
/* RETIRED 2026-09-05 by mc-1u0d — the markup these three targeted is gone. Kept as the record of
   what the flex-row layout needed, not as live styling:
     .card.step-card > .card-badge { margin-bottom: 0; }   (out-specifying `.card > .card-badge`,
        which is (0,2,0) and later in this file, so an equal-specificity override would lose)
     .step-card .step-body { min-width: 0; }               (let the words wrap, not widen the card)
     .step-card h3 { margin-bottom: var(--s2); }
   `.card-head` supplies the row and the gap now, and `.card-head h3 { margin-bottom: 0 }` the
   title spacing. */
.step-no { color: var(--accent-text); }      /* 7.04:1 on the card's white ground */

/* The flow connector. It lives in the grid GAP, not inside the card, so it never eats padding:
   half the gap out from the card's edge, centred on the card's own height. --hairline is the
   quiet structural line this design system already uses for exactly this kind of mark. It is
   decoration and carries no meaning a screen reader needs — the numbers already say the order —
   so it is a pseudo-element with no text. */
.step-card::after {
  content: '';
  position: absolute;
  inset-block-start: 50%;
  inset-inline-end: calc(var(--s4) / -2);
  width: 9px; height: 9px;
  border-inline-end: 2px solid var(--hairline);
  border-block-end: 2px solid var(--hairline);
  transform: translate(50%, -50%) rotate(-45deg);
}
/* No arrow after the last step: it would point at nothing. */
.step-card:last-child::after { content: none; }

/* Commitment cards have no number, so the badge leads the card on its own. */
.card > .card-badge { margin-bottom: var(--s3); }
/* DELETED 2026-09-05 (mc-3gpg): `.proof-item > .card-badge { margin-bottom: var(--s3) }`. The
   pricing card in the homepage proof strip was the only badge in a `.proof-item`, and it is now a
   full-width `.proof-media` like its three neighbours. Nothing matches the selector any more. */

/* Section hero art — full width above the section body. */
.section-art {
  display: block; width: 100%; height: auto;
  border-radius: var(--radius-lg);
  margin-bottom: var(--s5);
  image-rendering: pixelated;
}
@media (max-width: 700px) {
  .card-badge { width: 44px; height: 44px; }
  /* One column at phone width — here the square picture is only as wide as the phone, which is
     the size the operator asked for. */
  .portal-proof .grid-4 { grid-template-columns: 1fr; }
}

/* HERO ART LEADS THE PAGE (mc-4vio, operator ruling 2026-09-05, marks 23 + 34). Verbatim: "The
   hero image here should be above the consultation 'Tell us what you're planning' and it should be
   the width of the div body… I want it to be above the hero text not below it. I want that for all
   of the top-level navigation pages, including the events."
   THIS REVERSES WHAT USED TO BE WRITTEN HERE, and the old note is quoted rather than deleted so
   nobody re-derives it: "Hero art sits INSIDE .hero, after the lede — the section must still lead
   with words. Capped and centred: at full container width a 1200px pixel illustration consumed the
   entire viewport and pushed the h1 and lede below the fold, which inverts the hierarchy the
   section exists for." That was a real measurement and it is why the 720px cap existed; the ruling
   accepts the trade deliberately. The picture now leads, at the full width of the container, and
   the words follow it.
   The cap and the auto margins are therefore gone — "the width of the div body" is the container,
   and .container already holds the page's max width and gutters. The 700px override that released
   the cap on phones went with it: there is no cap left to release. */
/* Section art placed mid-copy (the sec-* set) is an ACCENT, not a hero. Full-bleed at 1200px
   would out-shout the paragraph it illustrates and push the next heading below the fold, so it
   is constrained and centred with air on both sides. Same precedent as the hero variant below. */
/* CASCADE COLLISION, measured: written first as `.section-art--inset` (0,1,0) it computed
   max-width:100%, because `img.section-art` further down this file is (0,1,1) AND later. The
   modifier has to out-rank the base rule it modifies, so it carries both classes and the element.
   Same shape as the nav-CTA guard above — a modifier that loses to its own base is not a
   modifier. Verified in the browser, not by reading the file. */
img.section-art.section-art--inset {
  max-width: 460px;
  margin-inline: auto;
  margin-block: var(--s5);
  border-radius: var(--radius);
}
@media (max-width: 700px) { img.section-art.section-art--inset { max-width: 100%; } }

/* Full container width, and the air moves from ABOVE the picture to BELOW it — the picture is
   now the first thing in the section and the eyebrow/h1/lede sit under it. */
.hero .section-art { margin-block: 0 var(--s5); }

/* AND THE NAVY ABOVE IT COMES DOWN. `.hero` pays 160px of top padding under the fixed nav, which
   was measured for a section that opens with an eyebrow — a line of small caps needs room above it
   to read as a beginning. A 1200px illustration does not: 160px of empty navy on top of a picture
   that is already the loudest thing on the page just pushes the h1 further down, which is the
   complaint the ruling was answering. Scoped with :has() to heroes that actually START with the
   art, so any hero that still opens with words keeps the 160px unchanged. */
.hero:has(> .container > .art-figure:first-child) {
  padding-block-start: calc(var(--nav-h) + var(--s6));
}

/* HERO IMAGE HEIGHT CAP (operator ruling 2026-09-05). The leading hero picture, at full container
   width, pushed the <h1> below the fold on a 1280x900 screen — the ruling that moved the art above
   the title did not intend to bury the title entirely. Capped to 60vh so the title clears the fold
   again, cropped rather than letter-boxed so the illustration still reads as intentional. Scoped to
   the FIRST-CHILD hero figure only — .art-figure elsewhere in .section content (the inset and
   full-width accent art further down each page) is untouched, and index.php's animated carousel
   is a `.carousel-wrap`, a different section entirely, so it never matches `.hero`. */
.hero > .container > .art-figure:first-child img {
  max-height: 60vh;
  width: 100%;
  object-fit: cover;
}

/* ── Monarch metamorphosis — 26-frame sprite animation ──────────────────────
   The full life cycle as MOTION, not four cuts: crawl on from the left, crawl
   to the fork, flip under the branch, turn, spin the silk button, hang in a J,
   the shell forming over the shed skin, chrysalis, darkening as the wings show
   through, the shell cracking, emerging, wings drooping while they dry, wings
   open, liftoff, gone.

   Twenty-six frames because twelve still read as a slideshow — the big moves
   (the flip, the silk, the crack) happen BETWEEN milestones, so the in-betweens
   are the animation. Every frame was generated against one shared twig plate and
   then crop-aligned on its own detected twig row, so the branch does not move a
   pixel while the creature changes. Asking independent generations to "keep the
   twig identical" did not work; referencing one plate does.

   steps(26, jump-none) — one step PER FRAME. `jump-none` emits exactly n values
   including both endpoints, so n must equal the frame count, not frame count − 1.
   I shipped steps(25) first on the reasoning that "n−1 steps gives n values".
   That is wrong: it emitted 25 positions at 100/24 = 4.1667% apart, while the
   26-frame grid sits at multiples of 100/25 = 4%. Every step then landed BETWEEN
   two frames, rendering the bottom of one and the top of the next at once — which
   looks exactly like the twig sliding upward with a second twig rising beneath it.
   The sheet was fine; the step count was off by one. */
.metamorphosis {
  width: 100%;
  max-width: 512px;
  aspect-ratio: 1024 / 600;
  margin-inline: auto;
  background-image: url("art/metamorphosis-sheet.png");
  background-repeat: no-repeat;
  background-size: 100% 2600%;      /* 26 frames stacked vertically */
  background-position: 0 0;
  image-rendering: pixelated;       /* never smooth a pixel grid */
  animation: metamorphosis-cycle 5.2s steps(26, jump-none) infinite;
}
@keyframes metamorphosis-cycle {
  from { background-position: 0 0; }
  to   { background-position: 0 100%; }
}
/* A looping animation with no pause control is a liability for anyone who needs
   motion stopped; hold the finished monarch rather than freezing mid-transformation. */
@media (prefers-reduced-motion: reduce) {
  .metamorphosis { animation: none; background-position: 0 100%; }
}

/* ── Metamorphosis, video build (2026-08-24) ────────────────────────────────
   Supersedes the 26-frame sprite sheet above. The sprite is KEPT, not deleted:
   its rules still work, and if the video ever fails to decode the markup falls
   back to it rather than to a hole in the page.
   Why video: the sprite could only ever hold 26 discrete poses, and the moments
   that read as wrong were the ones BETWEEN poses — the moult in particular has to
   travel continuously along the body or it looks like a cut.
   The source is cropped 248px off the left of a 1920 master: the branch had a sawn
   cut end sitting in mid-air, which read as a twig floating with nothing holding
   it. Cropping to 1672 runs the branch off the frame edge instead. */
.metamorphosis-video {
  display: block;
  width: 100%;
  max-width: 512px;
  aspect-ratio: 1672 / 1080;   /* the CROPPED master, not the 1920 original */
  margin-inline: auto;
  object-fit: contain;
  image-rendering: pixelated;  /* a pixel grid must never be smoothed on scale-up */
  background: transparent;
}
/* Same reasoning as the sprite: motion that cannot be stopped is a liability.
   CORRECTION (2026-08-25 accessibility audit): the note that used to sit here said the markup
   carried no `autoplay` under reduced motion. It never did, and it could not — the attribute is
   emitted by PHP, which cannot see a client media query. The video therefore autoplayed and
   looped for everyone, with no way to stop it: WCAG 2.2.2. axe reported nothing, because a
   video carrying a text alternative is all it can check.
   UPDATE (2026-09-04): the visible pause toggle that used to sit here was removed at the
   operator's request. The stop is now prefers-reduced-motion alone, honored in site.js. */
@media (prefers-reduced-motion: reduce) {
  .metamorphosis-video { outline: 1px solid var(--hairline); }
}

/* ── mc-f642 — THE METAMORPHOSIS RUNS THE FULL HEIGHT OF ITS ROW ────────
   REBUILT 2026-09-06 after the operator DENIED the first build. Her first instruction was to pin
   the top-left corner and hold the old right edge; seeing that built, she ruled instead:
   "Scale it up so the animation is the height of the row, if that makes sense, so it doesn't
   distort or stretch. Just scale it up so the width is proportional to the image when it is the
   height of the row."
   So the top-left anchor stays and the right edge is now free to move: the HEIGHT is the given, and
   the width is whatever that height implies at the picture's own proportions. The previous build's
   width arithmetic (hold the right edge at column/2 + 256) is gone — it is the thing she rejected.

   THE HEIGHT HAS TO COME FROM THE ROW, NOT FROM THE PICTURE, or it is circular: the row is as tall
   as its tallest column, so a picture that both sets and follows the row height has no answer. The
   video is therefore taken out of flow — `position: absolute` inside a `position: relative` column
   — so it contributes nothing to the row, and then `height: 100%` fills whatever height the row
   settled on (the copy column, or `.split-media`'s own 420px floor, whichever is taller). Nothing
   is typed: change the copy and the picture follows it.

   WIDTH IS DERIVED, AND CAPPED SO IT CANNOT REACH THE COPY. With a definite height and the
   `aspect-ratio: 1672/1080` already on this element, `width: auto` gives exactly the proportional
   width — no stretch, by construction. `max-width: 100%` stops it at the column edge, because a
   width that overran the column would slide under the words. WHEN THAT CAP BINDS the box is wider
   in proportion than the picture, so `object-fit: contain` draws the picture undistorted inside it
   and `object-position: left top` keeps the drawn top-left corner ON the column's corner, with the
   leftover falling at the bottom. That cap DOES bind at 1280 — the numbers are in the mc-f642
   receipt — and it is a cap, never a squash.

   THE FALLBACK SPRITE IS ON THE SAME HEIGHT-DRIVEN RULE, and getting it there took one extra step.
   `.metamorphosis` is the 26-frame div nested inside <video> as the fallback, and it is a BACKGROUND
   image on a plain box: there is no `object-fit` to protect it. Worse, the sheet MUST be painted at
   `background-size: 100% 2600%` — 26 frames stacked, each exactly one box tall — or the animation's
   26 steps stop landing on frame boundaries. So its box cannot be any shape but 1024/600: a box that
   is not that shape does not letterbox, it stretches the drawing.
   `height: 100%; width: auto` gives it the row height at its own proportions, and the width is then
   free to be wider than the video's, because it is a wider picture. What it must NOT do is overrun
   the column. `max-width: 100%` would cap the WIDTH while the height stayed pinned at 100% — the
   one combination that squashes it, measured at 11% at 1280. The cap therefore goes on the HEIGHT
   instead, expressed as the column's own width converted through the sprite's ratio: the column is
   an inline-size container, so `calc(100cqw * 600 / 1024)` is exactly "as tall as this picture may
   be before it is too wide for this column". Height clamps, `width: auto` re-derives from the ratio,
   and the shape survives.
   Measured: 640x375 at 1280 (the cap binds, 45px short of the row, exactly the regime the video is
   in at that width), and 716.8x420 — the full row height — from about 1400px up. Ratio 1.7067 in
   every case, top-left on the column's corner, and always clear of the copy.
   Its own 512px cap and auto side margins are cleared either way, or a browser without video support
   would show the small centred version in a flush-left column.

   THESE SELECTORS NAME THE CLASS AND NOT THE `video` TAG, ON PURPOSE. The fallback only ever
   renders in a browser that does not implement <video> at all, where the element is parsed as an
   unknown element and its children ARE laid out. Blocking the mp4 does not reproduce that — a
   modern browser with a dead source shows the poster and still never renders the children — so the
   only faithful way to exercise the fallback is to serve the same page with that one tag renamed,
   which is what scripts/batch3-proof.mjs does. Tag-qualified selectors would stop matching under
   that rewrite and the proof would be testing a different stylesheet than the one that ships.

   ONLY IN THE TWO-COLUMN STATE. Under 1151px `.split` collapses to one column and the art already
   spans the phone; pinning and re-scaling it there is not what was asked, so this stops at the same
   breakpoint the two-column layout does and the stacked phone layout is untouched. */
@media (min-width: 1151px) {
  .split-media.is-art:has(> .metamorphosis-video) {
    padding: 0;
    display: block;
    position: relative;
    /* inline-size only — the column's WIDTH is what the fallback's height cap is derived from.
       Not `size`: that would also contain the height, and this column's height is handed to it by
       the row it sits in. */
    container-type: inline-size;
  }
  .split-media.is-art > .metamorphosis-video {
    position: absolute;
    inset-block-start: 0;
    inset-inline-start: 0;
    height: 100%;
    width: auto;
    max-width: 100%;
    margin-inline: 0;
    object-fit: contain;
    object-position: left top;
  }
  .split-media.is-art > .metamorphosis-video > .metamorphosis {
    height: 100%;
    width: auto;
    max-width: none;
    /* the column's width, turned into the tallest this 1024/600 picture may be before it is too
       wide for that column — so the CAP clamps the height and the width re-derives from the ratio,
       instead of the width clamping and squashing the sheet */
    max-height: calc(100cqw * 600 / 1024);
    margin-inline: 0;
  }
}

/* .split-media normally paints a photo via background-size:cover. The metamorphosis is a
   transparent sprite on the page's own ground, so it gets centred with breathing room
   instead of being stretched edge to edge. */
/* Walk mark 5 (mc-3cvs): "the space between 'Watch it change shape' and the butterfly is too close
   to the … three-column row below it: we need more space there."
   Measured at 0px — the grid's .container carries no top spacing of its own, so the three-column row
   started the instant the split ended and the two read as one block. Scoped to a container that
   directly follows a split, so ordinary section rhythm elsewhere is untouched.
   (The mark's second half — "the image has nothing to do with the copy" — was the caption work
   already done; this is the spacing half.) */
.split + .container { margin-block-start: var(--s7); }

.split-media.is-art {
  /* Follows the row, not the token — see the band-grounds note on .split-field. The art column and
     the copy column must always share one colour or the bar reads as two panels. */
  background: var(--canvas);
  display: grid;
  place-items: center;
  /* place-items centres the figure inside its ROW; it does not move the row. With a single
     auto-sized row the track defaults to the top of the container, so whenever the copy column
     is taller than the art the whole picture pinned to the top and left a dead gap underneath
     it — visible the moment marks 1/2/3 got their real copy. align-content centres the TRACK. */
  align-content: center;
  padding: clamp(24px, 4vw, 56px);
  /* min-width:0 lets this grid/flex child actually shrink. Without it the track refuses to go
     below the image's intrinsic 1200px and the whole page scrolls sideways — which is exactly
     what happened: the art looked fine at 1920 because the column was wide enough to hide it,
     and only showed up when the viewport dropped to 920. */
  min-width: 0;
}
/* The images are authored at 1200x896 and MUST scale down to their column. An intrinsic width
   with no cap is a horizontal scrollbar waiting for a narrower viewport. */
.split-media.is-art img,
img.section-art {
  max-width: 100%;
  height: auto;
  display: block;
}

/* ── Art captions ───────────────────────────────────────────────────────────
   Walk marks 3 and 5, same complaint twice: "the image is just like all the other
   ones and it seems like it's a floating image that has nothing to do with that"
   and "these things need to just caption the images and show that they're linked
   up to the images in some meaningful way visually."

   A caption is the cheapest honest link between art and copy: it states what the
   picture is OF, in the section's own language, so the image reads as evidence for
   the text rather than decoration beside it. Styled as the quiet partner of the
   copy — same family, smaller, muted — so it supports the section instead of
   competing with the h2. */
.art-caption {
  display: block;
  margin-top: var(--s3);
  font-family: var(--font-body);
  font-size: .875rem;
  line-height: 1.5;
  color: var(--text-muted);
  max-width: 46ch;                   /* a caption that runs the full column reads as body copy */
}
/* mc-89yi (pin 28): "The text is centered but it's in the bottom left and out of alignment."
   Both halves of that sentence were true at once. `text-align:center` was inherited from
   `.section-header.center`, so the WORDS were centred — inside a 46ch block that has no auto
   margin and therefore hugged the left edge of a 1200px container. The box needs centring, not
   the text. One rule, scoped to centred headers so captions in left-aligned copy are untouched. */
.section-header.center .art-caption,
.creds > .container > .art-figure .art-caption { margin-inline: auto; }

/* ── mc-iopq, 2026-09-05 — the story picture moves BESIDE the block's opening ────────────────
   Operator verbatim, pin 2 of verify-batch3b-0905: "The image is almost 1000px bro, way to big.
   make it so its 250px high, and that it floats to the left of text here in the screenshot
   attached and float the image to the left of the <figcaption class="art-caption">The first one
   she planned was her own baby shower, end to end.</figcaption> as well" — and again on marks 30,
   31 and 32 for the other three stories, with the size given as a RULE instead of a number: "make
   it so its the height of the figcaption, the <p class="eyebrow">, the <h2>, and the <hr
   class="rule-v">, and that it floats to the left of text here in the screenshot attached".
   MEASURED BEFORE THE FIX at 1280: the baby-shower picture ran the column's whole 1192px and stood
   890px tall — the "almost 1000px" the operator read off it. The other three were 460x343 and sat
   mid-paragraph, BELOW their own headings, which is why they had to move up as well as shrink.

   THE TWO HALVES OF THE RULING DISAGREE BY ABOUT 100px. The four elements the operator listed
   stack to 158px at this column width, not 250, so they cannot both be obeyed literally.
   250px WINS, AND IT IS A FIXED SIZE. The first build tied the picture's height to the block's
   (`height: 100%` with a 250px floor) so that the element-list rule would take over if the words
   ever grew past 250. An audit was right to call that out: a long Spanish heading, an enlarged
   browser font, or a reader's own zoom would then push the picture past the size the operator
   actually measured, and "way too big" is the complaint this whole change exists to answer. The
   picture is now 250px tall FULL STOP — the number the operator typed on pin 2 — and the text
   beside it grows on its own without touching it. The element-list wording is recorded on the
   bead as the half that did not survive, rather than dropped in silence.

   A REAL GRID, AND NOT A FLOAT, and that is not a preference. `hr.rule-v` is a 1px-wide BLOCK box:
   a CSS float shortens the line boxes of following content but not its block boxes, so with
   `float: left` the words would have wrapped correctly and the vertical rule would have been laid
   out UNDERNEATH the picture. Two real columns put all four elements — the rule included — beside
   the picture at every width, with no magic padding number to keep in step with the art.

   WHY THE FIFTH ROW EXISTS, because it looks like debris and is not. The picture spans every row
   so that it sits beside all four elements. A spanning grid item hands its height to the rows it
   spans, so a 250px picture over four rows of 158px of text would have inflated each row and
   spread the header out. The four named rows are therefore `min-content` — frozen at the height of
   their own words, unable to grow — and the spare height lands in the one `auto` row underneath,
   which holds nothing. So: text shorter than the picture, the block is 250 and the slack is
   invisible; text taller, the rows carry it and the picture is still 250. Measured at 901, 1000,
   1150 and 1280 wide, in both languages, at a 16px and a 20px root font.
   335px is 250px at the art's own 1200x896 ratio, so `object-fit: contain` neither crops nor
   letterboxes — it is there as a guard, not as a resize. */
.story-head {
  display: grid;
  grid-template-columns: 335px minmax(0, 1fr);
  grid-template-rows: repeat(4, min-content) auto;
  grid-template-areas:
    "art cap"
    "art eyebrow"
    "art head"
    "art rule"
    "art slack";
  column-gap: var(--s5);
  align-items: start;
  margin-bottom: var(--s4);
}
/* The figure keeps the picture and its caption together in the markup — where a figcaption has to
   live — while the grid puts them in different columns. */
.story-head > figure.art-figure { display: contents; }
.story-head img.section-art {
  grid-area: art;
  align-self: start;
  width: 335px;
  height: 250px;
  max-width: none;
  object-fit: contain;
  object-position: center top;
  margin: 0;
}
.story-head .art-caption { grid-area: cap; }
.story-head .eyebrow     { grid-area: eyebrow; }
.story-head h2           { grid-area: head; }
.story-head hr.rule-v    { grid-area: rule; }
/* The caption's first line starts level with the top of the picture it captions — the default
   `margin-top: var(--s3)` was measured against a picture ABOVE it, and there is none now. */
.story-head .art-caption { margin-top: 0; margin-bottom: var(--s3); }

/* On a phone there is no column left beside a 335px picture, so it goes back above the words at
   the width of the reading column. No re-ordering is needed: the DOM order inside `.story-head`
   is already picture, caption, eyebrow, heading, rule. */
@media (max-width: 900px) {
  .story-head { display: block; }
  .story-head img.section-art {
    width: 100%; max-width: 335px; height: auto; margin: 0 0 var(--s3);
  }
}

/* mc-89yi, 2026-09-05 — operator verbatim: "The image is far too large. Scale it down and center
   it because all the stuff below it is below the fold so all you see is the image."
   MEASURED BEFORE THE FIX, at 1280x960: the illustration is authored 1200x896 and was running the
   container's full 1192px, which is 890px tall — 93% of the viewport. The section heading landed
   986px down, i.e. one pixel of it was on screen at best, so the operator's description was
   literally what the page did.
   CAPPED BY HEIGHT, NOT BY WIDTH, because the complaint is about the FOLD and the fold is a height.
   30vh is half of the 60vh cap the hero pictures already carry a few rules up this file — the same
   idiom, at accent size rather than hero size — so the picture stays proportionate to the screen it
   is on instead of being pinned to a number that is right on one monitor and wrong on the next.
   `width: auto` keeps the aspect ratio (no crop; the base `.section-art` rule's max-width:100% still
   guards narrow viewports), and `margin-inline: auto` is the "center it" half of the ruling. The
   caption's own centring, ruled on the walk before this one, is the rule directly above and is
   untouched — as are the Held / In progress lists and their token colours. */
.creds > .container > .art-figure .section-art {
  max-height: 30vh;
  width: auto;
  margin-inline: auto;
}
/* "Scale it down and CENTER it" is two things, and the second one only became visible once the
   first was done: with the picture down to ~386px the caption underneath it was still a 46ch box of
   LEFT-aligned text, so its second line hung off to one side of a centred picture. The rule above
   centres the caption's BOX (that was the pin-28 ruling, and it stands); this centres the words
   inside it, which is what every other art caption on the site already gets by inheriting
   `text-align: center` from `.section-header.center` — this figure is a SIBLING of the section
   header, not inside it, which is the only reason it was the odd one out. */
.creds > .container > .art-figure .art-caption { text-align: center; }

/* ── Credentials: held and in progress, listed separately (mc-89yi) ──────────
   V1 colour-coded these with three literals picked for a dark navy card — #E8D5A3 gold for held,
   #f0b59f terracotta for pursuing, #8ec0c8 teal for member. None is a token and none of them
   passes contrast on this file's white card, so the CODING is restored and the VALUES are not:
     held      --forest        7.9:1 on white
     pursuing  --accent-text   7.04:1 on white
     member    --forest-bright 5.3:1 on white
   --gold, v1's "held" colour, is dark-grounds-only by the token rule at the top of this file and
   is deliberately not used here. The pill shape is the one `.testimonial .demo-badge` already
   establishes: outline, .68rem, uppercase, wide tracking. */
.creds .card { text-align: center; }
.cred-status {
  display: inline-block;
  margin-bottom: var(--s2);
  padding: 2px 8px;
  border: 1px solid currentColor;
  border-radius: 999px;
  font-family: var(--font-body);
  font-size: 0.68rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
.cred-status.is-held     { color: var(--forest); }
.cred-status.is-pursuing { color: var(--accent-text); }
/* Currently UNUSED, and kept deliberately. The Chamber of Commerce card was the only "Member"
   in this section; plans/CAPABILITY-TRUTH-LEDGER.md does not grade that claim, so the card is
   listed as in progress until the operator settles it. When it is settled, restoring the member
   state is a one-class change rather than a fresh colour decision. */
.cred-status.is-member   { color: var(--forest-bright); }
/* RETIRED 2026-09-05 by mc-89yi, mark 33, which REVERSES the two-list layout above. The old note
   read: "The two group headings ("Held" / "In progress"), centred over their own list."
   Operator verbatim: "Please get rid of the `h3` class headers for "Held" and "In Progress" and
   move the "ABC Certified Wedding Planner" and "Cape Coral Chamber of Commerce Planner" to the 5th
   and 6th positions indicated in the screenshot please." The six cards are one grid now and no
   element carries this class any more, so nothing matches:
     .creds-group-title {
       text-align: center;
       margin-block: var(--s6) var(--s4);
       font-family: var(--font-body);
       font-size: 0.8rem;
       letter-spacing: 0.14em;
       text-transform: uppercase;
       color: var(--text-muted);
     }
   Kept as the record of the reversal, not as live styling. The COLOUR CODING the headings sat over
   is untouched — `.cred-status.is-held` and `.is-pursuing` a few rules up are what still tell the
   two states apart, and each pill says its state in words as well, so a card is never left
   distinguishable by colour alone. */

/* ── Captions move ONTO the art (walk marks 2, 3 and 4, 2026-08-23) ──────────
   The same note three times: "caption text should show up on the image tastefully not blocking
   the image… caption text should be more prominent here." The below-the-image treatment was
   denied, and it was also failing accessibility — see below.

   A CONTRAST REGRESSION I CAUSED. `--text-muted` on `--surface-2` measures 3.65:1, under the 4.5
   AA floor, and the old comment on this rule claimed "4.5:1 on every ground it is used on" — which
   was simply untrue. It went unnoticed because the captions USED to sit on the page canvas
   (#FAF6EE, 4.53:1, passing); restoring the art column's blush background — the stray-`}` fix —
   moved them onto --surface-2 and broke it. The measurement I reported was taken on the ground the
   captions had before my own change.

   Both problems have one answer: put the caption on the image under its own scrim. That is exactly
   the treatment the carousel caption already uses and that this walk APPROVED, so it is a pattern
   being reused rather than a second one being invented — and text on a dark scrim clears AA with
   room to spare instead of squeaking past it. */
.split-media.is-art figure.art-figure { position: relative; }
.split-media.is-art .art-caption {
  position: absolute;
  inset-inline: 0;
  inset-block-end: 0;
  margin: 0;
  max-width: none;
  padding: var(--s6) var(--s4) var(--s3);   /* tall top pad = the scrim fades in ABOVE the text */
  text-align: center;
  /* "More prominent": a real step up from .875rem, at the body face's heaviest real weight.
     600 not 700 — this file's Plus Jakarta Sans axis tops out at 600 (measured; see the carousel
     caption note), so asking for 700 would silently render 600 anyway. */
  font-size: clamp(.95rem, 1.05vw, 1.05rem);
  font-weight: 600;
  line-height: 1.4;
  color: var(--on-dark);
  /* Transparent at the top so it never reads as a slab covering the picture — "tastefully not
     blocking the image". Solid enough at the bottom to carry the text on any frame beneath it. */
  background: linear-gradient(to top,
              rgba(20, 24, 42, .90) 0%,
              rgba(20, 24, 42, .74) 55%,
              rgba(20, 24, 42, 0) 100%);
  text-shadow: 0 1px 3px rgba(0, 0, 0, .6);
  pointer-events: none;
}

/* mc-3zeo — walk marks b2/b3/b4 (2026-08-27): "the captions... at the top of the pixel art
   image, not at the bottom," font "bumped up" to the size of the animated hero pixel art's
   caption. b2 (wedding) was denied on first pass and b3/b4 (reveal, quince) approved, but per
   the bead's own note that is not a contradiction — the art stays, only the caption moves, on
   all three. Scoped to these three cards with :has() so the vendors card above them (not
   marked in the walk) keeps its existing bottom caption. Reuses the carousel caption's exact
   font-size (the "animated hero pixel art") rather than inventing a new value, and mirrors the
   scrim/padding instead of just flipping the anchor, so the fade still reads away from the text. */
.split-media.is-art figure.art-figure:has(img[src*="portal-wedding"]) .art-caption,
.split-media.is-art figure.art-figure:has(img[src*="portal-reveal"]) .art-caption,
.split-media.is-art figure.art-figure:has(img[src*="portal-quince"]) .art-caption {
  inset-block-end: auto;
  inset-block-start: 0;
  padding: var(--s3) var(--s4) var(--s6);
  font-size: clamp(1.15rem, 1.5vw, 1.4rem);        /* same value as .carousel-caption */
  background: linear-gradient(to bottom,
              rgba(20, 24, 42, .90) 0%,
              rgba(20, 24, 42, .74) 55%,
              rgba(20, 24, 42, 0) 100%);
}

/* mc-g2dn (pin 24), operator verbatim: "The font for this image's caption is not the same font as
   the caption for the images in the three rows above it. They need to be consistent. Also because
   there's so much empty space on the left side of this image, it'd be cool if the caption was on
   the left, almost like a column in that empty space."
   THE FONT IS ALREADY THE SAME — Plus Jakarta Sans at 600, the same as every caption on the page.
   What differs, and what the eye is actually reading as "not the same", is SIZE: the three rows
   above take clamp(1.15rem, 1.5vw, 1.4rem) from the rule directly above this one, while this
   caption was still on the default clamp(.95rem, 1.05vw, 1.05rem). So it takes the same value,
   from the same place, rather than a new one.
   AND IT MOVES LEFT. sec-testimonials.png is 1200x670 with the people at x≈540-1130, so the left
   45% is bare wall — a column of caption there covers nothing. Same scrim values as the rule
   above, rotated a quarter turn so the fade runs away from the text sideways instead of upward. */
.split-media.is-art figure.art-figure:has(img[src*="sec-testimonials"]) .art-caption {
  inset-block: 0;
  inset-inline: 0 auto;
  width: clamp(240px, 38%, 420px);
  display: flex;
  align-items: center;
  text-align: start;
  padding: var(--s4) var(--s5) var(--s4) var(--s4);
  font-size: clamp(1.15rem, 1.5vw, 1.4rem);        /* same value as the three rows above */
  background: linear-gradient(to right,
              rgba(20, 24, 42, .90) 0%,
              rgba(20, 24, 42, .74) 55%,
              rgba(20, 24, 42, 0) 100%);
}
/* On a phone the art column is 260px tall and the picture is scaled to fit it, so a caption column
   down the left would sit over the figures rather than beside them. Back to the default bottom
   scrim there — the same treatment every other caption on the page falls back to. */
@media (max-width: 700px) {
  .split-media.is-art figure.art-figure:has(img[src*="sec-testimonials"]) .art-caption {
    inset-block: auto 0;
    inset-inline: 0;
    width: auto;
    display: block;
    text-align: center;
    padding: var(--s6) var(--s4) var(--s3);
    background: linear-gradient(to top,
                rgba(20, 24, 42, .90) 0%,
                rgba(20, 24, 42, .74) 55%,
                rgba(20, 24, 42, 0) 100%);
  }
}

/* mc-ry9u (pin 20) — "the image height… is just not tall enough I guess. It doesn't really make
   sense and it doesn't visually link up to the associated text… We might need to rethink how we
   scale that image to scale with the text."
   MEASURED: the operator's own mark records the picture at 530x396 in a copy column over 600px
   tall. `.split-media.is-art` centres a WIDTH-bound image, so a row whose copy runs long leaves a
   band of empty ground above and below the art, and the two columns stop reading as one row.
   The fix is to let the art fill the row's height and crop rather than letterbox: `contain` would
   have kept the whole illustration and kept the empty bands, which is the thing being complained
   about. The crop costs about 17% off each side of this frame — the coffee cup and the plant.
   Jessica and the laptop, which are what the paragraph beside it is about, are untouched.
   SCOPED BY IMAGE FILENAME, the same way the caption-on-top rules above are scoped. A row is
   added here only when the operator has marked it; rows she has not marked do not move.
   WIDE LAYOUT ONLY. Under 1150px the row is a single column and the art column is already only
   260px tall, so filling it would crop hard for no gain — there is no copy beside it to match.

   mc-mzo1 ADDS THE WEDDINGS ROW (portal-wedding), same complaint, her words again: "The image
   height here… It's just not tall enough I guess… It just doesn't visually say that this is linked
   up to the text to the left of it in the same column." Measured before: at 1280 the row stood 754
   tall and the picture 401 — 353px of empty ground in the picture column, more than a third of it.
   mc-u227 ADDS THE EVENT-TYPES ROW (portal-reveal), and only after its copy was cut first, which
   is the order that task asked for. Shortening the copy took the row from 807 to 643 at 1280 and
   the empty ground from 406 to 242 — better, but still 139px more than the column's own padding,
   so the rule is applied here too rather than left half-done.
   WHAT THE CROP COSTS, measured at 1280 rather than guessed. These files are 1200x896, so filling
   a taller-than-wide frame trims the SIDES: about 19% off each side of the weddings picture (the
   flowers and the ring box at its edges survive; the couple and the calendar are untouched) and
   about 13% off each side of the event-types picture. That one does lose the right edge of the
   round inset face at its bottom right — the only real cost of this change, and it is on the
   operator's screen to rule on at the pin.
   CHECKED, NOT ASSUMED: both of these rows carry their caption ON TOP of the picture (the mc-3zeo
   rule above). The caption is positioned inside the figure, so filling the figure moves the frame
   under it and the caption stays on the picture — measured at 1280, not reasoned about. */
@media (min-width: 1151px) {
  .split:has(img[src*="portal-vendors"]) .split-media.is-art,
  .split:has(img[src*="portal-wedding"]) .split-media.is-art,
  .split:has(img[src*="portal-reveal"]) .split-media.is-art {
    place-items: stretch;
    align-content: stretch;
  }
  .split:has(img[src*="portal-vendors"]) figure.art-figure,
  .split:has(img[src*="portal-wedding"]) figure.art-figure,
  .split:has(img[src*="portal-reveal"]) figure.art-figure { width: 100%; height: 100%; }
  .split:has(img[src*="portal-vendors"]) figure.art-figure img,
  .split:has(img[src*="portal-wedding"]) figure.art-figure img,
  .split:has(img[src*="portal-reveal"]) figure.art-figure img {
    width: 100%; height: 100%;
    object-fit: cover;
    object-position: center;
  }
}

figure.art-figure { margin: 0; }
figure.art-figure img { display: block; max-width: 100%; height: auto; }
/* On a split, the caption sits with the ART, not the copy column — it explains the picture. */
.split-media.is-art figure.art-figure { display: flex; flex-direction: column; align-items: center; }

/* RETIRED 2026-09-05 by mc-4uoj, which REVERSES mc-h0a0. The old note read: "mc-h0a0 — walk mark 6,
   verbatim: 'Pixel Heart.' A section motif, deliberately small: it marks the row, it does not
   compete with the illustration already in the art column beside it." The operator saw the built
   result on the next walk and ruled the other way — mc-4uoj, pin 25: "Again get rid of this heart
   pixel art. I don't want that here." The markup is gone from index.php and the three
   motif-heart*.png files are deleted, so nothing matches these rules any more:
     .section-motif { display: block; width: 84px; height: auto; margin-bottom: var(--s3); }
     @media (max-width: 700px) { .section-motif { width: 64px; } }
   Kept as the record of the reversal, not as live styling. */

/* A hero sits on --surface-dark, and --text-muted is tuned for 4.5:1 on CANVAS, not on navy —
   measured at 3.08:1 there, i.e. a contrast failure introduced by captioning the hero images.
   Same relationship .hero .lede already has with --on-dark. */
.hero .art-caption { color: var(--on-dark-muted); }

/* mc-fiz7 (pin 29), operator verbatim: "This text needs to show up as a caption for the hero
   image. Same format as other image captions, with the see-through background, the black fade
   behind the text."
   The sentence was already a `<figcaption>`; what it was not was ON the picture — it sat as plain
   text under it, on the navy, above the PRICING eyebrow, which is why it read as body copy rather
   than as a caption. It now takes the same overlay every other art caption on the site uses.
   WIDENED 2026-09-05 TO EVERY V2 HERO, by operator ruling. It shipped scoped to the pricing hero,
   on the reasoning that only pricing had been marked; the operator ruled the other way — "the
   pricing-hero caption overlay is scoped to pricing only — fix this to reflect v2 copy" — so this
   is now one shared rule and there is one hero caption format on the site, not two.
   `:has(> .art-caption)` GUARDS THE CAPTIONLESS HEROES. The overlay itself can only ever land on a
   figcaption that exists, but the two rules on the FIGURE (position and margin) would otherwise
   change the spacing of a hero picture that has no caption at all. The guard means a hero without a
   caption is untouched and can never grow an empty scrim.
   The "black fade" is the navy rgba(20,24,42,…) every other scrim in this file uses; no new colour
   was introduced to match the word. */
.hero > .container > .art-figure:first-child:has(> .art-caption) {
  position: relative;
  margin-bottom: var(--s5);
}
/* Otherwise the picture's own 32px bottom margin opens a gap between it and the caption sitting
   on its bottom edge. */
.hero > .container > .art-figure:first-child:has(> .art-caption) .section-art {
  margin-bottom: 0;
}
.hero > .container > .art-figure:first-child:has(> .art-caption) .art-caption {
  position: absolute;
  inset-inline: 0;
  inset-block-end: 0;
  margin: 0;
  max-width: none;
  padding: var(--s6) var(--s4) var(--s3);   /* tall top pad = the scrim fades in ABOVE the text */
  text-align: center;
  /* SIZE BUMPED 2026-09-05 (mc-fiz7), operator verbatim: "The font for the caption for the hero
     image needs to be increased. It's hard to read and it needs to be the same for every hero image
     on the pages that have them. Aside from the index.php, of course, because that's animated."
     It was clamp(.95rem, 1.05vw, 1.05rem), which renders 15.2px at 1280 — the floor wins, because
     1.05vw is only 13.4px there, so the "1.05rem" in the old value was never what anybody actually
     saw. That is why it read as small.
     One step up on this file's scale, and NOT a new number: this is the exact value
     `.carousel-caption` uses and that the mc-3zeo art captions were already bumped to, so the site
     now has one caption size at the top of a picture rather than two. It renders 19.2px at 1280,
     comfortably past the 1.05rem the ruling asked for, and the clamp holds it between 18.4 and 22.4
     so a 2560px monitor does not turn it into a headline.
     index.php is excluded by the SELECTOR, not by an exception: its hero opens with an eyebrow and
     has no `.art-figure` at all, so `:first-child` never matches there. Its animated pixel art is a
     `.carousel-wrap`, a different section entirely. */
  font-size: clamp(1.15rem, 1.5vw, 1.4rem);
  font-weight: 600;
  line-height: 1.4;
  color: var(--on-dark);
  background: linear-gradient(to top,
              rgba(20, 24, 42, .90) 0%,
              rgba(20, 24, 42, .74) 55%,
              rgba(20, 24, 42, 0) 100%);
  text-shadow: 0 1px 3px rgba(0, 0, 0, .6);
  /* The picture is clipped to the container's 12px radius, so the scrim has to be too, or it
     squares off the two bottom corners. */
  border-end-start-radius: var(--radius-lg);
  border-end-end-radius: var(--radius-lg);
  pointer-events: none;
}
/* At 375px the hero picture is capped at 60vh and is short, and the Spanish caption runs to two or
   three lines — an overlay would cover most of the illustration. Below 700px the caption goes back
   under the picture, unscrimmed, which is the same fallback `.carousel-caption` uses. */
@media (max-width: 700px) {
  .hero > .container > .art-figure:first-child:has(> .art-caption) .art-caption {
    position: static;
    padding: 0;
    margin-top: var(--s3);
    /* The phone half of the same ruling: .875rem was the hard-to-read size, so this comes up to the
       body base. It stays BELOW the desktop overlay size on purpose — down here the caption is
       plain text on navy across the full 375px column, not a line sitting on a picture, and 1rem is
       the largest size that still holds the longest Spanish caption to three lines at 375px
       (measured, not assumed). */
    font-size: 1rem;
    font-weight: 400;
    color: var(--on-dark-muted);
    background: none;
    text-shadow: none;
  }
  .hero > .container > .art-figure:first-child:has(> .art-caption) .section-art {
    margin-bottom: 0;
  }
}

/* ===== mc-56w8 — the consultation row, as three columns =====================================
   Operator, walk mark 7: "This image is far too big ... It's like a three-column row: Jessica /
   No Commitment / Tell Us What You're Planning ... with the booking button in the center and the
   client on the far right column."

   The two figures come from splitting portal-call.png, which was already a diptych — same palette
   and line weight as everything else, because it IS the same asset.

   MOBILE IS A REQUIREMENT HERE, NOT A FOLLOW-UP. Verbatim: "I don't want to be scrolling on the
   mobile device and have the image be the only thing you can see. She's a bit oppressive." So the
   stacked order deliberately puts the COPY first and drops the two figures to a half-width pair
   beneath it — at no width does a single figure own the viewport. */
.consult-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr) minmax(0, 1fr);
  gap: var(--s5);
  align-items: center;
  text-align: center;
}
.consult-row .consult-copy > :first-child { margin-top: 0; }
.consult-row .consult-copy > :last-child { margin-bottom: 0; }

/* The cap is the fix for "far too big": the figure is sized by the art, not by the column. */
.consult-figure { position: relative; max-width: 260px; margin-inline: auto; }
.consult-figure img { width: 100%; height: auto; border-radius: var(--radius); }

/* "the caption below it also should be across the image in a bar" — same scrim treatment the
   splits use, which is why the values are lifted rather than re-invented. */
.consult-figure .art-caption {
  position: absolute;
  inset-inline: 0;
  inset-block-end: 0;
  margin: 0;
  max-width: none;
  padding: var(--s5) var(--s3) var(--s2);
  text-align: center;
  font-size: .8125rem;
  font-weight: 600;
  line-height: 1.35;
  color: var(--on-dark);
  background: linear-gradient(to top,
              rgba(20, 24, 42, .90) 0%,
              rgba(20, 24, 42, .74) 55%,
              rgba(20, 24, 42, 0) 100%);
  text-shadow: 0 1px 3px rgba(0, 0, 0, .6);
  border-end-start-radius: var(--radius);
  border-end-end-radius: var(--radius);
  pointer-events: none;
}

@media (max-width: 900px) {
  .consult-row {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--s4) var(--s3);
  }
  /* copy first, spanning both columns; the figures pair up underneath it */
  .consult-row .consult-copy { grid-column: 1 / -1; order: -1; }
  .consult-figure { max-width: 200px; }
}
@media (max-width: 420px) {
  /* Still two across rather than one stacked: one 200px figure per screen is the "oppressive"
     full-bleed the operator objected to, just at a smaller size. */
  .consult-figure { max-width: 100%; }
  .consult-row { gap: var(--s3) var(--s2); }
  .consult-figure .art-caption { font-size: .75rem; padding: var(--s4) var(--s2) var(--s2); }
}

/* ══════════════════════════════════════════════════════════════════════════════
   BLOG TOPIC FILTER (mc-j1da — walk mark 29) and FAQ SEARCH (mc-m6lr — mark 21)

   Both are progressive enhancements: without site.js the pills and the search box
   are visible but inert, and NOTHING is hidden — every post and every question is
   on the page as it always was. That is why there is no `display:none` default
   anywhere below; hiding only ever happens through the [hidden] attribute, which
   site.js sets.
   ══════════════════════════════════════════════════════════════════════════════ */

.topic-filter { max-width: 820px; margin: 0 auto var(--s4); }
.topic-filter .eyebrow { margin-bottom: var(--s2); }
.topic-pills { display: flex; flex-wrap: wrap; gap: var(--s2); }

.pill-topic {
  font-family: var(--font-body); font-size: .9rem; font-weight: 600;
  color: var(--text-primary); background: var(--surface-1);
  border: 1px solid var(--hairline); border-radius: 999px;
  padding: var(--s2) var(--s3); cursor: pointer;
  transition: background .15s ease, border-color .15s ease, color .15s ease;
}
.pill-topic:hover { border-color: var(--accent); }
/* The pressed pill has to be readable as "on" without relying on colour alone for
   anyone who cannot see the fill — aria-pressed carries that for assistive tech. */
.pill-topic[aria-pressed="true"] {
  background: var(--forest); color: var(--surface-1); border-color: var(--forest);
}
.pill-topic:focus-visible {
  outline: 3px solid color-mix(in srgb, var(--accent) 60%, transparent);
  outline-offset: 2px;
}

.topic-count { color: var(--text-muted); margin: var(--s2) 0 0; }

/* .post sets display:block, which outranks the browser's own [hidden] rule — without
   this a "hidden" card stays on screen. Learned the hard way; do not remove. */
.post[hidden] { display: none; }

/* ── FAQ search ───────────────────────────────────────────────────────────── */
.faq-search { margin-bottom: var(--s4); }
.faq-search label {
  display: block; font-family: var(--font-body); font-size: .84rem; font-weight: 600;
  letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted);
  margin-bottom: var(--s2);
}
.faq-search input[type="search"] {
  width: 100%; box-sizing: border-box;
  font-family: var(--font-body); font-size: 1rem; color: var(--text-primary);
  background: var(--surface-1); border: 1px solid var(--hairline);
  border-radius: var(--radius); padding: var(--s3);
}
.faq-search input[type="search"]:focus-visible {
  outline: none; border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent);
}
.faq-count { color: var(--text-muted); margin: var(--s2) 0 0; }

.faq-empty {
  background: var(--surface-1); border: 1px solid var(--hairline);
  border-radius: var(--radius-lg); padding: var(--s4);
  color: var(--text-secondary);
}
[data-faq-list] [hidden] { display: none; }
