/* =========================================================================
   Marketing site (twincitiestopdown.com). Consumes brand.css tokens only.

   Zero Firebase SDK / Firestore read / Cloud Function render is the default
   rule for every page that uses this file (see firebase-init.js's header
   comment and TC Studio Plan.md's "Architecture: static, and deliberately
   so"), not "zero <script> tags" for its own sake. Three exceptions: the
   mobile nav menu is a pure-CSS checkbox toggle (no script needed at all);
   the portfolio-driven parts of the homepage and /portfolio
   (assets/js/portfolio-gallery.js) read live from Firestore on purpose; and
   /services is the public storefront browse page (assets/js/shop-browse.js),
   same reasoning as portfolio — see either file's header for why. Everything
   else on the marketing site stays fully static.
   ========================================================================= */

body.marketing { padding-top: var(--tctd-mnav-h, 76px); }

/* ---- homepage scroll stack -------------------------------------------------
   Each top-level section on the homepage pins under the fixed nav and stays
   put while the next one scrolls up and covers it, like a stack of cards.
   Pure CSS: position: sticky plus an ascending z-index per section (so a
   later section always paints over an earlier one), and an opaque background
   per section so the one underneath actually disappears rather than showing
   through. The footer isn't part of the stack, but still needs a z-index
   above the last section's or it would be stuck rendering underneath it. */
body#home main > section {
  position: sticky;
  top: var(--tctd-mnav-h, 76px);
}
body#home main > section:nth-of-type(1) { z-index: 1; }
body#home main > section:nth-of-type(2) { z-index: 2; }
body#home main > section:nth-of-type(3) { z-index: 3; }
body#home main > section:nth-of-type(4) { z-index: 4; }
body#home main > section:nth-of-type(5) { z-index: 5; }
body#home main > section:nth-of-type(6) { z-index: 6; }
body#home main > section:nth-of-type(7) { z-index: 7; }
body#home main > section:nth-of-type(8) { z-index: 8; }
body#home main > section:nth-of-type(9) { z-index: 9; }
body#home main > section:nth-of-type(10) { z-index: 10; }
body#home main > section:nth-of-type(11) { z-index: 11; }
body#home main > section:nth-of-type(12) { z-index: 12; }
body#home .m-section:not(.is-alt) { background: var(--tctd-surface); }
body#home .m-footer { position: relative; z-index: 20; }

@media (prefers-reduced-motion: reduce) {
  body#home main > section { position: static; }
}

/* Phones get a plain scrolling page instead of the stack.
   Sections pinning under a fixed header eats most of a phone screen, and the
   effect reads as content refusing to scroll rather than as a deliberate
   transition. Desktop keeps it. The jump bar below is what replaces the
   structure the stack was providing. */
@media (max-width: 768px) {
  body#home main > section { position: static; }
}

/* ---- mobile section jump bar -------------------------------------------
   Pinned directly under the fixed header, so it stays reachable the whole way
   down the page. Horizontally scrollable because six chips do not fit across
   a phone, and that is fine: the first three are visible and the row scrolls. */
.m-jump-wrap, .m-totop { display: none; }

@media (max-width: 768px) {
  /* Fixed to the bottom of the screen, where a thumb already is. Fixed rather
     than sticky because the bar lives near the top of the document: sticky
     would only pin it once the reader scrolled that far, which on a bottom bar
     means it is missing exactly when the page first loads. */
  .m-jump-wrap {
    display: block;
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 90;
    padding: 8px 0 10px;
    /* Clears the iOS home indicator without adding a gap on devices that have
       no inset to report. */
    padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
    background: var(--tctd-bg-anchor);
    border-top: 1px solid rgba(255, 255, 255, 0.10);
    box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.25);
  }

  /* The bar floats over the page, so the last thing on it needs room to clear
     the bar rather than sitting permanently underneath. */
  body#home { padding-bottom: 80px; }

  /* Back to top, parked just above the jump bar in the corner a right thumb
     already rests in. */
  .m-totop {
    display: grid;
    place-items: center;
    position: fixed;
    right: var(--tctd-space-4);
    bottom: calc(74px + env(safe-area-inset-bottom, 0px));
    z-index: 91;
    width: 44px;
    height: 44px;
    padding: 0;
    border: 1px solid rgba(255, 255, 255, 0.22);
    border-radius: 50%;
    background: var(--tctd-bright-pink);
    color: var(--tctd-white);
    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
    cursor: pointer;

    /* Hidden until there is something to scroll back from. Kept in the layout
       and faded rather than display:none so it can animate in and out. */
    opacity: 0;
    transform: translateY(8px) scale(0.9);
    pointer-events: none;
    transition: opacity 200ms ease, transform 200ms ease;
  }
  .m-totop.is-visible {
    opacity: 1;
    transform: none;
    pointer-events: auto;
  }
  .m-totop svg { width: 20px; height: 20px; }
  .m-totop:active { transform: scale(0.92); }

  .m-jump {
    display: flex;
    gap: var(--tctd-space-5);
    padding: 0 var(--tctd-space-4);
    overflow-x: auto;
    /* No scrollbar on the chip row; it is short and obviously swipeable. */
    scrollbar-width: none;
  }
  .m-jump::-webkit-scrollbar { display: none; }

  .m-jump a {
    flex: none;
    position: relative;
    padding: 4px 0 8px;
    color: var(--tctd-whisper);
    font-size: 12px;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    text-decoration: none;
    white-space: nowrap;
    transition: color 200ms ease;
  }

  /* The underline is drawn on every link and scaled to nothing, so the one that
     becomes current grows its own rule in from the left rather than appearing.
     Painting it only on .is-current would give a hard cut with nothing to
     animate from. */
  .m-jump a::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    bottom: 2px;
    height: 2px;
    border-radius: 2px;
    background: var(--tctd-bright-pink);
    transform: scaleX(0);
    transform-origin: left center;
    transition: transform 260ms cubic-bezier(.2, .7, .3, 1);
  }
  .m-jump a.is-current { color: var(--tctd-white); }
  .m-jump a.is-current::after { transform: scaleX(1); }

  /* Reduced motion still needs the indicator, just not the sweep. */
  @media (prefers-reduced-motion: reduce) {
    .m-jump a::after { transition: none; }
  }

  /* Only the header sits above the content now, so that is all an anchored
     section has to clear. The jump bar is at the bottom and never covers the
     heading you jumped to. */
  body#home main > section[id] { scroll-margin-top: calc(var(--tctd-mnav-h, 68px) + 12px); }
}

/* ---- /portfolio scroll stack -----------------------------------------------
   Same idea as the homepage, but only the page header pins: the gallery
   scrolls up and covers it.

   Deliberately NOT the homepage's "every section is sticky" treatment. A
   sticky element freezes the moment its top edge reaches the offset, so
   anything taller than the viewport can never be scrolled through — and the
   gallery here is one continuous run of justified rows, thousands of pixels
   tall. Pinning it would strand everything below the first screenful. The
   header is short and fixed-height, so it pins safely; the gallery stays in
   normal flow and simply paints over it on the way past, which is the effect
   being asked for anyway. */
body#portfolio .m-page-hero {
  position: sticky;
  top: var(--tctd-mnav-h, 76px);
  z-index: 1;
}
/* Opaque, or the pinned header shows through the gaps between rows. */
body#portfolio .m-pf-full {
  position: relative;
  z-index: 2;
  background: var(--tctd-surface);
}
body#portfolio .m-cta-band { position: relative; z-index: 3; }
body#portfolio .m-footer { position: relative; z-index: 4; }

@media (prefers-reduced-motion: reduce) {
  body#portfolio .m-page-hero { position: static; }
}

/* ---- nav ----------------------------------------------------------------- */

.m-nav {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 100;
  background: var(--tctd-bg-anchor);
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.m-nav-row {
  max-width: 1180px;
  margin: 0 auto;
  padding: 0 var(--tctd-space-5);
  height: var(--tctd-mnav-h, 76px);
  display: flex;
  align-items: center;
  gap: var(--tctd-space-5);
}
.m-brand {
  font-family: var(--tctd-font-head);
  font-weight: 700;
  font-size: 1rem;
  line-height: 1.15;
  letter-spacing: var(--tctd-head-ls);
  text-transform: uppercase;
  color: var(--tctd-white) !important;
  text-decoration: none;
  margin-right: auto;
  /* Badge and wordmark are one lockup: the badge is sized off the two lines of
     type beside it (2 x 1rem x 1.15 line-height), so its height matches the
     text block rather than being a guessed pixel value. */
  display: flex;
  align-items: center;
  gap: 10px;
}
.m-brand-mark {
  width: auto;
  /* Taller than the two lines of type it sits beside (2 x 1.15em): the badge
     carries its own internal margin, so matching the text block exactly makes
     the mark itself read smaller than the words. */
  height: 2.8em;
  flex: none;
  /* The badge already carries its own circular edge; nothing here should crop
     or letterbox it. */
  object-fit: contain;
  /* On a white disc, because the badge's own ground is the same purple family
     as the header — unbacked it renders as a faint smudge that reads as
     nothing at all at 37px. The disc is what makes it a mark. */
  background: var(--tctd-white);
  border-radius: 50%;
  padding: 2px;
}
.m-brand-name { display: block; }
.m-nav-toggle-input { position: absolute; opacity: 0; pointer-events: none; }
.m-nav-burger {
  display: none;
  flex-direction: column;
  justify-content: center;
  gap: 5px;
  width: 44px;
  height: 44px;
  cursor: pointer;
}
.m-nav-burger span { display: block; width: 22px; height: 2px; background: var(--tctd-white); }

.m-nav-links {
  display: flex;
  align-items: center;
  gap: var(--tctd-space-5);
}
.m-nav-links a:not(.btn) {
  color: var(--tctd-white);
  font-size: 13px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  text-decoration: none;
  opacity: 0.85;
}
.m-nav-links a:not(.btn):hover { opacity: 1; color: var(--tctd-white); }
.m-nav-actions { display: flex; align-items: center; gap: var(--tctd-space-2); margin-left: var(--tctd-space-2); }
.m-nav-actions .btn-ghost { color: var(--tctd-white); border-color: rgba(255, 255, 255, 0.35); }
.m-nav-actions .btn-ghost:hover { color: var(--tctd-bg-anchor); background: var(--tctd-white); border-color: var(--tctd-white); }

@media (max-width: 900px) {
  .m-nav-burger { display: flex; }
  .m-nav-links {
    display: none;
    position: absolute;
    top: 100%; left: 0; right: 0;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    background: var(--tctd-bg-anchor);
    padding: var(--tctd-space-2) var(--tctd-space-5) var(--tctd-space-5);
    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  }
  .m-nav-links a:not(.btn) { padding: 14px 0; border-top: 1px solid rgba(255, 255, 255, 0.08); }
  .m-nav-actions { flex-direction: column; align-items: stretch; margin: var(--tctd-space-3) 0 0; }
  .m-nav-toggle-input:checked ~ .m-nav-links { display: flex; }
}

/* ---- entrance animation ----------------------------------------------------
   Plays automatically on paint (no JS/IntersectionObserver, so it can't
   trigger per-section on scroll) — used for the hero, where "on load" and
   "on scroll into view" are the same moment anyway. brand.css's global
   prefers-reduced-motion rule already collapses this to instant. */

@keyframes m-fade-up {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: none; }
}
.m-anim {
  animation: m-fade-up 700ms cubic-bezier(.2, .7, .3, 1) both;
}
.m-anim-1 { animation-delay: 40ms; }
.m-anim-2 { animation-delay: 140ms; }
.m-anim-3 { animation-delay: 240ms; }
.m-anim-4 { animation-delay: 340ms; }
.m-anim-5 { animation-delay: 440ms; }

/* ---- scroll reveals --------------------------------------------------------
   Everything below the hero animates when it is scrolled to, not on load.

   The hidden state is gated behind .reveal-ready, which reveal.js puts on
   <html> only once it is running and has an IntersectionObserver to work with.
   Without JS the class never appears and the page renders plainly and fully —
   deliberately, so a script failure can never blank the marketing site. Never
   move opacity: 0 out from behind this gate. */
html.reveal-ready [data-reveal] {
  opacity: 0;
  transform: translateY(16px);
  transition:
    opacity 700ms cubic-bezier(.2, .7, .3, 1),
    transform 700ms cubic-bezier(.2, .7, .3, 1);
  /* --reveal-i is set per child by reveal.js's stagger(), so a grid arrives as
     a sequence without numbered classes in the markup. */
  transition-delay: calc(var(--reveal-i, 0) * 90ms);
}
html.reveal-ready [data-reveal].is-in {
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  /* Show it, do not move it. The transition is dropped rather than shortened so
     nothing can be caught mid-fade. */
  html.reveal-ready [data-reveal] {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* ---- hero -----------------------------------------------------------------
   Full-bleed: the crossfading portfolio stack (mountPortfolioHero) IS the
   hero now, not a boxed photo beside the text — this is a photo/video
   business, so its own best work is what should fill the banner. Three
   stacked layers: .m-hero-media (the photo, z-index 0), .m-hero-scrim (a
   gradient for legibility, z-index 1), .m-hero-content (the copy, z-index 2). */

.m-hero {
  position: relative;
  overflow: hidden;
  /* A minimum, not a fixed height — long content on a narrow phone (three-line
     h1 + wrapped credentials) still gets to grow the section rather than
     being clipped, since the two absolutely-positioned layers below don't
     contribute to flex height and can't stop that growth. */
  min-height: min(640px, 92vh);
  display: flex;
  align-items: flex-end;
  /* Shows for the instant before the photo loads, and is what a visitor with
     images disabled or a failed read actually sees underneath the text. */
  background: var(--tctd-bg-anchor);
  color: var(--tctd-white);
  padding: var(--tctd-space-8) var(--tctd-space-5);
}

.m-hero-media {
  position: absolute;
  inset: 0;
  z-index: 0;
  /* Clips the crossfade stack's zoom animation to the section — see the
     .m-hero-media rule further down (shared with the old boxed layout's
     comment) for why this has to live on the actual layout parent. */
  overflow: hidden;
}
.m-hero-media > img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.m-hero-scrim {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  /* Two gradients doing two different jobs: the diagonal one darkens the left
     side, where the headline sits, while letting the photo breathe on the
     right; the vertical one darkens the bottom so the credential strip and
     buttons stay legible even over a bright patch of sky or siding. Colors are
     --tctd-ink's rgb (#1c1218) written as rgba — custom properties can't carry
     an alpha channel of their own. */
  background:
    /* Soft glow behind the text column. First pass at this (640x460, centered
       6% from the left) was verified against all 5 crossfade photos with a
       real headless-browser render, not just eyeballed at authoring time —
       and it failed on the two brightest interiors: the credential row runs
       to roughly x:110-810 at a 1400px viewport (44ch tagline, three
       checkmarked items), but the old ellipse's well-darkened zone only
       reached to about x:280 before fading, so "Registered & Insured in
       Minnesota" and the eyebrow line were relying on the faint tail of the
       diagonal gradient alone against a bright wall. Widened and re-centered
       to actually cover that row's real width, confirmed the same way. */
    radial-gradient(ellipse 950px 500px at 20% 56%, rgba(28,18,24,.62) 0%, rgba(28,18,24,.42) 38%, rgba(28,18,24,.22) 60%, rgba(28,18,24,.06) 80%, rgba(28,18,24,0) 96%),
    linear-gradient(100deg, rgba(28,18,24,.82) 0%, rgba(28,18,24,.6) 40%, rgba(28,18,24,.28) 66%, rgba(28,18,24,.08) 86%, rgba(28,18,24,0) 100%),
    linear-gradient(to top, rgba(28,18,24,.65) 0%, rgba(28,18,24,0) 46%);
}

.m-hero-content {
  position: relative;
  z-index: 2;
  max-width: 1180px;
  width: 100%;
  margin: 0 auto;
  /* Copy left, phone right. `auto` on the second track lets the frame size
     itself from its own clamp below and hand the remainder to the text, so the
     headline column shrinks on a narrow desktop instead of the frame
     squashing. align-items:end keeps both bottoms on the same line — the hero
     is bottom-aligned (.m-hero is align-items:flex-end), and a top-aligned
     phone would float away from the CTA row it sits next to. */
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: end;
  gap: var(--tctd-space-7);
}
/* Grid children default to stretch; the copy column has to be able to shrink
   below its content's intrinsic width or the 44ch tagline would force the
   frame off the right edge. */
.m-hero-copy { min-width: 0; }

/* ---- hero phone -----------------------------------------------------------
   The same physical frame as the showcase section further down the page: the
   .m-phone / .m-phone-screen rules do all the drawing (bezel, corner radius,
   camera dot, inner shadow), and this block only sizes it. Those dimensions
   come from --phone-long / --phone-short, normally set on .m-phones — setting
   them here is what lets one frame definition serve both sections instead of
   a second near-copy drifting out of sync with it.

   The frame's rendered height is --phone-long, so capping that against the
   usable viewport height keeps the phone inside the hero on a short laptop
   screen rather than growing the section past the fold. */
.m-hero-phone {
  --phone-long: clamp(300px, min(34vw, calc((100vh - var(--tctd-mnav-h, 76px)) * 0.62)), 470px);
  --phone-short: calc(var(--phone-long) * 200 / 410);
  justify-self: end;
}
/* The showcase stacks a caption under every frame and reserves the gap for it.
   The hero phone has no caption, so that margin would only push it off the
   baseline it shares with the CTA row beside it. */
.m-hero-phone .m-phone-screen { margin-bottom: 0; }

@media (max-width: 900px) {
  /* Below this the two columns leave the headline too narrow to read as a
     banner — stack, and let the frame sit under the copy at a smaller size. */
  .m-hero-content { grid-template-columns: 1fr; gap: var(--tctd-space-6); }
  .m-hero-phone {
    --phone-long: clamp(280px, 62vw, 380px);
    justify-self: start;
  }
}

/* On a phone the frame is the product demo, not a decorative prop beside the
   copy, so it gets the full column: centred, and sized off viewport height as
   well as width so a short screen does not have to scroll past it. */
@media (max-width: 768px) {
  .m-hero-phone {
    /* --phone-long is the FRAME'S HEIGHT; its width is that times 200/410, so
       sizing this off vw directly gave a frame half the width intended. 132vw
       is what lands the visible width at about two thirds of the screen, with
       58vh keeping it from swallowing a short viewport. */
    --phone-long: clamp(360px, min(132vw, 58vh), 500px);
    justify-self: center;
    margin-inline: auto;
  }
}

/* Default .tctd-eyebrow is mid-purple: barely readable over a photo (these
   sections aren't .tctd-on-dark, which would use whisper instead). Bright pink
   matches the credential checkmarks below it and actually reads. */
/* Smallest, thinnest text in the block (uppercase, letter-spaced, small caps)
   and bright pink against a near-white wall was the weakest point found in
   the render audit — its shadow needed to be stronger than the tagline's, not
   the same. */
.m-hero .tctd-eyebrow { text-shadow: 0 1px 10px rgba(0,0,0,.55); }
.m-hero .tctd-eyebrow, .m-page-hero .tctd-eyebrow { color: var(--tctd-bright-pink); }
.m-hero h1 { color: var(--tctd-white); margin-bottom: var(--tctd-space-4); text-shadow: 0 2px 16px rgba(0,0,0,.25); }

/* ---- rotating headline word --------------------------------------------
   The h1's last line cycles through the services (hero-rotator.js). The
   wrapper reserves the line so a longer word ("Listing Marketing") cannot
   reflow the hero, and `nowrap` keeps a two-word service from turning the
   three-line headline into four. */
.m-rotate {
  display: inline-block;
  white-space: nowrap;
  /* Larger than "Twin Cities / Top Down" above it, so the service is the
     loudest thing in the headline. Kept in `em` — it tracks the h1's own
     clamp() sizing down to phone widths instead of fighting it. */
  font-size: 1.24em;
}

.m-rotate-word {
  /* inline-block so the swap animation in hero-rotator.js has a box to
     translate. The motion itself lives in that module (Web Animations API),
     not here: an earlier CSS-class version could strand the word invisible
     when frames stopped arriving. */
  display: inline-block;
}

/* On phones the h1 bottoms out at clamp()'s 2.6rem, so the two-word services
   ("Listing Marketing" ~435px, "Virtual Staging" ~382px) run past the 343px
   text column and clip. Let them wrap there instead — a taller headline beats
   a cut-off one — and trim the size a little so the wrap is rarer. */
@media (max-width: 600px) {
  .m-rotate {
    white-space: normal;
    font-size: 1.06em;
    line-height: 1.02;
  }
  .m-rotate-word { max-width: 100%; }
}

.m-hero .tctd-tagline { font-size: 1.1rem; color: var(--tctd-whisper); max-width: 56ch; margin-bottom: var(--tctd-space-6); text-shadow: 0 1px 10px rgba(0,0,0,.4); }
.m-hero .m-credentials { text-shadow: 0 1px 8px rgba(0,0,0,.45); }
.m-hero-ctas { display: flex; gap: var(--tctd-space-3); flex-wrap: wrap; }
.m-hero-ctas .btn { padding: 15px 26px; }

/* .m-hero-row stays a plain two-column grid utility — about.html and
   contact.html both still use it for their own unrelated layouts, so nothing
   above may assume it means "hero row" anymore. */
.m-hero-row {
  max-width: 1180px;
  margin: 0 auto;
  display: grid;
  grid-template-columns: 1.1fr 1fr;
  gap: var(--tctd-space-7);
  align-items: center;
}

/* Trust/credential strip — since-year and licensing, not decoration. */
.m-credentials {
  display: flex;
  flex-wrap: wrap;
  gap: var(--tctd-space-2) var(--tctd-space-5);
  margin: 0 0 var(--tctd-space-6);
  padding: 0;
  list-style: none;
  font-size: 13px;
  font-weight: 600;
  color: var(--tctd-whisper);
}
.m-credentials li { display: flex; align-items: center; gap: 7px; }
.m-credentials svg { width: 16px; height: 16px; flex: none; color: var(--tctd-bright-pink); }
.m-credentials.on-light { color: var(--tctd-ink-muted); }
.m-credentials.on-light svg { color: var(--tctd-mid-purple); }

/* Column-ratio modifiers live here, not in inline style attributes: an inline
   grid-template-columns beats the stacking media query below and forces two
   columns onto phones. */
.m-hero-row--even { grid-template-columns: 1fr 1fr; }
.m-hero-row--wide { grid-template-columns: 1fr 1.4fr; }
.m-hero-row--top { align-items: start; }

@media (max-width: 900px) {
  .m-hero-row,
  .m-hero-row--even,
  .m-hero-row--wide { grid-template-columns: 1fr; }
}

/* ---- placeholders ---------------------------------------------------------
   Swap the class for a real <img>/<picture> when photos are ready — the box
   only exists so layout can be reviewed before assets land. */

.m-placeholder {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 6px;
  min-height: 200px;
  padding: var(--tctd-space-4);
  border-radius: var(--tctd-radius-lg);
  background: var(--tctd-surface-alt);
  border: 2px dashed var(--tctd-border);
  color: var(--tctd-ink-muted);
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.m-placeholder svg { width: 30px; height: 30px; opacity: 0.55; }
/* Once a real photo fills the placeholder (about/index.html's headshot mount,
   the only current user), the dashed "asset pending" styling has to go —
   it's replaced content now, not a gap waiting to be filled. */
.m-placeholder:has(img) {
  padding: 0;
  border: none;
  background: none;
  overflow: hidden;
}
.m-placeholder img { width: 100%; height: 100%; object-fit: cover; border-radius: var(--tctd-radius-lg); display: block; }
.m-placeholder.is-dark {
  background: rgba(255, 255, 255, 0.06);
  border-color: rgba(255, 255, 255, 0.25);
  color: var(--tctd-whisper);
}
/* .m-hero-media (the layer box) and .m-hero-media > img (the no-JS/no-image
   fallback) are both defined once, up in the ---- hero ---- block, now that
   this is a full-bleed background rather than a boxed card beside the text.
   .m-placeholder still needs a floor here for the rare case Storefront →
   Portfolio has no hero image set at all — see mountPortfolioHero's early
   return in that case, which leaves this markup exactly as authored. */
.m-hero-media .m-placeholder { min-height: 340px; }
.m-hero-media .m-placeholder svg { color: var(--tctd-whisper); }

/* Crossfading hero stack. mountPortfolioHero (portfolio-gallery.js) toggles
   .is-active with setInterval; this file only owns the transition and the
   ambient drift — a slow, continuous, whole-box zoom that runs independent of
   which layer is showing, which is what keeps the crossfade logic simple.
   Fills .m-hero-media exactly (itself full-bleed now), rather than the boxed,
   rounded card it used to be — .m-hero-media's own overflow:hidden is what
   clips the zoom to the section, the same reasoning as when this was a
   smaller box, just applied one level up. */
.m-hero-stack {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  animation: m-hero-drift 18s ease-in-out infinite alternate;
}
.m-hero-layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 1400ms ease;
}
.m-hero-layer.is-active { opacity: 1; }

@keyframes m-hero-drift {
  from { transform: scale(1); }
  to   { transform: scale(1.07); }
}

@media (prefers-reduced-motion: reduce) {
  /* Belt and suspenders: brand.css's global rule already collapses the
     transition/animation durations to ~0, but a 0.01ms animation still
     technically runs and can leave the box at a random point mid-zoom. Pin it
     to the resting frame explicitly. */
  .m-hero-stack { animation: none; transform: none; }
}

/* ---- phone showcase ---------------------------------------------------------
   CSS-drawn phone frames playing vertical product video. phone-showcase.js
   fills `[data-phone-slot]` with a live embed for a product whose video is a
   YouTube Shorts link; anything it can't fill keeps the placeholder screen
   authored in index.html. */

/* Frame size comes from the viewport, not a fixed pixel height, because this
   section pins under the nav (the homepage scroll stack at the top of this
   file) — a phone tall enough to push the row past the fold would sit there
   permanently half-visible, since a pinned section does not scroll. 448px is
   what everything else in the section costs vertically: the 76px nav, the
   section's own padding, its heading block, the caption, and the link
   underneath. Whatever is left over is the phone, floored so it stays
   recognisable on a short window and capped so it does not become absurd on
   a tall one.

   The 42vw term is a width guard, not a second opinion on height: the three
   frames plus their gaps have to stay on one line, because a wrapped row is
   taller than the viewport and this section cannot scroll to reveal it.

   --phone-short keeps the original 200:410 proportion, and the landscape
   frame below just swaps the two — so both orientations scale together off
   the single measurement. */
.m-phones {
  --phone-long: clamp(320px, min(calc(100vh - 448px), 42vw), 560px);
  --phone-short: calc(var(--phone-long) * 200 / 410);

  display: flex;
  justify-content: center;
  align-items: center;
  gap: var(--tctd-space-6);
  flex-wrap: wrap;
  padding-top: var(--tctd-space-4);
}

.m-phone { text-align: center; }
/* Left and right phones cant slightly opposite ways so the row reads as a
   composed shot; the landscape one in the middle stays flat — it's already
   the visual anchor and doesn't need the tilt to stand out. */
.m-phones > :first-child .m-phone-screen { transform: rotate(-3deg); }
.m-phones > :last-child .m-phone-screen { transform: rotate(3deg); }

/* 2026-modern frame: thin bezel, big sharp-rounded corners, a single camera
   dot instead of a notch/pill — a slim brushed-metal edge rather than a thick
   black bar. */
.m-phone-screen {
  position: relative;
  width: var(--phone-short);
  height: var(--phone-long);
  margin: 0 auto var(--tctd-space-3);
  border-radius: 40px;
  border: 6px solid;
  border-color: #2a2a2f;
  background:
    linear-gradient(155deg, rgba(255,255,255,.05), transparent 40%),
    var(--tctd-bg-anchor);
  overflow: hidden;
  box-shadow: var(--tctd-shadow-lg), inset 0 0 0 1px rgba(255,255,255,.06);
  transition: transform 300ms ease;
}
.m-phone-screen:hover { transform: none !important; }
/* The camera — a single small dot, centered at the top edge. Modern phones
   read as phones from this alone; a notch/pill would look dated next to it. */
.m-phone-screen::before {
  content: "";
  position: absolute;
  top: 12px; left: 50%;
  transform: translateX(-50%);
  width: 9px; height: 9px;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 35%, #4a4a52, #0a0a0c 70%);
  box-shadow: 0 0 0 2px rgba(255,255,255,.05);
  z-index: 2;
}

/* The middle phone, turned on its side: same frame, swapped dimensions, and
   the camera dot moves from the top edge to the left edge (still centered on
   whichever edge is now "the top" of the physical device). */
.m-phone--landscape .m-phone-screen {
  width: var(--phone-long);
  height: var(--phone-short);
}
.m-phone--landscape .m-phone-screen::before {
  top: 50%; left: 12px;
  transform: translateY(-50%);
}

.m-phone-video,
.m-phone-screen video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  object-fit: cover;
}

/* Sound button — the only interactive thing on an otherwise decorative frame,
   added by video-sound.js once a real player is in the slot (never over the
   static "coming soon" screen or a reduced-motion still). Sits above the
   iframe, which is the full size of the screen. */
.m-phone-sound {
  position: absolute;
  right: 10px;
  bottom: 10px;
  z-index: 3;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 1px solid rgba(255,255,255,.28);
  border-radius: 50%;
  /* Dark and semi-opaque rather than tinted: the footage behind it is
     arbitrary, so the button needs its own contrast rather than borrowing the
     frame's. */
  background: rgba(12,10,14,.55);
  backdrop-filter: blur(6px);
  color: var(--tctd-white);
  cursor: pointer;
  transition: background 200ms ease, border-color 200ms ease, transform 200ms ease;
}
.m-phone-sound svg { width: 17px; height: 17px; }
.m-phone-sound:hover { background: rgba(12,10,14,.78); border-color: rgba(255,255,255,.5); }
.m-phone-sound:active { transform: scale(.94); }
.m-phone-sound:focus-visible { outline: 2px solid var(--tctd-bright-pink); outline-offset: 2px; }
/* Sound on: filled pink so the state is readable from across the page, not
   only from the icon's two extra arcs. */
.m-phone-sound[aria-pressed="true"] {
  background: var(--tctd-bright-pink);
  border-color: transparent;
  color: var(--tctd-white);
}

/* Crop-to-fill, for a frame whose video is the same orientation it is.
   `object-fit: cover` on .m-phone-video crops an <img>, but has no power over
   an <iframe>'s contents: the player fits the clip inside whatever box it is
   given, so a 9:16 Short in the 200:410 frame gets black bands top and bottom,
   and a 16:9 clip in the landscape frame gets them down the sides. Sizing the
   iframe itself to the source's aspect and letting it overhang (the screen's
   own overflow:hidden does the cropping) is what removes them.

   Marked per frame in the HTML, never assumed — see the phone-showcase comment
   in index.html for why a mismatched source is left letterboxed instead. */
.m-phone-screen[data-phone-fill="cover"] .m-phone-video {
  inset: auto;
  top: 50%;
  left: 50%;
  width: auto;
  height: 100%;
  aspect-ratio: 9 / 16;
  /* The 1% is slop for fractional-pixel rounding on the two edges with no
     overhang to hide a hairline gap behind. */
  transform: translate(-50%, -50%) scale(1.01);
}
/* The landscape frame's sources are 16:9, so it covers by width instead. */
.m-phone--landscape .m-phone-screen[data-phone-fill="cover"] .m-phone-video {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
}

.m-phone-soon {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--tctd-space-5);
  margin: 0;
  text-align: center;
  font-size: 13px;
  color: var(--tctd-whisper);
  background: linear-gradient(160deg, rgba(255,255,255,.06), transparent);
}

.m-phone-caption {
  margin: 0;
  font-size: 13px;
  font-weight: 600;
  color: var(--tctd-ink-muted);
}

@media (max-width: 720px) {
  /* Canted phones do not fit a phone screen — stack them level and let the
     flex-wrap above take over. */
  .m-phones > * .m-phone-screen { transform: none !important; }
}
@media (max-width: 460px) {
  /* The landscape frame is wider than a small phone screen at its desktop
     size — shrink it (and its portrait siblings, to match) rather than
     letting it overflow or force horizontal scroll.

     Scoped to .m-phones, the showcase row this was written for. Unscoped it
     also caught the hero frame, which is a single portrait phone in its own
     column with no landscape sibling to fit beside, and pinned it to 160px
     regardless of what --phone-long said. */
  .m-phones .m-phone-screen { width: 160px; height: 328px; }
  .m-phones .m-phone--landscape .m-phone-screen { width: 328px; height: 160px; }
}

/* ---- before / after ---------------------------------------------------------
   before-after.js builds one .m-ba per tagged pair. The range input is the
   real source of truth (and the keyboard/a11y path) but is visually hidden;
   .m-ba-handle is what's actually seen, moved by JS reading the input's value
   on every `input` event. */

.m-ba-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
  gap: var(--tctd-space-6);
}

.m-ba { position: relative; }
.m-ba-frame {
  position: relative;
  aspect-ratio: 4 / 3;
  border-radius: var(--tctd-radius-lg);
  overflow: hidden;
  cursor: ew-resize;
  box-shadow: var(--tctd-shadow);
  /* Dragging a finger across the image must not also scroll the page. */
  touch-action: none;
  user-select: none;
}
.m-ba-before,
.m-ba-after {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  pointer-events: none;
}
/* clip-path, not width, so the "after" image never visibly resizes or
   distorts as the handle moves — only the visible slice of it changes. */
.m-ba-after-clip {
  position: absolute;
  inset: 0;
  clip-path: inset(0 50% 0 0);
}

.m-ba-handle {
  position: absolute;
  top: 0; bottom: 0;
  left: 50%;
  width: 3px;
  background: var(--tctd-white);
  transform: translateX(-50%);
  pointer-events: none;
  box-shadow: 0 0 0 1px rgba(0,0,0,.15);
}
.m-ba-handle::after {
  content: "";
  position: absolute;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 36px; height: 36px;
  border-radius: 50%;
  background: var(--tctd-white);
  box-shadow: var(--tctd-shadow);
  background-image:
    linear-gradient(45deg, transparent 46%, var(--tctd-ink-muted) 46% 54%, transparent 54%),
    linear-gradient(-45deg, transparent 46%, var(--tctd-ink-muted) 46% 54%, transparent 54%);
  background-size: 10px 10px, 10px 10px;
  background-position: center;
  background-repeat: no-repeat;
}
.m-ba.is-focused .m-ba-handle {
  box-shadow: 0 0 0 1px rgba(0,0,0,.15), 0 0 0 4px var(--tctd-bright-pink);
}

/* The actual input, nested inside .m-ba-frame (not a sibling of it — see the
   comment in before-after.js's sliderHtml) so inset:0 matches the image
   exactly, not the taller box that includes the caption below it. Invisible
   and above everything so it always receives the pointerdown that starts a
   drag; before-after.js also listens on the frame itself, so dragging works
   whether the browser routes the initial pointer event to the input or the
   frame beneath it. */
.m-ba-range {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: ew-resize;
  appearance: none;
  -webkit-appearance: none;
}
.m-ba-range::-webkit-slider-thumb { -webkit-appearance: none; width: 36px; height: 36px; }
.m-ba-range::-moz-range-thumb { width: 36px; height: 36px; border: 0; }

.m-ba-caption {
  margin: var(--tctd-space-3) 0 0;
  font-size: 13px;
  font-weight: 600;
  color: var(--tctd-ink-muted);
  text-align: center;
}

/* ---- sections --------------------------------------------------------------*/

.m-section { padding: var(--tctd-space-8) var(--tctd-space-5); }
.m-section.is-alt { background: var(--tctd-surface-alt); }
.m-section-inner { max-width: 1180px; margin: 0 auto; }
.m-section-head { max-width: 640px; margin: 0 auto var(--tctd-space-7); text-align: center; }
.m-section-head .tctd-eyebrow { justify-content: center; }
.m-section-head p { color: var(--tctd-ink-muted); }

.m-grid {
  display: grid;
  gap: var(--tctd-space-5);
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.m-grid-2 { grid-template-columns: repeat(2, 1fr); }
.m-grid-3 { grid-template-columns: repeat(3, 1fr); }
.m-grid-4 { grid-template-columns: repeat(4, 1fr); }
.m-grid-5 { grid-template-columns: repeat(5, 1fr); }
@media (max-width: 1080px) {
  .m-grid-5 { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
}
@media (max-width: 900px) {
  .m-grid-2, .m-grid-3, .m-grid-4 { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
}

.m-card { text-align: left; }
.m-card h3 { margin-bottom: var(--tctd-space-2); }
.m-card p { color: var(--tctd-ink-muted); margin-bottom: 0; }

/* Homepage Services row: an auto-scrolling right-to-left marquee, not a
   static grid. The track holds the 8 cards twice back to back — at
   translateX(-50%) it has moved exactly one full set, so the loop is
   seamless — and pauses on hover/focus so a card is actually readable. */
.m-services-scroller {
  /* One card plus one gap. The track is the 8 cards twice over, so shifting by
     exactly 8 of these lands the second copy where the first began — see the
     keyframes below for why this is spelled out in pixels. */
  --card-w: 260px;
  --card-step: calc(var(--card-w) + var(--tctd-space-5));

  overflow: hidden;
  width: 100%;
  padding: var(--tctd-space-2) 0 var(--tctd-space-4);
  /* A short, hard edge fade — just enough to stop cards appearing out of
     nowhere at the boundary. A long ramp reads as a haze over the artwork
     rather than an edge treatment. */
  mask-image: linear-gradient(to right, transparent, black 16px, black calc(100% - 16px), transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, black 16px, black calc(100% - 16px), transparent);
}
.m-services-track {
  display: flex;
  width: max-content;
  /* Promote the track to its own compositor layer so the scroll runs on the GPU
     rather than being re-rasterised on the main thread every frame, which is
     what the images decoding and the Firestore reads elsewhere on the page were
     competing with. */
  will-change: transform;
  backface-visibility: hidden;
  gap: var(--tctd-space-5);
  padding: 0 var(--tctd-space-5);
  animation: m-services-scroll 44s linear infinite;
}
.m-services-scroller:hover .m-services-track,
.m-services-scroller:focus-within .m-services-track {
  animation-play-state: paused;
}
.m-services-track .m-card {
  flex: 0 0 var(--card-w);
  width: var(--card-w);
}
/* Shifted by a whole number of pixels, NOT translateX(-50%). The track's total
   width is odd, so half of it lands on a fractional pixel — and text drawn at
   a half-pixel offset is resampled, which is what made the moving cards look
   soft and glowy. Eight card-steps is exactly one copy of the set, so the loop
   is still seamless. */
@keyframes m-services-scroll {
  from { transform: translateX(0); }
  to { transform: translateX(calc(-8 * var(--card-step))); }
}
@media (prefers-reduced-motion: reduce) {
  .m-services-track { animation: none; }
  .m-services-scroller { overflow-x: auto; }
}

/* services-marquee.js adds .is-manual once it is driving the row itself. From
   then on the row is a real horizontal scroller — a thumb can flick it, drag
   it, or stop it on a card — and the transform animation has to go, or the
   two would fight over the same movement. The scrollbar is hidden because the
   row already reads as scrollable: it is cut off mid-card at both edges. */
.m-services-scroller.is-manual,
.m-logos.is-manual {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.m-services-scroller.is-manual::-webkit-scrollbar,
.m-logos.is-manual::-webkit-scrollbar { display: none; }
.m-services-scroller.is-manual .m-services-track,
.m-logos.is-manual .m-logos-track {
  animation: none;
  /* The scroller moves the content now, so the track must not also be shifted
     by a leftover transform from the paused animation. */
  transform: none;
}

/* Phones only. Once the row stacks, 260px cards fill the viewport and only one
   service is ever on screen. Narrower cards with a tighter gap put two and a
   bit in view, so the marquee reads as a moving row rather than a slideshow.
   --card-step must keep matching the real card width + gap or the keyframe
   above stops landing the second copy exactly on the first. */
@media (max-width: 600px) {
  .m-services-scroller {
    --card-w: 156px;
    --card-step: calc(var(--card-w) + var(--tctd-space-4));
  }
  .m-services-track {
    gap: var(--tctd-space-4);
    padding: 0 var(--tctd-space-4);
  }
  /* The image bleeds by cancelling the card's padding, so the two have to be
     shrunk together. */
  .m-services-track .m-card {
    padding: var(--tctd-space-4);
  }
  .m-services-track .m-card-img {
    margin: calc(var(--tctd-space-4) * -1) calc(var(--tctd-space-4) * -1) var(--tctd-space-3);
  }
  .m-services-track .m-card h3 { font-size: 1rem; }
  .m-services-track .m-card p { font-size: 13px; }
}

/* The homepage "what agents get" trio, phones only: centred text with the icon
   centred over it. Scoped to .m-grid-props so the other m-grid-3 rows (blog,
   about, listing blast) keep their left-aligned cards. */
@media (max-width: 768px) {
  .m-grid-props .m-card { text-align: center; }
  .m-grid-props .m-card-icon { margin-left: auto; margin-right: auto; }
}

/* Photo at the top of a homepage service card, bleeding to the card's edges —
   .card carries the padding, so the negative margin cancels it on three sides.
   The gradient is the same "photo pending" wash the portal uses for a product
   with no image: a card whose product has no photo yet must read as deliberate,
   not broken. mountServiceImages() drops the <img> in when there is one. */
.m-card-img {
  margin: calc(var(--tctd-space-5) * -1) calc(var(--tctd-space-5) * -1) var(--tctd-space-4);
  aspect-ratio: 4 / 3;
  overflow: hidden;
  background: linear-gradient(135deg, #f3e9f5, #f6f6f4);
}
.m-card-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The bled image would otherwise square off the card's rounded top corners. */
.m-card { overflow: hidden; }

.m-card-icon {
  width: 44px; height: 44px;
  display: grid; place-items: center;
  border-radius: var(--tctd-radius);
  background: var(--tctd-gradient);
  color: var(--tctd-white);
  margin-bottom: var(--tctd-space-4);
  transition: transform 200ms ease;
}
.m-card-icon svg { width: 22px; height: 22px; }

/* Cards on the marketing site lift on hover — the storefront/admin .card
   stays flat, so this only touches marketing pages via body.marketing. */
body.marketing .card {
  transition: transform 200ms ease, box-shadow 200ms ease;
}
body.marketing .card:hover {
  transform: translateY(-4px);
  box-shadow: var(--tctd-shadow-lg);
}
body.marketing .card:hover .m-card-icon { transform: scale(1.08); }

/* Homepage teaser — fixed-crop tiles, since it's a small, even preview row. */
.m-pf-teaser-tile {
  display: block;
  aspect-ratio: 4 / 3;
  border-radius: var(--tctd-radius);
  overflow: hidden;
  background: var(--tctd-surface-alt);
}
.m-pf-teaser-tile img {
  width: 100%; height: 100%; object-fit: cover;
  transition: transform 400ms ease;
}
.m-pf-teaser-tile:hover img { transform: scale(1.06); }

/* ---- /portfolio: justified gallery + lightbox -----------------------------
   Full-bleed, edge to edge, zero gap — rows computed in JS (see
   portfolio-gallery.js's layoutRows) so every row's image widths sum exactly
   to the container width at a shared row height. ROW_GAP there must match
   the gap here (currently 0) — the width math accounts for exactly that many
   pixels of gap per row. */

.m-pf-full {
  padding: 0;
  max-width: 1800px;
  margin: 0 auto;
}
.m-pf-mount {
  display: flex;
  flex-direction: column;
  gap: 0;
  width: 100%;
  overflow: hidden;
}
.m-pf-row {
  display: flex;
  gap: 0;
}
.m-pf-tile {
  flex: none;
  margin: 0;
  overflow: hidden;
  cursor: pointer;
  position: relative;
  animation: m-fade-up 500ms ease both;
}
.m-pf-tile img {
  width: 100%; height: 100%; display: block;
  object-fit: cover;
  transition: transform 400ms ease, filter 400ms ease;
}
.m-pf-tile:hover img { transform: scale(1.05); }
.m-pf-tile:focus-visible { outline: 3px solid var(--tctd-bright-pink); outline-offset: -3px; z-index: 1; }

.m-lightbox {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(10, 4, 14, 0.92);
  padding: var(--tctd-space-6);
  animation: m-fade-up 180ms ease both;
}
.m-lb-img {
  max-width: min(1200px, 88vw);
  max-height: 86vh;
  width: auto;
  object-fit: contain;
  border-radius: var(--tctd-radius);
  box-shadow: var(--tctd-shadow-lg);
}
.m-lb-close, .m-lb-arrow {
  position: absolute;
  display: grid;
  place-items: center;
  background: rgba(255, 255, 255, 0.1);
  color: var(--tctd-white);
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  transition: background-color 150ms ease;
}
.m-lb-close:hover, .m-lb-arrow:hover { background: rgba(255, 255, 255, 0.22); }
.m-lb-close { top: var(--tctd-space-5); right: var(--tctd-space-5); width: 44px; height: 44px; }
.m-lb-close svg { width: 20px; height: 20px; }
.m-lb-arrow { top: 50%; transform: translateY(-50%); width: 52px; height: 52px; }
.m-lb-arrow svg { width: 24px; height: 24px; }
.m-lb-prev { left: var(--tctd-space-5); }
.m-lb-next { right: var(--tctd-space-5); }
.m-lb-counter {
  position: absolute;
  bottom: var(--tctd-space-5);
  left: 50%;
  transform: translateX(-50%);
  color: rgba(255, 255, 255, 0.8);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.04em;
}
@media (max-width: 640px) {
  .m-lb-arrow { width: 40px; height: 40px; }
  .m-lb-prev { left: var(--tctd-space-3); }
  .m-lb-next { right: var(--tctd-space-3); }
  .m-lb-close { top: var(--tctd-space-3); right: var(--tctd-space-3); }
}

/* Packages/pricing teaser — deliberately no dollar figures: pricing lives in
   the Storefront (see project memory "M10 marketing scope"), not baked into
   this static page where it could drift out of sync with the real catalog. */
.m-pricing-card { text-align: center; }
.m-pricing-card .m-card-icon { margin: 0 auto var(--tctd-space-4); }

.m-link-arrow { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; }
.m-link-arrow svg { width: 16px; height: 16px; transition: transform 120ms ease; }
.m-link-arrow:hover svg { transform: translateX(3px); }

/* ---- blog post cards ---------------------------------------------------
   Used by /blog and by the homepage teaser. The whole card is the link, so the
   image, the heading, and the blurb share one hit area; padding sits on the
   inner div because the image runs edge to edge. */
.m-post-card { display: block; padding: 0; overflow: hidden; text-decoration: none; }
.m-post-card img {
  width: 100%;
  aspect-ratio: 3 / 2;
  object-fit: cover;
  display: block;
}
.m-post-card-body { padding: var(--tctd-space-5); }
.m-post-card h3 { margin-bottom: var(--tctd-space-2); }
.m-post-card p:last-child { margin-bottom: 0; color: var(--tctd-ink-muted); font-size: 14px; }

/* ---- logo marquee ------------------------------------------------------------
   "Companies we've worked with." Pure CSS, no JS: the track is the tile list
   duplicated once, animated to -50% so the loop point is invisible. Swap each
   .m-logo-tile placeholder for a real <img> (brokerage logo) as they're added
   — see the note on the section itself about not using anyone's mark without
   their say-so. */

.m-logos { overflow: hidden; }
.m-logos-mask {
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.m-logos-track {
  display: flex;
  align-items: center;
  gap: var(--tctd-space-7);
  width: max-content;
  /* 32s was set when the strip was eight 168px placeholder tiles. It now
     carries wider 240px logo tiles and 340px review cards, so the same
     duration moved the track a good deal faster in pixels per second, which is
     the speed that actually matters. 56s brings it back to a pace a reader can
     finish a quote at. */
  animation: m-logos-scroll 56s linear infinite;
}
.m-logos-track:hover { animation-play-state: paused; }
.m-logos:focus-within .m-logos-track { animation-play-state: paused; }

/* The loop distance is one full set PLUS the gap that follows it, not half the
   track. With 2N tiles there are 2N-1 gaps, so 50% of the track lands half a
   gap short and the strip visibly snaps forward once per cycle: measured at
   exactly 24px against a 48px gap. Subtracting half a gap makes the seam
   land on the same pixel the loop started from.

   translate3d rather than translateX keeps the animation on the compositor. */
@keyframes m-logos-scroll {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(calc(-50% - (var(--tctd-space-7) / 2)), 0, 0); }
}

.m-logo-tile {
  flex: none;
  /* Sized off the worst case, which is a square logo: the tile's height minus
     its padding is all a square mark ever gets, so height is what decides
     whether these are readable. At 84px tall a round badge rendered 58px and
     was a squint; at 120px it renders 94px. */
  width: 240px;
  height: 120px;
  /* Flex, not grid, and this is load-bearing. In a grid whose items are
     centred, the row's height is content-derived, so a percentage height on
     the image has nothing definite to resolve against and silently falls back
     to `auto` — the image then takes its width cue alone and any logo taller
     than it is wide (a square badge, a stacked wordmark) overflows the tile
     and gets clipped by the marquee. A flex line against this tile's fixed
     84px height resolves the caps below correctly. */
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--tctd-radius);
  background: var(--tctd-white);
  border: 1px solid var(--tctd-border);
  color: var(--tctd-ink-muted);
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  /* Padding, not a percentage cap on the image: the tile is the frame and this
     is its mat. It gives every logo the same breathing room whether it is a
     wide wordmark or a square badge. */
  padding: var(--tctd-space-3) var(--tctd-space-4);
}
/* Caps, not sizes: the logo keeps its own aspect ratio and shrinks until both
   axes fit, whatever it arrives as. `width`/`height: auto` are stated rather
   than assumed — brand.css sets `height: auto` on every image, but the width
   needs saying here so neither axis is ever forced. */
.m-logo-tile img {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}

/* ---- review tiles in the same marquee ----------------------------------
   Every other tile in the strip is an agent review (see trusted-by.js). Wider
   than a logo tile because it carries running text. */

.m-review-tile {
  flex: none;
  width: 340px;
  /* Matches the logo tile's height so the band has one baseline top and
     bottom rather than two tile sizes stepping against each other. */
  min-height: 120px;
  margin: 0;
  padding: var(--tctd-space-4) var(--tctd-space-5);
  display: flex;
  flex-direction: column;
  gap: var(--tctd-space-2);
  border-radius: var(--tctd-radius);
  background: var(--tctd-white);
  border: 1px solid var(--tctd-border);
}

.m-review-stars {
  color: var(--tctd-bright-pink);
  font-size: 13px;
  letter-spacing: 0.08em;
  line-height: 1;
}

.m-review-tile blockquote {
  margin: 0;
  font-size: 13px;
  line-height: 1.5;
  color: var(--tctd-ink);
  /* Three lines, then an ellipsis. The marquee's height is set by its tallest
     tile, so an unbounded quote would push every logo tile's row taller. */
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.m-review-tile figcaption {
  display: flex;
  align-items: center;
  gap: var(--tctd-space-3);
  font-size: 11px;
  line-height: 1.3;
  color: var(--tctd-ink-muted);
}

.m-review-tile figcaption strong {
  display: block;
  color: var(--tctd-ink);
  font-size: 12px;
}

.m-review-photo {
  width: 32px;
  height: 32px;
  flex: none;
  border-radius: 50%;
  object-fit: cover;
}

.m-review-org { display: block; }

/* ---- reviews, under the logo strip -------------------------------------
   The same tile, no longer inside the marquee: it sizes to its grid column
   instead of carrying a fixed width, and the quote is no longer clamped,
   because nothing here has to match a neighbouring tile's height any more. */
.m-reviews {
  display: grid;
  /* Capped, not 1fr: two reviews across a 1180px row stretch to 578px each,
     which leaves a two-line quote floating in a mostly empty card. The cap
     keeps a card card-sized and centres however many there are.

     340 rather than 380 because `justify-content: center` stops the tracks
     absorbing the leftover space, so each one takes its max and the row fits
     however many of those go into 1180px: at 380 that was two, which put a
     set of five across three rows. At 340 it is three. */
  grid-template-columns: repeat(auto-fit, minmax(260px, 340px));
  justify-content: center;
  gap: var(--tctd-space-5);
  margin-top: var(--tctd-space-6);
}
.m-reviews .m-review-tile {
  width: auto;
  min-height: 0;
  padding: var(--tctd-space-5);
  gap: var(--tctd-space-3);
}

/* The person is the point. Headshot, name and stars run first and large; the
   quote follows in smaller, quieter type — it corroborates the rating rather
   than carrying the tile. */
.m-reviews .m-review-tile figcaption {
  gap: var(--tctd-space-4);
  font-size: 13px;
}
.m-reviews .m-review-photo {
  width: 64px;
  height: 64px;
  border: 2px solid var(--tctd-white);
  box-shadow: var(--tctd-shadow);
}
/* Stand-in for a testimonial with no headshot on file. Without it the name
   slides left and that card breaks the row's alignment — worse than a
   monogram, given the headshot is the thing being made prominent here. */
.m-review-initial {
  display: grid;
  place-items: center;
  background: var(--tctd-gradient);
  color: var(--tctd-white);
  font-family: var(--tctd-font-head);
  font-size: 24px;
  font-weight: 700;
  letter-spacing: 0.02em;
  user-select: none;
}
.m-reviews .m-review-who { display: grid; gap: 3px; }
.m-reviews .m-review-tile figcaption strong {
  font-size: 17px;
  line-height: 1.2;
}
.m-reviews .m-review-org { font-size: 13px; }
.m-reviews .m-review-tile blockquote {
  display: block;
  overflow: visible;
  font-size: 14px;
  color: var(--tctd-ink-muted);
}

/* ---- star row ----------------------------------------------------------
   A gradient sweeps along the filled stars: the row is painted with a wide
   gradient clipped to the glyphs, and it is the background POSITION that
   animates, not a pseudo-element sliding over the top — that keeps the whole
   effect on one element and leaves the stars selectable text.

   The unfilled stars sit outside the clipped span, or a sweep through them
   would read as a star filling in. */
.m-reviews .m-review-stars {
  font-size: 30px;
  letter-spacing: 0.06em;
  line-height: 1.1;
  margin-top: 4px;
}
.m-stars-filled {
  /* No white stop in here, however much a sheen wants one: the tile is white,
     so the star at the crest of the sweep disappears for those frames. The
     highlight tops out at a light pink that still reads against the card. */
  background-image: linear-gradient(
    100deg,
    var(--tctd-bright-pink) 0%,
    var(--tctd-bright-pink) 36%,
    #e879c4 46%,
    #f7b6e0 52%,
    #e879c4 58%,
    var(--tctd-mid-purple) 70%,
    var(--tctd-bright-pink) 100%
  );
  background-size: 300% 100%;
  background-position: 100% 0;
  -webkit-background-clip: text;
          background-clip: text;
  color: transparent;
  animation: m-star-sheen 3.6s linear infinite;
}
.m-stars-empty { color: var(--tctd-border); }

@keyframes m-star-sheen {
  from { background-position: 100% 0; }
  to   { background-position: -100% 0; }
}

/* Offset per card so five tiles do not shimmer in lockstep. */
.m-reviews > :nth-child(2) .m-stars-filled { animation-delay: -0.7s; }
.m-reviews > :nth-child(3) .m-stars-filled { animation-delay: -1.4s; }
.m-reviews > :nth-child(4) .m-stars-filled { animation-delay: -2.1s; }
.m-reviews > :nth-child(5) .m-stars-filled { animation-delay: -2.8s; }

@media (prefers-reduced-motion: reduce) {
  /* Still a gradient, just a still one — the stars must not go transparent. */
  .m-stars-filled { animation: none; background-position: 50% 0; }
}
@media (max-width: 640px) {
  .m-logo-tile { width: 190px; height: 96px; }
  .m-review-tile { width: 260px; min-height: 96px; }
  .m-reviews .m-review-tile { width: auto; min-height: 0; }
  /* Phones are where these were unreadably small, so they get bigger here
     rather than smaller: this is the one breakpoint that scales a tile UP. */
  .m-reviews .m-review-photo { width: 72px; height: 72px; }
  .m-reviews .m-review-tile figcaption strong { font-size: 18px; }
  .m-reviews .m-review-stars { font-size: 32px; }
}

@media (prefers-reduced-motion: reduce) {
  .m-logos-track { animation: none; }
  .m-logos { overflow-x: auto; }
}

/* ---- CTA band --------------------------------------------------------------*/

.m-cta-band {
  background: var(--tctd-gradient);
  color: var(--tctd-white);
  text-align: center;
  padding: var(--tctd-space-8) var(--tctd-space-5);
}
.m-cta-band h2 { color: var(--tctd-white); }
.m-cta-band p { color: rgba(255, 255, 255, 0.85); max-width: 50ch; margin: 0 auto var(--tctd-space-6); }
.m-cta-band .btn-primary { background: var(--tctd-white); color: var(--tctd-deep-purple); }

/* Hover goes pink, not a dimmer white.
 *
 * The old rule set only the background, so `color` fell through to brand.css's
 * `.btn-primary:hover:not(:disabled) { color: white }`, which outranks the
 * two-class `.m-cta-band .btn-primary` that had made the label purple. White
 * label on a white button: the button read as blank the moment you pointed at
 * it. The `:not(:disabled)` here is what wins that specificity race back. */
.m-cta-band .btn-primary:hover:not(:disabled) {
  background: var(--tctd-bright-pink);
  color: var(--tctd-white);
  filter: none;
  opacity: 1;
}

/* ---- footer ----------------------------------------------------------------*/

.m-footer { background: var(--tctd-bg-anchor); color: var(--tctd-whisper); padding: var(--tctd-space-7) var(--tctd-space-5) var(--tctd-space-5); }
.m-footer-inner { max-width: 1180px; margin: 0 auto; }
.m-footer-grid {
  display: grid;
  grid-template-columns: 1.4fr repeat(3, 1fr);
  gap: var(--tctd-space-6);
  padding-bottom: var(--tctd-space-6);
  border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.m-footer-grid h4 {
  color: var(--tctd-white);
  font-size: 12px;
  letter-spacing: 0.12em;
  margin-bottom: var(--tctd-space-3);
}
.m-footer-grid a {
  display: block;
  color: var(--tctd-whisper);
  text-decoration: none;
  font-size: 14px;
  margin-bottom: var(--tctd-space-2);
}
.m-footer-grid a:hover { color: var(--tctd-white); }

/* Social icon row, under the blurb in the footer's first column. Built by
   social-links.js; empty until URLs are set in the admin.

   The `display: block` on .m-footer-grid a above is what stacks the text link
   lists, so these are overridden back to inline-flex or each icon would sit on
   its own line down the side of the footer. */
.m-social {
  display: flex;
  flex-wrap: wrap;
  gap: var(--tctd-space-3);
  margin-top: var(--tctd-space-4);
}
.m-footer-grid .m-social a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  margin: 0;
  border-radius: 50%;
  color: var(--tctd-whisper);
  background: rgba(255, 255, 255, 0.08);
  transition: background-color 160ms ease, color 160ms ease, transform 160ms ease;
}
.m-footer-grid .m-social a:hover {
  color: var(--tctd-white);
  /* The brand pink, same accent the footer's other hovers and the site's
     primary buttons use, so the row reads as part of the palette rather than
     five vendor logos in their own colours. */
  background: var(--tctd-bright-pink);
  transform: translateY(-2px);
}
.m-social svg { width: 17px; height: 17px; display: block; }
.m-footer-bottom {
  display: flex;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--tctd-space-3);
  padding-top: var(--tctd-space-5);
  font-size: 12px;
  color: rgba(255, 255, 255, 0.55);
}
.m-footer-bottom a { color: rgba(255, 255, 255, 0.55); }

/* Privacy and Terms. Down here rather than in a column of their own: they are
   the links a visitor looks for only when they are specifically looking, and
   the bottom bar is where every site puts them. */
.m-footer-legal {
  display: flex;
  flex-wrap: wrap;
  gap: var(--tctd-space-4);
}
.m-footer-legal a:hover { color: var(--tctd-white); }

@media (max-width: 900px) {
  .m-footer-grid { grid-template-columns: 1fr 1fr; }
}

/* ---- footer on a phone -------------------------------------------------
 *
 * Two columns at 390px gave the grid a 98px first column and a 211px second:
 * the blurb broke over six lines while the column beside it ran half empty,
 * the social icons wrapped to three rows, and the taller first column left a
 * gap the height of a thumb before the next pair of headings.
 *
 * One column fixes all of that at once. The links then have room to be real
 * tap targets, which at 25px tall they were not — all sixteen of them.
 */
@media (max-width: 620px) {
  .m-footer { padding: var(--tctd-space-6) var(--tctd-space-4) var(--tctd-space-4); }

  .m-footer-grid {
    grid-template-columns: 1fr;
    gap: var(--tctd-space-5);
  }

  /* The blurb had a 32ch cap set inline for the desktop column; on one full
     column it can simply run to the edge. */
  .m-footer-grid > div:first-child p { max-width: none; }

  .m-footer-grid a {
    display: flex;
    align-items: center;
    min-height: 44px;
    margin-bottom: 0;
    /* A long email address must wrap rather than run under the screen edge. */
    overflow-wrap: anywhere;
  }

  /* Icons are a row, not a stacked list, and are already 34px — the min-height
     above would stretch them into ovals. */
  .m-footer-grid .m-social a { min-height: 0; height: 34px; }

  .m-footer-bottom {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--tctd-space-2);
    padding-top: var(--tctd-space-4);
  }
  .m-footer-legal { gap: var(--tctd-space-5); }
  .m-footer-legal a { display: flex; align-items: center; min-height: 44px; }
}

/* ---- /about: service area ---------------------------------------------------
   A long list of city names, set small on purpose — it is there to be indexed
   for local search, not read straight through. Four directional columns keep
   it scannable for the occasional visitor who does check whether their suburb
   is on it. */

.m-area {
  display: grid;
  gap: var(--tctd-space-5);
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}
.m-area-col h3 {
  font-size: 0.8rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--tctd-ink-muted);
  margin-bottom: 6px;
}
.m-area-col p {
  font-size: 0.8rem;
  line-height: 1.7;
  color: var(--tctd-ink-muted);
  margin: 0;
}
.m-area-note {
  margin: var(--tctd-space-5) 0 0;
  font-size: 0.85rem;
  color: var(--tctd-ink-muted);
}

/* ---- page hero (non-home pages: services, portfolio, about, etc.) --------- */

.m-page-hero {
  background: var(--tctd-bg-anchor);
  color: var(--tctd-white);
  padding: var(--tctd-space-7) var(--tctd-space-5) var(--tctd-space-6);
  text-align: center;
}
.m-page-hero h1 { color: var(--tctd-white); margin-bottom: var(--tctd-space-3); }
.m-page-hero p { color: var(--tctd-whisper); max-width: 56ch; margin: 0 auto; }

@media (max-width: 640px) {
  body.marketing { --tctd-mnav-h: 68px; }
  .m-hero, .m-section, .m-cta-band, .m-page-hero { padding-left: var(--tctd-space-4); padding-right: var(--tctd-space-4); }
}

/* ---- /services: public storefront browse ----------------------------------
   Browse-only cards, no auth, no cart — see shop-browse.js. */

/* Section tabs above the catalog. Sticky under the fixed nav so the jump list
   stays reachable once you have scrolled into the middle of a long catalog. */
.m-shop-tabs {
  position: sticky;
  /* Same token .m-nav-row sizes itself with, so the two can never drift. */
  top: var(--tctd-mnav-h, 76px);
  z-index: 20;
  display: flex;
  flex-wrap: wrap;
  gap: var(--tctd-space-2);
  margin-bottom: var(--tctd-space-6);
  padding: var(--tctd-space-3) 0;
  background: var(--tctd-white);
}
.m-shop-tab {
  font-family: var(--tctd-font-body);
  font-size: 15px;
  font-weight: 600;
  padding: 10px 20px;
  border-radius: 999px;
  border: 1px solid var(--tctd-border);
  background: var(--tctd-white);
  color: var(--tctd-ink-muted);
  cursor: pointer;
  transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
}
.m-shop-tab:hover { color: var(--tctd-ink); border-color: var(--tctd-ink-muted); }
.m-shop-tab.is-current {
  background: var(--tctd-ink);
  border-color: var(--tctd-ink);
  color: var(--tctd-white);
}

.m-shop-section { margin-bottom: var(--tctd-space-8); }
.m-shop-section h3 { font-size: 2rem; margin-bottom: 2px; }
/* The section blurb under the heading ("Individual listing services."). Spaced
   in CSS rather than the negative inline margin it used to carry, which pulled
   it up tight against the heading's descenders. */
.m-shop-blurb {
  font-size: 1.05rem;
  margin-top: 10px;
  margin-bottom: var(--tctd-space-5);
}
/* Clears BOTH the fixed nav and the sticky tab bar that sits under it, so a
   jumped-to heading lands below them instead of behind them. The second term
   tracks the tab bar's real height — bump it if .m-shop-tab's font size or
   padding changes again. */
.m-shop-section { scroll-margin-top: calc(var(--tctd-mnav-h, 76px) + 78px); }

/* Fixed-width columns rather than fractional ones, so a card — and therefore
   its thumbnail — is exactly the same size on a phone as on a desktop. The
   page stays responsive by changing HOW MANY columns fit, not how wide they
   are. `1fr` tracks would have stretched every thumbnail at every breakpoint.
   grid-auto-rows keeps rows level within the section on top of that. */
.m-shop-section .m-grid {
  grid-template-columns: repeat(auto-fit, 320px);
  /* Start, not center: the section heading and the tab bar both sit at the
     container's left edge, so centering the fixed-width tracks left the first
     card floating away from its own title. Any leftover space goes to the
     right of the last card instead. */
  justify-content: start;
  grid-auto-rows: 1fr;
}
/* Below a 320px card plus page gutters there is no room for a fixed track, so
   let it shrink rather than overflow the viewport. */
@media (max-width: 400px) {
  .m-shop-section .m-grid { grid-template-columns: 1fr; }
}

.m-shop-card {
  cursor: pointer;
  padding: 0;
  overflow: hidden;
  /* Stretch to the row height rather than sitting short inside it. */
  display: flex;
  flex-direction: column;
  transition: transform 200ms ease, box-shadow 200ms ease;
}
.m-shop-card:hover { transform: translateY(-4px); box-shadow: var(--tctd-shadow-lg); }
.m-shop-card:focus-visible { outline: 3px solid var(--tctd-bright-pink); outline-offset: -3px; }
.m-shop-card-img {
  position: relative;
  /* 4:3 landscape. Safe to express as a ratio rather than a pixel height
     because the column track above is a fixed 320px — the box works out to
     320x240 at every breakpoint, so it never resizes. object-fit: cover on the
     img crops whatever was uploaded to fill it. */
  aspect-ratio: 4 / 3;
  background: var(--tctd-surface-alt);
}
.m-shop-card-img img { width: 100%; height: 100%; object-fit: cover; }
/* flex: none stops the image box being squeezed when the card stretches. */
.m-shop-card-img { flex: none; }
.m-shop-card-body { padding: var(--tctd-space-5); flex: 1 1 auto; }

/* Same fixed-height treatment as the portal's .p-card, and for the same reason:
   a grid of cards should be a grid, not a skyline. Title and description are
   each pinned to two lines; the full copy is in the detail popup. */
.m-shop-card-body h4 {
  font-size: 1.3rem;
  line-height: 1.3;
  height: 2.6em;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.m-shop-card-body .tctd-caption {
  font-size: 0.95rem;
  /* Explicit, because min-height below is expressed in em and the inherited
     body line-height would make two lines slightly taller than the box. */
  line-height: 1.55;
  height: 3.1em;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.m-shop-price {
  flex: none;
  font-size: 1.2rem;
  font-weight: 700;
  color: var(--tctd-mid-purple);
  white-space: nowrap;
}

dialog.m-shop-detail {
  position: relative;
  border: none;
  border-radius: var(--tctd-radius-lg);
  padding: 0;
  width: min(560px, calc(100vw - 32px));
  max-height: min(88vh, 800px);
  box-shadow: var(--tctd-shadow-lg);
}
dialog.m-shop-detail::backdrop { background: rgba(28, 18, 24, 0.5); }
.m-shop-detail-body { padding: var(--tctd-space-5); overflow-y: auto; }
.m-shop-detail-img {
  width: 100%;
  aspect-ratio: 3 / 2;
  object-fit: cover;
  border-radius: var(--tctd-radius);
  margin-bottom: var(--tctd-space-4);
}
.m-shop-detail-close {
  position: absolute;
  top: var(--tctd-space-3); right: var(--tctd-space-3);
  width: 36px; height: 36px;
  display: grid; place-items: center;
  background: var(--tctd-white);
  border: 1px solid var(--tctd-border);
  border-radius: 50%;
  cursor: pointer;
  font-size: 18px;
  line-height: 1;
  color: var(--tctd-ink-muted);
}
.m-shop-detail-close:hover { color: var(--tctd-ink); }

/* Optional "Full details" link on the product popup — only products that carry
   a `learnMoreUrl` (currently the Listing Blast) render one. */
.m-shop-detail-more { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; margin-top: var(--tctd-space-4); }

/* ---- product video --------------------------------------------------------
   Optional per product (`videoUrl`, set in Storefront → Products). The card
   only advertises that a video exists; it plays in the popup, where an agent
   has actually chosen to look, rather than autoplaying in a grid. */
.m-shop-play {
  position: absolute;
  right: 10px; bottom: 10px;
  width: 34px; height: 34px;
  display: grid; place-items: center;
  background: rgba(28, 18, 24, 0.72);
  border-radius: 50%;
  pointer-events: none;
}
.m-shop-play::before {
  content: "";
  /* A triangle drawn with borders — no asset to load, no glyph to go missing. */
  border-style: solid;
  border-width: 6px 0 6px 10px;
  border-color: transparent transparent transparent var(--tctd-white);
  margin-left: 3px;
}

/* Promo badge ("Most Popular", "New", "Sale", "Sold Out"), set on the product
   in Storefront → Products. Opposite corner from the play marker so a video
   product with a badge never has the two collide. */
.m-shop-card-badge {
  position: absolute;
  top: 10px; left: 10px;
  background: var(--tctd-ink);
  color: var(--tctd-white);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  padding: 4px 10px;
  border-radius: 999px;
}
.m-shop-card-badge--popular { background: var(--tctd-error); }
.m-shop-video {
  position: relative;
  aspect-ratio: 16 / 9;
  /* Always the hero of the popup, so it needs bottom spacing only. */
  margin: 0 0 var(--tctd-space-4);
  background: var(--tctd-ink);
  border-radius: var(--tctd-radius);
  overflow: hidden;
}
.m-shop-video iframe,
.m-shop-video video { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
.m-shop-video-link { margin: 0 0 var(--tctd-space-4); font-weight: 600; }

/* ---- /services/listing-blast: single-product landing page ------------------
   Its own hero and comparison block; everything else reuses .m-section,
   .card, and .m-cta-band so it stays in step with the rest of the site. */

.lb-hero {
  background: var(--tctd-bg-anchor);
  color: var(--tctd-white);
  padding: var(--tctd-space-8) var(--tctd-space-5);
  position: relative;
  overflow: hidden;
}
/* Off-canvas gradient bloom, the same accent the flyer version uses. */
.lb-hero::after {
  content: "";
  position: absolute;
  top: -120px; right: -120px;
  width: 380px; height: 380px;
  border-radius: 50%;
  background: var(--tctd-gradient);
  opacity: 0.35;
  filter: blur(6px);
}
.lb-hero-inner { max-width: 1180px; margin: 0 auto; position: relative; z-index: 1; }
.lb-hero h1 { color: var(--tctd-white); margin-bottom: var(--tctd-space-4); }
.lb-hero h1 em { font-style: normal; color: var(--tctd-whisper); }
.lb-lead { color: var(--tctd-whisper); max-width: 58ch; }

.lb-pricepill {
  display: inline-flex;
  align-items: baseline;
  gap: var(--tctd-space-3);
  margin: var(--tctd-space-5) 0 0;
  padding: var(--tctd-space-3) var(--tctd-space-5);
  border-radius: 40px;
  background: var(--tctd-gradient);
}
.lb-amt { font-family: var(--tctd-font-head); font-weight: 700; font-size: 30px; line-height: 1; }
.lb-note { font-size: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.08em; }

.lb-num {
  width: 32px; height: 32px;
  display: grid; place-items: center;
  border-radius: 50%;
  background: var(--tctd-gradient);
  color: var(--tctd-white);
  font-family: var(--tctd-font-head);
  font-weight: 700;
  font-size: 14px;
  margin-bottom: var(--tctd-space-3);
}

.lb-compare {
  display: grid;
  grid-template-columns: 1fr 1fr;
  border: 1px solid var(--tctd-border);
  border-radius: var(--tctd-radius-lg);
  overflow: hidden;
  background: var(--tctd-surface);
}
.lb-col { padding: var(--tctd-space-5); }
.lb-col.is-ours { background: var(--tctd-bg-anchor); color: var(--tctd-white); }
.lb-col-tag {
  display: block;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--tctd-ink-muted);
  margin-bottom: var(--tctd-space-4);
}
.lb-col.is-ours .lb-col-tag { color: var(--tctd-whisper); }
.lb-col ul { list-style: none; margin: 0; padding: 0; }
.lb-col li { position: relative; padding-left: 26px; margin-bottom: var(--tctd-space-3); }
.lb-col li::before {
  content: "\2715";
  position: absolute; left: 0; top: 1px;
  color: var(--tctd-divider);
  font-weight: 700;
}
.lb-col.is-ours li::before { content: "\2713"; color: var(--tctd-whisper); }

.lb-step-num {
  font-family: var(--tctd-font-head);
  font-weight: 700;
  font-size: 40px;
  line-height: 1;
  color: var(--tctd-whisper);
}
.lb-step h3 { margin: var(--tctd-space-2) 0; }
.lb-step p { color: var(--tctd-ink-muted); margin: 0; }

.lb-split { display: grid; grid-template-columns: 1.2fr 1fr; gap: var(--tctd-space-5); }
.lb-chips { display: flex; flex-wrap: wrap; gap: var(--tctd-space-2); }
.lb-chip {
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--tctd-mid-purple);
  background: var(--tctd-surface-alt);
  border: 1px solid var(--tctd-border);
  padding: 7px 14px;
  border-radius: 30px;
}
.lb-guarantee {
  background: var(--tctd-gradient);
  color: var(--tctd-white);
  border-radius: var(--tctd-radius-lg);
  padding: var(--tctd-space-5);
}
.lb-guarantee h3 { color: var(--tctd-white); }
.lb-guarantee p { margin: 0; }
.lb-guarantee-big {
  display: block;
  font-family: var(--tctd-font-head);
  font-weight: 700;
  font-size: 44px;
  line-height: 1;
  margin-bottom: var(--tctd-space-2);
}

@media (max-width: 900px) {
  .lb-compare, .lb-split { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
  .lb-hero { padding-left: var(--tctd-space-4); padding-right: var(--tctd-space-4); }
}

/* ---- legal pages (/privacy, /terms) -----------------------------------------
 *
 * .m-section-inner is 1180px, which is right for a grid of cards and wrong for
 * a page of prose — a 1180px line of body copy is roughly 160 characters, and
 * the eye loses its place returning to the next line. These pages cap the
 * measure instead and left-align, since legal text is scanned by heading and
 * read in fragments rather than straight through.
 */

.m-legal {
  max-width: 74ch;
  font-size: 15.5px;
  line-height: 1.68;
}
.m-legal h2 {
  margin-top: var(--tctd-space-7);
  margin-bottom: var(--tctd-space-3);
  font-size: 1.25rem;
}
.m-legal h3 {
  margin-top: var(--tctd-space-5);
  margin-bottom: var(--tctd-space-2);
  font-size: 1rem;
  letter-spacing: 0.02em;
}
.m-legal p { margin-bottom: var(--tctd-space-4); }
.m-legal ul { margin: 0 0 var(--tctd-space-4); padding-left: 1.25em; }
.m-legal li { margin-bottom: var(--tctd-space-3); }
.m-legal a { color: var(--tctd-mid-purple); }

.m-legal-meta {
  color: var(--tctd-ink-muted);
  font-size: 13px;
  margin-bottom: var(--tctd-space-6);
}

/* The part most people will actually read. Stated first, in the same words the
   detail below uses, so the summary and the terms cannot say different things. */
.m-legal-summary {
  border: 1px solid var(--tctd-border);
  border-left: 3px solid var(--tctd-bright-pink);
  border-radius: var(--tctd-radius);
  background: var(--tctd-surface-alt);
  padding: var(--tctd-space-5);
  margin-bottom: var(--tctd-space-6);
}
.m-legal-summary h2 { margin-top: 0; font-size: 1rem; }
.m-legal-summary ul { margin-bottom: 0; }
.m-legal-summary li:last-child { margin-bottom: 0; }

.m-legal-table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: var(--tctd-space-4);
  font-size: 14.5px;
}
.m-legal-table th,
.m-legal-table td {
  text-align: left;
  vertical-align: top;
  padding: 10px 12px 10px 0;
  border-bottom: 1px solid var(--tctd-border);
}
.m-legal-table th {
  font-size: 11px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--tctd-ink-muted);
}
.m-legal-table td:first-child { white-space: nowrap; padding-right: var(--tctd-space-5); }

.m-legal-note {
  margin-top: var(--tctd-space-7);
  padding-top: var(--tctd-space-4);
  border-top: 1px solid var(--tctd-border);
  font-size: 13.5px;
  color: var(--tctd-ink-muted);
}

@media (max-width: 620px) {
  .m-legal { font-size: 16px; }
  /* A two-column table at 390px is a horizontal scroll. Stack it: the provider
     becomes the heading for what it handles. */
  .m-legal-table, .m-legal-table tbody, .m-legal-table tr, .m-legal-table td { display: block; }
  .m-legal-table thead { display: none; }
  .m-legal-table tr {
    border-bottom: 1px solid var(--tctd-border);
    padding-bottom: var(--tctd-space-3);
    margin-bottom: var(--tctd-space-3);
  }
  .m-legal-table td { border: 0; padding: 0; }
  .m-legal-table td:first-child {
    white-space: normal;
    font-weight: 600;
    margin-bottom: 4px;
  }
}

/* Product description in the /services detail dialog. Paragraph spacing, for
   the same reason as .p-detail-copy in portal.css. */
.m-shop-detail-copy p { margin: 0 0 var(--tctd-space-4); }
.m-shop-detail-copy p:last-child { margin-bottom: 0; }

/* ---- /services/ai-for-realtors ---------------------------------------------
   A second single-product landing page, built the way the Listing Blast one is:
   its own hero and a couple of blocks it needs, everything else borrowed from
   .m-section, .m-grid, .card, .m-faq and .m-cta-band so the two pages stay in
   step with the rest of the site rather than with each other. */

/* The four counts: 4 sessions, 7 days, 30 days, 3 clients. A number this size
   is the whole point of the block, so it leads and the label follows. */
.ai-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: var(--tctd-space-5);
}
.ai-stat {
  border-left: 2px solid var(--tctd-bright-pink);
  padding-left: var(--tctd-space-4);
}
.ai-stat-num {
  display: block;
  font-family: var(--tctd-font-head);
  font-size: 2.6rem;
  line-height: 1;
  color: var(--tctd-mid-purple);
  margin-bottom: var(--tctd-space-2);
}
.ai-stat strong { display: block; margin-bottom: 6px; }
.ai-stat p { font-size: 14px; color: var(--tctd-ink-muted); margin: 0; }

/* What you get, with the price and the booking button held beside it. The side
   column is sticky on a wide screen so the button stays reachable while the
   list beside it is read; it un-sticks below the breakpoint, where a sticky
   panel would eat a third of the viewport. */
.ai-program {
  display: grid;
  grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr);
  gap: var(--tctd-space-6);
  align-items: start;
}
.ai-program-main ul { margin: 0 0 var(--tctd-space-5); padding-left: var(--tctd-space-5); }
.ai-program-main li { margin-bottom: var(--tctd-space-2); }
.ai-program-tag {
  font-family: var(--tctd-font-head);
  font-size: 1.3rem;
  margin: 0 0 var(--tctd-space-2);
}
.ai-program-lead { color: var(--tctd-ink-muted); margin-bottom: var(--tctd-space-5); }

.ai-program-side {
  position: sticky;
  top: calc(var(--tctd-mnav-h, 72px) + var(--tctd-space-4));
  border: 1px solid var(--tctd-border);
  border-radius: var(--tctd-radius-lg);
  background: var(--tctd-surface-alt);
  padding: var(--tctd-space-5);
}
.ai-program-side .btn { width: 100%; justify-content: center; margin-top: var(--tctd-space-4); }
.ai-program-price {
  font-family: var(--tctd-font-head);
  font-size: 2.2rem;
  line-height: 1;
  margin: 0 0 var(--tctd-space-2);
}

.ai-bio { max-width: 68ch; }
.ai-bio p { margin-bottom: var(--tctd-space-4); }

.ai-faq { max-width: 760px; }

@media (max-width: 860px) {
  .ai-program { grid-template-columns: 1fr; }
  .ai-program-side { position: static; }
  .ai-stat-num { font-size: 2.2rem; }
}

/* ---- discovery call booking dialog -----------------------------------------
   Public, so it cannot lean on the portal's dialog styles in portal.css. Same
   shape as those: a fixed head and foot with the body scrolling between them,
   and full-screen on a phone so the submit button never sits under the
   keyboard. */

dialog.consult-dialog {
  width: min(620px, 94vw);
  max-height: 92vh;
  padding: 0;
  border: 0;
  border-radius: var(--tctd-radius-lg);
  box-shadow: var(--tctd-shadow-lg);
  overflow: hidden;
  color: var(--tctd-ink);
}
dialog.consult-dialog::backdrop { background: rgba(20, 8, 24, .55); }
/* Scoped to [open], or this beats the UA rule that hides a closed dialog and
   the form sits on the page permanently. */
dialog.consult-dialog[open] { display: flex; flex-direction: column; }
dialog.consult-dialog form { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }

.consult-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--tctd-space-4);
  padding: var(--tctd-space-5);
  border-bottom: 1px solid var(--tctd-border);
  flex: none;
}
.consult-head h3 { margin: 0; font-size: 1.15rem; }
.consult-body {
  padding: var(--tctd-space-5);
  overflow-y: auto;
  flex: 1 1 auto;
  min-height: 0;
  overscroll-behavior: contain;
}
.consult-foot {
  display: flex;
  align-items: center;
  gap: var(--tctd-space-3);
  padding: var(--tctd-space-4) var(--tctd-space-5);
  border-top: 1px solid var(--tctd-border);
  background: var(--tctd-surface-alt);
  flex: none;
}
.consult-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 0 var(--tctd-space-4);
}
.consult-done { text-align: center; padding: var(--tctd-space-6) var(--tctd-space-5); }
.consult-done h3 { margin-bottom: var(--tctd-space-3); }

/* Off-screen rather than display:none — a bot that skips hidden inputs is
   exactly the bot this is trying to catch. */
.consult-hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

@media (max-width: 860px) {
  dialog.consult-dialog {
    width: 100vw;
    max-width: 100vw;
    height: 100dvh;
    max-height: 100dvh;
    margin: 0;
    border-radius: 0;
  }
  .consult-foot {
    flex-wrap: wrap;
    padding-bottom: calc(var(--tctd-space-4) + var(--tctd-safe-bottom));
  }
  .consult-foot .btn { flex: 1 1 auto; justify-content: center; }
  .consult-grid { grid-template-columns: 1fr; }
}

/* ---- homepage: Marketing promos --------------------------------------------
   The two products with a landing page of their own. Whole-card links, so the
   target is the card rather than the small arrow at the bottom of it — on a
   phone that is the difference between one confident tap and three. */

.m-promo {
  display: flex;
  flex-direction: column;
  text-decoration: none;
  color: inherit;
  transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
}
.m-promo:hover {
  border-color: var(--tctd-soft-pink);
  box-shadow: var(--tctd-shadow);
  transform: translateY(-2px);
}
.m-promo h3 { margin: 0 0 var(--tctd-space-3); }
.m-promo p { color: var(--tctd-ink-muted); }

.m-promo-tag {
  font-family: var(--tctd-font-head);
  font-size: 11px;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--tctd-bright-pink);
  margin: 0 0 var(--tctd-space-2);
}

/* Pushed to the bottom so the price and the link line up across both cards
   however much the description above them runs to. */
.m-promo-meta {
  margin-top: auto;
  padding-top: var(--tctd-space-4);
  font-size: 14px;
}
.m-promo-meta strong {
  font-family: var(--tctd-font-head);
  font-size: 1.15rem;
  color: var(--tctd-ink);
}
.m-promo .m-link-arrow { margin-top: var(--tctd-space-3); }

@media (prefers-reduced-motion: reduce) {
  .m-promo:hover { transform: none; }
}
