/* ============================================================
   SNS Valley View — Testimonials
   No section like this exists in the design handoff — box, scrim, centered
   flex layout and the per-letter dissolve mechanism are deliberately
   borrowed from the hero's idioms (see hero.css), and the round-robin
   rotation technique (identical keyframe, phase-shifted by a per-item
   negative animation-delay) is borrowed from the pre-1c gallery crossfade
   (git history: web/static/css/experience.css before 80dc20a). See
   docs/builds/1d-testimonials.md and docs/DECISIONS.md for the full
   derivation, including why the quote/attribution colours deviate from the
   standard --c-cream-* ladder.
   ============================================================ */

.testimonials {
  position: relative;
  overflow: hidden;
  height: 560px;
}

.testimonials__video,
.testimonials__poster-fallback {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.testimonials__video {
  object-fit: cover;
  /* Biases the crop toward the valley floor and away from the sky/sun —
     an aesthetic choice, not a legibility requirement: the scrim below
     guarantees 4.5:1 contrast everywhere in frame regardless of crop. See
     docs/DECISIONS.md for the measured contrast this combination produces. */
  object-position: center 65%;
}

.testimonials__poster-fallback {
  display: none;
}

.testimonials__poster-fallback-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center 65%;
}

/* Flat scrim, not a directional gradient like the hero's: quote text
   rotates and the section is full-bleed, so the "hot" region of the source
   frame isn't fixed the way the hero's headline position is — a flat
   overlay stays correct regardless of viewport crop or which quote is
   showing. rgba(9,9,9,.40) is the hero's own scrim colour (--c-bg), just
   applied uniformly. 0.32 was the original desktop-only measurement;
   raised to 0.40, then to 0.45, after real-browser Gate 1 scans (1d) found
   mobile's crop (390px shows more of the source's vertical range than
   1280px+ does — see .testimonials__video's object-position comment above)
   needed more: worst-case contrast at 0.32 was 3.99:1 at 390px, under the
   4.5:1 attribution needs, even though the same value cleared 4.74:1 at
   1280px.
   Lowered to 0.42 in build/1e-testimonial-tint after the loop window
   extended to 8s (10.0-18.0s — see the source's loopStart/loopDuration
   comment in scripts/video/main.go) let more of the source through and a
   fresh real-browser scan re-measured the worst case for THIS window: not
   at 390px this time — at this window's content the *widest* breakpoints
   (1920/2560px) turned out more demanding than 390px, the opposite of 1d's
   finding, because a wider crop sweeps more of the sky horizontally and has
   a higher chance of catching its brightest patch. Worst-case measured
   contrast at 0.42, scanning every breakpoint (390–2560px) and the whole
   loop: 4.88:1 at 2560px (the binding case), 4.98:1+ everywhere else — all
   still comfortably over 4.5:1 for attribution and 3:1 for quote text
   (always ≥24px here, so always WCAG "large text"). The raw walked-down
   floor measured lower, ~0.40, but a cross-check against 1d's own original
   390px number (this same still-frame-free, real-decoded-video methodology
   re-run against the OLD 5s window reproduced 5.9-6.0:1 against 1d's
   recorded 5.03:1 — a consistent ~15-18% optimism gap, most likely
   compression ringing this method's per-frame sampling still doesn't fully
   catch) argued for shipping with headroom above the raw floor rather than
   at it. See docs/DECISIONS.md for the full measurement, including why
   0.42 rather than the raw floor. */
.testimonials__scrim {
  position: absolute;
  inset: 0;
  background: rgba(9, 9, 9, 0.42);
  pointer-events: none;
}

/* ============================================================
   Content: quotes + link, centred over the video like the hero's
   .hero__inner.
   ============================================================ */

.testimonials__content {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 48px var(--pad-x);
  text-align: center;
}

.testimonials__quotes {
  position: relative;
  width: 100%;
  max-width: 760px;
  /* Every .testimonial__quote below is position:absolute (they stack, one
     visible at a time, like the pre-1c .gallery__slide stack) and so
     contributes no height of its own — this reserves room for a two-line
     quote plus its attribution so the layout doesn't collapse. */
  min-height: 180px;
}

/* One rotating quote. All five stay mounted and in the accessibility tree
   at all times (see Testimonials()'s doc comment) — only their letters'
   opacity, not display, changes with the rotation, and aria-label carries
   the clean sentence past the letter-span markup entirely. */
.testimonial__quote {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  margin: 0;
}

/* Shared by both the animated per-quote text and the reduced-motion
   static quote. Cormorant Garamond at the same 300 weight as every other
   display heading on the site (hero h1, .experience__title, .rooms__title)
   — full-strength --c-cream, not a dimmed tier: at 4.5:1 the worst-case
   region of the graded/scrimmed video needs ~97% cream even at this large
   a size, so there's no meaningful opacity headroom to spend here either;
   see docs/DECISIONS.md. Decorative quote marks are pure CSS generated
   content — never part of the DOM or the --i letter count, so
   scripts/quotes' generated positions can't drift from what's rendered. */
.testimonial__quote-text {
  margin: 0;
  font-family: var(--f-display);
  font-size: clamp(26px, 2.8vw, 36px);
  font-weight: 300;
  line-height: 1.35;
  color: var(--c-cream);
  /* A coarse per-quote gate on top of the per-letter animation below: the
     decorative ::before/::after quote marks are pseudo-elements of THIS
     paragraph, not of any .word span, so they never inherit the letters'
     own opacity animation — without this, all five quotes' quote marks
     would sit at opacity:1 simultaneously, stacked, since nothing else
     ever hides the paragraph itself during its four off-turns. Open for
     the paragraph's whole 0%-18% active window (not the attribution's
     delayed 4% fade-in below) so it never clips a letter that's already
     mid-reveal. */
  animation: testimonialQuoteGate 40s ease-in-out infinite;
  animation-delay: var(--offset, 0ms);
}
.testimonial__quote-text::before { content: '\201C'; }
.testimonial__quote-text::after  { content: '\201D'; }

/* Prevents mid-word breaks: the browser can still wrap at the ordinary
   text-node space rendered between .word spans (see QuoteWords' doc
   comment in testimonial_data.go) — a flat sequence of per-letter spans
   alone, the hero's technique, gives the browser no such opportunity,
   which is fine for the hero's fixed 2-line headline and not fine for a
   quote up to 94 characters that must reflow at any width. */
.word,
.word span {
  display: inline-block;
}

/* Per-letter dissolve. --offset (which of the five 8s slots this quote
   owns) and --stagger (this quote's own reveal-sweep increment, letters
   fewer = larger step) come from scripts/quotes' generated
   testimonials-quotes.css — never hand-authored, never inline (this
   site's CSP has no 'unsafe-inline' in style-src; same constraint the
   hero's --i solves the same way). animation-duration is the full 40s
   cycle (5 quotes x rotationSlotMs=8s, testimonial_data.go) so a phase
   shift via delay is enough to give each quote its own slot — the same
   round-robin the pre-1c gallery crossfade used for its four slides. */
.word span {
  animation: testimonialDissolve 40s ease-in-out infinite;
  animation-delay: calc(var(--offset, 0ms) + var(--i, 0) * var(--stagger, 40ms));
  will-change: opacity, filter, transform;
}

.testimonial__attribution {
  margin: 0;
  font-family: var(--f-ui);
  font-size: 13px;
  font-weight: 400;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--c-cream);
  animation: testimonialFade 40s ease-in-out infinite;
  animation-delay: var(--offset, 0ms);
}
.testimonial__attribution cite { font-style: normal; }

/* Reduced-motion-only static fallback — one quote, no letters, no
   animation. Hidden by default (both visually and from the accessibility
   tree, since display:none removes it) so a normal-motion screen-reader
   user never hears this quote twice: once from this block, once from the
   rotating .testimonial__quote's aria-label. Swapped with the rotating
   quotes' visibility under prefers-reduced-motion below. */
.testimonials__static-quote {
  display: none;
  margin: 0;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.testimonials__link {
  position: relative;
  z-index: 1;
  margin-top: 40px;
  font-size: 11px;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--c-cream);
  transition: color var(--dur) var(--ease);
}
.testimonials__link:hover { color: var(--c-gold); }

/* ============================================================
   Keyframes
   ============================================================ */

/* 40s = five 8s slots. A letter is invisible for 80% of the cycle (its
   quote's four off-turns) and only animates during its own quote's first
   20% — --offset (a negative multiple of 8s) is what shifts which 20%
   slice of real time that is for a given quote, exactly like the pre-1c
   gallery's gallerySlide keyframe did for its four slides. Unlike the
   hero's sandDrift (a headline that's always visible, dissolving in place)
   this needs a real hidden phase, so the shape differs from sandDrift on
   purpose — see docs/DECISIONS.md for why translateY was chosen over
   sandDrift's specific -44px/+52px horizontal jump: that jump makes the
   hero's return read as "from the right", which has no equivalent meaning
   for a quote materializing on schedule rather than blowing back into a
   headline. */
@keyframes testimonialDissolve {
  0%        { opacity: 0; filter: blur(10px); transform: translateY(10px); }
  2%        { opacity: 1; filter: blur(0);    transform: translateY(0); }
  16%       { opacity: 1; filter: blur(0);    transform: translateY(0); }
  18%       { opacity: 0; filter: blur(10px); transform: translateY(-10px); }
  20%, 100% { opacity: 0; filter: blur(0);    transform: translateY(0); }
}

/* Coarse open/closed gate for .testimonial__quote-text — open across the
   letters' whole possible active range (0%-18%) so it can never clip a
   letter mid-reveal or mid-exit, unlike testimonialFade below which opens
   later on purpose. */
@keyframes testimonialQuoteGate {
  0%, 18%   { opacity: 1; }
  20%, 100% { opacity: 0; }
}

/* Attribution fades in just after the quote text resolves (2%-4%) rather
   than in lockstep with it — a small sequential reveal, quote then byline. */
@keyframes testimonialFade {
  0%, 2%    { opacity: 0; }
  4%        { opacity: 1; }
  16%       { opacity: 1; }
  18%       { opacity: 0; }
  20%, 100% { opacity: 0; }
}

/* ============================================================
   Responsive
   ============================================================ */

@media (max-width: 1024px) {
  .testimonials { height: 480px; }
}

@media (max-width: 680px) {
  .testimonials { height: 420px; }
  .testimonials__content { padding: 32px 24px; }
  .testimonials__quotes { min-height: 220px; }
}

@media (prefers-reduced-motion: reduce) {
  .testimonials__video { display: none; }
  .testimonials__poster-fallback { display: block; }
  .testimonial__quote { display: none; }
  .testimonials__static-quote { display: flex; }
}
