/* ============================================================
   Videmory — motion.css

   An additive layer. styles.css is untouched; everything here is
   scoped to .js so that with JavaScript off the site is exactly the
   static one, with nothing hidden waiting for an animation.

   The approach is deliberately the opposite of the earlier attempt:
   no pinned set piece, no scroll hijacking, no section that owns five
   screens of scroll. Lots of small movement spread through the page,
   each piece cheap enough that none of it can strand the reader.

   Everything below is disabled wholesale under prefers-reduced-motion,
   at the bottom of this file.
   ============================================================ */

/* ---------- 1. Scroll progress ---------- */

.scroll-progress {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: 2px;
  z-index: 100;
  pointer-events: none;
  transform: scaleX(var(--sp, 0));
  transform-origin: 0 50%;
  background: linear-gradient(90deg, rgba(231, 226, 217, 0.85), rgba(216, 201, 180, 0.45));
}

/* ---------- 2. The nav ---------- */

/* Transparent over the photograph, the way the app's chrome is, and
   frosting only once it has something to sit against. */
.js .block-glow .site-nav {
  position: sticky;
  top: 0;
  z-index: 40;
  background: transparent;
  transition: background-color 0.35s ease, box-shadow 0.35s ease;
}
.js .block-glow .site-nav.is-stuck {
  background: rgba(11, 13, 16, 0.72);
  backdrop-filter: blur(18px) saturate(1.1);
  box-shadow: 0 1px 0 rgba(231, 226, 217, 0.10);
}

/* ---------- 3. The photograph behind the top block ---------- */

/* styles.css paints the scrim and the photograph as two background
   layers of one element, which cannot be moved independently. Here they
   are separated so the photograph can drift under a stationary scrim. */
.js .block-glow { background-image: none; }

/* Pitigliano, not the Sardinian coast. The app's design_system.dart names
   these as the two shots its scrim was designed against — "one is blue sky
   over sea, the other is a dark storm cloud over a lit hill town" — so the
   hero takes one and the demo frame keeps the other, and the same fixed
   scrim carries both. It also stops the same photograph appearing twice
   within a screen and a half. */
/* The photograph belongs to the top panel and stops with it.

   It used to be sized as a fraction of the block, which meant it carried on
   past the last line of content into the block's trailing padding — a strip
   of picture under nothing, ending in a hard line wherever the box happened
   to run out. motion.js measures where the content actually ends and sets
   --hero-h, so the cut lands just below the last thing anybody reads.

   The drift is done with background-position rather than a transform on the
   box. Moving the box would need it taller than its own frame, and a taller
   frame is precisely what was overflowing before. Moving the image inside a
   fixed frame keeps the bottom edge exactly where it was put. */
.js .block-glow::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: var(--hero-h, 78vh);
  z-index: 0;
  background-image: url("assets/images/pitigliano.JPG");
  background-size: cover;
  background-position: center calc(50% + var(--par, 0px));
  background-repeat: no-repeat;
}
.js .block-glow::after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: var(--hero-h, 78vh);
  z-index: 1;
  background: var(--scrim);
  pointer-events: none;
}
.js .block-glow > * { position: relative; z-index: 2; }

/* ---------- 4. Hero entrance ---------- */

/* On load rather than on scroll: the hero is already on screen, and
   waiting for a scroll to animate what someone is looking at reads as
   broken rather than as restraint.

   Driven by a class rather than a keyframe animation, even though an
   animation is the obvious tool here. An animation whose base state is
   opacity 0 cannot be rescued by script: if it never runs, the hero is
   invisible and nothing can put it back. This way the same failsafe that
   covers every other reveal covers the hero too. */
.js .m-hero > * {
  opacity: 0;
  transform: translateY(18px);
  transition:
    opacity 0.85s cubic-bezier(0.22, 0.61, 0.36, 1) calc(var(--i, 0) * 90ms + 100ms),
    transform 0.85s cubic-bezier(0.22, 0.61, 0.36, 1) calc(var(--i, 0) * 90ms + 100ms);
}
.js .m-hero > *.is-in { opacity: 1; transform: none; }

/* ---------- 5. Reveal on scroll ---------- */

/* Tagged by motion.js, so nothing is hidden unless the script that can
   un-hide it is running. */
.js .m-rise {
  opacity: 0;
  transform: translateY(26px);
  transition:
    opacity 0.75s cubic-bezier(0.22, 0.61, 0.36, 1) calc(var(--i, 0) * 80ms),
    transform 0.75s cubic-bezier(0.22, 0.61, 0.36, 1) calc(var(--i, 0) * 80ms);
}
.js .m-rise.is-in { opacity: 1; transform: none; }

/* Cards arrive with a touch of scale as well — the one thing worth
   keeping from how Polarsteps does it. */
.js .m-pop {
  opacity: 0;
  transform: translateY(24px) scale(0.965);
  transition:
    opacity 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) calc(var(--i, 0) * 90ms),
    transform 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) calc(var(--i, 0) * 90ms);
}
.js .m-pop.is-in { opacity: 1; transform: none; }

/* ---------- 6. The demo still ---------- */

/* The renderer pans and zooms every photograph it puts in a film. The
   still that stands in for the film does the same thing, slowly. */
.js .demo-poster {
  animation: m-kenburns 28s ease-in-out infinite alternate;
  will-change: transform;
}
@keyframes m-kenburns {
  from { transform: scale(1.05) translate3d(0, 0, 0); }
  to   { transform: scale(1.13) translate3d(-1.6%, -1.2%, 0); }
}

.js .m-frame {
  opacity: 0;
  transform: scale(0.97);
  transition: opacity 0.9s ease, transform 0.9s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.js .m-frame.is-in { opacity: 1; transform: none; }

/* ---------- 7. The route draws itself ---------- */

.js .journey-rail::before {
  transform: scaleX(var(--draw, 0));
  transform-origin: 0 50%;
  transition: transform 1.4s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* Each stop's dot lands as the line reaches it. */
.js .journey-stop::before {
  transform: scale(var(--dot, 0));
  transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) calc(var(--i, 0) * 130ms);
}

/* ---------- 8. Hover and press ---------- */

.step-card, .trust-card, .role-card, .choose-card, .roll-tile {
  transition: transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1),
              border-color 0.3s ease,
              box-shadow 0.3s ease;
}
@media (hover: hover) {
  .step-card:hover, .trust-card:hover, .role-card:hover, .choose-card:hover {
    transform: translateY(-4px);
    border-color: rgba(231, 226, 217, 0.28);
  }
  .roll-tile:hover { transform: translateY(-2px); }
  .demo-frame-lg:hover .demo-pending-disc { transform: scale(1.06); }
}
.demo-pending-disc { transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); }

/* The app scales a button to 0.97 while it is held. Same number, so the
   two feel like one product under the finger. */
.btn { transition: transform 0.12s ease, background-color 0.25s ease, filter 0.25s ease; }
.btn:active { transform: scale(0.97); }

/* Nav links get a rule that grows from the left. */
.nav-links a:not(.btn) { position: relative; }
.nav-links a:not(.btn)::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -6px;
  height: 1px;
  background: var(--pink);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.nav-links a:not(.btn):hover::after,
.nav-links a:not(.btn)[aria-current="page"]::after { transform: scaleX(1); }

/* ---------- 9. Mobile ---------- */

@media (max-width: 720px) {
  /* The parallax range shrinks: on a short viewport a big offset drags
     the photograph off its own frame. */
  .js .block-glow::before { inset: -8% 0; }
  /* Sticky nav on a phone eats the top of a small screen; it stays put. */
  .js .block-glow .site-nav { position: relative; }
}

/* ---------- 10. Reduced motion ---------- */

/* One block, everything off. Not a lighter version — off. Anyone who has
   asked their operating system to stop animation gets the static site,
   which is the whole reason this file is additive. */
@media (prefers-reduced-motion: reduce) {
  .scroll-progress { display: none; }

  .js .m-hero > *,
  .js .m-rise,
  .js .m-pop,
  .js .m-frame {
    opacity: 1 !important;
    transform: none !important;
    animation: none !important;
    transition: none !important;
  }
  .js .demo-poster { animation: none !important; transform: none !important; }
  .js .block-glow::before { transform: none !important; }
  .js .journey-rail::before { transform: none !important; transition: none !important; }
  .js .journey-stop::before { transform: none !important; transition: none !important; }
  .btn:active { transform: none; }
  .step-card:hover, .trust-card:hover, .role-card:hover,
  .choose-card:hover, .roll-tile:hover { transform: none; }
  .nav-links a:not(.btn)::after { transition: none; }
}

/* ============================================================
   11. Photographs

   assets/images/backgrounds holds 49 pictures from four of Damian's
   own trips. Its README explains why they are his and not a
   customer's, and privacy.html makes that a promise: "There is no
   public gallery, and Videmory never publishes your film or uses it
   to advertise anything." Anything added here has to keep it true.
   ============================================================ */

/* ---------- a different photograph behind every page ---------- */

/* Set by data-hero on <body>, so one attribute swaps a page's picture
   and the rule stays out of the markup. Portrait crops on narrow
   viewports: the hero crops to center/cover, and a landscape frame
   squeezed into a phone loses both its edges. */
body[data-hero="pitigliano"]       { --hero: url("assets/images/pitigliano.JPG"); --hero-p: url("assets/images/backgrounds/flinders-ranges-09-portrait.jpg"); }
body[data-hero="aosta-07"]         { --hero: url("assets/images/backgrounds/aosta-07.jpg"); --hero-p: url("assets/images/backgrounds/flinders-ranges-09-portrait.jpg"); }
body[data-hero="lake-garda-12"]    { --hero: url("assets/images/backgrounds/lake-garda-12.jpg"); --hero-p: url("assets/images/backgrounds/lake-garda-16-portrait.jpg"); }
body[data-hero="flinders-13"]      { --hero: url("assets/images/backgrounds/flinders-ranges-13.jpg"); --hero-p: url("assets/images/backgrounds/flinders-ranges-14-portrait.jpg"); }
body[data-hero="tuscany-06"]       { --hero: url("assets/images/backgrounds/tuscany-06.jpg"); --hero-p: url("assets/images/backgrounds/flinders-ranges-06-portrait.jpg"); }
body[data-hero="lake-garda-19"]    { --hero: url("assets/images/backgrounds/lake-garda-19.jpg"); --hero-p: url("assets/images/backgrounds/lake-garda-22-portrait.jpg"); }
body[data-hero="flinders-10"]      { --hero: url("assets/images/backgrounds/flinders-ranges-10.jpg"); --hero-p: url("assets/images/backgrounds/flinders-ranges-17-portrait.jpg"); }
body[data-hero="lake-garda-08"]    { --hero: url("assets/images/backgrounds/lake-garda-08.jpg"); --hero-p: url("assets/images/backgrounds/flinders-ranges-08-portrait.jpg"); }
body[data-hero="tuscany-01"]       { --hero: url("assets/images/backgrounds/tuscany-01.jpg"); --hero-p: url("assets/images/backgrounds/lake-garda-03-portrait.jpg"); }
body[data-hero="flinders-21"]      { --hero: url("assets/images/backgrounds/flinders-ranges-21.jpg"); --hero-p: url("assets/images/backgrounds/flinders-ranges-03-portrait.jpg"); }
body[data-hero="lake-garda-09"]    { --hero: url("assets/images/backgrounds/lake-garda-09.jpg"); --hero-p: url("assets/images/backgrounds/lake-garda-24-portrait.jpg"); }
body[data-hero="aosta-03"]         { --hero: url("assets/images/backgrounds/aosta-03.jpg"); --hero-p: url("assets/images/backgrounds/aosta-02-portrait.jpg"); }
body[data-hero="flinders-20"]      { --hero: url("assets/images/backgrounds/flinders-ranges-20.jpg"); --hero-p: url("assets/images/backgrounds/flinders-ranges-04-portrait.jpg"); }
body[data-hero="tuscany-04"]       { --hero: url("assets/images/backgrounds/tuscany-04.jpg"); --hero-p: url("assets/images/backgrounds/flinders-ranges-15-portrait.jpg"); }

.js body[data-hero] .block-glow::before { background-image: var(--hero); }

/* The hero was the worst case of the same fault: 1265 wide by 1312 tall
   with a landscape photograph, so cover matched the height exactly, there
   was no vertical slack, and --par climbing past 8% moved it nowhere. It is
   measured along with everything else now. */
.js .block-glow.is-fit::before { background-size: var(--bgw) auto; }

@media (max-width: 760px) {
  .js body[data-hero] .block-glow::before { background-image: var(--hero-p, var(--hero)); }
}

/* ---------- the camera-roll tiles hold photographs now ---------- */

.roll-tile {
  display: block;
  padding: 0;
  overflow: hidden;
}
.roll-tile img {
  display: block;
  width: 100%;
  height: 132px;
  object-fit: cover;
}
.roll-tile figcaption {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 16px 14px;
}
/* Two by two makes each roll about half as wide, and the name and the count
   stop fitting on one line together: "Your phone" wrapped onto two lines
   while "412 photos, 38 videos" was cut off beside it. Stacked, both get a
   line of their own and nothing is clipped. */
@media (min-width: 901px) {
  .roll-stack .roll-tile figcaption {
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
  }
  /* Each count is its own line and never breaks inside itself, so no
     stranded "videos" under "412 photos, 38". */
  .roll-stack .roll-count { white-space: nowrap; }
}

/* ---------- the drifting band ---------- */

/* A film strip: tall frames, close together, with only a thin margin above
   and below. The picture is the point, not the space around it. */
.photo-band { padding: 26px 0; overflow: hidden; }

.band-row {
  display: flex;
  gap: 8px;
  width: max-content;
  /* The row drifts up to 240px either way. Started flush with the left edge
     it would pull a bare strip into view at the right-hand end of its
     travel, so it begins that far out and runs correspondingly wider. */
  margin-left: -280px;
  transform: translate3d(var(--x, 0px), 0, 0);
  will-change: transform;
}

.band-row img {
  display: block;
  height: 208px;
  width: auto;
  /* Barely rounded, and close-packed: sprocket-hole spacing, not a gallery
     of cards. */
  border-radius: 6px;
  object-fit: cover;
  box-shadow: 0 8px 22px rgba(0, 0, 0, 0.34);
}

@media (max-width: 720px) {
  .photo-bg,
  .photo-bg.section,
  .photo-bg.section-tight,
  .photo-bg.section-end { padding-top: 64px; padding-bottom: 64px; }

  .photo-band { padding: 18px 0; }
  .band-row img { height: 148px; }
  .roll-tile img { height: 108px; }
}

@media (prefers-reduced-motion: reduce) {
  /* The band stops drifting but stays: it is photographs, not an effect. */
  .band-row { transform: none !important; }
}


/* ============================================================
   12. Photographs behind the rest of it

   A section carrying .photo-bg paints its own picture, set by an inline
   --photo, under a near-solid scrim. Near-solid on purpose: the photograph
   is texture, not subject, and text over it has to keep the same 4.5:1 it
   has everywhere else. The hero is the one place the picture is allowed to
   be the picture.
   ============================================================ */

:root {
  /* Deep at the edges, where the picture meets the section above and
     below, and considerably lighter through the middle so the photograph
     is actually a photograph rather than a grey wash with a hint of
     landscape behind it. Every one of these numbers is checked against the
     brightest pixel in the picture it covers, not eyeballed. */
  --scrim-deep:  linear-gradient(180deg, rgba(11,13,16,0.76) 0%, rgba(11,13,16,0.54) 45%, rgba(11,13,16,0.80) 100%);
  /* Was lighter, for sections that were mostly headings. With the text
     panels gone every section carries body text on the bare picture, so
     both scrims now measure the same. Kept as two names because the
     stylesheet refers to them in a dozen places. */
  --scrim-mid:   linear-gradient(180deg, rgba(11,13,16,0.76) 0%, rgba(11,13,16,0.54) 50%, rgba(11,13,16,0.80) 100%);
}

.photo-bg {
  position: relative;
  isolation: isolate;
}
/* Full width, not the width of the text column.

   .wrap caps the section at 1160px, so a photograph drawn to inset:0 stopped
   short of both edges — a floating slab with square corners butting into the
   rounded cards above it. Edge to edge it reads as a band, and there are no
   corners left to argue with. */
.photo-bg::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  /* 100vw counts the vertical scrollbar, so a full-bleed band built from it
     is a dozen-odd pixels wider than the page and drags a horizontal
     scrollbar into existence. motion.js measures the real gap once and
     publishes it as --sbw. The alternative, overflow-x: hidden on the body,
     would silently kill the sticky nav. */
  width: calc(100vw - var(--sbw, 0px));
  margin-left: calc((100vw - var(--sbw, 0px)) / -2);
  z-index: 0;
  background-image: var(--scrim-deep), var(--photo);
  background-size: cover, cover;
  background-position: center, center;
  background-repeat: no-repeat, no-repeat;
  pointer-events: none;
}
.photo-bg > * { position: relative; z-index: 1; }

/* Mid-strength where a section is mostly headings. */
.photo-bg.photo-bg-mid::before { background-image: var(--scrim-mid), var(--photo); }

/* The warm block is a lifted step off the ground; its photographs sit under
   a scrim tuned to that surface instead. */
.block-warm .photo-bg::before {
  background-image:
    linear-gradient(180deg, rgba(16,19,23,0.76) 0%, rgba(16,19,23,0.54) 50%, rgba(16,19,23,0.80) 100%),
    var(--photo);
}

/* The closing panel is allowed more of its picture: there is one line of
   text on it and it is the last thing anybody reads. */
.cta-panel.photo-bg::before {
  background-image:
    linear-gradient(180deg, rgba(11,13,16,0.74) 0%, rgba(11,13,16,0.54) 50%, rgba(11,13,16,0.78) 100%),
    var(--photo);
  border-radius: inherit;
}

/* The two film cards each get a picture across the top. */
.choose-card { position: relative; overflow: hidden; }
.choose-card::after {
  content: "";
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 176px;
  z-index: -1;
  background-image:
    linear-gradient(180deg, rgba(11,13,16,0.28) 0%, rgba(28,31,36,0.80) 62%, rgba(28,31,36,0.99) 94%, var(--surface-solid) 100%),
    var(--photo);
  background-size: cover, cover;
  background-position: center, center;
}

@media (prefers-reduced-motion: reduce) {
  .js .block-glow::before { transform: none !important; }
}


/* The "After" frame stands for the finished film, so it holds a frame of
   one. It was an empty black rectangle with a play glyph on it, which is a
   poor advertisement for a product that makes films. */
.film-result-frame {
  position: relative;
  overflow: hidden;
  background-image:
    linear-gradient(180deg, rgba(11,13,16,0.34), rgba(11,13,16,0.52)),
    url("assets/images/backgrounds/thumbs/lake-garda-12-640.jpg");
  background-size: cover, cover;
  background-position: center, center;
}

/* The inner pages carry one row rather than two, above the footer. */
.photo-band-single { padding: 26px 0; }


/* A section that carries a photograph keeps less trailing padding: the
   picture fills the whole box, so generous bottom padding shows as a band
   of photograph under the last line of text with nothing on it. */
/* A band needs room above it, or it starts flush against whatever was
   sitting on the ground colour a moment earlier.

   The padding is deliberately symmetric. The section classes it inherits
   from are not — .section has 120px on top and nothing underneath, which is
   right on the flat ground where the next section supplies the gap, and
   wrong inside a photograph: the content sat hard against the bottom edge
   with a band of picture above it. Equal padding centres the text in its
   own panel. */
/* No margin. It stacked on top of whatever padding the section above
   already had — 72 of margin under 72 of padding put 144px below the
   honesty card and 72 above it. The gap is owned by the section that ends,
   below, so it cannot be counted twice. */
.photo-bg,
.photo-bg.section,
.photo-bg.section-tight,
.photo-bg.section-end {
  padding-top: 96px;
  padding-bottom: 96px;
}

/* ============================================================
   13. Line breaking

   styles.css sets text-wrap: pretty on headings, which only protects the
   last line from a single-word orphan. It still allows "A trip has a shape.
   The film / keeps it." — a full line, then two words.

   balance evens the lines instead: "A trip has a shape. / The film keeps
   it." Browsers apply it to short blocks only, which is exactly where
   headings live. Paragraphs keep pretty, because balance on a long body
   paragraph gives up after a handful of lines and reads worse.
   ============================================================ */

h1, h2, h3,
.section-title,
.stop-label,
.faq-item summary,
.demo-pending-title,
/* Once motion.js has put the break where the sentence ends, balancing on top
   of it would try to even out each half separately and undo the point. Each
   part just wraps normally if it is still too long. */
.h-split { text-wrap: pretty; }

p, li, figcaption { text-wrap: pretty; }

/* ---------- a photograph inside a rounded panel ----------

   .photo-bg runs edge to edge. This one is the opposite: the picture is
   clipped to the panel it belongs to, keeping the rounded corners and
   staying inside the darker section around it.

   The scrim is mixed over the panel's own colour rather than the page
   ground, so the card still reads as the same surface it was — a
   photograph in the panel, not a hole cut through it. */
.photo-card {
  position: relative;
  isolation: isolate;
  overflow: hidden;
}
.photo-card::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  border-radius: inherit;
  background-image:
    linear-gradient(180deg, rgba(23,26,31,0.93) 0%, rgba(23,26,31,0.84) 48%, rgba(23,26,31,0.94) 100%),
    var(--photo);
  background-size: cover, cover;
  background-position: center, center;
  background-repeat: no-repeat, no-repeat;
  pointer-events: none;
}
.photo-card > * { position: relative; z-index: 1; }

/* ---------- the second price ---------- */

.price-second {
  margin-top: -6px;
  font-size: 16px;
  line-height: 1.6;
  color: var(--on-dark-70);
  max-width: 30ch;
}
.price-second strong { color: #fff; font-weight: 600; }

/* ============================================================
   14. Drift

   Every photograph moves a little as it crosses the screen — some
   vertically, some sideways — so the page never feels like a stack of
   stills.

   All of it is done in PERCENTAGES of background-position and
   object-position, never pixels. A percentage is measured against the
   overflow the image already has, so 50% is centred, 0% and 100% are the
   two edges, and anything between is guaranteed to stay inside the
   picture. Pixel offsets have no such guarantee: on a box where cover
   leaves no slack in that axis, a pixel shift opens a gap at the edge.
   That is the same class of mistake as the hero photograph overflowing
   its block, and this is the version that cannot make it.
   ============================================================ */

/* Sideways movement needs sideways slack, and cover does not leave any.

   cover scales a picture until BOTH axes are covered, which means one axis
   matches the box exactly. On a wide, short band with a 4:3 photograph that
   axis is the width — so horizontal slack is zero, and a percentage shift
   of zero slack moves the image not at all. That is why these looked still.

   So the photo layer is over-scaled until the axis it travels along has
   somewhere to go. How far is not a number that can be guessed at here: it
   depends on the box, the picture's own proportions and which way this
   particular band was told to move, so motion.js measures it and publishes
   the result as --bgw. The scrim layer stays cover, because a gradient has
   nothing to reveal.

   Until that measurement lands the element keeps plain cover. That is what
   .is-fit is for. A photograph that never loads can then never leave a gap
   along the top of a band, which is the fault this replaced. */
.js .photo-bg::before,
.js .photo-card::before,
.js .choose-card::after,
.js .film-result-frame {
  background-position:
    center,
    calc(50% + var(--dx, 0%)) calc(50% + var(--dy, 0%));
}
.js .photo-bg.is-fit::before,
.js .photo-card.is-fit::before,
.js .choose-card.is-fit::after,
.js .film-result-frame.is-fit {
  background-size: cover, var(--bgw) auto;
}

/* The camera-roll tiles do not drift. Small, captioned pictures shifting
   under their own labels reads as a rendering fault, not an effect. */

/* The band photographs get a slow vertical shift on top of the sideways
   travel of the row itself. */
.js .band-row img {
  object-fit: cover;
  object-position: center calc(50% + var(--dy, 0%));
}

@media (prefers-reduced-motion: reduce) {
  .js .photo-bg::before,
  .js .photo-card::before,
  .js .choose-card::after,
  .js .film-result-frame { background-position: center, center !important; }
  .js .band-row img { object-position: center !important; }
}

/* ---------- the section holding a photo card ----------

   .section and .section-end are asymmetric by design — 120 on top, nothing
   underneath — which works on flat ground where the next section supplies
   the gap. Around a card that carries its own picture it reads as a lot of
   empty room, unevenly split. Symmetric and tighter. */
.section:has(.photo-card),
.section-end:has(.photo-card) {
  padding-top: 72px;
  padding-bottom: 72px;
}

/* The card's own inside can come in a little too. */
.honesty.photo-card { padding: 56px 60px 60px; }

@media (max-width: 1000px) {
  .honesty.photo-card { padding: 36px 30px 40px; }
  .section:has(.photo-card),
  .section-end:has(.photo-card) { padding-top: 56px; padding-bottom: 56px; }
}

/* A block whose last section has no bottom padding would otherwise run
   straight into the next block's photograph. This supplies the gap once,
   from the section that ends, rather than from the band that follows. */
.block-dark > .section:last-child:not(.section-end),
.block-warm > .section:last-child:not(.section-end) { padding-bottom: 72px; }

/* Optically centred rather than numerically: the heading's line box carries
   about 18px of leading above its cap height that the eye does not see as
   space, so the top padding is set shorter than the bottom. */
.honesty.photo-card { padding: 44px 60px 70px; }

@media (max-width: 1000px) {
  .honesty.photo-card { padding: 30px 30px 42px; }
}

/* ============================================================
   14. Glass over a photograph has to be dark glass

   --surface is the app's own kSurface: white at 14%, which reads as a lift
   when it sits on the near-black ground. Over a bright photograph it reads
   as a wash — the picture comes up through it and the small text on top
   loses its contrast. Measured, the trust cards needed the scrim behind
   them at 0.87 and the price section at 0.92 to survive it, which is a
   solid grey panel with a rumour of landscape behind it.

   Same card, same blur, dark glass instead. The photograph then shows in
   the gaps between the cards, which is where it was doing the work anyway,
   and the scrim can stay light enough to be worth having.
   ============================================================ */
.photo-bg .trust-card,
.photo-bg .choose-card,
.photo-bg .step-card,
.photo-bg .role-card,
.photo-bg .price-card,
.photo-bg .roll-tile,
.photo-card .trust-card,
.photo-card .step-card {
  background: rgba(16, 19, 23, 0.62);
  backdrop-filter: blur(18px) saturate(1.05);
  -webkit-backdrop-filter: blur(18px) saturate(1.05);
}

/* The dashed chips are 12px and sat on nothing at all. */
.photo-bg .owner-chip,
.photo-card .owner-chip {
  background: rgba(11, 13, 16, 0.72);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}

/* And the dimmed whites. --on-dark-66 and --on-dark-70 are right on the flat
   ground, where they read as a second voice below the headings. Over a
   photograph they cannot get to 4.5:1 at all: at 66% opacity the text is
   partly made of whatever is behind it, so the brighter the picture the
   brighter the text, and the ratio barely moves however hard the scrim is
   pushed. That is why raising the scrim alone never fixed these — it was
   solved at the text, which costs the photograph nothing. */
.photo-bg p,
.photo-bg li,
.photo-bg figcaption span,
.photo-bg .price-unit,
.photo-card p,
.photo-card li {
  color: rgba(255, 255, 255, 0.92);
}
/* The pink accents and the chips are spans outside those selectors, so
   they keep their own colours untouched. */

/* ============================================================
   15. The camera rolls becoming one film

   The four rolls arrive one at a time, then cross the section and fold
   into the film as it lights up. motion.js drives every frame of it and
   only adds .is-seq on a wide viewport with motion allowed, so a phone and
   a reduced-motion browser keep the plain static layout untouched.

   It is not pinned. The section is 1,360px tall — the rolls are
   photographs, not list rows — so on a laptop a sticky pin would hold its
   own top off the screen. Driven by the crossing instead, it gets about
   2,200px of travel on that same screen, which is more than a pin would
   have given it.
   ============================================================ */
.js .contrast-scene.is-seq .roll-tile,
.js .contrast-scene.is-seq .film-result,
.js .contrast-scene.is-seq .contrast-arrow {
  will-change: transform, opacity;
}
/* Deliberately NOT hidden here. An opacity: 0 base in the stylesheet is
   not recoverable if the script dies — the hero was built that way once and
   the photograph simply never appeared. measureScene() runs and onFrame()
   sets the opening state in the same frame as boot, so there is nothing to
   flash past anyway. */

/* The film brightens as the rolls fold into it. --lit runs 0 to 1. */
.js .contrast-scene.is-seq .film-result {
  box-shadow:
    var(--shadow),
    0 0 calc(var(--lit, 0) * 56px) calc(var(--lit, 0) * 6px) rgba(216, 201, 180, calc(var(--lit, 0) * 0.20));
}
.js .contrast-scene.is-seq .film-result-frame {
  border-color: rgba(231, 226, 217, calc(0.12 + var(--lit, 0) * 0.3));
}
.js .contrast-scene.is-seq .film-result-meta span { opacity: 0; }

/* Reduced motion never reaches here — motion.js does not add .is-seq — but
   the rules are kept honest anyway. */
@media (prefers-reduced-motion: reduce) {
  .js .contrast-scene .roll-tile,
  .js .contrast-scene .film-result,
  .js .contrast-scene .contrast-arrow { transform: none !important; opacity: 1 !important; }
  .js .contrast-scene .film-result-meta span { opacity: 1 !important; }
}

/* ============================================================
   16. The route lights up as you go

   The line already drew itself once on entry. Tracked, it follows the
   scroll instead: the route grows as the section comes up and each stop
   fills as the line reaches it, which is the whole point of a route.

   Only the dots and the line move. The labels keep their colours and their
   full opacity throughout — dimming text to animate it in would put every
   one of these six captions below 4.5:1 for as long as someone sat there
   mid-section, and a highlight is not worth that.
   ============================================================ */
.js .journey-rail.is-tracked::before {
  transition: none;                  /* the scroll is the timeline now */
  transform: scaleX(var(--draw, 0));
  transform-origin: 0 50%;
}

.js .journey-rail.is-tracked .journey-stop::before {
  transition: none;
  transform: scale(calc(0.62 + 0.38 * var(--dot, 0)));
  /* the inset ring fills the dot from its own edge inwards, so it goes from
     an outline to solid without the background having to be interpolated */
  box-shadow:
    inset 0 0 0 6px rgba(216, 201, 180, var(--dot, 0)),
    0 0 0 calc(var(--dot, 0) * 5px) rgba(216, 201, 180, 0.18);
  opacity: calc(0.4 + 0.6 * var(--dot, 0));
}
/* the last stop is pre-filled in the base sheet; tracked, it earns it */
.js .journey-rail.is-tracked .journey-stop:last-child::before {
  background: var(--bg-deep);
}

/* Read downwards on a narrow screen, so it draws downwards. */
@media (max-width: 900px) {
  .js .journey-rail.is-tracked::before {
    transform: scaleY(var(--draw, 0));
    transform-origin: 50% 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .js .journey-rail.is-tracked::before { transform: none !important; }
  .js .journey-rail.is-tracked .journey-stop::before {
    transform: none !important; opacity: 1 !important;
  }
}

/* ============================================================
   17. The FAQ list arrives quickly

   Reviewed as "the FAQ expansion animation feels slow". There is no
   expansion animation — <details> opens natively and instantly. What was
   actually slow is this list arriving: .m-rise runs 0.75s with an 80ms
   stagger per item, so the eighth question took about 1.4s to turn up, and
   a list of questions you are scanning should not keep you waiting.

   A shorter throw and a tighter stagger. The reveal stays, because these
   still arrive as a group rather than snapping in.
   ============================================================ */
.js .faq-item.m-rise {
  transform: translateY(14px);
  transition:
    opacity 0.28s cubic-bezier(0.22, 0.61, 0.36, 1) calc(var(--i, 0) * 35ms),
    transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1) calc(var(--i, 0) * 35ms);
}

/* ============================================================
   18. The camera rolls, at a size that fits the argument

   The before-and-after section was 1,360px — the tallest thing on the page
   by a wide margin, and taller than the viewport it has to be read in.
   Four stacked photographs at full height is more room than the point
   needs: the point is that there are four phones and one film.

   Capping the picture inside each roll keeps every caption, every count and
   the whole comparison, and takes the section back under one screen. It
   also makes the fold-into-the-film sequence legible, which was being
   played out across more height than anyone can see at once.
   ============================================================ */
@media (min-width: 901px) {
  .roll-tile img {
    height: 116px;
    object-fit: cover;
    object-position: center;
  }
  /* Two by two rather than a column of four. Four phones is the point being
     made, and a grid makes it in half the height — which matters, because
     this section was taller than the screen it has to be read in. */
  .roll-stack {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
  }
}

/* The homepage runs long, so its sections sit a little tighter. The inner
   pages are for reading and keep the roomier rhythm. */
@media (min-width: 901px) {
  body.home .photo-bg .section,
  body.home .photo-bg .section-tight { padding-top: 78px; padding-bottom: 78px; }
  body.home .journey { padding-top: 84px; padding-bottom: 84px; }
}

/* ============================================================
   19. A step up for the quietest text

   Reviewed as "some grey body text is beautiful but quite subdued".
   Measured first: nothing was failing. The dimmest text on the site —
   the footer at 55% white — sits at 6.25:1 against a 4.5 requirement, so
   this is legibility rather than compliance, and the fix is a step rather
   than a rebuild.

   The tokens themselves are left alone. --on-dark-55 and --ink-soft mirror
   the app's design_system.dart, and a token named 55 that renders at 72
   is a trap for whoever reads it next. The call sites move instead.
   ============================================================ */
.footer-brand p,
.site-footer .note,
.footer-legal,
.footer-legal p { color: var(--on-dark-70); }
.footer-col-title { color: var(--on-dark-70); }

/* Cards and the pricing column carry the most reading on a flat surface. */
.step-card p,
.trust-card p,
.role-card p,
.price-card li,
.faq-answer p { color: rgba(255, 255, 255, 0.78); }

/* ============================================================
   20. Only the route stops keep a panel

   The panels went on every block of body text and were too much: dark
   slabs sitting on top of the photographs, which is the opposite of what
   the photographs are there for. Off everywhere except the six route
   stops, where they are doing a different job — they are the stations on
   a line, they want an edge, and they arrive one at a time as the line
   reaches them.

   Everything else goes back onto the bare picture, which means the scrim
   has to carry it again. What buys that back is the new image treatment:
   the highlights are rolled off, so the brightest pixel behind a line of
   text is lower than it was even though the pictures read punchier.
   ============================================================ */
.photo-bg .journey-stop {
  background: rgba(12, 15, 19, 0.72);
  -webkit-backdrop-filter: blur(16px) saturate(1.06);
  backdrop-filter: blur(16px) saturate(1.06);
  border: 1px solid rgba(231, 226, 217, 0.10);
  border-radius: 16px;
  padding: 18px 16px 20px;
}

/* They arrive with the line. --dot is the same value that fills each dot,
   so a stop lands exactly as the route reaches it.

   The fallback is 1, not 0. If the script never runs, or dies, these are
   fully visible rather than permanently blank — the mistake the hero made
   once already. */
.js .journey-rail.is-tracked .journey-stop {
  opacity: var(--dot, 1);
  transform: translateY(calc((1 - var(--dot, 1)) * 16px));
  /* No transition. These inherit .m-rise's 0.75s ease, which made them
     drift toward the scroll position instead of following it — the scroll
     is the timeline here, and a timeline should not have its own lag. */
  transition: none;
}

/* The route line runs behind the stops. */
.photo-bg .journey-stop::before { z-index: 2; }

@media (prefers-reduced-motion: reduce) {
  .js .journey-rail.is-tracked .journey-stop {
    opacity: 1 !important; transform: none !important;
  }
}
