@import url('/reference/tokens.css');

/* reset */
* { box-sizing: border-box; }
body { margin: 0; }
/* height:auto here, not just on each component's own img rule, because this
   bug has now recurred twice per-component (.hero__shot/.prose__portrait -
   see docs/PILOT-NOTES.md's "founder portrait rendered as a grotesquely
   stretched vertical sliver" entry - then .related-card, 2026-09-09): an
   <img>'s HTML width/height attributes (required everywhere for CLS) act as
   low-priority presentational hints, so a component rule that sets CSS
   width (or aspect-ratio) without ALSO setting height leaves the browser
   using the attribute's raw pixel height as the effective CSS height -
   invisible when that raw height happens to be close to the container's
   real height, a badly stretched or oversized image when it isn't. A
   component that genuinely wants a fixed pixel height (e.g. .nav__mark img)
   still overrides this cleanly - a class selector beats this bare element
   selector regardless of source order, so nothing downstream needs to
   change for that case. */
img { max-width: 100%; height: auto; display: block; }
html { scroll-padding-top: 5rem; }

/* base typography */
body { background: var(--paper); color: var(--ink);
  font: var(--t-base)/var(--lead-body) var(--font-text); -webkit-font-smoothing: antialiased; }
h1, h2 { font-family: var(--font-display); }
h1 { font-weight: 500; letter-spacing: var(--track-snug); line-height: var(--lead-tight); font-size: var(--t-2xl); margin-bottom: 50px; }
h2 { font-weight: 400; font-size: var(--t-xl); letter-spacing: var(--track-snug); }
h3 { font-size: var(--t-lg); }
a { color: var(--ink); text-decoration-color: var(--accent); text-underline-offset: .18em; }
/* No global reading-width cap on <p>: the live WordPress source renders body
   paragraphs at close to the full content-column width everywhere (prose
   intros, step bodies, FAQ answers, offer text), not clipped to a reading
   measure. A blanket `p { max-width: var(--measure) }` here fought that in
   every section that uses <p> tags, not just the one first reported - each
   section-scoped override (.prose p, etc.) was patching one symptom at a
   time instead of removing the actual cause. Cedric's explicit call,
   2026-09-08: match the source's proportions, not a typographic default. */

/* nav */
.nav { position: fixed; z-index: 60; top: 0; left: 0; width: 100%; display: flex;
  align-items: center; justify-content: space-between; gap: var(--s4);
  padding: var(--s2) var(--gutter); font-family: var(--font-text); font-size: var(--t-xs);
  color: var(--ink-soft);
  background: linear-gradient(var(--paper) 62%, color-mix(in oklab, var(--paper) 15%, transparent));
  backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); }
.nav a { text-decoration: none; color: inherit; }
.nav__mark img { height: clamp(2.3rem, 1.7rem + 2.2vw, 3.3rem); width: auto; }
.nav__list { display: flex; align-items: center; gap: var(--s5); list-style: none; margin: 0; padding: 0; }
.nav__list > * > a[aria-current="page"], .nav__list [data-nav][aria-current="page"] > summary { color: var(--ink); }
.nav__services { position: relative; }
.nav__services > summary { list-style: none; cursor: pointer; }
.nav__services > summary::-webkit-details-marker { display: none; }
.nav__services[open] > .nav__panel { display: block; }
.nav__panel { display: none; position: absolute; top: 100%; right: 0; margin-top: var(--s3);
  background: #fff; border: 1px solid var(--paper-edge); border-radius: var(--r-md);
  box-shadow: var(--shadow-card); padding: var(--s3); min-width: 16rem; list-style: none; }
.nav__panel a { display: block; padding: .45rem .5rem; border-radius: var(--r-sm); white-space: nowrap; }
.nav__panel a:hover { background: var(--surface); }
.nav__mobile { display: none; }
main { padding-top: 5rem; }
@media (max-width: 820px) {
  .nav__list { display: none; }
  .nav__mobile { display: block; }
  .nav__mobile > summary { cursor: pointer; list-style: none; }
  .nav__mobile[open] .nav__panel { display: block; position: static; box-shadow: none; }
}

/* browser-frame card */
.frame { border: 1px solid var(--paper-edge); border-radius: 10px; overflow: clip; background: #fff;
  box-shadow: var(--shadow-card); }
.frame__bar { display: flex; align-items: center; gap: .4rem; padding: .5rem .7rem;
  background: var(--surface); border-bottom: 1px solid var(--paper-edge); }
.frame__bar i { width: .5rem; height: .5rem; border-radius: 999px; background: var(--paper-edge); }
.frame__bar i:first-child { background: color-mix(in oklab, var(--scan-red) 55%, var(--paper-edge)); }
.frame__url { margin-left: .5rem; font-size: .68rem; color: var(--ink-soft);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.frame__shot { aspect-ratio: 1280 / 860; overflow: clip; }
.frame__shot img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
/* Additive variant for near-square sectionShots images (Map Pack screenshots,
   intrinsic ratios 0.99-1.14 per assets/pages/manifest.json) - the fixed
   1280/860 (1.488) ratio above crops 23-33% off the bottom of those. 17/16
   (1.0625) is the ratio that equalizes worst-case visibility across the full
   measured range: 0.992/1.0625 = 93.4% shown (vertical crop, narrowest image)
   and 1.0625/1.139 = 93.3% shown (horizontal crop, widest image) - both comfortably
   over the 90% floor. Opt-in via browserFrame({ tall: true }); does not touch
   .frame__shot's own default for every other page. */
.frame__shot--tall { aspect-ratio: 17 / 16; }

/* faq accordion */
.faq { border-top: 1px solid var(--ink); margin-top: var(--s7); }
.faq details { border-bottom: 1px solid var(--paper-edge); }
.faq summary { display: grid; grid-template-columns: 1fr 1.4rem; gap: var(--s4); align-items: baseline;
  padding: var(--s4) 0; cursor: pointer; list-style: none; }
.faq summary::-webkit-details-marker { display: none; }
.faq summary:hover { background: var(--surface); }
.faq .q-t { font-family: var(--font-display); font-size: var(--t-lg); line-height: 1.22; }
.faq .q-i { position: relative; width: 1.4rem; height: 1.4rem; justify-self: end; align-self: center; }
.faq .q-i::before, .faq .q-i::after { content: ""; position: absolute; left: 50%; top: 50%;
  width: .85rem; height: 2px; background: var(--accent); transform: translate(-50%, -50%);
  transition: opacity var(--d-base) var(--ease-out); }
.faq .q-i::after { transform: translate(-50%, -50%) rotate(90deg); }
.faq details[open] .q-i::after { opacity: 0; }
.faq .q-a { padding: 0 var(--s4) var(--s5); }
.faq .q-a :is(p, li) { color: var(--ink-soft); font-size: var(--t-sm); line-height: 1.7; }

/* editorial pull-quote: a frozen-source `> ...` block rendered outside the
   testimonial strip (a real <blockquote>, not a customer review). The site's
   own guillemets (« ... ») already open every one of these, so no decorative
   glyph is added here - it would double up. `.quote blockquote` below (the
   testimonial card's own quote styling) outranks this on specificity, so it
   is unaffected. */
blockquote { margin: var(--s6) 0; padding: var(--s4) var(--s6); border-left: 3px solid var(--accent);
  background: var(--surface); border-radius: 0 var(--r-md) var(--r-md) 0;
  font-family: var(--font-display); font-style: italic; font-size: var(--t-lg); line-height: 1.45; }
blockquote p { margin: 0; }
/* renderQuote() in render-helpers.mjs splits a quote's own trailing
   "Name, Title" line (present on 3 of the site's 9 real pull-quotes) into
   this <cite> - same words, real markup, so it can read as an attribution
   line instead of running into the quote as more quoted text. */
blockquote cite { display: block; margin-top: var(--s3); font-family: var(--font-text);
  font-style: normal; font-size: var(--t-sm); color: var(--ink-soft); }

/* ===== standardized testimonials (2026-09-09) - shared across the 10
   service pages, tarifs-offres and portfolio (see standardTestimonials()
   in templates/common.mjs). Same 2 pieces as the homepage: 3 featured
   quotes, then a marquee of the same 7 additional reviews. ===== */

/* 3 featured quotes - the generic blockquote{} rule below (a bordered,
   tinted pull-quote for inline editorial content) does not belong here;
   every property it sets is reset back to plain quote styling. */
.feat-quotes { display: grid; gap: var(--s6); margin: var(--s7) 0; }
/* <figure> carries a UA-default `margin: 1em 40px` that neither this file
   nor tokens.css's reset ever clears. Harmless-looking on a wide desktop
   grid cell, but on the narrow (~167px) 2-column mobile marquee it ate 80px
   off an already-tight card, shrinking it to ~87px - the actual cause of
   the "cramped, not mobile-friendly" cards Cedric flagged, 2026-09-09 (not
   the tilt, which was already correctly flattened below 700px). Reset here
   since both .feat-quote and .tw-card are <figure>s hitting the same bug. */
.feat-quote, .tw-card { margin: 0; }
@media (min-width: 860px) { .feat-quotes { grid-template-columns: repeat(3, 1fr); } }
.feat-quote blockquote { margin: 0; padding: 0; border: 0; background: none;
  font-style: normal; font-family: var(--font-display); font-size: var(--t-lg); line-height: 1.4; color: var(--ink); }
.feat-quote blockquote::before { content: "\201C"; color: var(--accent); }
.feat-quote figcaption { margin-top: var(--s4); display: flex; align-items: center; gap: .8rem; font-size: var(--t-xs); color: var(--ink-soft); }
.feat-quote figcaption img { width: 2.8rem; height: 2.8rem; border-radius: 999px; object-fit: cover; flex-shrink: 0; }
.feat-quote figcaption b { display: block; color: var(--ink); font-weight: 600; font-family: var(--font-text); }

/* marquee wall: pure-CSS @keyframes loop, no scroll dependency, no runtime
   JS - every column is a doubled card list translated by exactly -50%, the
   standard seamless-loop trick. .tw-stage:hover pauses via CSS only. */
.tw-wall { margin-top: var(--s8); --tw-dur: 46s; }
.tw-head { font-size: var(--t-sm); color: var(--ink-soft); margin: 0 0 var(--s4); display: flex; align-items: center; gap: .8ch; flex-wrap: wrap; }
.tw-head .tw-stars { color: var(--accent); letter-spacing: .08em; }
.tw-link { display: inline-flex; align-items: baseline; gap: .4ch; margin: 0 0 var(--s5);
  font-family: var(--font-text); font-size: var(--t-sm); color: var(--ink);
  text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: .22em; }
.tw-link span:last-child { transition: transform var(--d-fast) var(--ease-out); }
.tw-link:hover span:last-child { transform: translateX(3px); }
.tw-stage { position: relative; height: clamp(20rem, 46vh, 26rem); overflow: hidden;
  border-top: 1px solid var(--paper-edge); padding-top: var(--s5); perspective: 360px; perspective-origin: 50% 42%; }
/* Static (non-scroll-linked) 3D tilt, matching the homepage's own wall
   mechanic (index.html .tw-3d) - a fixed transform, not driven by --sc-p,
   so it costs nothing in JS and holds on a zero-runtime-JS-for-chrome page. */
.tw-cols { display: flex; gap: var(--s4); height: 100%; transform-style: preserve-3d;
  transform: translateZ(-70px) rotateX(14deg) rotateY(-7deg) rotateZ(9deg); }
.tw-col { flex: 1 1 0; min-width: 0; overflow: hidden; position: relative; }
.tw-track { display: flex; flex-direction: column; gap: var(--s4); position: absolute; inset: 0 0 auto 0;
  animation: tw-scroll var(--tw-dur) linear infinite; }
.tw-col[data-dir="down"] .tw-track { animation-direction: reverse; }
.tw-stage:hover .tw-track { animation-play-state: paused; }
@keyframes tw-scroll { from { transform: translateY(0); } to { transform: translateY(-50%); } }
@media (max-width: 700px) {
  /* Flattened, not just reduced: on a narrow viewport the stage's fixed
     height + overflow:hidden clips a rotated/translated .tw-cols against
     the header above it (confirmed visually at 390px - cards floated out
     of the clipped stage and overlapped the nav). The tilt is a wide-
     viewport delight detail, not something worth fighting the clip for. */
  .tw-col:nth-child(n+3) { display: none; }
  .tw-stage { perspective: none; }
  .tw-cols { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .tw-stage { perspective: none; height: auto; overflow: visible; }
  .tw-cols { transform: none; }
  .tw-col { overflow: visible; }
  .tw-col:nth-child(n+2) { display: none; }
  .tw-col { width: 100%; max-width: 34rem; }
  .tw-track { position: static; animation: none; }
  .tw-track > :nth-child(n+8) { display: none; }
}

.tw-card { background: #fff; border: 1px solid var(--paper-edge); border-radius: var(--r-md);
  box-shadow: var(--shadow-soft); padding: var(--s4); }
.tw-card__top { display: flex; align-items: center; gap: .55rem; margin-bottom: .4rem; }
.tw-card__mono { width: 1.9rem; height: 1.9rem; flex: none; border-radius: 999px;
  background: color-mix(in oklab, var(--accent) 15%, var(--surface)); color: var(--accent);
  font-weight: 600; font-size: .66rem; display: grid; place-items: center; font-family: var(--font-text); }
.tw-card__id b { display: block; font-family: var(--font-text); font-weight: 600; font-size: var(--t-xs); color: var(--ink); line-height: 1.2; }
.tw-card__id span { font-size: .6rem; color: var(--ink-soft); }
.tw-card__stars { color: var(--accent); font-size: .66rem; letter-spacing: .08em; margin-bottom: .3rem; }
.tw-card blockquote { margin: 0; padding: 0; border: 0; background: none;
  font-style: normal; font-family: var(--font-display); font-size: var(--t-sm); line-height: 1.38; color: var(--ink); }

.tw-fade { position: absolute; pointer-events: none; z-index: 2; }
.tw-fade--t { inset: 0 0 auto 0; height: 22%; background: linear-gradient(var(--paper), transparent); }
.tw-fade--b { inset: auto 0 0 0; height: 22%; background: linear-gradient(transparent, var(--paper)); }

/* button */
.btn { display: inline-block; background: var(--coral-bright); color: var(--ink); border: 0;
  border-radius: var(--r-sm); padding: .8rem 1.5rem; font-family: var(--font-text); font-weight: 600;
  font-size: var(--t-sm); text-decoration: none; transition: transform var(--d-fast) var(--ease-out); }
.btn:hover { transform: translateY(-1px); }
.btn--ghost { background: transparent; color: var(--ink); padding: 0; border-radius: 0;
  text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: .22em; }

/* /contact/ - form styling adapted from the homepage's own .contact-form
   (index.html), same field/button behavior, re-themed for this light-
   background page (the homepage's version sits on a dark section, hence
   its light-canvas input text - here it's plain ink on paper). */
.contact-page { display: grid; gap: var(--s8); padding-block: var(--s7) var(--s9); }
@media (min-width: 860px) { .contact-page { grid-template-columns: 1.2fr 1fr; align-items: start; } }
.contact-form { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s5); }
.contact-form .full { grid-column: 1 / -1; }
.contact-form label { display: block; font-size: var(--t-xs); text-transform: uppercase;
  letter-spacing: .1em; color: var(--ink-soft); margin-bottom: .4rem; }
.contact-form input, .contact-form textarea {
  width: 100%; background: transparent; color: var(--ink); font-family: var(--font-text);
  font-size: var(--t-base); border: 0; border-bottom: 1px solid var(--paper-edge);
  padding: .5rem 0; border-radius: 0; box-sizing: border-box;
}
.contact-form textarea { min-height: 4rem; resize: vertical; }
.contact-form input:focus-visible, .contact-form textarea:focus-visible {
  outline: none; border-bottom-color: var(--accent); border-bottom-width: 2px;
}
.contact-form button { justify-self: start; grid-column: 1 / -1; }
.contact-form.is-sending button, .contact-form.is-sent button { opacity: .55; pointer-events: none; }
.contact-form .hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.form-status { grid-column: 1 / -1; margin: 0; font-size: var(--t-sm); line-height: 1.6; max-width: 46ch; }
.form-status[hidden] { display: none; }
.form-status[data-state="ok"] { color: var(--ink); }
.form-status[data-state="err"] { color: var(--accent); }
.form-status a { color: inherit; }
@media (max-width: 620px) { .contact-form { grid-template-columns: 1fr; } }
.contact-info { display: grid; gap: var(--s5); }
.contact-coords h3 { font-size: var(--t-xs); text-transform: uppercase; letter-spacing: .1em;
  color: var(--ink-soft); margin: 0 0 .6rem; font-weight: 600; }
.contact-coords p { margin: 0 0 var(--s4); color: var(--ink-soft); line-height: 1.7; }
.contact-map iframe { width: 100%; height: 260px; border-radius: var(--r-md); }

/* pricing table -> cards on small screens */
.ptable { width: 100%; border-collapse: collapse; margin: var(--s6) 0; font-size: var(--t-sm); }
.ptable th, .ptable td { border: 1px solid var(--paper-edge); padding: .7rem .9rem; text-align: left; vertical-align: top; }
.ptable thead th { background: var(--surface); font-family: var(--font-text); font-weight: 600; }
@media (max-width: 640px) {
  .ptable thead { position: absolute; left: -9999px; }
  .ptable tr { display: block; border: 1px solid var(--paper-edge); border-radius: var(--r-md); margin-bottom: var(--s4); overflow: hidden; }
  .ptable td { display: block; border: 0; border-bottom: 1px solid var(--paper-edge); }
  .ptable td::before { content: attr(data-label) " "; font-weight: 600; color: var(--ink-soft); display: block; font-size: var(--t-xs); text-transform: uppercase; letter-spacing: .06em; }
  /* Cedric's report, 2026-09-10: on mobile, a "Site Référencement vs
     named competitors" comparison table (only - see render-helpers.mjs's
     .ptable--compare detection, never applied to this site's other data
     tables) is unreadable/pointless to scroll through row by row - only
     this page's own row matters there, so every other row is hidden. */
  .ptable--compare tbody tr:not(:first-child) { display: none; }
  /* Same report, follow-up (2026-09-10): the Sources note + prose paragraph
     naming those same competitors right after the table were still fully
     visible - render-helpers.mjs's renderProse wraps everything after a
     .ptable--compare table (within its own section) in this class. */
  .compare-footnote { display: none; }
}

/* layout primitives */
.wrap { max-width: var(--maxw); margin: 0 auto; padding-inline: var(--gutter); }
/* .prose intentionally carries no width rule of its own. A `.prose { max-width: ... }`
   rule here previously collided with `.wrap`'s max-width at equal specificity and, being
   declared later, silently won on every `<section class="wrap prose">`, collapsing the whole
   section to ~62ch instead of the intended 82rem. Do not re-add max-width to this selector. */
/* 25px each: two adjacent sections each contribute their own padding-block,
   so 25px (this section's bottom) + 25px (the next section's top) = the
   50px total visible gap Cedric asked for between sections. */
section { padding-block: 25px; }
.hero { padding-block: clamp(4rem, 8vw, 7rem) 25px; }
/* h1/.lead carry no width cap by default - matches how every other <p>/<h#>
   on the page now renders (full content-column width, no reading-measure
   clip), per the source. The 20ch/46ch caps below apply only under
   :has(.hero__shot), the 2-column image-beside-text layout, where a narrow
   text column is the actual point (room is reserved for the image). */
/* Scoped to p.lead (the body-copy paragraphs), not h2.lead: the source's
   own H2 tagline is a real heading and must render like every other H2 on
   the page (Newsreader, same size/weight/color) - it was being overridden
   into the muted lead-paragraph treatment just because it shared the
   ".lead" class with the actual paragraphs. */
.hero p.lead { font-family: var(--font-text); font-weight: 400; letter-spacing: normal; font-size: var(--t-lg); color: var(--ink-soft); margin-top: var(--s5); }
.hero h2.lead { margin-top: var(--s5); }
/* Cedric's explicit call, 2026-09-08: H1 centered, everything else (H2
   tagline, both lead paragraphs) stays left-aligned - only the heading and
   the button (see .btn rule above) are centered, not the full block. */
.hero:not(:has(.hero__shot)) h1 { text-align: center; }
/* Eyebrow kicker, matching the homepage's own .hero__kicker (index.html) -
   same flanking-rule treatment, minus its scroll-triggered .is-loaded fade-
   in (this page has no runtime JS for chrome, so the rules just render
   visible immediately rather than animating in). Cedric's explicit call,
   2026-09-09, applied to all 12 service/portfolio/pricing pages. */
.hero:not(:has(.hero__shot)) .hero__kicker { text-align: center; }
.hero__kicker { font-family: var(--font-text); font-size: var(--t-xs); line-height: 1.5;
  letter-spacing: .2em; text-transform: uppercase; color: var(--ink-soft); margin: 0 0 var(--s5); }
.hero__kicker b { color: var(--ink); font-weight: 600; display: inline-flex; align-items: center; gap: .9ch; }
.hero__kicker b::before, .hero__kicker b::after { content: ""; width: 2rem; height: 2px; background: var(--accent); flex: none; }
/* The mechanically-built label ("SERVICES · <full H1>") runs longer than
   the homepage's own short kicker text, and wraps to 2-3 lines on a phone
   width - confirmed visually at 390px: the left/right rules stayed
   pinned to the flex row's ends and centered against the now-tall
   wrapped block, landing next to the middle line instead of flanking the
   text. Cedric's call, 2026-09-09: keep the rules on mobile (an earlier
   pass had dropped them entirely - wrong call, flagged as looking bare),
   just re-oriented above/below the text instead of beside it - a stacked
   rule-text-rule column holds up at any wrap length, unlike side rules. */
@media (max-width: 700px) {
  .hero__kicker b { flex-direction: column; gap: .5rem; }
}
.hero:has(.hero__shot) h1 { max-width: 20ch; }
.hero:has(.hero__shot) .lead { max-width: 46ch; }
/* Cedric's standing preference: buttons default to centered. Scoped to the
   no-image hero (single-column flow) only - the 2-column, image-beside-text
   case further down keeps its own justify-self:start, matching the image
   column rather than the full hero width. */
.hero:not(:has(.hero__shot)) > .btn { display: block; width: max-content; margin-inline: auto; }
/* Cedric's explicit call, 2026-09-09: the hero CTA button needs real air
   above and below it, not just whatever gap its neighboring <p class="lead">
   happened to carry - it read as cramped against the paragraphs on either
   side, especially the 7 service pages where real lead copy now renders
   after the button too (see service.mjs's postCtaHtml). Applies to both the
   centered and image-beside-text hero shapes. */
.hero > .btn { margin-block: 50px; }
.hero__shot { max-width: 30rem; margin-top: var(--s5); border-radius: var(--r-lg); overflow: clip; }
.hero__shot img { width: 100%; height: auto; display: block; }
@media (min-width: 860px) {
  /* Pages whose hero carries an image (google-ads, tarifs-offres): place it
     beside the text instead of stacking it below, so it doesn't leave the
     right half of the viewport empty on first view. Portfolio's hero has no
     heroShot, so :has() leaves it in its original single-column flow. */
  .hero:has(.hero__shot) { display: grid; grid-template-columns: minmax(0, 1fr) minmax(22rem, 30rem); column-gap: var(--s7); align-items: start; }
  .hero:has(.hero__shot) > :not(.hero__shot) { grid-column: 1; }
  .hero:has(.hero__shot) .hero__shot { grid-column: 2; grid-row: 1; margin-top: 0; }
  /* Grid items default to justify-items:stretch; .btn is display:inline-block
     and must stay sized to its own text, not stretch to the text column's width. */
  .hero:has(.hero__shot) > .btn { justify-self: start; }
}
.prose__portrait { max-width: 14rem; margin-top: var(--s5); border-radius: var(--r-lg); overflow: clip; }
.prose__portrait img { width: 100%; height: auto; display: block; }
@media (min-width: 860px) {
  /* Same fix as the hero's :has(.hero__shot): a portrait dropped below a
     bullet list left it stranded under empty space on a wide viewport.
     Generic on `section:has(.prose__portrait)`, not scoped to any one
     page, so it applies anywhere this exact pattern (prose text ending in
     a portrait image) shows up. Confirmed 2026-09-08. */
  section:has(.prose__portrait) { display: grid; grid-template-columns: minmax(0, 1fr) minmax(12rem, 14rem); column-gap: var(--s7); align-items: center; }
  section:has(.prose__portrait) > :not(.prose__portrait) { grid-column: 1; }
  /* span 2, not just row 1: this pattern's text column starts with a short
     eyebrow label *then* the real heading - pinning the image to row 1
     alone left the eyebrow isolated beside it with a big gap before the
     heading (row 2) actually appeared. Spanning both groups them under the
     image instead. `span N` (not `1 / -1`) because it works in an implicit
     grid regardless of total row count - see the hero's own grid-row:1/-1
     mistake in an earlier round for why that matters. */
  section:has(.prose__portrait) .prose__portrait { grid-column: 2; grid-row: 1 / span 3; margin-top: 0; }
}
/* Desktop-only 3D portfolio carousel (templates/common.mjs's
   portfolioCarousel()) - Cedric's request, 2026-09-10, explicitly desktop
   only. Hidden below 820px, the same breakpoint .nav uses to switch to the
   mobile menu (see .nav__list/.nav__mobile above) - "desktop" on this site
   already means "above 820px", so this reuses that line rather than
   inventing a second one. The script itself also skips starting its
   autoplay timer under that width or prefers-reduced-motion. */
.portfolio-carousel { display: none; }
@media (min-width: 821px) {
  .portfolio-carousel { display: block; margin-top: var(--s6); }
  .portfolio-carousel__label { text-align: center; font-size: var(--t-xs); letter-spacing: .06em;
    text-transform: uppercase; color: var(--ink-soft); margin-bottom: var(--s4); }
  /* 1.5x, Cedric's request 2026-09-10 - every size below scaled from the
     original by that same factor (stage 40rem->60rem, item 8x4.5rem->
     12x6.75rem, center 19x10.7rem->28.5x16.05rem, button 2.5rem->3.75rem),
     keeping every ratio (aspect-ratio, item/center proportions) identical. */
  .portfolio-carousel__stage { position: relative; width: 100%; max-width: 60rem; margin: 0 auto;
    aspect-ratio: 16/7; perspective: 2100px; }
  .portfolio-carousel__ring { position: absolute; inset: 0; transform-style: preserve-3d; }
  .portfolio-carousel__item { position: absolute; inset: 0; display: flex; align-items: center;
    justify-content: center; transform-style: preserve-3d; transition: transform .6s cubic-bezier(.22,1,.36,1); }
  .portfolio-carousel__item img { width: 12rem; height: 6.75rem; object-fit: cover; border-radius: var(--r-md);
    box-shadow: var(--shadow-card); opacity: .85; }
  .portfolio-carousel__center { position: absolute; inset: 0; display: flex; align-items: center;
    justify-content: center; pointer-events: none; }
  .portfolio-carousel__center-img { width: 28.5rem; height: 16.05rem; object-fit: cover; border-radius: var(--r-lg);
    box-shadow: var(--shadow-card); transition: opacity .3s ease; }
  .portfolio-carousel__controls { display: flex; justify-content: center; gap: var(--s3); margin-top: var(--s4); }
  .portfolio-carousel__btn { width: 3.75rem; height: 3.75rem; border-radius: var(--r-pill); border: 1px solid var(--paper-edge);
    background: var(--paper); color: var(--ink); font-size: 1.95rem; line-height: 1; cursor: pointer;
    display: flex; align-items: center; justify-content: center; }
  .portfolio-carousel__btn:hover { background: var(--surface); }
}
/* padding-inline only, not the padding shorthand: the shorthand set
   padding-top/bottom too, which (being a class selector, higher specificity
   than the shared `section { padding-block }` rule) silently overrode it
   with a smaller value (~56px vs ~72px) - every cta-band transition ended
   up a different size than a plain section-to-section one. Padding-block
   now comes from the shared rule like every other section; only the
   horizontal box padding stays cta-band-specific. */
.cta-band { background: var(--surface); border-radius: var(--r-lg); padding-inline: clamp(2rem, 5vw, 3.5rem); text-align: center; }
.cta-band h5 { font: inherit; margin: 0; }

@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }

/* numbered steps */
.steps{list-style:none;counter-reset:s;padding:0}
.steps > li{counter-increment:s;padding-left:3rem;position:relative;margin-bottom:var(--s6)}
.steps > li::before{content:counter(s);position:absolute;left:0;top:0;font-family:var(--font-display);font-size:var(--t-lg);color:var(--accent)}
.steps .frame{margin-top:var(--s5)}
/* A step carrying a real screenshot goes text-beside-image instead of
   stacking the shot below full-width text - same pattern as
   .hero:has(.hero__shot) / .cards--rows / .prose-shot. The counter badge
   above is position:absolute, so it's excluded from this grid. .step-copy
   (h3 + .step-body together) is ONE grid item in column 1, matching
   .card-copy's own fix - h3 and step-body as two separate auto-placed
   items each claiming their own row was the actual bug: the shot pins to
   row 1 in column 2, so row 1's height became the shot's full height and
   the paragraph (row 2) landed below the whole image, not beside it. */
@media (min-width: 860px) {
  .steps > li:has(.frame){display:grid;grid-template-columns:minmax(0,1fr) minmax(20rem,26rem);column-gap:var(--s7);align-items:start}
  .steps > li:has(.frame) .step-copy{grid-column:1}
  .steps > li:has(.frame) .frame{grid-column:2;grid-row:1;margin-top:0}
}

/* benefit cards: matches the homepage's own device for this kind of content
   (index.html's .kicker::before - a small accent rule, no boxed card) rather
   than a bordered/shadowed white box, which read as generic boxy rectangles
   next to the rest of this page's more editorial treatment. */
.cards{display:grid;gap:var(--s7);grid-template-columns:repeat(auto-fit,minmax(15rem,1fr));align-items:start}
/* On .card-copy, not article: the accent dash must stay inside the text
   column's own block flow. It briefly lived on article::before, which
   worked fine here (article isn't a grid container in the plain multi-
   column case below) but broke .cards--rows below - once article itself
   becomes display:grid, its ::before is a real grid item too, competing
   with the shot for row 1 and pushing the actual heading down a row. */
.cards > article .card-copy::before{content:"";display:block;width:2.6rem;height:2px;background:var(--accent);margin-bottom:var(--s3)}
.cards > article h3{font-size:var(--t-base);margin:0 0 var(--s2)}
.cards > article p{color:var(--ink-soft);font-size:var(--t-sm);max-width:none}
/* Cedric's explicit call, 2026-09-09: a "cards" section where at least one
   card carries a real screenshot (audits-de-referencement, referencement-
   local's "Nos Services..." section - see renderCardsSection's own
   cardsCls comment) reads as full-width rows, one per card, text beside its
   own image - not the narrow multi-column grid above, which squeezed every
   screenshot down to card width. Same text-column/pinned-image pattern as
   .hero:has(.hero__shot) above, not a new one. */
.cards--rows{display:flex;flex-direction:column;gap:var(--s7)}
@media (min-width: 860px) {
  .cards--rows > article:has(.frame){display:grid;grid-template-columns:minmax(0,1fr) minmax(20rem,26rem);column-gap:var(--s7);align-items:start}
  .cards--rows > article:has(.frame) .card-copy{grid-column:1}
  .cards--rows > article:has(.frame) .frame{grid-column:2;grid-row:1}
}
/* Same pattern again for a plain prose section carrying one real screenshot
   (services-danalyse-concurrentielle-seo's 2 Ahrefs-table sections) - the
   <h2> stays full-width above (rendered outside this div, see
   renderProseSection), only the paragraphs and the shot split into columns.
   .prose-copy wraps every paragraph/list as ONE grid item (same fix as
   .card-copy/.step-copy) - a section with 2+ separate <p>/<ul> blocks placed
   directly as grid children would each claim their own row in column 1,
   and since the shot pins to row 1 in column 2, anything after the first
   block would land below the whole image instead of continuing beside it. */
@media (min-width: 860px) {
  .prose-shot:has(.frame){display:grid;grid-template-columns:minmax(0,1fr) minmax(20rem,26rem);column-gap:var(--s7);align-items:start}
  .prose-shot:has(.frame) .prose-copy{grid-column:1}
  .prose-shot:has(.frame) .frame{grid-column:2;grid-row:1}
}

/* Cedric's explicit call, 2026-09-09: the heading above an embedded tool
   (facebook-ads' "Simulateur de rentabilité", renderEmbedSection in
   templates/service.mjs) is centered - the iframe below it is itself a
   centered widget, so a left-aligned heading over it read as misaligned. */
.embed h2 { text-align: center; }

/* Section label: the homepage's own .kicker device (index.html), reused
   verbatim for the same purpose - a small uppercase label with a leading
   accent rule, above a heading. No scroll-triggered rule animation here
   (the homepage's own `.in-view .kicker::before` scaleX reveal) - inner
   pages stay faster and less experiential than the homepage on purpose,
   per Cedric's explicit call 2026-09-08; the rule renders already "on". */
.kicker{display:flex;align-items:center;gap:.9ch;font-family:var(--font-text);font-size:var(--t-xs);
  letter-spacing:var(--track-wide);text-transform:uppercase;color:var(--ink-soft);margin-bottom:var(--s3)}
.kicker::before{content:"";width:2.6rem;height:2px;background:var(--accent);flex-shrink:0}

/* One-accent-moment device: the homepage's own h2.disp em treatment
   (italic, --accent color), reused for exactly one hand-picked phrase per
   page - see accentPhrase() in templates/common.mjs. Not a global `em`
   rule on purpose; scattering this everywhere is the opposite of the point. */
em.accent{font-style:italic;color:var(--accent)}
.pf-grid{display:grid;gap:var(--s6);grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}
.pf-card{display:flex;flex-direction:column}
.pf-card .frame{transition:transform var(--d-fast) var(--ease-out)}
.pf-card:hover .frame{transform:translateY(-2px)}
.pf-card__name{margin-top:var(--s3);font-family:var(--font-display);font-size:var(--t-lg);line-height:1.25}
.pf-card__name a{text-decoration:none;color:var(--ink)}
.pf-card__name a:hover{text-decoration:underline;text-decoration-color:var(--accent);text-underline-offset:.18em}
.pf-card__visit{display:block;margin-top:.15rem;font-family:var(--font-text);font-size:var(--t-xs);
  font-weight:600;letter-spacing:var(--track-wide);text-transform:uppercase;color:var(--accent)}

/* pricing: tick lists, offer cards, fine print */
.ticks{list-style:none;padding:0}
.ticks li{padding-left:1.6rem;position:relative;margin:.4rem 0}
.ticks li::before{content:"\2714";position:absolute;left:0;color:var(--scan-green)}
/* margin-block:25px, matching the shared section standard (25px each side
   = 50px total). Round 2 removed this margin on the theory that the shared
   `section { padding-block }` rule already spaces every section - true for
   a plain section, where padding is invisible against the matching page
   background, but wrong here: .offer has its own visible border/background,
   and Offers A-D sit back-to-back with nothing else between them, so a 0px
   gap between two offer *boxes* means their borders visibly touch (padding
   is inside the border, it only affects the box's own internal breathing
   room, never the gap to the next box). Confirmed via screenshot 2026-09-08. */
.offer{background:#fff;border:1px solid var(--paper-edge);border-radius:var(--r-md);padding:var(--s6);margin-block:25px}
.offer__price{font-family:var(--font-display);font-size:var(--t-lg)}
.fine{font-size:var(--t-xs);color:var(--ink-soft);font-style:italic}

/* footer - dark background matching the homepage's own colophon
   (index.html's .colophon, background:#17130F, named --footer-ink in
   tokens.css since that hex was never a token there either). Redefining
   --ink/--ink-soft/--paper-edge inside this selector's own scope, exactly
   as the homepage's .colophon does with --sc-ink/--sc-ink-soft/--paper-edge,
   means every existing .site-foot__* rule below picks up light-on-dark
   colors automatically - none of them needed to change. Confirmed
   2026-09-08: this started as a background-color-only match (no map, no
   form). The GMB map added below (2026-09-08) mirrors the homepage's own
   .colophon-map treatment exactly: a light --surface card (not reassigned
   dark) floating on the dark footer, border on the reassigned --paper-edge
   so it reads correctly against this scope's background. */
.site-foot{background:var(--footer-ink);--ink:var(--paper);--ink-soft:var(--footer-ink-soft);
  --paper-edge:color-mix(in oklab, var(--paper) 18%, transparent);color:var(--paper);margin-top:var(--s9)}
.site-foot__inner{max-width:var(--maxw);margin:0 auto;padding:var(--s8) var(--gutter) var(--s6);display:flex;flex-direction:column;gap:var(--s6)}
.site-foot__top{display:grid;gap:var(--s6) var(--s9);grid-template-columns:1fr}
@media (min-width:700px){.site-foot__top{grid-template-columns:minmax(0, 17rem) 1fr;align-items:start}}
.site-foot__nap{font-size:var(--t-sm);color:var(--ink-soft)}
.site-foot__nap p{margin:0 0 var(--s2);max-width:none}
.site-foot__brand{font-family:var(--font-display);font-size:var(--t-lg);color:var(--ink);margin:0 0 var(--s3)}
.site-foot__nap a{color:var(--ink-soft);text-decoration-color:var(--paper-edge-2)}
.site-foot__map{border:1px solid var(--paper-edge);border-radius:var(--r-md);overflow:hidden;background:var(--surface)}
.site-foot__map iframe{display:block;width:100%;height:clamp(15rem, 32vw, 21rem);border:0}
.site-foot__links{display:flex;flex-wrap:wrap;gap:var(--s3) var(--s5);padding-top:var(--s6);border-top:1px solid var(--paper-edge)}
.site-foot__links a{font-size:var(--t-sm);text-decoration:none}
.site-foot__links a:hover{text-decoration:underline;text-decoration-color:var(--accent)}
.site-foot__legal{display:flex;flex-wrap:wrap;gap:var(--s2) var(--s4);align-items:center;padding-top:var(--s5);border-top:1px solid var(--paper-edge);font-size:var(--t-xs);color:var(--ink-soft);max-width:none}
.site-foot__legal a{color:var(--ink-soft);font-size:inherit;text-decoration-color:var(--paper-edge-2)}


/* Trim trailing margins, placed last on purpose: whatever element ends up
   last inside a section (a <p>/<ul>'s own default browser margin,
   .steps > li's margin-bottom, .quotes' own margin, etc.) was stacking its
   own bottom margin on top of the section's padding-bottom - the same
   content ending a section in a <p> vs a <ul> vs an <li> produced a
   visibly different gap before the next heading, even though every
   section's own padding-block was identical. This reaches to whatever is
   actually last, at any nesting depth, and zeroes only its margin-bottom -
   margin-top (heading-to-content spacing within a section) is untouched.
   Declared last so it wins every same-specificity tie (e.g. against
   .steps > li's own margin-bottom rule) by source order, not just by being
   written once and hoped for. */
main :last-child { margin-bottom: 0; }

/* article template: sticky TOC (desktop), collapsible <details> (mobile).
   No scroll-spy / active-link JS - see reference/REFERENCE.md's Article
   template section for why. */
.toc { background: var(--surface); border: 1px solid var(--paper-edge); border-radius: var(--r-md); padding: var(--s5); }
.toc summary { font-size: var(--t-sm); text-transform: uppercase; letter-spacing: var(--track-wide); margin: 0 0 var(--s3); cursor: pointer; }
/* No `display: flex` here (mobile-default collapsed state depends on the
   UA's own `details:not([open]) > *:not(summary) { display: none }` rule
   winning when the `open` attribute is absent) - only .toc[open] ul below
   turns the content on once a phone visitor actually opens it. */
.toc ul { list-style: none; margin: 0; padding: 0; flex-direction: column; gap: var(--s2); }
.toc[open] ul { display: flex; }
.toc a { font-size: var(--t-sm); text-decoration: none; color: var(--ink-soft); }
.toc a:hover { text-decoration: underline; text-decoration-color: var(--accent); }
@media (min-width: 860px) {
  /* `:has(> .toc)` - direct child only. Article-template v2 pilot pages
     (2026-09-09) wrap .toc one level deeper inside .article-rail (see that
     selector below) so it can share the sticky sidebar column with the new
     services widget; without the ">" here, THIS rule would also match on
     those pages (.toc is still somewhere inside .article-body) and put
     .toc itself into a second, independently sticky grid-column-2 - never
     discovered by testing because it was caught before rollout, not after. */
  .article-body:has(> .toc) { display: grid; grid-template-columns: minmax(0, 1fr) minmax(14rem, 18rem); column-gap: var(--s7); align-items: start; }
  .article-body:has(> .toc) > :not(.toc) { grid-column: 1; }
  /* span 999, not 400: Round 17/18's audit found guide-complet-du-
     referencement-local-2025 already at 412 rows, over the old 400 span
     (re-tested at 999 on that exact page and confirmed the sticky-release
     point and article-body height are unaffected either way - Round 18). */
  .article-body:has(> .toc) > .toc { grid-column: 2; grid-row: 1 / span 999; position: sticky; top: var(--s6); max-height: calc(100vh - var(--s7)); overflow-y: auto; }
  /* Article-template v2 pilot (2026-09-09): the sidebar rail now holds the
     TOC (if any) stacked above the new "Decouvrez nos services" widget -
     one shared sticky container instead of each fighting for the same
     column independently. Direct-child-scoped for the same reason as the
     .toc rule above. */
  .article-body:has(> .article-rail) { display: grid; grid-template-columns: minmax(0, 1fr) minmax(14rem, 18rem); column-gap: var(--s7); align-items: start; }
  .article-body:has(> .article-rail) > :not(.article-rail) { grid-column: 1; }
  .article-body:has(> .article-rail) > .article-rail { grid-column: 2; grid-row: 1 / span 999; position: sticky; top: var(--s6); max-height: calc(100vh - var(--s7)); overflow-y: auto; display: flex; flex-direction: column; gap: var(--s5); }
  /* Desktop's sticky sidebar is always visible, regardless of the <details>
     [open] state - the markup carries no `open` attribute so a phone still
     gets a genuinely closed disclosure by default (see article.mjs). A
     closed <details> here isn't hiding its content via a plain `display`
     on the child (confirmed by direct measurement: the <ul> itself keeps
     computed display:block/flex either way) - Chromium wraps non-summary
     content in an internal `::details-content` box whose own
     content-visibility/block-size is what actually clips it, so THAT is
     the pseudo-element that has to be overridden, not the <ul>.
     `::details-content` is a recent feature (Chrome/Edge 131+, Safari
     18.4+, Firefox 139+) - on an older browser this whole ruleset is
     dropped and the <details> just stays closed on desktop, same as
     mobile. That's an acceptable, graceful fallback (the visitor can still
     click "Sommaire" to open it) PROVIDED the click still works - do not
     add `pointer-events: none` / `cursor: default` to `.toc summary` here
     (reviewer finding, 2026-09-09): that would disable the one remaining
     way to reach the content on a browser where the force-open trick
     doesn't apply. Only the marker/triangle is suppressed, which is purely
     cosmetic and harmless either way. */
  .toc::details-content { content-visibility: visible !important; block-size: auto !important; overflow: visible !important; }
  .toc > ul { display: flex !important; }
  .toc summary { list-style: none; }
  .toc summary::-webkit-details-marker { display: none; }
}

.byline { font-size: var(--t-xs); color: var(--ink-soft); line-height: 1.6; }
.byline a { color: var(--ink-soft); text-decoration-color: var(--paper-edge-2); }
.byline--top { margin-top: var(--s3); margin-bottom: 0; }
.byline--bottom { margin-top: var(--s7); padding-top: var(--s5); border-top: 1px solid var(--paper-edge); }
.byline strong { color: var(--ink); font-weight: 600; }

/* ===== article-template v2 pilot additions (2026-09-09) ===== */

/* featured image, below the H1 */
/* Cedric's report, 2026-09-09: full-.wrap-width was far too big on desktop
   for a single illustrative image - capped and centered to a normal
   featured-image size instead, matching .hero__shot's own cap elsewhere. */
.article-hero-img { max-width: 40rem; margin: var(--s5) auto 0; border-radius: var(--r-lg); overflow: clip; }
.article-hero-img img { width: 100%; height: auto; display: block; }

/* "Resumer cet article avec" AI-summary links - unofficial, undocumented
   query-param schemes (verified working 2026-09-09, not vendor-guaranteed -
   see docs/PILOT-NOTES.md for the periodic-check note). */
.ai-summary { margin: var(--s6) 0; padding: var(--s4) var(--s5); background: var(--surface);
  border: 1px solid var(--paper-edge); border-radius: var(--r-md); }
.ai-summary__label { margin: 0 0 var(--s3); font-size: var(--t-sm); font-weight: 600; color: var(--ink); }
.ai-summary__list { display: flex; flex-wrap: wrap; gap: var(--s3); }
.ai-summary__list a { display: inline-flex; align-items: center; gap: .4ch; padding: .5rem .9rem;
  background: #fff; border: 1px solid var(--paper-edge); border-radius: var(--r-sm);
  font-size: var(--t-sm); text-decoration: none; color: var(--ink); }
.ai-summary__list a:hover { border-color: var(--accent); }

/* sidebar services widget - shares .article-rail with .toc, see the grid
   rules above. Plain box matching .toc's own visual weight. */
.services-widget { background: var(--surface); border: 1px solid var(--paper-edge);
  border-radius: var(--r-md); padding: var(--s5); }
.services-widget h3 { font-size: var(--t-sm); text-transform: uppercase; letter-spacing: var(--track-wide);
  margin: 0 0 var(--s3); }
.services-widget ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
.services-widget a { font-size: var(--t-sm); text-decoration: none; color: var(--ink-soft); }
.services-widget a:hover { text-decoration: underline; text-decoration-color: var(--accent); }

/* boxed author callout, bottom of article only - no CTA button here
   (Cedric's report, 2026-09-10): the ctaBand right above the author box
   already covers it, so no `.author-box + .btn` rule is needed. */
.author-box { margin-top: var(--s7); padding: var(--s5); background: var(--surface);
  border-left: 3px solid var(--accent); border-radius: var(--r-sm); }
.author-box .byline--bottom { margin: 0; padding: 0; border: 0; }

/* share buttons */
.share-buttons { margin-top: var(--s7); text-align: center; }
.share-buttons p { color: var(--ink-soft); max-width: 46ch; margin: 0 auto var(--s4); }
.share-buttons__list { display: flex; justify-content: center; gap: var(--s3); flex-wrap: wrap; }
.share-buttons__list a { display: inline-flex; align-items: center; padding: .55rem 1rem;
  border-radius: var(--r-sm); font-size: var(--t-xs); font-weight: 600; text-decoration: none; color: #fff; }
.share-buttons__list a:hover { opacity: .88; }
.share--facebook { background: #1877f2; }
.share--twitter { background: #14171a; }
.share--linkedin { background: #0a66c2; }
.share--pinterest { background: #e60023; }
.share--whatsapp { background: #25d366; }
.share--email { background: var(--ink-soft); }

/* "Autres Blogs" related-post grid */
.related-posts { margin-top: var(--s9); }
.related-posts h2 { text-align: center; margin-bottom: var(--s6); }
/* Cedric's report, 2026-09-10: cards need equal height across the row, not
   each sized to its own title length. Grid back to default stretch (not
   the earlier align-items:start, which fixed a different bug - see the
   height:auto note above - but left ragged card bottoms as a side effect);
   .related-card itself is now a flex column so the image keeps its fixed
   4:3 box and the title span grows to fill whatever extra height the row's
   tallest sibling forces, instead of leaving a bare gap under it. */
.related-posts__grid { display: grid; gap: var(--s5); grid-template-columns: repeat(2, 1fr); }
@media (min-width: 700px) { .related-posts__grid { grid-template-columns: repeat(4, 1fr); } }
.related-card { display: flex; flex-direction: column; text-decoration: none; color: var(--ink);
  border: 1px solid var(--paper-edge); border-radius: var(--r-md); overflow: clip; background: #fff; }
.related-card img { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; display: block; flex: none; }
.related-card span { display: flex; align-items: center; flex: 1; padding: var(--s3); font-size: var(--t-sm); font-weight: 600; line-height: 1.35; }
.related-card:hover span { text-decoration: underline; text-decoration-color: var(--accent); }

/* /blog-siteref/ index - same card language as .related-card, plus an
   excerpt and date since this is the full index, not a small sidebar
   pick. (2026-09-10) */
.blog-index__grid { display: grid; gap: var(--s6); grid-template-columns: 1fr; }
@media (min-width: 700px) { .blog-index__grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1000px) { .blog-index__grid { grid-template-columns: repeat(3, 1fr); } }
.blog-card { display: flex; flex-direction: column; text-decoration: none; color: var(--ink);
  border: 1px solid var(--paper-edge); border-radius: var(--r-md); overflow: clip; background: #fff; }
.blog-card img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; display: block; flex: none; }
.blog-card__body { display: flex; flex-direction: column; gap: var(--s2); padding: var(--s5); flex: 1; }
.blog-card__date { margin: 0; font-size: var(--t-xs); text-transform: uppercase; letter-spacing: var(--track-wide); color: var(--ink-soft); }
.blog-card__title { margin: 0; font-family: var(--font-display); font-size: var(--t-lg); line-height: 1.3; color: var(--ink); }
.blog-card__excerpt { margin: 0; font-size: var(--t-sm); color: var(--ink-soft); line-height: 1.5; flex: 1; }
.blog-card:hover .blog-card__title { text-decoration: underline; text-decoration-color: var(--accent); }

/* /facebook-ads-examples-for-local-businesses/ video gallery (2026-09-10).
   Real ad creatives ship in a mix of native aspect ratios (square, 9/16
   vertical) - each <video>'s own aspect-ratio (set inline per file in
   build-facebook-ads-examples.mjs) reserves its real layout space so
   nothing shifts once metadata loads, same reasoning as the img
   width/height rule above, just via CSS since <video> has no width/height
   presentational-hint equivalent worth relying on here. preload="metadata"
   (not "auto") keeps 11 heavy video files from all fetching on page load -
   only their thumbnail frame + duration load until a visitor presses play. */
.video-gallery { display: grid; gap: var(--s5); grid-template-columns: repeat(2, 1fr); }
@media (min-width: 700px) { .video-gallery { grid-template-columns: repeat(3, 1fr); } }
.video-gallery__item { margin: 0; }
.video-gallery__item video { width: 100%; max-height: 32rem; border-radius: var(--r-md); box-shadow: var(--shadow-card);
  background: #000; display: block; }
.video-gallery__item figcaption { margin-top: var(--s2); font-size: var(--t-sm); color: var(--ink-soft); text-align: center; }

/* Same site everywhere else: no custom bullet on plain prose <ul>s, so this
   checklist's real checkmark is spelled out here rather than relying on a
   default disc marker that would look wrong beside "✔"-style copy. */
.checklist { list-style: none; margin: var(--s4) 0; padding: 0; display: grid; gap: var(--s3); }
.checklist li { padding-left: 1.8em; position: relative; }
.checklist li::before { content: "✔"; position: absolute; left: 0; color: var(--accent); font-weight: 600; }
