/* ------------------------------------------------------------------
   FLUO — project list & project detail (prototype).

   Loaded AFTER style.css, which supplies every token, .grid, .btn,
   .para, the nav and the whole .contact section. Nothing from there is
   repeated here.

   Same scale model as style.css: desktop numbers are design px @1920
   multiplied by var(--u), which projects.js writes on width change.
   ------------------------------------------------------------------ */

:root {
  /* The identity block's own tokens and rules are in style.css: the homepage's
     hero is the same component. What is here belongs to these pages only. */

  /* Top of the identity (= top of the logo) from the top of the page.
     The list's first item and the detail's identity both start here. */
  --rest: 278;

  /* Detail body text (marketing, copy, prev / next), design px @1920.
     Measured off detail-desktop.png: cap height 28.9 = 0.72 × 40, line
     pitch 40. --txt-cap / --txt-base are that line's cap drop and
     baseline rise, in design px, from the two factors above. */
  --txt-size: 40;
  --txt-pitch: 40;
  --txt-cap: calc(var(--txt-pitch) / 2 - var(--cap-drop) * var(--txt-size));
  --txt-base: calc(var(--txt-pitch) / 2 - var(--base-rise) * var(--txt-size));
}

/* Rule 4 of AGENTS.md: the html background is a design decision, never
   transparent. It is also the first paint after the home → detail swap.
   Dark while the contact section is uncovered (projects.js, THE CONTACT
   REVEAL): Safari's bands over it, and a bounce past the end of the page. */
html { background: var(--light); }
html.contact-covers { background: var(--dark); }
body { background: var(--light); color: var(--dark-grey); }

/* ---- The contact section on these pages (projects.js, THE CONTACT REVEAL)
   The same fixed dark box as the homepage's (style.css, built by contact.js),
   but ABOVE the page and the nav here, clipped from the top: its visible
   top edge is the wipe. It rises at the light panels' speed over the
   natively scrolling content, and cuts the nav off exactly as the
   homepage's panel edge does. projects.js writes the clip from the scroll
   position; until it does, and whenever the page is above the reveal,
   nothing of it shows (and it takes no pointer: .is-off). The light nav
   copy it carries (startNavPaint) is painted only while a nav link has
   keyboard focus, so a focus under the contact is never invisible. */
.contact { z-index: 42; clip-path: inset(100% 0 0 0); }
.contact.is-off { visibility: hidden; }
.contact .nav--paint { opacity: 0; transition: opacity var(--rm-fade) ease; }
.contact--nav .nav--paint { opacity: 1; }
/* Reduced motion: no wipe. The box is unclipped and cross-fades in where the
   wipe would have started, opacity only, --rm-fade. */
.contact--reduced {
  clip-path: inset(0 0 calc(-1 * var(--contact-overscan)) 0);
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--rm-fade) ease, visibility 0s var(--rm-fade);
}
.contact--reduced.contact--in { opacity: 1; visibility: visible; transition: opacity var(--rm-fade) ease, visibility 0s; }
/* The reveal: the page's last stretch of scroll, as long as a light panel's
   exit on the homepage (one viewport at PANEL_SPEED_MULT, script.js: 2.5).
   In flow after the views, so it is the end of every page; the stepper's
   last stop and, on the desktop list, a snap area (the desktop block). */
.contact-reveal { height: calc(100vh / 2.5); height: calc(100lvh / 2.5); }

/* ---- Nav: the flat, single-layer nav (dark-grey text, back link, centred
   label, pill colours) is in style.css, because the homepage draws the same
   nav while it hands over to these pages. Only the `with` lockup is here. */

/* Desktop detail only. */
.nav__lockup { display: none; }

/* The detail head needs the identity's --id-gap too (the perex hangs off the
   title's last line); same formula as .identity in style.css. */
.dhead { --id-gap: calc(var(--logo-gap, var(--id-gap-default)) * var(--id-gap-k)); }

/* ---- Transport + measuring hosts ------------------------------------
   #identity exists only during a transition (projects.js), which writes
   its left / top: the DESTINATION's rect. A flight is a transform that
   animates to none, added inline only when there is a distance to fly, so
   a resting transport has no transform and no will-change and sits on the
   pixel grid exactly like the real node that replaces it. Small, no
   background: AGENTS.md rule 5. */
.transport {
  position: fixed;
  z-index: 50;
  pointer-events: none;
}
/* Mobile: the image block travelling with the identity. The same full-bleed
   box as the list image and the detail cover; only its clip-path and its
   transform animate, never its size and never the picture inside it. */
.transport--cover { z-index: 49; overflow: hidden; background: var(--light-grey); }

/* A piece waiting to be revealed by a transition or by the block observer. */
.pending { opacity: 0; }

/* ---- The nav's painted copies (projects.js) ---------------------------
   The content sits above the dark copy; a media frame clips the light copy
   it carries to itself (the contact section's own clip, above, does the
   same for the copy it carries). clip-path, because overflow does not clip
   a fixed descendant. */
.view { position: relative; z-index: 1; }
.media { clip-path: inset(0); }
/* A box with a transform is the containing block of what is fixed inside it:
   while a block rises into place its copy would rise with it, so it waits.
   The travelling cover (a transform too) simply has none.
   opacity, not visibility: a slot inside the copy that style.css sets
   visibility: visible (the Projects slot on a detail) would escape a
   hidden parent; nothing escapes opacity 0. */
.is-moving .nav--paint, .pending .nav--paint { opacity: 0; }

/* The detail's <h1> takes focus after a transition so a screen reader
   announces the page; it is not a control and shows no ring. */
.identity__title:focus { outline: none; }

/* The hidden list, laid out unseen for one measurement (measureListItem). */
.view--measuring {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  visibility: hidden;
  pointer-events: none;
}

/* Lays a detail head out at the top of the document, unseen, so the rest
   line can be measured from a real node whichever view is showing. */
.measure {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  visibility: hidden;
  pointer-events: none;
}

/* ---- Media frame ------------------------------------------------------
   One image or video, cropped to its frame. The frame's shape comes from
   the block type (below) or from the item's own --ratio. */

.media {
  display: block;
  overflow: hidden;
  background: var(--light-grey);
}
.media__el {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ---- The scrub block's slider (spec §12, step 9) --------------------------
   PLACEHOLDER LOOK, measured off the mocks (where the slider sits on the
   cover; here it sits on the scrub block's own full-bleed frame, at the same
   offsets from that frame's edges). The design pass is a change of the
   values in this block only. A thin rule in --light with round ends, a
   square --light-grey handle carrying three --dark-grey strokes, centred on
   the rule, in the lower part of the frame. No hover, no motion, no easing:
   the handle sits exactly where the pointer put it.

   Desktop, design px @1920 off detail-desktop.png (frame 0 → 1920, 883 tall
   there; the scrub block's frame is 1920 × 1028):
     rule    6 thick, x 432 → 1408 (976 wide; the mock's own insets, 432
             left and 512 right, not centred), its centre 89.5 above the
             frame's bottom edge
     handle  112 × 46, square corners; strokes 4 × 30, 8 apart (pitch)
   Mobile, css px @393 off detail-mobile-top.png (frame 786 × 441 = 2×; the
   scrub block's frame is 393 × 210):
     rule    2 thick, inset by the side margin (--gutter, 40) on both sides,
             its centre 69.75 above the frame's bottom edge
     handle  58 × 24; strokes 2.25 × 12, 4.5 apart
   Spec §12's first numbers (rule inset 80 mobile at ~68% height, 105 × 55
   handle) were the mobile frame's; the desktop frame differs and the mock wins.

   Geometry: --pos (projects.js, 0 → 1) moves the handle's box along the rule
   from its left end to its right end, like a native range thumb. The
   slider element is the hit area: the full rule length, at least 44px tall
   around the rule, and it contains the handle's own box, since the handle
   is never taller than that. Only it is touch-action: none, so the rest of
   the frame keeps scrolling the page. */
.scrub {
  --scrub-rule-left: 432;       /* design px, from the frame's left edge */
  --scrub-rule-width: 976;
  --scrub-rule-h: 6;
  --scrub-bottom: 89.5;         /* the rule's CENTRE above the frame's bottom edge */
  --scrub-handle-w: 112;
  --scrub-handle-h: 46;
  --scrub-stroke-w: 4;
  --scrub-stroke-h: 30;
  --scrub-stroke-pitch: 8;
  --scrub-hit-min: 44px;        /* a real 44px: the touch target, never scaled down */
  --scrub-rule-colour: var(--light);
  --scrub-handle-colour: var(--light-grey);
  --scrub-stroke-colour: var(--dark-grey);

  position: absolute;
  z-index: 2;                   /* above the video and the frame's nav copy */
  left: calc(var(--scrub-rule-left) * var(--u));
  width: calc(var(--scrub-rule-width) * var(--u));
  bottom: calc(var(--scrub-bottom) * var(--u));
  height: max(var(--scrub-hit-min), calc(var(--scrub-handle-h) * var(--u)));
  transform: translateY(50%);   /* centred on the rule's line; the box holds no fixed descendant */
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
  color: var(--scrub-rule-colour);   /* the focus ring's colour (style.css draws it in currentColor) */
  cursor: default;
}
.scrub__rule {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: calc(var(--scrub-rule-h) * var(--u));
  transform: translateY(-50%);
  border-radius: calc(var(--scrub-rule-h) * var(--u) / 2);
  background: var(--scrub-rule-colour);
}
.scrub__handle {
  position: absolute;
  top: 50%;
  left: calc(var(--pos, 0) * (100% - var(--scrub-handle-w) * var(--u)));
  width: calc(var(--scrub-handle-w) * var(--u));
  height: calc(var(--scrub-handle-h) * var(--u));
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  justify-content: center;
  column-gap: calc((var(--scrub-stroke-pitch) - var(--scrub-stroke-w)) * var(--u));
  background: var(--scrub-handle-colour);
  cursor: grab;
}
.scrub.is-dragging, .scrub.is-dragging .scrub__handle { cursor: grabbing; }
.scrub__handle i {
  display: block;
  width: calc(var(--scrub-stroke-w) * var(--u));
  height: calc(var(--scrub-stroke-h) * var(--u));
  background: var(--scrub-stroke-colour);
}
/* The frame itself keeps vertical page scroll everywhere outside the slider. */
.media--scrub { position: relative; touch-action: pan-y; }

/* ---- List ------------------------------------------------------------ */

.plist {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* ---- Detail ---------------------------------------------------------- */

.dperex { margin: 0; font-weight: 800; color: var(--dark-grey); }

/* Display copy (marketing, copy blocks, prev / next). These pages' copy
   sizes are their own, not style.css's .para. */
.dpara { margin: 0; font-weight: 800; color: var(--dark-grey); }

.dpn__link { display: block; font-weight: 800; color: var(--dark-grey); -webkit-tap-highlight-color: transparent; }
.dpn__line { display: block; white-space: nowrap; }

/* ==================================================================
   DESKTOP (768px and wider): the 1920 design, scaled by --u.
   Mobile is step 6; below 768px neither view is laid out yet.
   ================================================================== */

@media (min-width: 768px) {
  /* ---- Nav. The compact geometry (mark 38 at 30 / 21, links 20px at
     -0.05em on the 20 → 60 band, the 99 × 40 pill) is the site's one compact
     nav and lives in style.css. Here: only what these pages add.
     This nav has a single self-painting layer, so the pill's colours are
     its own: --dark-grey fill, --light label. Text widths fitted at -0.05em:
     "Back to home" 117, "Back to projects" 139, "Projects of Fluo" 160
     (24px), "Contact" 72. */
  /* The `with` lockup: [project logo] with [FLUO]. Logo at the mark's own
     left (30), centred on the band; "with" 12px; gaps 15 and 15; FLUO moves
     right by the lockup's width plus one gap (projects.js measures it, as
     --with-shift). In: fade + 20px from the left, --with-ms, var(--ease).
     Out: the same, reversed, at the same scroll position. */
  body[data-view="detail"] .nav__lockup {
    display: inline-flex;
    align-items: center;
    column-gap: calc(15 * var(--u));
    position: absolute;
    left: calc(30 * var(--u));
    top: calc(20 * var(--u));
    height: calc(40 * var(--u));
    opacity: 0;
    transform: translate3d(-20px, 0, 0);
    transition: opacity var(--with-ms, 220ms) var(--ease), transform var(--with-ms, 220ms) var(--ease);
  }
  .nav__with { font-weight: 700; font-size: max(10px, calc(12 * var(--u))); line-height: 1; letter-spacing: var(--nav-track); }
  body[data-view="detail"] .nav--with .nav__lockup { opacity: 1; transform: none; }   /* must out-rank the hidden state above */
  /* The mark's compact position is a transform (style.css), so the lockup's
     shift is the separate `translate` property and the two compose. */
  .nav--flat .nav__logo { transition: transform var(--nav-ms) var(--ease), translate var(--with-ms, 220ms) var(--ease); }
  .nav--with .nav__logo { translate: var(--with-shift, 0px) 0; }

  /* First logo top 278; last image bottom → contact 452. */
  .plist { padding: calc(var(--rest) * var(--u)) 0 calc(452 * var(--u)); }

  /* An item is a .grid (184 margins). Its height IS the pitch, 762, so
     the rhythm never depends on content; the last one ends with its image. */
  .pitem {
    position: relative;
    display: grid;
    grid-template-columns: repeat(6, minmax(0, 1fr));
    column-gap: var(--gut);
    align-items: start;
    height: calc(762 * var(--u));
  }
  .pitem:last-child { height: auto; }

  /* One gesture, one project (spec §6). Two layers:
     - BASE, here: native mandatory snap. It serves everything the stepper
       does not drive: touch, scrollbar drags, find-in-page, focus jumps.
       Scoped to the list, because <html> is the scroller for both views and
       the detail must scroll natively. --rest-line is the MEASURED rest line
       (projects.js, restLine()); the calc only covers the first paint. The
       end of the page (the reveal spacer's end: the contact section fully
       uncovered) is the last stop.
     - ON TOP, projects.js: the wheel and key stepper. While it glides it
       sets scroll-snap-type: none inline on <html> (a programmatic scroll
       under mandatory snap is re-snapped every frame) and removes it only
       after landing exactly on a stop, so restoring moves nothing. */
  html[data-view="list"] { scroll-snap-type: y mandatory; }
  .pitem {
    scroll-snap-align: start;
    scroll-snap-stop: normal;
    scroll-margin-top: var(--rest-line, calc(var(--rest) * var(--u)));
  }
  /* Only once a view is up (body gets data-view after the list is built).
     <html> has data-view from the head script, before the list exists: with
     the reveal spacer as the only snap area of an empty page, Chrome snaps
     to it on the first layout and then FOLLOWS it down as the list is built
     above it, so the page would open at its end. */
  html[data-view="list"] body[data-view] .contact-reveal { scroll-snap-align: end; }

  /* Separator: 1px, full grid width, 94 below the image (image 574 tall,
     so its top edge is 667 below the item's top). */
  .pitem__rule { display: none; }
  .pitem:not(:last-child) .pitem__rule {
    display: block;
    position: absolute;
    left: var(--gx);
    right: var(--gx);
    top: calc(667 * var(--u));
    height: 1px;
    background: var(--light-grey);
  }

  .pitem__body { display: contents; }   /* desktop: not a box, the <li> is the grid */
  .pitem .identity { grid-column: 1 / span 2; }

  /* Columns 3–6: 715 → 1736, 1021 × 574 (16:9). */
  .pitem__media {
    grid-column: 3 / span 4;
    height: calc(574 * var(--u));
  }

  /* ---- Detail ------------------------------------------------------
     Text is placed by the cap top of its first line and the baseline of
     its last, like the identity (--txt-cap / --txt-base in :root).
     Gaps are the GAP_* knobs in projects.js. */

  /* Head: exactly one list pitch tall (278 + 484 = 762), so the cover
     starts where the next list item would. The identity sits where a
     resting list item's does. */
  .dhead {
    position: relative;
    height: calc(762 * var(--u));
    padding-top: calc(var(--rest) * var(--u));
  }

  /* Perex: 24/24, left 986, two columns (490) wide. It hangs off the END
     of the title: its first line's cap top is the title's LAST line's cap
     top, so it moves up with a shorter title (505 with three lines, 425
     with two). That cap top is rest + logo height + gap + (lines - 1) ×
     pitch; --ls, --id-gap and --lines are set on .dhead by projects.js
     from the same data the identity is built from, so nothing is measured.
     (12 - 0.337×24 = 3.91 is a 24/24 line's cap drop.)
     Tracking -0.06em, fitted like the title's: at 24px it reproduces
     detail-desktop.png's three wraps exactly ("…with access to / …and
     production, / …approach.") with line ends at 1467 / 1413 / 1280
     against the mock's 1467 / 1415 / 1281. It is the only value that
     does: at -0.05em the first line already loses a word (ends 1451 /
     1450 / 1287), and every looser value wraps differently again. */
  .dperex {
    position: absolute;
    left: calc(986 * var(--u));
    top: calc((var(--rest) + var(--ls) + var(--id-gap) + (var(--lines) - 1) * var(--id-pitch) - 3.91) * var(--u));
    width: calc(490 * var(--u));
    font-size: max(14px, calc(24 * var(--u)));
    line-height: 1;
    letter-spacing: var(--id-track);
  }

  /* Cover: full-bleed, 762 → 1645. */
  .media--cover { height: calc(883 * var(--u)); }

  /* Block frames. */
  .media--full { aspect-ratio: var(--ratio, 1920 / 1028); }
  .media--wide { aspect-ratio: var(--ratio, 1552 / 928); }
  .media--duo  { aspect-ratio: var(--ratio, 4 / 3); }
  .dduo {
    display: grid;
    grid-template-columns: 1fr 1fr;
    column-gap: var(--gut);
    align-items: start;
  }

  /* Block rhythm, decided by the two neighbours' kinds. */
  .dblock { margin-top: calc(var(--gap-loose) * var(--u)); }
  .dblock--inset + .dblock--inset { margin-top: calc(var(--gap-tight) * var(--u)); }
  .dblock--text + .dblock { margin-top: calc((var(--gap-loose) - var(--txt-base)) * var(--u)); }
  .dblock.dblock--text { margin-top: calc((var(--gap-text-top) - var(--txt-cap)) * var(--u)); }

  .dpara {
    width: var(--w3);
    max-width: 100%;
    font-size: max(18px, calc(var(--txt-size) * var(--u)));
    line-height: calc(var(--txt-pitch) / var(--txt-size));
    letter-spacing: var(--id-track);
  }

  /* Prev / next: 40/40, two lines each, on the grid's two edges. */
  .dpn {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    margin-top: calc((var(--gap-prevnext-top) - var(--txt-cap)) * var(--u));
    padding-bottom: calc((var(--gap-prevnext-end) - var(--txt-base)) * var(--u));
  }
  .dpn__link {
    font-size: max(18px, calc(var(--txt-size) * var(--u)));
    line-height: calc(var(--txt-pitch) / var(--txt-size));
    letter-spacing: var(--id-track);
  }
  .dpn__link--next { text-align: right; }
}

/* ==================================================================
   MOBILE (below 768px). --u is 1px on a 393pt phone (projects.js), so the
   numbers are css px at that width and scale with the screen.

   Measured off list-mobile-top.png and detail-mobile-top.png. Those frames
   are 786 × 1704 = 2× a 393 × 852pt iPhone SCREEN, not the page: the page
   starts 59pt (118 mock px) down, under the status bar (AGENTS.md rule 3).
   Check: the FLUO mark is 48 mock px tall = the 24px compact mark, and its
   top at 136 → (136 − 118) / 2 = 9px, against the nav's own 8px.
   So css = (mock − 118) / 2 vertically, mock / 2 for sizes.

   Identity, re-derived like the desktop one:
   - M stem: cap top 461.63, bottom 502.00 → cap height 40.37 = 0.72 × 56.07
     → font-size 56 mock = 28px. Stem 7px wide = 0.125em (ExtraBold).
   - pitch: line 1 ascender top 461, line 3 ascender top 573 → 56 mock = 28px,
     line-height 1, as on desktop (80 / 80).
   - tracking -0.06em: "shoe store" measures 258 mock px; the desktop one is
     368 @80px → 257.6 @56px. Same tracking, confirmed by width.
   - logo top 400.3 → 141px from the page top; adidas 37.7 tall × 56 wide,
     Apple TV 30 × 59: both 1.31 × their desktop height in mock px, so 0.655
     in css px. Logo bottom → cap top 23.6 → 12px.
   - title's last baseline → image top 60 mock = 30px (three-line item 1 and
     two-line item 2 both).
   List: image 80 → 706 of 786 and 442 tall, i.e. the full-bleed 786 × 442
   cover clipped 80 (40px) each side. Separator 108 below the image → 54px,
   next logo 110 below the separator → 55px.
   Detail: cover 0 → 786 at the same top, perex 40 mock = 20/20 with cap top
   191 below the cover → 96px, next block 192 below its last baseline → 96px.
   ================================================================== */

@media (max-width: 767px) {
  :root {
    --rest: 141;
    /* The side margin (and the list image's inset) is --gutter, from style.css:
       40 @393 for all content on every page. Not repeated here. */
    --id-img-gap: 30;            /* title's last baseline → image / cover top */
    --txt-size: 20;              /* perex and display copy: 20/20 */
    --txt-pitch: 20;
  }

  /* ---- List ---- */
  .plist { padding: calc(var(--rest) * var(--u)) 0 calc(213 * var(--u)); }
  .pitem.grid { padding: 0; }
  .pitem__body { position: relative; display: block; }
  .pitem .identity, .dhead .identity { margin: 0 var(--gutter); }

  /* The whole block is the link: `view project` is stretched over the body
     and shows no text here. It stays the real <a href>, so long-press, open
     in a new tab and the focus ring (around the whole block) all work. */
  .pitem .identity__cta {
    position: absolute;
    inset: 0;
    z-index: 1;
    margin: 0;
    font-size: 0;
    color: transparent;
    text-decoration: none;
  }
  /* Its text is transparent, so the site's currentColor ring (style.css,
     "Focus") needs the block's own colour spelt out here. */
  .pitem .identity__cta:focus-visible { outline-color: var(--dark-grey); }

  /* The image is ALWAYS laid out at the full-bleed size and only clipped,
     so opening it to the detail's cover (spec §10.4) is a clip-path change
     and nothing else. Never sized, never the <img> transformed. */
  .pitem__media, .media--cover {
    width: 100%;
    aspect-ratio: 786 / 442;
    margin-top: calc((var(--id-img-gap) - (var(--id-pitch) / 2 - var(--base-rise) * var(--id-size))) * var(--u));
  }
  .pitem__media { clip-path: inset(0 var(--gutter)); }

  .pitem__rule {
    display: block;
    height: 1px;
    margin: calc(54 * var(--u)) var(--gutter) calc(55 * var(--u));
    background: var(--light-grey);
  }
  .pitem:last-child .pitem__rule { display: none; }

  /* ---- Detail ---- */
  .dhead.grid { padding: calc(var(--rest) * var(--u)) 0 0; }

  /* The scrub block's slider on a phone (see the block above the list rules
     for the measurements): the rule runs between the side margins. */
  .scrub {
    --scrub-rule-h: 2;
    --scrub-bottom: 69.75;
    --scrub-handle-w: 58;
    --scrub-handle-h: 24;
    --scrub-stroke-w: 2.25;
    --scrub-stroke-h: 12;
    --scrub-stroke-pitch: 4.5;
    left: var(--gutter);
    right: var(--gutter);
    width: auto;
  }
  .dperex-m.grid, .dblock--text.grid, .dblock--wide.grid, .dpn.grid { padding: 0 var(--gutter); }
  .dblock--duo.grid { padding: 0; }   /* duo: stacked, full width */

  /* Perex: below the cover, 20/20, cap top 96 under it. -0.06em reproduces
     detail-mobile-top.png's four wraps; see the note at .dperex above. */
  .dperex-m { margin-top: calc((var(--gap-text-top) - var(--txt-cap)) * var(--u)); }
  .dperex, .dpara, .dpn__link {
    font-size: calc(var(--txt-size) * var(--u));
    line-height: calc(var(--txt-pitch) / var(--txt-size));
    letter-spacing: var(--id-track);
  }

  .media--full { aspect-ratio: var(--ratio, 1920 / 1028); }
  .media--wide { aspect-ratio: var(--ratio, 1552 / 928); }
  .media--duo  { aspect-ratio: var(--ratio, 4 / 3); }
  .dduo { display: grid; row-gap: calc(var(--gap-tight) * var(--u)); }

  .dblock, .dperex-m + .dblock { margin-top: calc(var(--gap-loose) * var(--u)); }
  .dblock--inset + .dblock--inset { margin-top: calc(var(--gap-tight) * var(--u)); }
  .dblock--text + .dblock, .dperex-m + .dblock { margin-top: calc((var(--gap-loose) - var(--txt-base)) * var(--u)); }
  .dblock.dblock--text { margin-top: calc((var(--gap-text-top) - var(--txt-cap)) * var(--u)); }

  .dpn {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    margin-top: calc((var(--gap-prevnext-top) - var(--txt-cap)) * var(--u));
    padding-bottom: calc((var(--gap-prevnext-end) - var(--txt-base)) * var(--u));
  }
  .dpn.grid { padding-bottom: calc((var(--gap-prevnext-end) - var(--txt-base)) * var(--u)); }
  .dpn__link--next { text-align: right; }
}

/* Reduced motion (spec §10.6, the rule is in style.css): opacity only. The
   snap is kept (it is the browser's own scroll, not decoration), never a
   smoothed scroll. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  /* The `with` lockup cross-fades in --rm-fade without its 20px slide, and
     the mark's shift beside it snaps: no transition on a position. */
  body[data-view="detail"] .nav__lockup { transform: none; transition: opacity var(--rm-fade) ease; }
  .nav--flat .nav__logo { transition: none; }
}
