/* ============================================================================
   HYDRO2050 - PAGE 07, CASE STUDIES
   ----------------------------------------------------------------------------
   Source of truth: Paper artboards 7WW-0 (1440), A4F-0 and ACL-0 (375).
   ========================================================================== */

/* ===== PAGE 07, CASE STUDIES ============================================= */

.p07-open { position: relative; padding: var(--rhythm-open) 0 0; overflow: clip; }
.p07-open__mark {
  /* Anchored to the right of the content column rather than to a fixed left
     offset, which reached past the viewport between 900 and 1440. */
  position: absolute;
  /* The artboard puts the mark at x964 with a width of 344, so its right edge
     lands 132px inside the 1440 page edge. Anchored to the right of the
     content column plus that 68px, rather than to a fixed left offset, which
     reached past the viewport between 900 and 1440. */
  right: calc(50% - var(--content) / 2 + 68px);
  top: 10px;
  width: min(344px, 26vw);
  height: auto;
  object-fit: contain;
  opacity: 0.9;
  pointer-events: none;
}
.p07-open h1 { max-width: 900px; margin-block: var(--s-7, 28px); }

/* ---- filters ------------------------------------------------------------ */

.filters { padding-top: 72px; }
.filters__row { display: flex; flex-wrap: wrap; gap: var(--s-3); }
.pill {
  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
  min-height: 44px;
  padding-inline: 20px;
  border: 1px solid var(--rule);
  border-radius: 22px;
  background: transparent;
  font-family: var(--font-display);
  font-size: var(--text-small);
  line-height: 20px;
  color: var(--ink);              /* 14.68:1 on page */
  transition: background-color var(--dur-hover) var(--ease-out),
              border-color var(--dur-hover) var(--ease-out),
              color var(--dur-hover) var(--ease-out);
}
.pill__dot { width: 8px; height: 8px; border-radius: 4px; flex-shrink: 0; }
.pill__count { color: var(--ink-muted); }   /* 5.51:1 on page */
.pill:hover { border-color: var(--brand-navy); background: var(--surface); }
.pill:active { transform: translateY(1px); }
.pill[aria-pressed="true"] {
  background: var(--brand-navy);
  border-color: var(--brand-navy);
  color: var(--on-navy);          /* 9.60:1 on navy */
  font-weight: var(--wt-semi);
}
.pill[aria-pressed="true"] .pill__count { color: var(--on-navy-mute); }  /* 6.69:1 */

.filters__note {
  max-width: 900px;
  margin-top: var(--s-5);
  font-size: var(--text-prose);
  line-height: 26px;
  color: var(--ink-muted);
}
/* The results count, always present and always carrying text. */
.filters__status {
  margin-top: var(--s-3);
  font-size: var(--text-prose);
  line-height: 26px;
  color: var(--ink);              /* 14.68:1 on page. Darker than the note it
                                     sits under, because it is the live answer
                                     to what the visitor just pressed. */
  font-weight: var(--wt-semi);
}
/* Not `:empty { display: none }`. A live region that is display:none at load
   is outside the accessibility tree, which is the standard way a role="status"
   misses its very first announcement. It always carries text instead. */

/* ---- the gallery, desktop ------------------------------------------------ */

/* 1312 container, 416 cards, 32 gaps. 416 x 3 + 32 x 2 = 1312 exactly. */
.gallery { padding: var(--rhythm-sm) 0 var(--rhythm-section); }
.gallery__grid {
  display: grid;
  /* 416 x 3 + 32 x 2 = 1312 at the 1440 artboard. Expressed as fractions
     rather than literal pixels: as pixels this overflowed the viewport at
     every width between the breakpoint and 1440. minmax(0, 1fr) rather than
     1fr, because a plain 1fr floors at min-content and a long study title
     pushes the track wide again. */
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 56px 32px;
}
.study {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  border-top: 2px solid var(--cat, var(--rule));
  padding-top: var(--s-3);
}
/* The image band, 416 x 160, the measured size from the artboard.

   This note used to say all twenty six card photographs were held pending
   licence and the band carried pattern artwork until they cleared. Neither
   half is true now. The page carries ten cards, not twenty six, and seven of
   the ten show a photograph or a generated still through
   `.study__band--photo` further down this sheet. The rules below are what the
   remaining three cards use: the brand's own pattern artwork over the surface
   tint, washed with the category colour. Which three, and why each keeps the
   artwork, is in the comment above the grid in case-studies.html. */
.study__band {
  position: relative;
  height: 160px;
  overflow: clip;
  background: var(--surface);
}
.study__band::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url('../assets/img/pattern-a.jpg') center 86% / 150% auto;
  opacity: 0.8;
}
.study__band::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(120deg,
              color-mix(in srgb, var(--cat) 32%, transparent) 0%,
              color-mix(in srgb, var(--cat) 6%, transparent) 74%);
}
.study:nth-child(3n+2) .study__band::before { background-image: url('../assets/img/pattern-b.jpg'); background-position: center 79%; }
.study:nth-child(3n)   .study__band::before { background-image: url('../assets/img/pattern-c.jpg'); background-position: center 61%; }

/* THE FIGURE PANEL IS GONE, session 032. `.study__figure` was a 416 x 132
   block between the image band and the category eyebrow, carrying a label and
   a measured value, or a 1px-ruled absence string where a study had no figure.
   The client asked for the index to read as a title and a short description
   and nothing else, so the panel went, along with the `--none` modifier, the
   `-label` element and the `--none .t-figure` override. A rule kept for a
   markup shape that no longer exists is a trap for whoever reads this sheet
   next.

   Ten panels came out of the grid and ten values out of the mobile list, but
   only eight label and value PAIRS: the two research rows carried a value
   string and no label. No figure was withdrawn from the site. Eight of the
   ten are on their own detail pages, and the two that are not are accounted
   for in the comment above the grid in case-studies.html, which is worth
   reading before assuming a number moved somewhere it did not.

   The card lost 132px of panel plus one --s-3 flex gap. Nothing was added back
   in its place. The 56px row gap on `.gallery__grid` already separates the
   rows, and a three column grid of shorter cards is a shorter grid, not a
   broken one. Card height is content height here and always has been.

   ONE ORPHAN IS LEFT ON PURPOSE, AND IT IS NOT IN THIS SHEET. The panel's
   value was set in `.t-figure`, which lives in base.css with the rest of the
   type roles and is fed by `--text-figure`, `--lh-figure` and `--track-figure`
   in tokens.css. This page was its only user, so it is now dead. It is left
   because it is a role in the type scale rather than a component of this
   page, base.css is loaded by all thirty pages, and pulling a documented type
   role out of the shared scale is a design system decision, not a tidy-up.
   Whoever takes that decision should take it for all of base.css: a sweep on
   4 September 2026 found 79 orphaned selectors in site/css/, 78 of which
   predate this change. */
.study__cat { color: var(--ink-muted); }
.study__body { font-size: var(--text-prose); line-height: var(--lh-prose); color: var(--ink-muted); }

.study[hidden] { display: none; }

/* One disclosure per category, written twice because the two galleries are
   mutually exclusive: the grid is display:none below 900px and the grouped
   list is display:none above it, so exactly one copy ever renders. Without the
   mobile copy the four tinted bands appeared on a phone with nothing saying a
   photograph is being held, which is the one thing these blocks exist to say. */
.cat-gap { grid-column: 1 / -1; }
.cat-gap--mob { display: none; }

/* ---- the gallery, mobile ------------------------------------------------- */

/* A different structure, not a narrower one. The image band never rendered
   down here, and since session 032 the label and value that used to open each
   row are gone with the desktop figure panels, so a row is its title and its
   description over a 1px rule. The grouping by category is what this list
   does that the grid does not. */
.gallery__groups { display: none; }

@media (max-width: 899px) {
  .p07-open { padding-top: 28px; }
  .p07-open__mark { display: none; }
  .filters { padding-top: 24px; }
  .filters__row {
    flex-wrap: nowrap;
    overflow-x: auto;
    gap: var(--s-2);
    padding-bottom: var(--s-2);
    scrollbar-width: none;
    margin-inline: calc(50% - 50vw);
    padding-inline: var(--gutter-mob);
  }
  .filters__row::-webkit-scrollbar { display: none; }
  .pill { padding-inline: 18px; white-space: nowrap; flex-shrink: 0; }
  .filters__note { font-size: var(--text-caption); line-height: 20px; margin-top: var(--s-3); }

  .gallery__grid { display: none; }
  .gallery__groups { display: block; }
  .gallery { padding: 0 0 var(--s-10); }

  .cat-group { margin-top: 48px; }
  .cat-group[hidden] { display: none; }
  .cat-group__band {
    position: relative;
    width: 100vw;
    height: 140px;
    margin-inline: calc(50% - 50vw);
    overflow: clip;
    background: var(--surface);
  }
  .cat-group__band::before {
    content: '';
    position: absolute;
    inset: 0;
    background: url('../assets/img/pattern-b.jpg') center 79% / 150% auto;
    opacity: 0.8;
  }
  .cat-group__band::after {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(120deg,
                color-mix(in srgb, var(--cat) 34%, transparent) 0%,
                color-mix(in srgb, var(--cat) 7%, transparent) 74%);
  }
  .cat-group__heading { padding-top: 24px; padding-bottom: 4px; }
  .cat-gap--mob { display: block; margin-top: var(--s-4); }
  .row {
    display: flex;
    flex-direction: column;
    gap: 4px;
    margin-top: 20px;
    padding-top: 20px;
    border-top: 1px solid var(--rule);
  }
  /* `.row__label`, `.row__value` and `.row--none .row__value` were removed in
     session 032 with the desktop figure panel. The title keeps its 4px
     padding-top: it reads against the 1px rule above it now rather than
     against a 28px value, and 4 is what the artboard draws under either. */
  .row__title { padding-top: 4px; font-size: 17px; line-height: 24px; color: var(--ink); }
  .row__source { font-size: var(--text-caption); line-height: 20px; color: var(--ink-muted); }
  .row[hidden] { display: none; }
}

/* ===== THE CARD AS A CONTROL =============================================
   Twenty six cards shipped inert: no hover, no focus, nothing to activate.
   The design file draws a hover for the homepage capabilities row and not for
   this page, so the values below are that row's mechanic carried onto a white
   ground rather than copied off it. What was carried, and what was not, is in
   the build brief. Three things move:

     the frame, 1px rule        -> 3px all round, 20px radius
     the artwork, resting opacity -> full strength
     the note, 16px on open    -> 24px, and from muted to full ink

   The frame is a pseudo element at inset -3px, not a border. A real border
   would take 3px out of the card on every side the moment a pointer crossed
   it, and in a three column grid that moves the two cards beside it.
   ========================================================================== */

.study { position: relative; }
.study.is-interactive,
.row.is-interactive { cursor: pointer; }

.study::after {
  content: '';
  position: absolute;
  inset: -3px;
  border: 3px solid transparent;
  border-radius: 20px;
  pointer-events: none;
  transition: border-color var(--dur-drawer) var(--ease-out);
}
/* 50% of the category colour, which is what #FFFFFF80 is on the navy row:
   the ground colour at half strength. On white the ground is not the accent,
   so the card's own category colour carries it instead. */
.study.is-interactive:hover::after,
.study.is-interactive:focus-within::after {
  border-color: color-mix(in srgb, var(--cat, var(--rule)) 50%, transparent);
}
.study[data-open="1"]::after { border-color: var(--cat, var(--rule)); }

/* The resting 2px rule is square and full width, so with the frame up it
   poked out past both top corners as a second line. It has to fade rather
   than blink, and it cannot fade as a border: motion.css puts a 620ms
   transition and a stagger delay on every direct child of this grid at a
   specificity a plain `.study` rule loses to, so a transition declared there
   never runs. The border keeps its 2px and gives up its paint; a pseudo
   element draws the rule instead, and a pseudo element is nobody else's
   child, so its transition is uncontested. */
.study { border-top-color: transparent; }
.study::before {
  content: '';
  position: absolute;
  top: -2px;                      /* the absolute box starts inside the border */
  left: 0;
  right: 0;
  height: 2px;
  background: var(--cat, var(--rule));
  pointer-events: none;
  transition: opacity var(--dur-drawer) var(--ease-out);
}
.study.is-interactive:hover::before,
.study.is-interactive:focus-within::before,
.study[data-open="1"]::before { opacity: 0; }

.study__band { border-radius: 0; transition: border-radius var(--dur-drawer) var(--ease-out); }
.study__band::before { transition: opacity var(--dur-drawer) var(--ease-out); }
.study.is-interactive:hover .study__band,
.study.is-interactive:focus-within .study__band,
.study[data-open="1"] .study__band { border-radius: 17px 17px 0 0; }
.study.is-interactive:hover .study__band::before,
.study.is-interactive:focus-within .study__band::before,
.study[data-open="1"] .study__band::before { opacity: 1; }

/* ---- the disclosure ------------------------------------------------------ */

/* Written by js/filters.js rather than by the markup, for the same reason the
   filter row is: without JavaScript there is no expanding to do, so a visitor
   who has none is never shown a control that does nothing. Everything the
   panel holds is either already on the card or is a link to a page that
   exists. */
.cs-toggle {
  display: flex;
  align-items: center;
  align-self: flex-start; /* hugs its label, so the global focus ring lands
                             clear of the 3px frame instead of on top of it.
                             The whole card is still clickable. */
  gap: 10px;
  min-height: 44px;      /* WCAG 2.5.8, and tools/audit.mjs asserts it */
  margin-top: auto;      /* sits at the foot of the flex column card */
  padding: 0;
  border: 0;
  background: none;
  font-family: var(--font-display);
  font-size: var(--text-small);
  font-weight: var(--wt-semi);
  line-height: 20px;
  color: var(--azure-ink);        /* 4.94:1 on page */
  text-align: left;
  cursor: pointer;
  transition: color var(--dur-hover) var(--ease-out);
}
.cs-toggle:hover { color: var(--azure-hover); }
.cs-toggle::after {
  content: '';
  width: 8px;
  height: 8px;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  rotate: 45deg;
  translate: 0 -2px;
  transition: rotate var(--dur-hover) var(--ease-out);
}
.study[data-open="1"] .cs-toggle::after,
.row[data-open="1"] .cs-toggle::after { rotate: 225deg; translate: 0 2px; }

/* 0fr to 1fr rather than a measured height, so the panel animates without
   anyone measuring it and a rewrapped line cannot leave a stale pixel height
   behind. The child carries the overflow, which is what the row clips. */
.cs-detail {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition: grid-template-rows var(--dur-drawer) var(--ease-out),
              opacity var(--dur-hover) var(--ease-out);
}
.cs-detail > * { overflow: hidden; min-height: 0; }
.study[data-open="1"] .cs-detail,
.row[data-open="1"] .cs-detail { grid-template-rows: 1fr; opacity: 1; }
/* The card is a flex column with a gap, and a gap still applies to a child of
   zero height. Left alone, every closed card carried the gap as dead space at
   its foot. Cancelled here and paid back inside the panel, where it is part of
   what the panel animates. */
.study > .cs-detail { margin-top: calc(-1 * var(--s-3)); }
.row   > .cs-detail { margin-top: -4px; }

.cs-detail__inner {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  padding-top: 8px;
}
.cs-detail__filed {
  padding-bottom: 2px;
  font-size: var(--text-prose);
  line-height: 26px;
  color: var(--ink-muted);        /* 5.51:1 on page */
}
.cs-detail__ask {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  font-family: var(--font-display);
  font-size: 17px;
  font-weight: var(--wt-semi);
  line-height: 22px;
  color: var(--azure-ink);        /* 4.94:1 on page */
  text-decoration: underline;
  text-decoration-color: var(--brand-turq);
  text-decoration-thickness: 1.5px;
  text-underline-offset: 4px;
  transition: color var(--dur-hover) var(--ease-out),
              text-decoration-color var(--dur-hover) var(--ease-out);
}
.cs-detail__ask:hover { color: var(--azure-hover); text-decoration-color: var(--azure-hover); }

/* The note is what the open card is for, so it takes the design's own 16 to
   24 growth. The line height holds at 26px while the size grows, which is
   what the file draws: the open note is nearly solid set. Bound to open
   rather than to hover, because rewrapping a paragraph under a pointer moves
   the row's bottom edge and every card below it. */
.study__body { transition: font-size var(--dur-drawer) var(--ease-out),
                           color var(--dur-drawer) var(--ease-out); }
.study[data-open="1"] .study__body {
  font-size: 24px;
  line-height: 26px;
  color: var(--ink);              /* 14.68:1 on page */
}

/* ---- filtering ----------------------------------------------------------
   The snap this replaces was two separate omissions. `.study[hidden]` is
   display:none, which cancels an opacity transition outright, and the height
   the script was already writing onto the container had no `transition:
   height` in this stylesheet to run against, so that jumped as well.

   The order matters for more than looks. A card on its way out is made
   `inert` before it starts fading, so it leaves the tab order and the
   accessibility tree at the moment it stops being a result rather than
   140ms later when display:none finally arrives. Fading it with
   `visibility` instead would have left it laid out and holding its grid
   cell, and the grid would never have closed up. */
/* Both selectors below are pitched above motion.css deliberately. That file
   owns the scroll reveal, it runs on this exact grid, and two of its rules
   land on the properties this section needs:

     .js-reveal .reveal                    transition: opacity, transform
     .js-reveal .reveal[data-stagger] > *  the same, plus a per-card delay

   Neither can just be overridden away, because they are the entrance. So the
   height transition is scoped to the group, where a `[data-stagger]` group is
   already held at its finished state and has nothing left of its own to
   animate, and the card fade is scoped to `.has-filtered`, which
   js/filters.js adds only once a filter has actually been pressed. Until
   then the entrance owns the cards, which is correct.

   The filter's state classes are `is-filtered-out` and `is-filtered-in`
   rather than the obvious `is-in`, because motion.css already uses `.is-in`
   on this grid to mean the opposite thing. */
.gallery .gallery__grid.reveal[data-stagger],
.gallery .gallery__groups {
  transition: height var(--dur-drawer) var(--ease-out);
  overflow: clip;
  /* Enough for the 3px frame at inset -3px and for the 24px reveal rise. */
  overflow-clip-margin: 28px;
}
.gallery .gallery__grid.has-filtered > .study,
.gallery .gallery__groups.has-filtered .row {
  transition: opacity var(--dur-fast) var(--ease-out),
              border-top-color var(--dur-hover) var(--ease-out);
  transition-delay: 0s;
}
.gallery .gallery__grid.has-filtered > .study.is-filtered-out,
.gallery .gallery__grid.has-filtered > .study.is-filtered-in,
.gallery .gallery__groups.has-filtered .row.is-filtered-out,
.gallery .gallery__groups.has-filtered .row.is-filtered-in { opacity: 0; }
.study[hidden], .row[hidden] { display: none; }

@media (prefers-reduced-motion: reduce) {
  .gallery .gallery__grid.reveal[data-stagger],
  .gallery .gallery__groups { overflow: visible; }
}

/* ---- the disclosure on the mobile list ----------------------------------- */

@media (max-width: 899px) {
  /* No 3px frame here. The mobile list is rules on a white page rather than
     cards, and a frame at inset -3px on a full width row lands in the gutter.
     The open row is marked by its own rule taking the category colour. */
  .row {
    position: relative;
    transition: opacity var(--dur-fast) var(--ease-out),
                border-top-color var(--dur-hover) var(--ease-out);
  }
  .row[data-open="1"] { border-top-color: var(--cat, var(--rule)); }
  .row__source { transition: font-size var(--dur-drawer) var(--ease-out),
                             color var(--dur-drawer) var(--ease-out); }
  .row[data-open="1"] .row__source {
    /* Not the design's 24, and not the 19 that scaling 13 by the design's own
       1.5 gives either. The mobile row's title is 17px, so a note at 19 read
       larger than the study it belongs to. It stops one under the title and
       lets the colour lift, muted to full ink, carry the rest. */
    font-size: 16px;
    line-height: 24px;
    color: var(--ink);            /* 14.68:1 on page */
  }
  .cs-toggle { margin-top: 4px; }
  .cs-detail__filed { font-size: var(--text-caption); line-height: 20px; }
}


/* ---- THE WHOLE CARD IS CLICKABLE, with one real link ----------------------
   The same mechanism as `.tile__more` in css/home.css: the link drops to
   position:static so its ::after resolves against `.study`, which is already
   the positioned ancestor, and covers it. There is exactly one <a> per card
   and it wraps the title, so keyboard tab order, middle click, copy link
   address and the accessible name all keep working. No overlay anchor, no
   click handler on the article.

   THE ONE THING THAT IS NOT THE TILE, and it is worth writing down because it
   is invisible in the source. `.study` already had a whole-card click target:
   js/filters.js delegates a click on the grid and toggles the disclosure. That
   handler opens with `if (e.target.closest('a')) return;`, so the moment a
   stretched link covers the card, every pointer click resolves to the anchor
   and the disclosure can no longer be opened by pointer at all.

   So the two actions are separated by layer rather than by handler. The link
   takes the card at z-index 2; the disclosure's own button and its panel are
   lifted to 3, which is the same move `.tile__body` makes to stay above the
   tile's hit area. Both are direct children of a flex container, so z-index
   applies to them without position, and `position: relative` is written
   anyway so the rule does not quietly stop working if `.study` ever stops
   being a flex container.

   Card body -> the study. "More on this study" -> the panel. "Ask for this
   report", inside the open panel, still reaches contact-us.html. */
.study__link {
  position: static;
  /* 44px and a flex box for the same reason `.tile__more` carries them: the
     audit measures the anchor's own rect, not the stretched hit area, and four
     of the twenty six titles set on one line at 25px. Centring the line keeps
     the title optically where it was in the taller box. */
  display: flex;
  align-items: center;
  min-height: 44px;
  text-decoration: none;
}
.study__link::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  border-radius: 20px;
}
.study > .cs-toggle,
.study > .cs-detail { position: relative; z-index: 3; }
/* `.study` was position:relative with z-index:auto, which positions a child but
   does NOT open a stacking context, so the 2 and the 3 above were resolving
   against the document instead of against the card. tools/audit.mjs enforces
   exactly this ("any element claiming a z-index in the page range must have a
   non-root ancestor that establishes a stacking context") and reported both as
   uncontained. `.cap__row` on the homepage already isolates for the same
   reason. Nothing is clipped by it: the 3px frame at inset -3px and the 2px
   rule at top -2px still paint outside the card. */
.study { isolation: isolate; }

/* The focus ring is the card, not the three lines of title the anchor happens
   to wrap, so the element's own outline gives way to one drawn on the
   stretched box. Inset and radius match the 3px hover frame, so focus and
   hover read as the same object rather than as two. --focus is 4.94:1 on this
   page, past the 3:1 floor for an indicator. `.study:focus-within` already
   lights that frame in the category colour, so the state is doubly marked. */
.study__link:focus-visible { outline: none; }
.study__link:focus-visible::after {
  outline: 3px solid var(--focus);
  outline-offset: -4px;
}

/* ===== THE CARD BAND AS A PHOTOGRAPH, session 031 =========================
   The band was built as a 416 x 160 slot carrying pattern artwork because the
   photographs were held pending licence, and the note on `.study__band` above
   says the measured size is kept so the photographs drop straight in when they
   clear. Six of them have: the client supplied his own site photography with
   the case study copy on 3 September 2026, and IMAGERY-ATTRIBUTION.md 5b
   records each one as his to publish.

   A modifier rather than `:has()`. The photograph is not an extra layer over
   the artwork, it replaces it, so the card that has one turns the pattern off
   at the source. `content: none` removes the pseudo-element outright, which
   also makes the two `::before` opacity rules in the hover block above no-ops
   on these cards rather than animating something that is not drawn.

   The `::after` category wash stays on both kinds of band. It is what makes a
   row of six photographs and four patterns read as one set rather than as two,
   and at 32% to 6% of the category colour it does not carry any text. */
.study__band--photo::before { content: none; }
.study__band-photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ===== THE WHOLE MOBILE ROW IS CLICKABLE ==================================
   `.study__link` above does this for the desktop card and had no counterpart
   here, because until this session no study had a page to point at. Same
   mechanism, same three reasons: position:static so the stretched `::after`
   resolves against the row rather than against the anchor, a 44px floor and a
   flex box because tools/audit.mjs measures the anchor's own rect and not the
   hit area it stretches to, and the focus ring drawn on the stretched box so
   it marks the row the reader is on rather than the one line of title.

   No border-radius on either. The mobile list is rules on a white page rather
   than cards, which is the same reason the note at `.row[data-open="1"]` gives
   for there being no 3px hover frame down here. */
.row { position: relative; isolation: isolate; }
.row__link {
  position: static;
  display: flex;
  align-items: center;
  min-height: 44px;
  color: inherit;
  text-decoration: none;
}
.row__link::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
}
.row__link:focus-visible { outline: none; }
.row__link:focus-visible::after {
  outline: 3px solid var(--focus);
  outline-offset: -3px;
}
