/*
  Section content layouts.

  css/layout.css positions the sections themselves; css/cards.css holds the
  reusable surface/button system. This file is only the per-section
  arrangement of those pieces.

  Everything is sized to fit ONE viewport without scrolling on a laptop. That
  is not a stylistic preference: js/scroll-engine.js pages one full section
  per wheel notch, and .section is `overflow: hidden`, so anything taller
  than the screen is simply unreachable. Hence the clamp()ed type and a
  portrait that takes its height from the column beside it.

  Below the paging breakpoint none of that applies -- the page scrolls
  normally, sections grow, and the layout is free to be tall.
*/

/* The wider of the two content measures. "Who's Roman?" uses it because it
   is a two-column layout carrying a full-height portrait: at the default
   1200px the portrait column shrank to 408px and the whole composition
   floated in the middle of a 1655px screen instead of filling it, which is
   not what the user's original did. Single-column pages keep the narrower
   --content-max-width, where a wide measure would only hurt line length. */
.section__inner--wide {
  max-width: var(--content-max-width-wide);
}

/* ---- "Who's Roman?" ----
   The mockup's composition, which keeps the user's frame -- text left,
   portrait right.

   The title is NOT here: it lives in the section band with every other
   section's title (css/layout.css). What that buys this page is height --
   the heading used to take a row out of the same grid the portrait is sized
   by, so moving it out is what lets the picture run the full height of the
   content, which is the size it was in the user's original and the thing he
   asked for back. */
.about {
  display: grid;
  /* The portrait column is `auto` -- it takes its width from the picture
     rather than the picture being fitted into a width. The photograph is
     571x836 and must not be cropped, so the frame is sized by the row's
     HEIGHT and its width simply follows from the aspect. Handing it a
     percentage instead means the frame's shape and the photo's disagree, and
     something has to give: either a crop or a band of empty mat down two
     sides.

     The text column keeps a floor of its own so a tall screen cannot let the
     picture push it into a gutter. */
  grid-template-columns: minmax(18rem, 1fr) auto;
  /* The row has to be DEFINITE, not auto. The frame is height-driven --
     `height: 100%` on a box whose parent is auto-height resolves to auto,
     and the photograph then falls back to its intrinsic 836px and pushes the
     section 22px past the fold. Stating the row makes the height the picture
     is measuring against a real number. */
  grid-template-rows: minmax(0, 1fr);
  gap: clamp(var(--space-4), 2.4vw, 2.75rem);
  align-items: stretch;
  height: 100%;
}

/* Centred beside the portrait rather than stretched to match it. The
   portrait is the full-height element on this page -- that is the point of
   it -- and an earlier version handed the leftover height to the quote,
   which turned a three-line quotation into a 200px slab of empty card. The
   text column is its own natural height, centred against the picture, which
   is what the user's original does. */
.about__body {
  /* The credential rows reflow on THIS element's width, not the viewport's
     -- see the container query in css/cards.css. It has to be here because a
     container query cannot style its own container, and this is the box
     whose width actually decides whether the numeral column is affordable. */
  container-type: inline-size;
  container-name: textcol;

  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: clamp(var(--space-2), 2.2vh, var(--space-4));
  min-width: 0;
  min-height: 0;
}

.about__creds {
  padding: 0;
}

.about__quote {
  min-height: 0;
  margin: 0;
}


/* ---- The portrait ----
   Two boxes, because the bloom and the frame want different geometry: the
   bloom is a blurred rectangle that deliberately leaks PAST the frame's
   right edge, so it cannot be a ::before on the frame itself without either
   being clipped by the frame's radius or dragging the frame's own overflow
   rules with it.

   The frame is HEIGHT-driven: it fills the row and takes its width from the
   photograph's aspect, which is what lets the picture be shown whole. The
   row's height comes from the section, not from the picture, so the photo
   still contributes no height of its own -- and the column it sits in is
   `auto`, so it gives back whatever width the aspect does not need. */
.about__figure {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  min-height: 0;
}

/* The bloom: a blurred coral-to-violet rectangle behind the frame. The
   asymmetric inset is from the mockup and is not a rounding error -- the
   colour is meant to spill further past the right edge than the left, which
   is what makes it read as light behind the picture rather than as a glow
   drawn around it.

   It is wider and stronger here than the mockup's, and that is doing a job
   the mockup never had to: the mockup's portrait sits on a plain field,
   while this one sits over the cascade. Measured at 1900x1050, FIVE separate
   wave edges pass behind the frame, entering at 356, 492, 617, 797 and 892px
   down a frame that runs from 140 to 1008 -- so no size of picture avoids
   them, and the top one enters barely 200px below the frame's own top edge.
   What can be fixed is how the crossing READS: a halo of colour around the
   frame gives every one of those edges something soft to arrive at instead
   of running hard into a corner. */
.about__figure::before {
  content: "";
  position: absolute;
  inset: 3% -30px 1% -6px;
  border-radius: 40px;
  background: linear-gradient(150deg, var(--coral-400), var(--violet-500));
  filter: blur(56px);
  opacity: var(--bloom-opacity);
  pointer-events: none;
}

/* Its own surface tokens rather than the card fill: the mat stays bright in
   both themes, because it is a picture frame. A dark mat on the dark theme
   read as a hole cut in the page rather than as a mounted photograph.

   The explicit width and height on the image are NOT redundant with the
   insets. CSS 2.1 10.3.8: an absolutely positioned REPLACED element with
   `width: auto` takes its intrinsic width and the over-constrained inset is
   simply dropped -- so the four insets alone left the placeholder at its
   natural size with a pale band of empty card below it. */
.about__photo {
  --surface-top: var(--frame-top);
  --surface-bottom: var(--frame-bottom);
  --surface-line: var(--frame-line);

  /* 571 x 836 plus the mat: the ratio is written on the BORDER box, which is
     the image plus 10px of mat a side, so it is (571 + 20) / (836 + 20)
     rather than the photograph's own ratio. Getting that wrong by the mat is
     what leaves a two-pixel sliver of fill down one edge.

     The ceiling is a max-HEIGHT, and that is the whole trick. It was a
     max-width on the column, which the frame hit on a tall screen: the width
     clamped, the height stayed at 100%, the ratio broke, and the picture
     letterboxed inside a mat half again as tall as itself -- which also
     squared off its corners, since the radius belongs to the box and the
     painted image had shrunk away from it. Capping the HEIGHT at the width
     ceiling divided by the ratio keeps the frame's shape exactly, so it
     always hugs the photograph and the image well is always full. */
  aspect-ratio: 591 / 856;
  height: 100%;
  max-height: calc(min(46vw, 52rem) * 856 / 591);
  width: auto;
  margin: 0;
  border-radius: 28px;
  padding: 10px;
  box-shadow: var(--frame-shadow);
}

/* The landing page has its own photograph (2026-PortraitWithBeard.jpg,
   896 x 1152) while Contact keeps the Code4Good one, so its frame carries its
   own ratio -- same arithmetic, (896 + 20) / (1152 + 20). */
.about__photo--landing {
  aspect-ratio: 916 / 1172;
  max-height: calc(min(46vw, 52rem) * 1172 / 916);
}

/* `contain`, not `cover`. The user asked for the photograph uncropped, and
   the frame is cut to its ratio precisely so that `contain` has nothing to
   letterbox -- but it is the backstop that decides which way the frame gives
   if it ever does: a little more mat, never a crop.

   The radius is CONCENTRIC with the frame's -- 28px outer, 10px of mat, so
   18px inner. Two rounded rectangles sharing a centre look wrong unless the
   inner one's radius is the outer's less the gap between them. */
.about__photo img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  border-radius: 18px;
}


/* ---- Case study pages ----
   The layout the user's reference screenshot asks for, and the shape every
   project page will take: a text column on the left carrying the page's own
   heading, a lede and one detail card, and a stack of product screens on the
   right.

   Its band names the PROJECT rather than the page, because a project is four
   screens the reader walks with Back/Next and the constant across them is
   which project they are in. The page's own name is the first thing in the
   column instead.

   Sized in `cqw` against the text column throughout, for the reason set out
   in css/cards.css: the column is the viewport minus a rail minus a gallery,
   so its width has little to do with the viewport's. This page carries more
   copy than any other and it still has to clear one fold. */
/* The row is as tall as the TALLER of the card and the screens, capped at the
   section, and centred in it. Both columns stretch to that row, and the card
   fills its column (`flex: 1` below), so the card is never shorter than the
   screens beside it. It was a `1fr` row with the card at its own height, and
   where the two came out close the mismatch read as a mistake: 675px of card
   beside a 698px frame on Project 3 at 1920x1080, a 460px card beside a 453px
   frame on Project 2 at 1280x720. When the copy is the taller, the screens
   centre on it as before. */
.case {
  display: grid;
  grid-template-columns: minmax(20rem, 1fr) minmax(0, 1.05fr);
  grid-template-rows: minmax(0, max-content);
  align-content: center;
  gap: clamp(var(--space-4), 2.4vw, 2.75rem);
  height: 100%;
}

.case__body {
  container-type: inline-size;
  container-name: textcol;

  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: clamp(var(--space-2), 1.6vh, var(--space-3));
  min-width: 0;
  min-height: 0;
}

/* ---- One surface for the whole column ----
   The page name, the lede and the Mission/Role/Activities block are one
   card, not a heading and a paragraph on the artwork with a card under them.

   Two reasons, in this order. The artwork is going to change -- these waves
   are placeholders for whatever palette each project ends up with -- and
   copy sitting directly on it has to be re-checked for contrast every time
   it does. Inside a card, the ink is measured against the card's own fill,
   which is a colour this stylesheet controls. Nothing but the band title now
   sits on the artwork, and that is large text against a colour js/waves.js
   measures on every flip.

   And they read as one thought: what this page is, what the project was, and
   the specifics. One card, no nesting -- the detail block was briefly a
   `.panel` inside this card and the inner frame earned nothing: MISSION,
   ROLE and ACTIVITIES are already set apart by colour and size, so a border
   around them was a second way of saying what the small caps had said. */
.case__card {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  gap: clamp(var(--space-2), 1.6vh, var(--space-3));
  min-height: 0;
  padding: clamp(var(--space-3), 2vh, var(--space-4));
}

/* The page's own name, and now a card heading: it takes the card's ink
   rather than the field's. The size stays in `cqw` -- `.case__body` is the
   container, and the card is inside it, so the query still resolves against
   the text column and not the viewport. */
.case__heading {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(1.25rem, 5.5cqw, 2rem);
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--card-text);
}

/* The lede takes the card's FULL ink, not the muted tone `.card p` hands
   every other paragraph. It is the summary of the whole page; the detail
   below it is what the muted tone is for. */
.case__card .case__lede {
  margin: 0;
  font-size: clamp(0.9375rem, 3.6cqw, 1.1875rem);
  line-height: 1.45;
  text-wrap: pretty;
  color: var(--card-text);
}

/* The last block in the card takes the column's remaining height and clips,
   so a long Activities list cannot push the card past the fold. */
.case__detail {
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;

  /* A little more air above than BETWEEN the three terms, or the lede reads
     as a fourth one. The card's own gap is the floor; this is on top of it. */
  margin-top: clamp(var(--space-1), 0.8vh, var(--space-2));
}


/* ---- Mission / Role / Activities ----
   A definition list, because that is what it is: three terms and what each
   one means on this project. The terms are set as small caps in the card's
   accent rather than as headings, so three of them in a card do not read as
   three sections. */
.facts {
  margin: 0;
}

.facts__term {
  margin: 0;
  font-size: clamp(0.6875rem, 2.4cqw, 0.8125rem);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--card-text-accent);
}

.facts__term + .facts__value {
  margin: 0.25em 0 0;
}

.facts__value {
  margin: 0;
  font-size: clamp(0.875rem, 3.2cqw, 1.0625rem);
  line-height: 1.45;
  text-wrap: pretty;
  color: var(--card-text);
}

.facts__value + .facts__term {
  margin-top: clamp(var(--space-2), 1.4vh, var(--space-3));
}

/* Marker outside the text, so the second line of an item lines up with the
   first rather than with the bullet. */
.facts__list {
  margin: 0;
  padding-left: 1.1em;
  list-style: disc;
}

.facts__list li + li {
  margin-top: 0.4em;
}

.facts__list::marker,
.facts__list li::marker {
  color: var(--card-text-accent);
}


/* ---- The screens ----
   Two frames stacked, centred in the column.

   THE FRAME HUGS THE PICTURE. That is the whole shape of this block, and it
   was not true at first: the frames filled two `minmax(0, 1fr)` rows and the
   image sat inside with `object-fit: contain`, which fits the picture to the
   box and leaves whatever is left over as mat. Measured on a 1774x1322
   window: a 696x480 frame around a 696x362 picture -- 59px of empty mat above
   and below each shot, and the hover scrim and the magnifier covered that
   emptiness too, because they track the box and not the image.

   So the image is the thing that is sized and everything above it wraps.
   `width`/`height: auto` with both a max-width and a max-height is the one
   combination that always keeps a replaced element's own ratio: the browser
   scales it down to fit both bounds rather than squashing it into one. No
   `object-fit` is needed once the box IS the picture, and `justify-items`
   plus `align-content` make the figure and the button shrink to it.

   The rows are `auto` now rather than `1fr`, so the height cap is what stops
   the pair pushing the section past the fold -- the trap the portrait fell
   into on the landing page. It is in `vh` because everything sizing this
   layout is: the band, the gaps and the section itself. 37vh a shot leaves
   the pair inside the ~77vh the gallery gets, gap included. */
.case__gallery {
  display: grid;
  grid-template-rows: auto auto;
  gap: clamp(var(--space-2), 1.6vh, var(--space-4));
  align-content: center;
  justify-items: center;
  min-width: 0;
  min-height: 0;
}

.case__shot {
  --surface-top: var(--frame-top);
  --surface-bottom: var(--frame-bottom);
  --surface-line: var(--frame-line);

  margin: 0;
  max-width: 100%;
  padding: 8px;
  border-radius: 20px;
  box-shadow: var(--frame-shadow);
}

.case__shot img {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 37vh;
  border-radius: 12px;
}

/* ---- One screen instead of two ----
   37vh is a SHARE, not a house style: it is what one of a stacked pair may
   take so the pair plus its gap stays inside the ~77vh the gallery gets. A
   page with a single capture has no one to share with, and holding it to 37vh
   leaves the bottom half of the column empty while the shot itself is half the
   size the reader needs -- Project 2's invoice generator is 1600x1551, so at
   37vh of a 1000px window it is 358px wide and its table rows are unreadable.

   So the lone frame takes the whole share. One `auto` row, not `1fr`: the row
   must stay the height of the picture or `align-content: center` has nothing
   left over to centre it in, and the frame sits at the top of a full-height
   row with the slack below it. Measured at 1600x1000 before the fix -- a
   606px frame pinned to the top of a 766px column. */
.case__gallery--single {
  grid-template-rows: auto;
}

.case__gallery--single .case__shot img {
  max-height: 74vh;
}


/* ---- Phase pages ----
   The second case-study composition, and the one for a page whose evidence is
   a single wide artefact:

       the intro card, full width
       the artefact, filling everything left over

   Not a variant of `.case`. That page splits into two columns because it has
   a lot to SAY and two screens to show; this one has three lines to say and
   one thing to show, and that thing is 2400px across. In a half-width column
   a journey map with seven swimlanes is a texture, not a diagram. So the copy
   takes a shallow band across the top and the artefact takes the rest.

   `minmax(0, 1fr)` on the second row rather than `auto` is what makes "the
   rest" mean anything: with `auto` the row is sized by the picture, which is
   sized by its max-height, which resolves against a row of indefinite height
   -- and the section grows past the fold. The row is measured first and the
   picture fits into it. */
.phase {
  /* The picture's height cap, hoisted to a custom property because the CARD
     is measured from it too -- see .phase__intro. One number, two rules, and
     the short-viewport block below only has to move it once. */
  --shot-cap: 55vh;

  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  gap: clamp(var(--space-3), 2.2vh, var(--space-4));
  height: 100%;
  min-height: 0;
}

/* The card is the container the type is measured against, not the section.
   Same argument as `.case__body` and css/cards.css: a card interior sized in
   `vw` ignores the rail and the page padding, which between them are most of
   a laptop's width. The coefficients are much smaller than the case card's
   because this container IS most of the screen -- .case__heading's 5.5cqw
   against a 400px column is 22px; against 1300px it would be 72px. */
.phase__intro {
  container-type: inline-size;
  container-name: textcol;

  /* The card is as wide as the picture under it, not as wide as the section.
     The picture is height-bound, not width-bound -- its cap is in `vh` and it
     keeps its own 2400x1319 ratio -- so how wide it actually draws is
     `cap * ratio`, plus the 18px the frame puts around it -- 8px of mat on
     each side and the figure's own hairline border. That is the same sum the
     browser does; doing it here lets the card land on the same edges instead
     of running past them to the section's own width.

     `min()` with 100% keeps the width bound biting first on a wide-and-short
     screen, exactly as it does for the picture, and the two stay aligned
     either way. Centred to match the figure's `justify-self`. */
  justify-self: center;
  width: 100%;
  /* `--shot-ratio` is the picture's width over its height; a page whose
     artefact is not 1-A's map writes its own on `.phase`. */
  max-width: calc(var(--shot-cap) * var(--shot-ratio, 1.8196) + 18px);

  display: flex;
  flex-direction: column;
  gap: clamp(var(--space-1), 0.9vh, var(--space-2));
  padding: clamp(var(--space-3), 2vh, var(--space-4));
}

/* The page's own name, in the card's ink -- the band above carries the
   project and the phase, this carries what the phase was FOR. */
.phase__heading {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(1.25rem, 2.2cqw, 1.875rem);
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.02em;
  color: var(--card-text);
}

/* Three peer statements, not a lede and its detail, so they share one weight
   and one ink. Capped in `ch` because the card runs the full width and a
   measure of 150 characters is unreadable however good the type is; the cap
   is what turns a full-width card into a readable column inside it. */
.phase__line {
  margin: 0;
  max-width: 108ch;
  font-size: clamp(0.9375rem, 1.15cqw, 1.0625rem);
  line-height: 1.45;
  text-wrap: pretty;
  color: var(--card-text);
}

/* The frame hugs the picture, exactly as `.case__shot` does and for the same
   reason -- see the long note there. The figure is sized BY the image and
   everything above it wraps, so there is never mat for the hover scrim to
   cover. */
.phase__shot {
  --surface-top: var(--frame-top);
  --surface-bottom: var(--frame-bottom);
  --surface-line: var(--frame-line);

  justify-self: center;
  align-self: center;
  margin: 0;
  max-width: 100%;
  padding: 8px;
  border-radius: 20px;
  box-shadow: var(--frame-shadow);
}

/* `auto` on both axes with a cap on both is the one combination that keeps a
   replaced element's own ratio while fitting it inside a box: the browser
   scales to whichever bound bites first instead of squashing.

   THE HEIGHT CAP IS A LENGTH, NOT A PERCENTAGE, and that is the whole reason
   this rule is not two lines. The obvious version is `max-height: 100%` and a
   `minmax(0, 1fr)` row, which reads as "fill what is left" -- and does
   nothing. The figure is sized by the image, so the box the percentage
   resolves against is itself indefinite, and an indefinite percentage
   max-height computes to `none`: the picture draws at its natural size and
   pushes the section past the fold. Giving the figure a definite height
   instead un-hugs the frame, which is the bug the case page's note is about.

   So the cap is in `vh`, like every other measure sizing this layout, and the
   figure is what it is measured from: one screen, less the band, less the
   section's padding, less the intro card and the gap under it. Measured at
   1600x1000 -- 767px of content row, 162px of card -- and again at 1280x720,
   where the short-viewport block below tightens the card and hands the
   difference back to the picture. */
.phase__shot img {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: var(--shot-cap);
  border-radius: 12px;
}


/* ---- The trio: three artefacts in the row 1-A gives to one ----
   Project 1 / Phase 1-B. Same composition as Phase 1-A -- the intro card
   across the top, the evidence filling everything left over -- with three
   pictures in that second row instead of one. It is a MODIFIER on `.phase`
   rather than a layout of its own, because everything above the pictures is
   the same page: the same card, the same heading, the same three lines, and
   the picture row is still what the card is left over from.

   The frames are `.phase__shot`, unchanged and shared by selector rather
   than copied. One frame style for every capture in the deck was the point
   of building it.

   ALL THREE ARE THE SAME HEIGHT, which is the whole job of this block. They
   are three different shapes -- a tall stack of persona sheets, a pair of
   mind maps, a nearly square planning board -- so equal height is not what
   any of them does on its own. It is `height` on the frame, set to the
   SMALLER of the two things that can stop a picture growing:

     100%     the row, which .phase has already made definite;
     the width term, `(row - gaps - chrome) / sum-of-ratios + chrome`,
              since a picture of ratio r in a column of width r*k is k tall,
              so all three fit across exactly when they share that k.

   A `min()` of a percentage and a length is fine here precisely because
   .phase's second row is a definite height -- see the long note on
   `.phase__shot img` for what happens when it is not.

   `--ratio` is written on each figure in the section markup, as the file's
   own width/height, because it is a fact about the file rather than a layout
   choice. */
.phase--trio {
  /* The query container for the width term below. It has to be declared HERE
     and used one level down: a container element's own `cqw` resolves against
     itself, so computing the cap on the element it sizes made the cap a
     function of the width that the cap decides. The three pictures came out
     27, 189 and 67 pixels tall -- three different answers to one equation. */
  container-type: inline-size;

  --trio-gap: clamp(var(--space-3), 1.6vw, var(--space-4));

  /* The three ratios' sum, written out because CSS cannot add a list. Change
     a picture and this changes with it. */
  --trio-ratios: 1.9993;

  /* Per frame: 8px of mat each side plus the figure's own hairline. */
  --trio-chrome: 54px;

  /* `100cqw` is .phase's inline size -- the section's content measure, which
     already knows about the rail and the page padding where a `vw` figure
     would not. The trailing chrome is added back because this is the FRAME's
     height, and the frame is the picture plus its mat. */
  --trio-frame-cap: calc(
    (100cqw - 2 * var(--trio-gap) - var(--trio-chrome)) / var(--trio-ratios)
      + 18px
  );

  min-height: 0;
}

/* The intro card runs the full measure here. On 1-A it is capped to the width
   of the single picture under it, which is a rule about matching ONE edge to
   another; three centred pictures have no such edge, and a card that stopped
   short of the row would only look like it had missed.

   AND THAT IS WHY THE COPY IS IN COLUMNS. `.phase__line` caps itself at
   108ch, which is a sane last-resort measure for 1-A's card and no measure at
   all for this one: on a 1512px screen 108ch resolves to 1108px of 15px type,
   about 145 characters to the line, and all three statements ended in a
   two-word widow. There are exactly three of them and exactly three pictures,
   so each takes a third of the card and lands over the artefact it is about
   -- one column each, a measure of about fifty characters, and the reader's
   eye travels down rather than back. */
.phase--trio .phase__intro {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  column-gap: clamp(var(--space-3), 1.6vw, var(--space-4));
  max-width: none;
}

.phase--trio .phase__heading {
  grid-column: 1 / -1;
  margin-bottom: clamp(var(--space-1), 0.8vh, var(--space-2));
}

/* The 108ch cap is off: the column IS the measure now, and a `ch` figure left
   in place would be inert at best and would fight the column the moment the
   type scaled. */
.phase--trio .phase__line {
  max-width: none;
}

.trio {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: var(--trio-gap);
  height: 100%;
  min-height: 0;
}

/* The frame is height-driven, so the three of them agree. `flex-shrink: 0`
   because the width term above already guarantees they fit: letting flex
   shrink one of them would make it shorter than the other two, which is the
   one thing this block exists to prevent. */
.trio .phase__shot {
  flex: 0 0 auto;
  height: min(100%, var(--trio-frame-cap));
}

/* THE BUTTON HAS TO CARRY THE HEIGHT DOWN, and leaving it out is what cut
   the bottom off every picture on this page. `height: 100%` on the image
   resolves against its parent -- and its parent is the .shot button, not the
   figure. The button is `width: fit-content` with an auto height, so the
   percentage had nothing definite to resolve against and computed to `auto`;
   the image then fell back to the cap it inherits from 1-A's rule below,
   `max-height: var(--shot-cap)`, which is 55vh. Three pictures 55vh tall in
   frames sized for 516px, with `overflow: hidden` on .card--media quietly
   cropping the difference: the bottom mat of every frame was gone and the
   pictures ran off both sides of the row.

   So the chain is made definite the whole way down -- figure, button, image
   -- and the inherited cap is switched off, since on this page the height is
   not a ceiling to fit under but the number the frame was given. */
.trio .shot {
  height: 100%;
}

/* `width: auto` against a definite height keeps the file's own ratio, which
   is what lets three different shapes share one height and take whatever
   width each needs. */
.trio .phase__shot img {
  display: block;
  width: auto;
  height: 100%;
  max-width: 100%;
  max-height: none;
  border-radius: 12px;
}


/* ---- The duo: two artefacts, one row ----
   Project 3 / Screen Samples 1 and 3. The trio with one picture fewer, so one
   gap and two frames of chrome in the width term instead of two and three.
   The page writes `--trio-ratios` (the two ratios' sum) on the element.

   A frame that carries a label is wrapped in `.duo__item`, which holds the
   badge over the frame's top edge (the frame itself clips, see `.pair__item`)
   and takes the height the frame would have. It stops 1rem short of the row
   so the half of the badge that overhangs stays inside the section. */
.phase--duo {
  --trio-chrome: 36px;
  --trio-frame-cap: calc(
    (100cqw - var(--trio-gap) - var(--trio-chrome)) / var(--trio-ratios)
      + 18px
  );
}

.phase--duo .phase__intro {
  grid-template-columns: minmax(0, 1fr);
}

.duo__item {
  position: relative;
  flex: 0 0 auto;
  height: min(100% - 1rem, var(--trio-frame-cap));
}

.duo__item .phase__shot {
  height: 100%;
}


/* ---- Even: three artefacts of equal WIDTH ----
   Project 3 / Mobile 2. The trio shares one height; this shares one width, as
   the user's slide does, so a tall phone and two small screen captures read
   as the same size of thing and the row centres them vertically. Each
   figure's height is the shared width over its own `--ratio` (written on the
   figure), still capped by the row: on a short screen the tallest frame stops
   at the row and narrows, rather than running past the fold. */
.phase--even {
  --even-width: calc((100cqw - 2 * var(--trio-gap)) / 3);
}

.phase--even .phase__intro {
  grid-template-columns: minmax(0, 1fr);
}

.phase--even .trio .phase__shot {
  height: min(100%, (var(--even-width) - 18px) / var(--ratio) + 18px);
}


/* ---- The quad: four artefacts, two rows ----
   Project 1 / Phase 2. The third arrangement of `.phase`'s second row -- one
   artefact on 1-A, three across on 1-B, two-by-two here -- and a MODIFIER on
   `.phase` for the same reason the trio is: everything above the pictures is
   the same page, the same card, the same heading, the same lines.

   FOUR ACROSS WOULD HAVE BEEN SIMPLER and it is the wrong answer. The row is
   about 1300px on a laptop; four in a line come out near 300px wide, and
   these are wireframes of a dense worklist rather than diagrams that survive
   being small. Two by two doubles the width at the cost of halving the
   height, which for a 16:9 capture is the trade that keeps a toolbar legible.

   THE HEIGHT IS TAKEN FROM THE ROW, NOT COMPUTED FROM A CAP, which is the
   one real difference from the trio. The trio's frames are `min(100%, cap)`
   because three pictures across can be stopped by either the row's height or
   the row's width; here the rows are flex children of a definite-height
   column, so each row already IS a definite height and `100%` on the frame
   means something. The width term is still needed, as a cap, for the case
   where the section is wide and short -- a 21:9 monitor, or the short
   viewport block below -- and it is written the same way:

     frame width  = (h - 18) * ratio + 18
     two of them plus a gap fit when
     h <= (row width - gap - 2 * 18) / (r1 + r2) + 18

   THE CELL IS A FIXED SHAPE AND THE PICTURE SITS IN IT. This is the fix for
   the first version's real fault, which the user caught immediately: the two
   rows were each sized by their own pictures and each centred, so the top
   row came out 4% narrower than the bottom one and NOTHING lined up down the
   columns. Two centred rows of different widths do not read as a considered
   asymmetry; they read as a grid that has slipped.

   So every cell is the same box -- `--quad-cell` wide by one row tall, at the
   ratio of the WIDEST of the four (1.7664; three of them are effectively
   16:9) -- and each picture is centred inside its cell at its own ratio. The
   odd one out, the 1438x894 overlay, then carries about 20px more mat down
   each side than its neighbours. That reads as matting, which is what a frame
   with an 8px mat is already doing, and every edge in the block lines up.

   `--quad-ratios` is two cells' worth of that one ratio. Swap in a picture
   wider than 1.7664 and BOTH numbers move, because the cell is cut to the
   widest thing that has to fit in it. */
.phase--quad {
  /* The query container for the width term, declared here and used one level
     down -- see the same note on `.phase--trio`, and the three differently
     sized pictures that came of getting it wrong. */
  container-type: inline-size;

  --quad-gap: clamp(var(--space-3), 1.6vw, var(--space-4));

  /* The cell's ratio: the WIDEST of the four files, TopBarSearch and
     PreviewReadingPane-2 at 1936/1096. Everything narrower is centred in it
     and carries the difference as mat. */
  --quad-cell-ratio: 1.7664;

  /* Two cells per row. */
  --quad-ratios: calc(2 * var(--quad-cell-ratio));

  /* Two frames per row: 8px of mat a side plus the figure's own hairline. */
  --quad-chrome: 36px;

  --quad-frame-cap: calc(
    (100cqw - var(--quad-gap) - var(--quad-chrome)) / var(--quad-ratios) + 18px
  );

  min-height: 0;
}

/* Same three columns the trio uses, and the same argument: `.phase__line`'s
   108ch cap is no measure at all on a card this wide -- about 145 characters
   to the line -- and there are three statements to set. They do not line up
   with the two picture columns below and are not meant to; the card is its
   own object. */
.phase--quad .phase__intro {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  column-gap: clamp(var(--space-3), 1.6vw, var(--space-4));
  max-width: none;
}

.phase--quad .phase__heading {
  grid-column: 1 / -1;
  margin-bottom: clamp(var(--space-1), 0.8vh, var(--space-2));
}

.phase--quad .phase__line {
  max-width: none;
}

/* `.phase`'s second row is definite, so `height: 100%` here is definite, and
   the two rows below can split it. That chain is the whole mechanism; break
   any link in it and the frames fall back to `--shot-cap` and run off the
   page, exactly as the trio's button note describes. */
.quad {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--quad-gap);
  height: 100%;
  min-height: 0;
}

/* `flex: 1 1 0` rather than `auto`: the rows split what the grid row gives
   them, instead of being sized by pictures that are waiting to be told how
   tall they are. `min-height: 0` because a flex item's default floor is its
   content, which would put the two rows back in charge of the height. */
.quad__row {
  display: flex;
  flex: 1 1 0;
  justify-content: center;
  align-items: center;
  gap: var(--quad-gap);
  min-height: 0;
}

.quad .phase__shot {
  flex: 0 0 auto;
  height: min(100%, var(--quad-frame-cap));
}

/* THE BUTTON IS THE CELL, and that is what makes the columns line up. It
   takes the row's height and the cell's ratio, so all four are one size
   whatever shape the file inside them is; the picture is then centred in it.
   Everywhere else in the deck `.shot` shrink-wraps its image, deliberately,
   so the hover scrim never covers bare mat (see the note in css/lightbox.css)
   -- here it is the one place that trade is worth making, because the
   alternative is four frames that do not share an edge. The scrim runs about
   20px wide of the overlay's picture and nowhere else.

   `width: auto` against the definite height is what the ratio then acts on;
   the figure around it is shrink-to-fit, so the width the ratio computes is
   the width the frame takes. */
.quad .shot {
  height: 100%;
  width: auto;
  aspect-ratio: var(--quad-cell-ratio);
}

/* THE PICTURE IS CENTRED BY `inset: 0` + `margin: auto`, NOT BY A PERCENTAGE
   HEIGHT, and that is not a stylistic preference. The obvious version is the
   trio's -- `height: 100%; width: auto` on the image, in a button made a grid
   with `place-items: center` -- and it silently does not work here: a `%`
   height on a child of a box whose own height came from `height: 100%` and
   whose width came from `aspect-ratio` does not resolve, so it falls back to
   `auto`, the file draws at its natural size, `max-width: 100%` catches the
   width and the height comes out at the file's own ratio of the CELL's width.
   Measured: the 1438x894 overlay drew 563x350 inside a 563x319 cell and
   `overflow: hidden` on `.card--media` quietly cropped 31px off the bottom.

   `auto` on both axes with a max on both is the sizing that keeps a replaced
   element's ratio inside a box, and absolute positioning is what makes those
   two maxes resolve: an absolutely positioned box's percentages are taken
   from its containing block's padding box, which is definite whatever the
   aspect-ratio did. `inset: 0` with `margin: auto` then centres it on both
   axes. The three 16:9 files fill the cell exactly; the overlay comes up
   short and sits in the middle of it. */
.quad .phase__shot img {
  position: absolute;
  inset: 0;
  margin: auto;
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  border-radius: 12px;
}


/* ---- The pair: an old screen and the new one laid over it ----
   Project 1 / Phase 3-A. The fourth arrangement of `.phase`'s second row, and
   the first where the pictures are NOT peers: the old screen is a reference
   and the new one is the work. So the new frame is larger, anchored to the
   opposite corner, and drawn over the old one's -- "this, then this instead".

   THE PAIR IS ONE BOX OF A FIXED SHAPE, and both frames are placed in it as
   percentages of its width. The box is fitted into the row the way a picture
   is -- the smaller of the row's width and its height times the ratio -- so
   the overlap is the same composition at every size instead of something the
   two frames negotiate. That needs the row's HEIGHT as a container query
   length, which is why the stage is `container-type: size`: `.phase`'s second
   row is definite, so the stage is too.

   `--pair-ratio` is the box's width over its height, and it comes from the
   two pictures: old at 50% wide (1532x940), new at 70% (1920x980), pinned to
   opposite corners, so the box is as tall as the new frame plus the part of
   the old one that shows above it. 1.9 puts the new frame's top about 45% of
   the way down the old one. Change either picture's width or file and this
   moves with it. */
.phase--pair {
  --pair-ratio: 1.9;
  --pair-old: 50%;
  --pair-new: 70%;
}

/* Two statements, so two columns -- the trio's argument for three, one fewer. */
.phase--pair .phase__intro {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: clamp(var(--space-3), 2.4vw, var(--space-5));
  max-width: none;
}

.phase--pair .phase__heading {
  grid-column: 1 / -1;
  margin-bottom: clamp(var(--space-1), 0.8vh, var(--space-2));
}

.phase--pair .phase__line {
  max-width: none;
}

/* One statement instead of two (Project 2 / Phase 3-A): it takes the card. */
.phase--pair .phase__line:only-of-type {
  grid-column: 1 / -1;
}

/* A `.phase` with no intro card (Phase 3-B, whose slide has no copy): one
   row, all of it for the pictures. */
.phase--bare {
  grid-template-rows: minmax(0, 1fr);
}

.pair-stage {
  container-type: size;
  display: grid;
  place-items: center;
  height: 100%;
  min-height: 0;
}

.pair {
  position: relative;
  width: min(100cqw, 100cqh * var(--pair-ratio));
  aspect-ratio: var(--pair-ratio);
}

/* The item carries the badge and the frame together, so the badge can hang
   over the frame's edge without being clipped by `.card--media`'s
   `overflow: hidden`. The old one is inset from the top by half a badge,
   which is exactly how far the badge overhangs. */
.pair__item {
  position: absolute;
}

.pair__item--old {
  top: 0.9rem;
  left: 0;
  width: var(--pair-old);
  z-index: 1;
}

.pair__item--new {
  right: 0;
  bottom: 0;
  width: var(--pair-new);
  z-index: 2;
}

/* `.phase--pair-mid` (Project 2 / 3-A and 3-B): both frames centred on the
   box's middle, so their mid-lines meet and NEITHER edge lines up -- the user
   asked that stacked frames not share a top or a bottom. Auto margins, not a
   transform: js/page-turn.js animates `transform` on `.pair__item`. The page's
   `--pair-ratio` leaves room above the taller frame for its badge. Desktop
   only; the phone layout keeps its stacked overlap. */
@media (min-width: 1024px) {
  .phase--pair-mid .pair__item {
    top: 0;
    bottom: 0;
    height: fit-content;
    margin-block: auto;
  }
}

/* Centred on the frame's top edge, half over it. Opaque, so it reads over the
   artwork above and the screenshot below -- see `.badge` in css/cards.css. */
.pair__badge {
  position: absolute;
  top: 0;
  left: 50%;
  z-index: 1;
  margin: 0;
  transform: translate(-50%, -50%);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
  white-space: nowrap;
}

/* The reference steps back: its label is the quieter of the two and the
   screen itself is slightly dimmed until it is pointed at. */
.pair__item--old .pair__badge,
.duo__item--old .pair__badge {
  background: var(--text-on-accent);
  color: var(--accent);
}

.pair__item--old img {
  filter: saturate(0.85);
  transition: filter 350ms ease;
}

.pair__item--old .phase__shot:hover img {
  filter: none;
}

/* The frame is width-driven here, not height-driven like every other
   `.phase__shot`: the box above has already decided how big each one is. */
.pair .phase__shot {
  width: 100%;
}

.pair .shot {
  width: 100%;
}

.pair .phase__shot img {
  width: 100%;
  height: auto;
  max-height: none;
}

/* The new frame sits over the old one, so it gets a deeper shadow -- the
   usual frame shadow reads as lying flat on the page, and this one has
   something under it. */
.pair__item--new .phase__shot {
  box-shadow: var(--frame-shadow), 0 18px 48px rgba(0, 0, 0, 0.28);
}


/* ---- The strip: a row of artefacts at ONE shared scale ----
   Phase 3-D (design-system sheets) and 3-E (phone and tablet layouts). The
   pictures are exports from one Figma file at one zoom, so their sizes MEAN
   something -- a tablet is wider than a phone, the colours sheet is taller
   than the cards sheet -- and the trio's equal-height rule would erase that.
   So every picture is drawn at the same scale, top-aligned, the way the
   user's slides set them.

   The scale is `--k`, a length per image pixel: the largest that fits the
   row both ways, and never above 1px, since these are 1x exports and would
   only blur if blown up.

     height:  (row height) / (tallest picture's height)
     width:   (row width - gaps) / (sum of the pictures' widths)

   `--strip-sumw`, `--strip-maxh` and `--strip-n` are written on the
   .strip in the markup, and each picture carries its own `--w` -- facts
   about the files, like the trio's `--ratio`.

   Each picture sits in the deck's usual `.phase__shot` frame and opens in
   the lightbox, like every other capture -- the user asked for exactly that
   after a first version without either.

   THE EXPORTS ARE CROPPED IN THE PAGE. Every one carries a transparent
   margin left by the drop shadow Figma exported with it -- 8px left and
   right, 4px top, 11px bottom, measured -- and the frame's mat showed
   through it, so these frames looked much heavier than every other page's
   (the user caught it). The files are left as they are; `--ct/--cr/--cb/--cl`
   say how much of each edge to hide, in image pixels, and the image is
   drawn that much larger inside a clipping `.shot`. A re-export without
   the shadow just sets them to 0. The widths and heights written on the
   .strip are the VISIBLE ones, after the crop. */
/* The card runs the full measure: there is no single picture edge to match. */
.phase--strip .phase__intro {
  max-width: none;
}

.strip {
  --strip-gap: clamp(var(--space-2), 1.2vw, var(--space-4));
  /* 8px of mat a side plus the hairline -- the usual .phase__shot frame. */
  --strip-chrome: 18px;
  --ct: 4;
  --cr: 8;
  --cb: 11;
  --cl: 8;

  container-type: size;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  gap: var(--strip-gap);
  height: 100%;
  min-height: 0;
}

.strip__item {
  --k: min(
    1px,
    (100cqh - var(--strip-chrome)) / var(--strip-maxh),
    (100cqw - (var(--strip-n) - 1) * var(--strip-gap) - var(--strip-n) * var(--strip-chrome)) /
      var(--strip-sumw)
  );

  flex: 0 0 auto;
  width: calc((var(--w) - var(--cl) - var(--cr)) * var(--k) + var(--strip-chrome));
  max-width: none;
  justify-self: auto;
  align-self: flex-start;
}

/* A figure with `--show` draws only the top of its picture, that many image
   pixels tall -- a long sheet cropped the way the user cropped it in Figma,
   with the whole thing in the lightbox. Without `--show` this custom property
   is invalid and the box falls back to the picture's own height. */
.strip__item {
  --shot-h: calc(var(--show) * var(--k));
}

.strip__item .shot {
  width: 100%;
  height: var(--shot-h, auto);
  overflow: hidden;
}

.strip__item img {
  display: block;
  width: calc(var(--w) * var(--k));
  max-width: none;
  height: auto;
  max-height: none;
  margin: calc(var(--ct) * var(--k) * -1) 0 calc(var(--cb) * var(--k) * -1)
    calc(var(--cl) * var(--k) * -1);
  border-radius: 0;
}


/* ---- The board: two columns, three photographs ----
   Project 2 / Phase 1-A. The fifth arrangement of `.phase`, and the first
   where the card does not run across the top: the intro card and one photo
   stack in the left column, two wide photos stack in the right one, which
   spans the full height.

     intro card   |  top photo
     left photo   |  bottom photo

   A GRID, NOT THREE PICTURES EACH FITTED ON ITS OWN, which is what the first
   version was and what the user rightly said felt worse than the slide. Each
   photo was drawn at its own ratio inside its cell, so whatever a cell had
   spare became a gap: 75px between the two right-hand photos at 1512x982 and
   20px at 1280x720, and the sketch 80px narrower than the card above it.

   Now every frame FILLS its cell, so the three photos and the card share
   every outer edge and every gap is the same one, `--board-gap`. The photos
   are `object-fit: cover`, so a cell that is not exactly the photo's shape
   trims its edges a little; the lightbox still opens the whole thing.

   THE RIGHT COLUMN IS AS WIDE AS ITS TWO PHOTOS NEED TO FILL THE HEIGHT, so
   those two are not trimmed at all. Two photos of ratio r1 and r2 at one
   width W stack to (W-18)/r1 + (W-18)/r2 + 36 + gap, so the width that fills
   a height H is:

     W = (H - gap - 36px) / (1/r1 + 1/r2) + 18px      1/1.519 + 1/2.323 = 1.0887

   capped at 62% so the copy keeps a column. The rows split in the same
   proportions. The left column takes the rest, and the sketch -- the
   squarest photo, with the most empty wall around it -- is the one that
   gives. `cqh`/`cqw` are the content box's: see the :has() rule below. */
.section__inner:has(> .phase--board) {
  container-type: size;
}

/* `--board-right`, when set, is the right column's width outright: js/
   board-align.js publishes it on boards marked `data-align-bottoms`, solved
   so the left picture and the right column end on one line. Everything else
   below is the no-JS width, and every other board's.

   `--stack-cap` is the right column's ceiling. Phase 2 lowers it to half the
   row, so the stack never runs wider than the left column; its frames are
   then `.board__cell--fit` and stop filling the height rather than being cut
   to it. */
.phase--board {
  --board-gap: clamp(var(--space-3), 2.2vh, var(--space-4));
  --board-col-gap: clamp(var(--space-5), 4vw, var(--space-6));

  grid-template-columns:
    minmax(0, 1fr)
    var(--board-right, min(var(--stack-cap, 62cqw), (100cqh - var(--board-gap) - 36px) / var(--stack-inv, 1.0887) + 18px));
  grid-template-rows: auto minmax(0, 1fr);
  column-gap: var(--board-col-gap);
  row-gap: var(--board-gap);
}

.phase--board .phase__intro {
  grid-column: 1;
  grid-row: 1;
  justify-self: stretch;
  max-width: none;
}

/* The card is a column here, not the full measure, so the type is sized
   against a much narrower container than on the other `.phase` pages. */
.phase--board .phase__heading {
  font-size: clamp(1.25rem, 5cqw, 1.875rem);
}

.phase--board .phase__line {
  max-width: none;
  font-size: clamp(0.9375rem, 3cqw, 1.125rem);
}

.phase--board .phase__line + .phase__heading {
  margin-top: clamp(var(--space-2), 1.6vh, var(--space-4));
}

.board__cell {
  display: grid;
  min-width: 0;
  min-height: 0;
}

.board__cell--sketch {
  grid-column: 1;
  grid-row: 2;
}

/* 1/r1 and 1/r2, so the two frames come out the same width as the column.
   The defaults are 1-A's photos; another page writes its own on the grid as
   `--stack-inv` (1/r1 + 1/r2) and `--stack-rows` -- Phase 2 does. */
.board__stack {
  grid-column: 2;
  grid-row: 1 / -1;
  display: grid;
  grid-template-rows: var(--stack-rows, minmax(0, 0.6583fr) minmax(0, 0.4304fr));
  /* With `auto` rows (Phase 2) the pair sits at the top, one gap apart,
     instead of the rows stretching to the column's height. */
  align-content: start;
  gap: var(--board-gap);
  min-height: 0;
}

/* The frame fills the cell on both axes, rather than hugging its picture the
   way every other `.phase__shot` does -- the picture is cut to the frame. */
.board__cell .phase__shot {
  justify-self: stretch;
  align-self: stretch;
  width: 100%;
  height: 100%;
  min-height: 0;
}

.board__cell .shot {
  display: block;
  width: 100%;
  height: 100%;
}

.board__cell .phase__shot img {
  width: 100%;
  height: 100%;
  max-height: none;
  object-fit: cover;
}

/* Where the sketch is trimmed, keep the writing: there is more bare wall
   under the four lists than above the arrow. */
.board__cell--sketch .phase__shot img {
  object-position: 50% 35%;
}

/* Phases 1-B and 1-C: ONE picture on the right, spanning both rows, in place
   of the stack. The same sizing with one frame instead of two:

     W = (H - 18px) * r + 18px

   still capped at 62cqw. The page writes r as `--tall-ratio` on the
   `.phase--board-tall` element, and each cell's `--frame-ratio` beside it
   (see below), so another page of this shape is only new numbers.

   A page can set `--tall-col` to override that width outright: 1-C sets
   `minmax(0, 1fr)`, two equal columns, as on the user's slide -- its tall
   diagram then no longer fills the height, and the two frames' bottoms land
   close together instead of the left one ending ~90px short. */
.phase--board-tall {
  grid-template-columns:
    minmax(0, 1fr)
    var(--board-right, var(--tall-col, min(62cqw, (100cqh - 18px) * var(--tall-ratio) + 18px)));
}

.board__cell--tall {
  grid-column: 2;
  grid-row: 1 / -1;
}

/* NEAR THEIR OWN SHAPE, NOT FILLING THE CELL. The cap binds at every desktop
   size, and filling the cells cost the right board 16-22% of its width.
   Widening its column only moves the loss to the left capture, so instead
   each frame takes a ratio within ~6% of its picture's and sits at the top
   of its cell; the spare stays under it. `max-height` hands back to the
   cover trim only when a cell is shorter than that. `--frame-ratio` is
   written on each cell in the page; where the picture fits, it is simply the
   picture's own ratio. Any board cell can opt in with `.board__cell--fit` --
   Phase 2's left-hand wireframe does, under a filled stack. */
.board__cell--fit .phase__shot {
  align-self: start;
  height: auto;
  max-height: 100%;
  aspect-ratio: var(--frame-ratio);
}

/* Where the board is trimmed, trim from the right as the slide did: EBB and
   the first columns stay, Reports goes first. */
.board__cell--tall .phase__shot img {
  object-position: 0 50%;
}

/* Project 3 / Screen Samples 6: the stack under the card on the LEFT, with
   one tall frame on the right -- the board mirrored. */
.board__stack--left {
  grid-column: 1;
  grid-row: 2;
}

/* Project 3 / Screen Samples 4: two tall frames side by side in the right
   column, BOTH UNCROPPED. A first version held each frame at the row's full
   height and its own `--frame-ratio`; when the 62cqw cap bit, the frames
   narrowed and cover trimmed the wireframe and the phone capture at the sides.

   Now the WIDTH decides. Each frame grows in proportion to its picture's
   ratio from a zero basis, so after the gap and the 18px of chrome each, the
   pictures come out (W - gap - 36px) * r / (r1 + r2) wide -- the same height
   for both, each at its own shape. The pair is never wider than the height
   allows, (H - 18px) * (r1 + r2) + 36px + gap, with the page writing r1 + r2
   as `--row-ratio`; a wider column centres the pair rather than pushing it
   past the fold. */
.board__cell--row {
  display: flex;
  justify-content: center;
  align-items: start;
  gap: var(--board-gap);
  justify-self: center;
  width: 100%;
  max-width: calc((100cqh - 18px) * var(--row-ratio, 1.1750) + 36px + var(--board-gap));
}

.board__cell--row .phase__shot {
  flex: var(--frame-ratio) 1 0;
  align-self: start;
  min-width: 0;
  width: auto;
  height: auto;
}

.board__cell--row .shot,
.board__cell--row .phase__shot img {
  height: auto;
}

.board__cell--row .phase__shot img {
  object-fit: fill;
}


/* ---- A click-through sequence inside one frame ----
   Phase 3-C. Three captures of the same screen and the two clicks between
   them, played on a loop: a pointer moves to the target, presses, and the
   next capture fades in. CSS rather than a GIF -- a GIF is 256 colours and
   dithers the UI's small type, and three 1924px frames would be a heavy
   file. The first capture stays in flow and sizes the box; the other two are
   laid exactly over it.

   The targets are percentages of the capture, measured off FaceSheet-1/2
   (1924x1003): the May 19 MRI visit in the timeline at 262,500, and the
   Physicians Order in Appt. Documents at 512,297. `--seq-*` holds them so a
   different sequence is only new numbers. One 10s cycle:

      0-20%   pointer travels to click 1        20%  press
      24-27%  frame 2 fades in
      30-45%  pointer travels to click 2        47%  press
      49-52%  frame 3 fades in
      88-94%  back to frame 1, pointer home */
.seq {
  --seq-x1: 13.6%;
  --seq-y1: 49.9%;
  --seq-x2: 26.6%;
  --seq-y2: 29.6%;
  --seq-home-x: 62%;
  --seq-home-y: 72%;
  --seq-cycle: 10s;
}

.seq .seq__frame {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  animation: var(--seq-cycle) linear infinite;
}

.seq .seq__frame--2 { animation-name: seq-frame-2; }
.seq .seq__frame--3 { animation-name: seq-frame-3; }

@keyframes seq-frame-2 {
  0%, 24% { opacity: 0; }
  27%, 88% { opacity: 1; }
  92%, 100% { opacity: 0; }
}

@keyframes seq-frame-3 {
  0%, 49% { opacity: 0; }
  52%, 88% { opacity: 1; }
  92%, 100% { opacity: 0; }
}

/* The pointer's tip is its top-left corner, so it is placed by that corner
   and the target percentages mean "the tip is here". */
.seq__pointer {
  position: absolute;
  z-index: 1;
  width: clamp(14px, 3.2%, 26px);
  aspect-ratio: 12 / 19;
  left: var(--seq-home-x);
  top: var(--seq-home-y);
  pointer-events: none;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.45));
  animation: seq-pointer var(--seq-cycle) infinite;
}

.seq__pointer svg {
  display: block;
  width: 100%;
  height: 100%;
}

@keyframes seq-pointer {
  0%, 8% {
    left: var(--seq-home-x);
    top: var(--seq-home-y);
    transform: scale(1);
    animation-timing-function: cubic-bezier(0.45, 0, 0.25, 1);
  }
  20% { left: var(--seq-x1); top: var(--seq-y1); transform: scale(1); }
  21.5% { transform: scale(0.82); }
  23%, 30% {
    left: var(--seq-x1);
    top: var(--seq-y1);
    transform: scale(1);
    animation-timing-function: cubic-bezier(0.45, 0, 0.25, 1);
  }
  45% { left: var(--seq-x2); top: var(--seq-y2); transform: scale(1); }
  46.5% { transform: scale(0.82); }
  48%, 86% {
    left: var(--seq-x2);
    top: var(--seq-y2);
    transform: scale(1);
    animation-timing-function: cubic-bezier(0.45, 0, 0.25, 1);
  }
  96%, 100% { left: var(--seq-home-x); top: var(--seq-home-y); transform: scale(1); }
}

/* The press: a ring that opens out from the tip and fades. */
.seq__ripple {
  position: absolute;
  z-index: 1;
  width: clamp(26px, 5%, 44px);
  aspect-ratio: 1;
  margin: calc(clamp(26px, 5%, 44px) / -2) 0 0 calc(clamp(26px, 5%, 44px) / -2);
  border: 3px solid #2f7bf5;
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
  animation: var(--seq-cycle) ease-out infinite;
}

.seq__ripple--1 { left: var(--seq-x1); top: var(--seq-y1); animation-name: seq-ripple-1; }
.seq__ripple--2 { left: var(--seq-x2); top: var(--seq-y2); animation-name: seq-ripple-2; }

@keyframes seq-ripple-1 {
  0%, 20.5% { opacity: 0; transform: scale(0.3); }
  21.5% { opacity: 0.9; transform: scale(0.5); }
  26%, 100% { opacity: 0; transform: scale(1.4); }
}

@keyframes seq-ripple-2 {
  0%, 45.5% { opacity: 0; transform: scale(0.3); }
  46.5% { opacity: 0.9; transform: scale(0.5); }
  51%, 100% { opacity: 0; transform: scale(1.4); }
}

/* Reduced motion: no loop. The last capture is shown, since it carries both
   clicks' results -- the visit selected and its document open. */
@media (prefers-reduced-motion: reduce) {
  .seq .seq__frame,
  .seq__pointer,
  .seq__ripple {
    animation: none;
  }

  .seq .seq__frame--3 {
    opacity: 1;
  }

  .seq__pointer,
  .seq__ripple {
    display: none;
  }
}


/* Flow mode: one column, screens after the copy. The page scrolls down here,
   so the frames go back to being width-driven and the card stops clipping --
   `overflow: hidden` on .case__detail exists to keep a long Activities list
   inside one screen, and there is no one screen to keep it inside. Truncating
   the copy on the narrow layout, where there is room to simply run on, is the
   opposite of what it is for.

   1023.98px, matching js/scroll-engine.js. It was 900 here while the engine
   paged at 820, and those 80px were a band where a single-column stack was
   locked into a 100vh box with its overflow hidden. */
@media (max-width: 1023.98px) {
  .case {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto auto;
    height: auto;
  }

  .case__body {
    justify-content: flex-start;
  }

  .case__card {
    min-height: auto;
  }

  .case__detail {
    flex: 0 0 auto;
    overflow: visible;
  }

  .case__gallery {
    align-content: start;
  }

  /* The page scrolls here, so there is no fold to stay inside and no reason
     to cap a screenshot at a third of the viewport. Width is the only bound
     that should bite in one column. */
  .case__shot img {
    max-height: none;
  }

  /* The page scrolls here, so the artefact is width-bound like every other
     picture in flow mode and the rows stop competing for a fold that does not
     exist. */
  .phase {
    grid-template-rows: auto auto;
    height: auto;
  }

  .phase__shot img {
    max-height: none;
  }

  /* Width-bound picture, width-bound card: with the cap gone there is no
     `cap * ratio` to match, and both simply fill the column. */
  .phase__intro {
    max-width: none;
  }

  /* One column at phone width is nowhere near 108ch, so the cap stops doing
     anything -- but it is removed rather than left to be inert, because a
     `ch` measure on a container query unit is the sort of thing that comes
     back at an unexpected size. */
  .phase__line {
    max-width: none;
  }

  /* The three artefacts become three stacked cards. The ratio-proportional
     columns were an answer to "three pictures, one screen, one height", and
     down here there is no one screen: the page scrolls, so each picture is
     width-bound like every other picture in flow mode and the cap goes away.
     Dropping the aspect-ratio with it lets the image size the button again,
     which keeps the scrim on the picture. */
  /* One column of copy again. Three columns over three pictures was an
     argument about a card 1100px wide; down here the card is the screen. */
  .phase--trio .phase__intro {
    grid-template-columns: minmax(0, 1fr);
  }

  /* The three artefacts become three stacked frames. Equal height was an
     answer to "three pictures, one row", and down here there is no one row:
     the page scrolls, so each picture is width-bound like every other picture
     in flow mode and the height term goes away with the row it measured. */
  .trio {
    flex-direction: column;
    align-items: center;
    height: auto;
  }

  .trio .phase__shot {
    height: auto;
  }

  .trio .shot {
    height: auto;
  }

  .trio .phase__shot img {
    width: 100%;
    height: auto;
  }

  /* The even row stacks like the trio; its width term goes with the row. */
  .phase--even .trio .phase__shot {
    height: auto;
  }

  /* One column of copy again, same as the trio. */
  .phase--quad .phase__intro {
    grid-template-columns: minmax(0, 1fr);
  }

  /* The four artefacts become four stacked frames. Two-by-two was an answer
     to "four pictures, one row"; down here there is no one row, so both the
     rows and the height term go away and each picture is width-bound like
     every other picture in flow mode. */
  .quad,
  .quad__row {
    flex-direction: column;
    align-items: center;
    height: auto;
  }

  .quad__row {
    flex: 0 0 auto;
  }

  .quad .phase__shot {
    height: auto;
  }

  /* The cell goes with the grid it was a cell of. Down here the column IS the
     shared edge, so the button wraps its picture again like every other
     `.shot` in the deck and the scrim goes back to covering only the image. */
  .quad .shot {
    width: 100%;
    height: auto;
    aspect-ratio: auto;
  }

  /* The pair keeps its overlap as a stack: old at 88% of the column on the
     left, new at 88% on the right, pulled up over the old one's bottom
     corner. There is no row to fit into, so the fixed-shape box goes and the
     two frames are ordinary blocks. */
  .phase--pair .phase__intro {
    grid-template-columns: minmax(0, 1fr);
  }

  .pair-stage {
    container-type: normal;
    display: block;
    height: auto;
  }

  .pair {
    width: 100%;
    aspect-ratio: auto;
  }

  .pair__item {
    position: relative;
    inset: auto;
    width: 88%;
  }

  .pair__item--new {
    margin: -12% 0 0 auto;
  }

  /* The strip wraps: the page scrolls, so each picture goes back to its own
     1x size (or the column's width, whichever is less) and the row flows. */
  .strip {
    container-type: normal;
    flex-wrap: wrap;
    height: auto;
  }

  .strip__item {
    --k: min(1px, (100vw - 3rem - var(--strip-chrome)) / (var(--w) - var(--cl) - var(--cr)));
  }

  .quad .phase__shot img {
    position: static;
    inset: auto;
    margin: 0;
    width: 100%;
    height: auto;
    max-height: none;
  }

  /* The board becomes one column: the card, then the three photos in the
     slide's reading order. There is no fold to fit, so the cells stop being
     size containers and each photo takes the column's width. */
  .phase--board {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto;
    row-gap: var(--space-4);
  }

  .phase--board .phase__intro,
  .board__cell--sketch,
  .board__cell--tall,
  .board__stack {
    grid-column: 1;
    grid-row: auto;
  }

  /* The content box is as tall as its content here, so it cannot be a size
     container -- size containment would collapse it to nothing. */
  .section__inner:has(> .phase--board) {
    container-type: normal;
  }

  .board__stack {
    grid-template-rows: auto auto;
    gap: var(--space-4);
  }

  /* Each photo at its own shape again: no fold to fill, so nothing to trim. */
  .board__cell .phase__shot,
  .board__cell .shot {
    height: auto;
  }

  .board__cell .phase__shot img {
    height: auto;
    object-fit: fill;
  }

  .board__cell--fit .phase__shot {
    aspect-ratio: auto;
  }

  /* The duo stacks like the trio; a labelled frame's wrapper goes with the
     row height it was measured from. */
  .duo__item {
    width: 100%;
    height: auto;
  }

  /* Two tall frames stack, each no wider than a phone would draw it. */
  .board__cell--row {
    flex-direction: column;
    align-items: center;
    max-width: none;
  }

  .board__cell--row .phase__shot {
    flex: none;
    align-self: center;
    width: min(100%, 24rem);
    height: auto;
    aspect-ratio: auto;
  }
}


/* ---- Short viewports ----
   A height query, because the thing that runs out on a 1280x720 laptop is
   vertical room, not width: the section is locked to one screen and clipped
   at its edges. Measured: 754px of content in a 720px section before this,
   711px after.

   The title is not here any more -- it is sized against the band's own depth
   (css/layout.css), which already tracks the viewport's height. What is left
   is the air between the blocks, which is generous on a tall monitor and not
   load-bearing on a short one. */
@media (max-height: 800px) {
  /* The case-study page carries more copy than any other and is the one that
     runs out of room first: at 1280x720 its detail card overflowed by 52px
     once the band was deepened to give the title its size back. Everything
     here is air and type scale, not content -- the page still says the same
     things, in a tighter setting, on the screens that need it. */
  .case__body {
    gap: var(--space-2);
  }

  .case__card .case__lede {
    font-size: clamp(0.875rem, 3.2cqw, 1rem);
    line-height: 1.4;
  }

  .case__card {
    gap: var(--space-2);
    padding: clamp(var(--space-2), 1.6vh, var(--space-3));
  }

  .facts__value {
    font-size: clamp(0.8125rem, 3cqw, 0.9375rem);
    line-height: 1.4;
  }

  /* Same argument as the case page above: air and type scale, not content.
     The intro band is what the artefact's row is left over from, so every
     millimetre taken out of it goes straight into the picture. */
  .phase__intro {
    gap: var(--space-1);
    padding: clamp(var(--space-2), 1.6vh, var(--space-3));
  }

  .phase__line {
    font-size: clamp(0.875rem, 1.05cqw, 0.9375rem);
    line-height: 1.4;
  }

  /* The picture's cap has to come down further than the card gives back. At
     1280x720 the content row is 552px and the tightened card still takes 139
     of it: 55vh of picture is 396 where 398 is all there is, and a section
     with `overflow: hidden` does not report that -- it simply cuts the bottom
     of the frame off. This is the number that was measured there. */
  .phase {
    --shot-cap: 51vh;
  }

  .facts__value + .facts__term {
    margin-top: var(--space-2);
  }

  .facts__list li + li {
    margin-top: 0.25em;
  }

  /* The last 30px at 1280x720, and it comes out of air rather than out of
     type: the gaps between the three blocks and the credential rows' own
     vertical padding. Both are generous on a tall monitor and neither is
     load-bearing on a short one. */
  .about__body {
    gap: var(--space-2);
  }

  .creds__value,
  .creds__label {
    padding-block: var(--space-2);
  }
}


/* ---- Tablet and below ----
   900px is where the two-column frame stops being worth it: the portrait
   turns into a letterbox and the credential labels get too narrow to hold a
   sentence. Single column, and the portrait goes SECOND -- after the
   heading, before the credentials -- which is the order a phone reads best
   and the reason the intro is a sibling of the body rather than sitting
   inside it.

   The paging engine is off down here (js/scroll-engine.js gates it at
   820px + a fine pointer), so the section may run taller than the viewport
   and simply scroll. */
@media (max-width: 1023.98px) {
  .about {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto auto;
    gap: var(--space-4);
    height: auto;
  }

  /* The portrait goes FIRST down here -- after the band's title, before the
     credentials -- which is the order a phone reads best.

     The frame stays height-driven, but off the VIEWPORT rather than off a
     row: the page scrolls down here, so there is no row height to fill. The
     aspect ratio still supplies the width, so the photograph is shown whole
     with no mat down its sides -- a width-driven frame with a max-height cap
     gave it a 305x335 box to sit a 229x335 picture in.

     The figure takes its size FROM the frame inside it and states none of
     its own. It used to state both -- `aspect-ratio: 4/3` and
     `max-height: 40vh` -- against a 52vh photograph, so the picture stood
     30% taller than the box that was supposed to contain it and lapped over
     whatever came next. Two boxes cannot both decide the height; the one
     cut to the photograph's ratio is the one that gets to. */
  .about__figure {
    order: -1;
    margin: 0 auto;
    max-width: 100%;
  }

  /* The bloom's -30px right inset carries it past the screen edge once the
     frame reaches the column's edge (the phone rule below): 30px past a frame
     that stops 24px short of it. Its box -- not its blur, which is paint --
     counts as scrollable overflow, and a phone widens its layout viewport to
     take it in, so the fixed #wave-art-box and #topbar grew with it and the
     page scrolled 6px sideways. Clipped at the section, which is the screen's
     full width, so nothing that was visible is lost; `clip` rather than
     `hidden` so the section does not become a scroll container. */
  #hero,
  #contact {
    overflow-x: clip;
  }

  .about__photo {
    height: 52vh;
    width: auto;
    /* The desktop ceiling is derived from 46vw, which on a phone is a third
       of the screen -- it capped the frame at 172px wide. Down here the vh
       height is the only limit the picture needs, plus a width cap so a
       short landscape window cannot make it wider than the column. */
    max-height: none;
    max-width: 100%;
  }

  /* No longer sizing anything beside it, so the column stops being a column
     and the blocks just stack from the top. */
  .about__body {
    justify-content: flex-start;
  }
}

/* ---- Phone ----
   The credential rows need no rule here: the container query in
   css/cards.css already sees that the column is narrow and has stacked each
   numeral above its label.

   The frame stops being height-driven here and becomes WIDTH-driven, which
   is the one place in the deck where that is the right way round. Everywhere
   else the picture is sized against a height -- a row, or the viewport --
   and its width simply follows from the 591/856 ratio; on a phone that left
   a 285px frame centred over a 393px card, and two boxes of different widths
   stacked one above the other read as a mistake rather than as a
   composition. Given the column's full width the frame lines up edge to edge
   with the credentials card under it, and the ratio supplies the height.

   It is a taller picture than the 44vh it replaces -- about 570px on a
   390px-wide phone -- and that is the trade: the page scrolls down here
   anyway (the paging engine is off below 820px, js/scroll-engine.js), so
   height costs a scroll where a mismatched edge costs the composition. */
@media (max-width: 560px) {
  .about__photo {
    width: 100%;
    height: auto;
    max-width: none;
  }
}

/* ---- The Resume pages ----
   Four screens: two of work history, one of skills and education, one plain
   document. The first three share this block.

   ---- What this replaced, and why ----

   The first build gave each of these screens the deck's usual two-column
   frame: a tall card of content on the left, a column of "evidence" cards on
   the right. Three things went wrong with it and all three were structural
   rather than cosmetic.

   The timeline used `justify-content: space-between` to fill its card. With
   three roles in a card 1150px tall that is not filling, it is a hole: the
   gaps grew to 200px each and the page read as three unrelated blocks. It is
   `center` now, with the gap clamped, so a short screen packs and a tall one
   breathes without the list coming apart.

   The four figures sat in a card of their own beside the timeline -- so 260%
   was three feet from the sentence that earned it, and $200K, 150+ and 28K
   were on a screen that does not even contain the PGA years. A number that
   is not attached to its claim is decoration. They are `.stats` tiles inside
   the role now, directly under the job title they belong to.

   And the mission statement sat in the bottom-right corner because that is
   where there was room, which is exactly what it looked like. It opens the
   resume on paper; it opens the first screen here.

   ---- The one-column consequence ----

   With the aside gone the sheet is the full measure, roughly 1400px, which
   is far too wide for a single run of bullets -- so a role with four or more
   of them sets them in TWO columns. That is what buys the vertical room for
   every bullet on the resume to be present rather than summarised, which is
   what the user asked for. */

/* ---- The vertical timeline ----
   Three columns:

       dates | rail | the job, on its own card

   ---- No outer card ----

   The rail and the dates sit on the wave artwork itself; only the jobs are on
   cards. That is the user's call and it is the right one -- a card inside a
   card was two borders and two paddings to hold a line and six numbers, and
   it walled the timeline off from the page it runs down.

   It does mean the dates and the rail are now on a surface whose colour
   changes with every section, so:

     - the dates sit on their own CHIP, built from the card fill tokens, so
       the pair `--card-text` on a card fill is one `tools/check-contrast.js`
       already proves over every wave colour in both themes;
     - the rail uses `--text-primary`, which js/waves.js flips to ink or cream
       per section by measured luminance. It is the same token the case-study
       ledes use on the field, and the checker sweeps it over the whole
       palette.

   Nothing here uses `--text-secondary` on the field: that token is not in the
   checker's on-artwork pair, and the artwork is not a surface you can eyeball
   a muted grey against.

   ---- Concurrency ----

   Two of these six roles overlap another one. It is shown by indenting the
   CARD and nothing else -- no label, no dashed line, no ring, no chart. The
   reader who is counting years can see it in the dates; the reader who is
   not, is not told.

   ---- One straight rail ----

   The indent used to move the whole row, date chip and node included, so the
   rail stepped right at SimpliLearn and back again at NextEra. And each row
   was its own grid with an `auto` date column, so even unindented rows put
   their node wherever their chip ended: "Present / 2019" is wider than
   "2019 / 9 mos", and the rail jogged 19px between them.

   Now `.tl` owns the three columns and every row is a SUBGRID of it, so the
   date column is as wide as the widest chip on the screen and every node sits
   on the same x. Only the card moves for a concurrent role. */
.tl {
  /* Height only. A width term made the gaps between roles grow on a wide
     1536x864 laptop and pushed the first screen 7px past its bottom. */
  --tl-row: clamp(0.5rem, 1.7vh - 0.3rem, 2.25rem);
  /* Wide enough that the current role's node -- a dot in a dark disc with
     a halo -- clears the card edge instead of touching it. */
  --tl-rail: clamp(2.75rem, 1.1cqw + 1.6vh, 3.5rem);
  --tl-indent: clamp(2rem, 3.5cqw + 0.7vh, 5.5rem);
  container-type: inline-size;
  display: grid;
  grid-template-columns: auto var(--tl-rail) minmax(0, 1fr);
  grid-auto-rows: auto;
  align-content: safe center;
  row-gap: var(--tl-row);
  margin: 0;
  padding: 0;
  list-style: none;
  height: 100%;
  min-height: 0;
}

.tl__item {
  display: grid;
  grid-column: 1 / -1;
  grid-template-columns: subgrid;
  align-items: start;
}

/* The chips fill the shared column, so they are one width down the screen. */
.tl__when {
  justify-self: stretch;
}

/* A role held during the one above it. Indenting the card is the whole
   device; its date and its node stay on the rail. */
.tl__item--with .tl__card {
  margin-left: var(--tl-indent);
}


/* ---- The rail ----
   The node and the line are the timeline. They are drawn on their own grid
   cell, so neither has to know how wide the date chip turned out to be -- it
   is `auto`, and it changes with the type size, which changes with the
   viewport height.

   These rules were deleted by accident in 6f26dc1: the block that replaced
   the date styles ran from "The dates" to "The schools" and this sat between
   them. The `.tl__node` spans stayed in the markup with no CSS behind them,
   so every dot and every connecting segment silently disappeared and the
   screens stopped reading as a timeline at all. Worth remembering that
   nothing failed and nothing warned -- an unstyled empty span is invisible.

   The line is drawn PER ITEM, from its own node down through the row gap to
   the next one, rather than as one line behind the whole column. `.tl` is a
   flex box that fills the section and centres its items, so a single line
   positioned against it began 40px above the first node whenever the list had
   slack. A segment that starts at its own node cannot drift. */
.tl__node {
  align-self: stretch;
  position: relative;
  min-height: 1.5em;
  color: var(--text-primary);
}

.tl__node::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 1.1em;
  width: 0.6em;
  height: 0.6em;
  margin-left: -0.3em;
  border-radius: 50%;
  background-color: currentColor;
  opacity: 0.9;
}

.tl__node::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 2em;
  bottom: calc(-1 * var(--tl-row));
  width: 2px;
  margin-left: -1px;
  border-radius: 1px;
  background-color: currentColor;
  opacity: 0.5;
}

/* The role still held. A coral dot alone disappeared into the pink field
   behind the first screen, so it sits in a dark disc -- the same plum as the
   cards, which is dark on every wave colour -- with a faint coral halo
   outside that. Larger than the other nodes, since it is the one that says
   "now". */
.tl__item--now .tl__node::before {
  top: 0.95em;
  width: 0.75em;
  height: 0.75em;
  margin-left: -0.375em;
  background-color: var(--coral-400);
  opacity: 1;
  box-shadow:
    0 0 0 0.3em var(--plum-950),
    0 0 0 calc(0.3em + 3px) rgba(242, 117, 92, 0.45);
}

/* 2005 is where the career starts, so the closing screen's line ends at its
   last node; the opening screen's runs on, because the career does. */
.tl--close .tl__item:last-child .tl__node::after {
  display: none;
}

.tl--open .tl__item:last-child .tl__node::after {
  bottom: calc(-2 * var(--tl-row));
}


/* ---- The dates ----
   Newest at the TOP of the chip, because the list is newest-first and the
   two orders were disagreeing: "2019 / Present" read upward inside a column
   that reads downward. Present sits above 2019 now, with a rule between them
   that says "through", and the tenure under it.

   The tenure is borrowed from the Stitch reference, and it earns its line:
   "2007" over "2019" makes a reader subtract, and twelve years at the PGA is
   the kind of thing a hiring committee is looking for.

   On a chip, because these are on the artwork now and the artwork changes
   colour under them -- the fill is the card tokens, so `--card-text` on it is
   a pair tools/check-contrast.js already proves over every wave colour. */
.tl__when {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.25em;
  margin: 0.35em 0.3em 0 0;
  padding: 0.5em 0.8em;
  border-radius: var(--panel-radius);
  background-image: linear-gradient(var(--card-fill-top), var(--card-fill-bottom));
  border: 1px solid var(--card-border);
  font-family: var(--font-display);
  font-variant-numeric: tabular-nums;
  font-size: clamp(0.8125rem, 0.62cqw + 0.5vh, 1.25rem);
  line-height: 1.15;
  text-align: center;
  color: var(--card-text-muted);
}

.tl__year {
  font-weight: 700;
  color: var(--card-text);
}

.tl__year--now {
  color: var(--card-text-accent);
}

/* Reads as "through". A rule rather than an en dash, which at this size is a
   hyphen, and rather than nothing, which the user asked to fix. The chip's
   muted text colour at reduced strength: `--card-divider` was 12% white,
   which on the chip's dark fill was all but gone. */
.tl__span {
  width: 1px;
  height: 0.7em;
  background-color: currentColor;
  opacity: 0.45;
}

.tl__dur {
  margin-top: 0.15em;
  padding-top: 0.3em;
  border-top: 1px solid var(--card-divider);
  align-self: stretch;
  font-family: var(--font-body);
  font-size: 0.72em;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--card-text-muted);
}


/* ---- The job ---- */
/* Registered as lengths so they compute to px ON THE CARD, where `cqw` is
   the timeline's, and inherit as that number. Unregistered, the head would
   re-resolve them against the card's own width and get a different answer. */
@property --tl-pad {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

@property --tl-gap {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

.tl__card {
  /* Declared here and resolved in the children, so `cqw` is the card's. */
  --tl-role-fs: clamp(1.0625rem, 1cqw + 0.85vh, 1.875rem);
  --tl-where-fs: clamp(0.875rem, 0.75cqw + 0.45vh, 1.25rem);
  --tl-pad: clamp(var(--space-2), 0.7cqw + 0.85vh, var(--space-5));
  --tl-gap: clamp(0.4rem, 0.22cqw + 0.6vh, 1.1rem);
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  gap: var(--tl-gap);
  padding: var(--tl-pad);
}

/* The space under the head matches the card's padding, so the logo sits in
   the same margin on all four sides rather than crowding the bullets. The
   flex gap is already there; this is the rest.

   Tapered by height: on a 1536x864 laptop the first timeline screen had 6px
   to spare and the full amount costs about 24, so it is nothing at 864 and
   the whole of it from about 930 up (1080 gets all of it). Measured, not
   guessed -- 1440x900 has 12px to give and 1680x1050 has 60. */
.tl__head {
  display: flex;
  align-items: center;
  gap: clamp(var(--space-2), 1.2cqw, var(--space-3));
  margin-bottom: clamp(0px, var(--tl-pad) - var(--tl-gap), (100vh - 864px) * 0.15);
}

/* The logo is exactly as tall as the two lines beside it -- title and
   employer -- which is computed from their type sizes rather than measured,
   so it holds at every viewport. Its WIDTH follows the logo's own
   proportions: js/resume.js sets `--ar` from the image, and the tile is that
   ratio across its inner height plus the plate's padding. A 5:1 wordmark gets
   a wide tile and the square PGA crest a nearly square one, and both fill
   their height, instead of one 2:1 box shrinking the wide ones to a sliver.
   Capped, so a very wide mark cannot push the title into the sector tag. */
.tl__head > .logo {
  --h: calc(var(--tl-role-fs) * 1.2 + var(--tl-where-fs) * 1.5);
  flex: 0 0 auto;
  height: var(--h);
  width: calc((var(--h) - 0.6rem - 2px) * var(--ar, 2) + 0.9rem + 2px);
  max-width: 11rem;
}

.tl__headings {
  min-width: 0;
  flex: 1 1 auto;
}

/* The sector, at the end of the head row. Six roles across healthcare,
   energy, sport, education and media is a range worth being able to see
   without reading, and it is one word per card. */
.tl__domain {
  flex: 0 0 auto;
  align-self: flex-start;
  margin: 0;
  padding: 0.25em 0.7em;
  border-radius: 999px;
  background-color: var(--panel-fill-top);
  border: 1px solid var(--panel-border);
  /* Was 10-13px, which read as a footnote; the sector is one of the few
     things on the card a skimming reader takes in, so it sets closer to the
     employer line. */
  font-size: clamp(0.8125rem, 0.5cqw + 0.75vh, 1.1875rem);
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--card-text-muted);
}

.tl__role {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--tl-role-fs);
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--card-text);
  text-wrap: balance;
}

.tl__where {
  margin: 0.2em 0 0;
  font-size: var(--tl-where-fs);
  line-height: 1.3;
  color: var(--card-text-accent);
}

.tl__dot {
  opacity: 0.55;
  padding: 0 0.2em;
}

/* The earlier title at the same employer, on the title line where it
   belongs rather than trailing the location. */
.tl__role-note {
  font-family: var(--font-body);
  font-size: 0.62em;
  font-weight: 500;
  letter-spacing: 0;
  color: var(--card-text-muted);
  white-space: nowrap;
}

.tl__note {
  color: var(--card-text-muted);
}


/* ---- Two named halves ----
   The Stitch reference splits each role's bullets into two labelled groups
   rather than one run, and that is the idea worth taking from it: the user's
   complaint was that the screens are "mostly just a list of bullet points",
   and a list with two headings over it is no longer just a list. It also
   happens to name the two things a Director / VP reader is weighing --
   what he ran, and what he built -- so the labels do work beyond decoration.

   Only on roles carrying four or more bullets. Two headings over one bullet
   would be furniture. */
.split {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(0.4rem, 0.3cqw + 0.4vh, 1rem) clamp(var(--space-3), 2.5cqw, var(--space-5));
}

.split__label {
  margin: 0 0 0.4em;
  font-family: var(--font-body);
  font-size: clamp(0.6875rem, 0.3cqw + 0.6vh, 0.9375rem);
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--card-text-accent);
}


/* ---- The toolchain ----
   Also from the reference. Four or five words per role, and they do two
   things a sentence cannot: they let a recruiter scanning for "Figma" or
   "WCAG" find it without reading, and they give the bottom of every card a
   band of texture so the screen stops being uniform grey text. */
.tl__tools {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35em;
  margin: 0;
  padding: 0;
  list-style: none;
}

.tl__tools li {
  padding: 0.22em 0.65em;
  border-radius: 999px;
  background-color: var(--panel-fill-top);
  border: 1px solid var(--panel-border);
  color: var(--card-text-muted);
  font-size: clamp(0.6875rem, 0.35cqw + 0.72vh, 1rem);
  line-height: 1.4;
  white-space: nowrap;
}


/* ---- The schools ----
   The SimpliLearn bootcamps teach Caltech's and Purdue's curriculum, and the
   user wanted those shown rather than named. They are the schools' own
   REVERSE marks -- Purdue's wordmark is white, Caltech's is orange on
   transparent -- so their tiles take a dark plate. A white wordmark on the
   white plate the company logos use is not a logo, it is a blank. */
/* On the employer line, not under it: a row of its own cost 40px for two
   marks and a caption that repeated them. Sized in `em` so they track the
   line they sit in. */
.tl__schools {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  vertical-align: middle;
}

.tl__schools .logo {
  height: 1.7em;
  width: calc((1.7em - 0.4em - 2px) * var(--ar, 2.7) + 0.7em + 2px);
  padding: 0.2em 0.35em;
  border-radius: 0.4em;
}

.logo--rev.logo--img {
  background-color: var(--plum-950);
  border-color: rgba(255, 255, 255, 0.16);
}


/* ---- The achievements ----
   A plain dot. This has been a tick (a completed to-do list), then a short
   rule (better, still a graphic), and it is now the quietest thing that can
   still start a line. The user asked for "just bullet points, or just space",
   and a 0.3em dot is the version of that which still gives a wrapped second
   line something to hang off.

   Two columns once a role carries four or more, because the card is wide and
   a 700px line of 13px text is a bad measure. Reading order is across then
   down, which is what a browser does with a grid and what a reader does with
   a list of peers. */
.wins {
  display: grid;
  gap: 0.4em clamp(var(--space-4), 3cqw, var(--space-6));
  margin: 0;
  padding: 0;
  list-style: none;
}

.wins--two {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

/* The marker is positioned, not laid out. It was a grid column with the text
   beside it, and that quietly broke every sentence containing an inline
   element: a text node in a grid container is an ANONYMOUS GRID ITEM, so
   "Ensure <strong>WCAG 2.1 AA</strong> accessibility compliance" became three
   items in a two-column grid and set as three stacked fragments. Indent plus
   an absolute mark leaves the sentence as ordinary inline flow, where a
   <strong> is just bold. */
.wins li {
  position: relative;
  padding-left: 1.15em;
  font-size: clamp(0.8125rem, 0.72cqw + 0.5vh, 1.1875rem);
  line-height: 1.45;
  color: var(--card-text-muted);
  text-wrap: pretty;
}

.wins li::before {
  content: "";
  position: absolute;
  left: 0.15em;
  top: 0.6em;
  width: 0.3em;
  height: 0.3em;
  border-radius: 50%;
  background-color: var(--card-text-accent);
  opacity: 0.7;
}

/* A figure inside an achievement is the reason the line is there. Where a
   `.stats` tile already carries that figure the sentence does NOT bold it --
   two emphases on the same number twenty pixels apart read as a mistake. */
.wins strong {
  color: var(--card-text);
  font-weight: 700;
}


/* ---- Skills & Education: the bento ----
   Seven tiles in three columns that do not share row lines:

       Product design      Software            Education
       (the lead tile)     Strategy
       Digital media       1st place           Honours

   ---- Why a bento ----

   The two versions before this were one idea in different clothes: a long
   card of rows beside two stacked cards. However the rows were dressed the
   screen read as a form, and the user's verdict was "plain, not modern". A
   skills page is the one place in a design portfolio where the layout is
   allowed to be the argument, so this one is composed rather than listed.

   Each column is its own flex stack and its tiles GROW to fill it, so the
   three columns end on one line without their rows having to agree -- which
   is what lets Strategy (nine chips) sit beside Product design (fourteen)
   without either being padded to match the other.

   ---- What the tiles carry ----

     - a drawn icon on a coral squircle, and the same drawing as a faint
       corner watermark. The icons are this page's own line drawings in
       currentColor;
     - Tools, as pills carrying the tool's mark, and Skills, as a line of
       text (see "Tools and skills" below);
     - ONE lit tile, Product design, because it is the primary domain and lit
       is how the deck says "featured" everywhere else;
     - the dual degree as a Venn diagram -- Computer Science and Digital Arts,
       overlapping in UX -- with each degree's research set under its own
       circle, so the projects are attributed by position as well as label;
     - the hackathon as a stat tile. It is the only honour that is a
       leadership claim, "led a team to 1st place", and a Director / VP reader
       should not have to dig it out of a list of five. */
.bento {
  /* Air is driven by HEIGHT alone. It had a `cqw` term too, which on a
     1440x900 screen made the padding and gaps full-size while the chips were
     still near their floor, and the page ran 116px over. */
  --bento-gap: clamp(0.6rem, 1.1vh, 1.5rem);
  /* One size for every tile's mark -- the coral icons and Stetson's logo.
     From the viewport, not the tile: in cqw each tile drew its icon at its
     own width, 40px in Product design beside 51px in Strategy at 1920x1080,
     and the logo could not match both. */
  --tile-mark: clamp(2.25rem, 1.5vw + 1.4vh, 3.5rem);
  display: grid;
  /* Measured, not guessed, and different per height -- see the short-viewport
     block for 720. On a tall screen the chips set large and the middle stack
     (Leadership & strategy, Software, the stat) is the one that wraps, so it gets the
     width; the education tile has 50px to spare there. */
  grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.14fr) minmax(0, 1fr);
  /* The outer columns SHARE their row line, so the four corner tiles line
     up: Product design beside Education, Digital media beside Honours. Each
     row is as tall as the taller of its two tiles needs, and any slack is
     split between the rows. The middle column spans both as its own stack
     of three -- that is where the bento's irregularity lives. Before, each
     column was a separate stack and the outer pairs split at different
     heights for no reason the reader could see. */
  grid-template-rows: minmax(min-content, 1fr) minmax(min-content, 1fr);
  grid-template-areas:
    "design mid edu"
    "media  mid hon";
  gap: var(--bento-gap);
  height: 100%;
  min-height: 0;
}

.bento__col {
  display: flex;
  flex-direction: column;
  gap: var(--bento-gap);
  min-height: 0;
}

.bento > .bento__col:first-child,
.bento > .bento__col:last-child {
  display: contents;
}

.bento > .bento__col:nth-child(2) { grid-area: mid; }
.tile--design { grid-area: design; }
.tile--media { grid-area: media; }
.tile--edu { grid-area: edu; }
.tile--honours { grid-area: hon; }

.tile {
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  gap: clamp(0.4rem, 0.8vh, 1rem);
  flex: 1 1 auto;
  min-height: 0;
  padding: clamp(0.6rem, 1.5vh - 0.2rem, 1.6rem) clamp(0.75rem, 1.5vh, 1.75rem);
  overflow: hidden;
}

/* Every `p` in a card takes the card's muted colour at (0,1,1), so the tile's
   own paragraphs are written as `.tile .x` to outrank it. */

/* The corner watermark: the tile's own icon again, large and faint and
   cropped by the tile's edge, so it is texture that says what the tile is
   about and costs no space. It was an outlined index numeral, 01 to 04, and
   the user was right that a number told the reader nothing. The tile's
   content is stacked above it. */
.tile__mark {
  position: absolute;
  right: clamp(-1.25rem, -4cqw, -0.5rem);
  bottom: clamp(-1.25rem, -4cqw, -0.5rem);
  width: clamp(5rem, 30cqw + 1vh, 10rem);
  aspect-ratio: 1;
  color: var(--card-text-accent);
  opacity: 0.16;
  transform: rotate(-12deg);
  pointer-events: none;
}

.tile__mark .icon {
  display: block;
  width: 100%;
  height: 100%;
  stroke-width: 1.25;
}

.tile > :not(.tile__mark, .tile__corner) {
  position: relative;
}

/* Every tile takes the warm edge the lit and stat tiles had -- the user
   liked it there and asked for it everywhere. It is a border colour only, so
   the one lit tile still reads as the lead by its wash. */
.tile,
.tl__card {
  --surface-line: var(--tint-warm-line);
}

/* ---- Tool marks ----
   The tool's own logo at the start of its pill, on white -- most of these
   marks are drawn for white, and GitHub's and Miro's are near black. Brand
   colours are kept, because recognising the colour is half of recognising
   the mark. The name stays beside it: a logo alone is a quiz.

   The white is the pill's whole LEFT CAP, not a plate inside it. The plate
   was a small rounded square with a hair of dark pill showing above and to
   its left, almost touching, and the user found it awkward. Now the logo
   fills the pill's height from its left edge, the pill's own rounded end is
   the white's shape (the pill clips it), and the white stops square before
   the name.

   Positioned out of flow so it takes the pill's height rather than setting
   it: a pill with a mark is exactly as tall as one without. The `:has()`
   rules outrank each pill's own padding, including the short-viewport ones. */
.chip:has(> .tool-logo),
.tl__tools li:has(> .tool-logo) {
  /* As narrow as the pill's curve allows. At 2.05em the cap left spare white
     right of the mark and every pill grew wide enough to push a tool onto
     a new row. */
  --logo-cap: 1.8em;
  position: relative;
  overflow: hidden;
  padding-left: calc(var(--logo-cap) + 0.4em);
}

.tool-logo {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--logo-cap);
  height: 100%;
  margin: 0;
  /* More on the left, where the pill's curve takes its bite. */
  padding: 0.3em 0.2em 0.3em 0.5em;
  box-sizing: border-box;
  background-color: #FFFFFF;
  object-fit: contain;
  /* The cap's square edge. Invisible on a dark pill, where white against
     dark is edge enough; on a light pill the cap would otherwise dissolve. */
  box-shadow: 1px 0 0 var(--chip-border);
}

.tile__head {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: clamp(0.55rem, 3cqw, 1rem);
}

.tile__icon {
  display: grid;
  place-items: center;
  width: var(--tile-mark);
  aspect-ratio: 1;
  border-radius: 30%;
  background-image: linear-gradient(145deg, var(--coral-400), var(--coral-600));
  color: #fff;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    0 8px 18px -8px rgba(226, 80, 58, 0.75);
}

.tile__icon .icon {
  width: 56%;
  height: 56%;
}

.tile--lead .tile__icon {
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    0 0 0 4px rgba(242, 117, 92, 0.18),
    0 10px 24px -8px rgba(226, 80, 58, 0.85);
}

.tile .tile__kicker {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.6em;
  margin: 0;
  font-family: var(--font-body);
  font-size: clamp(0.625rem, 1.1cqw + 0.55vh, 0.9375rem);
  font-weight: 700;
  letter-spacing: 0.1em;
  line-height: 1.3;
  text-transform: uppercase;
  color: var(--card-text-accent);
}

.tile__name {
  margin: 0.15em 0 0;
  font-family: var(--font-display);
  font-size: clamp(0.9375rem, 2.6cqw + 1vh, 1.75rem);
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--card-text);
  text-wrap: balance;
}

.tile__label {
  margin: 0 0 0.45em;
  font-family: var(--font-body);
  font-size: clamp(0.625rem, 1cqw + 0.5vh, 0.875rem);
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--card-text-muted);
}

/* Rows 3px further apart than the pills sit side by side, and each pill (in
   .chip below) 1px shorter top and bottom -- the user's call: the pills were
   taller than they needed to be and the rows, 4.5px apart, ran together. A
   tile of three rows comes out the same height as before, which matters at
   1280x720, where Digital media had 1px to spare. */
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: calc(clamp(0.25em, 0.4vh, 0.4em) + 3px) clamp(0.25em, 0.4vh, 0.4em);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* ---- Tools and skills ----
   Two kinds of thing, drawn as two kinds of thing. A tool is a product, so it
   is a badge: a raised pill with the product's mark. A skill is a practice,
   so it is words: one line of text, dot-separated. They used to be the same
   pill told apart by solid vs dashed, with the group names hidden, and the
   user was right that it read as a pile of words thrown at a card. The names
   are visible again, as the screen's small uppercase labels. */
.tile__group {
  display: flex;
  flex-direction: column;
  gap: clamp(0.2rem, 0.45vh, 0.5rem);
}

/* Skills set off from Tools by a hairline and a little air, the same
   divider the Honours tile's footer uses. Without it the SKILLS label sat
   as close to the last row of chips as to its own line of text. */
.tile__group + .tile__group {
  margin-top: clamp(0.1rem, 0.35vh, 0.45rem);
  padding-top: clamp(0.4rem, 0.9vh, 1rem);
  border-top: 1px solid var(--card-divider);
}

.tile .tile__sub {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 700;
  letter-spacing: 0.1em;
  line-height: 1.2;
  text-transform: uppercase;
}

.chip {
  padding: max(0px, calc(clamp(0.15em, 0.25vh, 0.28em) - 2px)) clamp(0.5em, 0.8vh, 0.75em);
  border-radius: 999px;
  background-color: var(--chip-fill);
  border: 1px solid var(--chip-border);
  color: var(--card-text);
  font-size: clamp(0.75rem, 1.1cqw + 1.1vh, 1.125rem);
  line-height: 1.4;
  white-space: nowrap;
}

/* A dot only BETWEEN two skills on the same line. Most lines hold one or two
   skills, and a trailing dot on a line of one read as clutter (the user's
   call). CSS cannot ask where a line wrapped, so the dot is laid out to fall
   off the edge when it starts one:

   every skill carries its dot in a fixed 1.2em strip BEFORE it, pulled left
   by its own width. Mid-line, the strip sits in the gap after the previous
   skill. At the start of a line it sits left of the text column -- and the
   list is shifted 1.2em left and clipped by exactly that much, so there it is
   cut away. The text itself still starts on the column edge, because every
   skill's margin puts back the 1.2em the list gave up.

   Each skill is unbreakable; the whitespace between the items in the markup
   is where a line may wrap, which is always BEFORE a dot. */
.skills {
  --skill-gap: 1.2em;
  margin: 0 0 0 calc(-1 * var(--skill-gap));
  padding: 0;
  list-style: none;
  font-size: clamp(0.75rem, 1.1cqw + 1.1vh, 1.125rem);
  line-height: 1.5;
  color: var(--card-text);
  /* Left inset only; the other sides are opened so nothing else is cut. */
  clip-path: inset(-1em -1em -1em var(--skill-gap));
}

.skills li {
  display: inline;
  margin-left: var(--skill-gap);
  white-space: nowrap;
}

.skills li::before {
  content: "\00B7";
  content: "\00B7" / "";
  display: inline-block;
  width: var(--skill-gap);
  margin-left: calc(-1 * var(--skill-gap));
  text-align: center;
  font-weight: 700;
  color: var(--card-text-accent);
}

/* A tool with no mark in the open icon sets gets its initials in the same
   white cap, so every tool pill has the same shape. The letters are drawn
   from `data-mono` at a smaller size, which leaves the cap's em-based box
   exactly the size of an image cap. They are centred on the cap's padding
   box, nudged right by the same extra left padding the images get. */
.tool-logo--text::before {
  content: attr(data-mono);
  position: absolute;
  inset: 0 0 0 0.25em;
  display: grid;
  place-items: center;
  font-family: var(--font-body);
  font-size: 0.56em;
  font-weight: 800;
  letter-spacing: -0.03em;
  line-height: 1;
  color: #231C33;
}


/* ---- The stat tile ---- */
.tile--stat {
  justify-content: center;
  gap: 0.35em;
}

.tile__corner {
  position: absolute;
  top: clamp(0.5rem, 3cqw + 0.4vh, 1.25rem);
  right: clamp(0.6rem, 3.4cqw + 0.4vh, 1.4rem);
  width: clamp(1.75rem, 9cqw + 0.5vh, 3rem);
  aspect-ratio: 1;
  color: var(--card-text-accent);
  opacity: 0.8;
}

.tile__corner .icon {
  display: block;
  width: 100%;
  height: 100%;
}

.tile .stat {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(2.5rem, 13cqw + 1.4vh, 5.5rem);
  font-weight: 700;
  line-height: 0.9;
  letter-spacing: -0.04em;
  color: var(--card-text-accent);
}

.stat__sup {
  margin-left: 0.04em;
  font-size: 0.4em;
  letter-spacing: 0;
  vertical-align: 1.1em;
}

.tile .stat__text {
  margin: 0;
  font-size: clamp(0.8125rem, 2.2cqw + 0.75vh, 1.25rem);
  line-height: 1.35;
  color: var(--card-text-muted);
  text-wrap: pretty;
}

.tile .stat__text strong {
  color: var(--card-text);
  font-weight: 700;
}


/* ---- Education ---- */
.tile--edu .tile__head {
  padding-right: 0;
}

.tile--edu .logo {
  width: clamp(4rem, 21cqw + 0.5vh, 7.5rem);
  /* The coral icons' own size, so the three marks across the top row are
     one height. It was its own, shorter clamp and read as smaller. */
  height: var(--tile-mark);
}

/* ---- The top row's heads are one height ----
   Product design's title runs to two lines (three at 1280x720) and
   Leadership, Strategy & Ops's to one, so each head was as tall as its own
   title and the first group under it started at a different depth:
   LEADERSHIP sat visibly higher than Product design's TOOLS beside it.

   js/resume.js measures the three top-row heads and sets `--head-row` to
   the tallest; each centres what it holds in that, so the first group starts
   at one depth and the marks share one centre line. It is measured rather
   than computed because the titles are sized from each tile's own width
   (cqw), so "two lines of title" is a different height in every tile -- a
   formula built from the clamps put Strategy's group 9px LOWER than Product
   design's at 1920x1080 and 15px higher at 1280x720. */
.tile--design .tile__head,
.tile--strategy .tile__head,
.tile--edu .tile__head {
  min-height: var(--head-row, 0);
}

/* The words sit at the TOP of the shared height; only the mark is centred
   in it. Centred too, a head with a one-line title dropped its kicker below
   the others -- ENTERPRISE ALIGNMENT sat lower than PRIMARY DOMAIN and
   EDUCATION beside it. Top-aligned, the three kickers share one line, the
   marks share another, and the first groups a third. */
.tile--design .tile__head > div,
.tile--strategy .tile__head > div,
.tile--edu .tile__head > div {
  align-self: start;
}

.tile__pill {
  padding: 0.15em 0.6em;
  border-radius: 999px;
  background-color: var(--panel-fill-top);
  border: 1px solid var(--tint-warm-line);
  font-size: 0.9em;
  letter-spacing: 0.06em;
  white-space: nowrap;
}

.tile .tile__where {
  margin: 0.1em 0 0;
  font-size: clamp(0.75rem, 1.9cqw + 0.7vh, 1.125rem);
  color: var(--card-text-muted);
}

/* Drawn in SVG units, so it scales as one picture; the height cap keeps a
   wide tile from turning it into the biggest thing on the screen. */
.venn {
  display: block;
  width: 100%;
  height: auto;
  max-height: clamp(5.5rem, 13vh, 10rem);
  overflow: visible;
}

.venn__a {
  fill: rgba(242, 117, 92, 0.16);
  stroke: var(--card-text-accent);
  stroke-width: 1.25;
}

.venn__b {
  fill: rgba(109, 85, 201, 0.16);
  stroke: var(--card-text-muted);
  stroke-width: 1.25;
}

.venn text {
  font-family: var(--font-display);
  fill: var(--card-text);
}

.venn__deg {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.08em;
  fill: var(--card-text-accent) !important;
}

.venn__name {
  font-size: 13px;
  font-weight: 600;
}

.venn__mid {
  font-size: 19px;
  font-weight: 800;
  letter-spacing: 0.02em;
  fill: var(--card-text-accent) !important;
}

.research {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.4em clamp(0.6rem, 4cqw, 1.25rem);
}

.research__label {
  display: block;
  margin-bottom: 0.2em;
  font-family: var(--font-body);
  font-size: clamp(0.5625rem, 0.9cqw + 0.5vh, 0.8125rem);
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--card-text-accent);
}

.tile .research p,
.tile .tile__line {
  margin: 0;
  font-size: clamp(0.75rem, 1.9cqw + 0.7vh, 1.125rem);
  line-height: 1.35;
  color: var(--card-text-muted);
  text-wrap: pretty;
}

.tile .research strong {
  color: var(--card-text);
  font-weight: 600;
}



/* ---- Honours ---- */
.honours {
  display: flex;
  flex-direction: column;
  gap: clamp(0.35rem, 0.9vh, 0.8rem);
  margin: 0;
  padding: 0;
  list-style: none;
}

.honours li {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: start;
  gap: 0.65em;
  font-size: clamp(0.8125rem, 2cqw + 0.7vh, 1.1875rem);
  line-height: 1.35;
  color: var(--card-text-muted);
  text-wrap: pretty;
}

.honours .icon {
  width: 1.3em;
  height: 1.3em;
  color: var(--card-text-accent);
}

.tile__foot {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4em clamp(var(--space-3), 6cqw, var(--space-5));
  margin: auto 0 0;
  padding-top: clamp(0.4rem, 0.3cqw + 0.6vh, 0.9rem);
  border-top: 1px solid var(--card-divider);
}

.tile__foot dt {
  font-family: var(--font-body);
  font-size: clamp(0.5625rem, 0.9cqw + 0.5vh, 0.8125rem);
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--card-text-muted);
}

/* One style for every small uppercase heading on the screen -- the area
   kickers, the research labels, "Activities & honours", the footer terms.
   They had drifted into three sizes and two colours; this is the larger size
   and the accent colour, which the user picked out of the two. */
.tile .tile__kicker,
.tile__label,
.tile .tile__sub,
.research__label,
.tile__foot dt {
  font-size: clamp(0.625rem, 1cqw + 0.5vh, 0.875rem);
  color: var(--card-text-accent);
}

.tile__foot dd {
  margin: 0.2em 0 0;
  font-size: clamp(0.8125rem, 1.9cqw + 0.7vh, 1.125rem);
  font-weight: 600;
  color: var(--card-text);
}


/* ---- The logo tile ----
   Ships as a monogram and is upgraded to a real logo by js/resume.js if a
   file for it exists -- see the comment there.

   The monogram sits on PANEL tokens rather than a colour of its own, so its
   contrast is one `node tools/check-contrast.js` already proves against
   every wave palette in both themes. The light plate only appears once an
   IMAGE has replaced the text, so it never has type on it to check. */
/* 2:1, not square, and the reason is measured rather than aesthetic: the six
   real marks run from 1:1 (the PGA of America crest) to 5:1 (Palm Beach Code
   School's wordmark). Contained in a SQUARE tile 48px across, a 5:1 wordmark
   renders 48x10 and is unreadable; in this one, roughly 96x44, it renders at
   about 19px tall -- the size a logo appears in a website footer. The square
   crest is then height-limited to 44px, which is a perfectly good size for a
   crest. One tile shape has to serve both, and this is the one that does not
   fail either. */
.logo {
  display: grid;
  place-items: center;
  /* A definite height rather than an aspect ratio: `object-fit: contain`
     needs a box whose height the browser has actually resolved, and an
     aspect-ratio box inside a grid track is not that. */
  height: clamp(2rem, 5cqw, 2.625rem);
  width: 100%;
  /* The panel radius: the date chips' radius, and the one that sits
     concentric inside a card's 26px corner. It was 14% / 28% -- elliptical,
     and a different curve on every tile, since the tiles are now as wide as
     their logos. The card's own 26px on a 41px-tall tile is a pill, and it
     clips the corners of the wordmarks. */
  border-radius: var(--panel-radius);
  overflow: hidden;
  background-color: var(--panel-fill-top);
  border: 1px solid var(--panel-border);
}

.logo__mono {
  color: var(--card-text-muted);
  font-family: var(--font-display);
  font-size: clamp(0.625rem, 2.2cqw, 0.875rem);
  font-weight: 700;
  letter-spacing: 0.02em;
}

/* Most logos are drawn for white and vanish on a dark card, so a tile
   holding a real one gets a plate. `contain`, never `cover`: a cropped logo
   is a damaged logo. */
/* The logo is a BACKGROUND, not an <img>, and js/resume.js sets the url. Two
   attempts with an element in the box both overflowed it: a percentage height
   on a grid item that is centred rather than stretched resolves against a
   height Chrome treats as indefinite, so `height: 100%` and `max-height: 100%`
   were both ignored and the PGA crest -- the one square mark in the set --
   stood 65px tall in a 36px tile. `background-size: contain` has no such
   ambiguity: it fits the box it is given, whatever the box is.

   `background-origin: content-box` is what makes the padding an inset for the
   artwork while the white plate still runs to the tile's edge. */
.logo--img {
  background-color: #FFFFFF;
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
  background-origin: content-box;
  border-color: rgba(255, 255, 255, 0.6);
  padding: 0.3rem 0.45rem;
}

/* The current role's tile. The accent that says "still here" is on the rail
   node and the card's warm tint now, so this is a supporting cue rather than
   the only one. */
.tl__item--now .logo {
  border-color: var(--tint-warm-line);
}

.tl__item--now .logo__mono {
  color: var(--card-text-accent);
}

.logo--lg {
  width: clamp(4.5rem, 20cqw, 6rem);
}


/* ---- The plain-text screen ----
   One card across the full measure holding the whole document, set the way
   the user's own pixeltrics.com/resume.php sets it: name and contact, then a
   dashed rule under every capitalised heading, then `(dates) Title` over
   `Company, Location` over bullets.

   TWO COLUMNS, ONE SCREEN, NO SCROLLBAR

   It used to be one column in a panel with its own scrollbar -- a scrollbar
   in the middle of the page, over a document 3.4 screens long, that also
   took the wheel away from the deck until it reached its end. The user found
   both strange. The document now flows into two newspaper columns the height
   of the sheet, and js/resume.js fits the type size so the whole resume lands
   in those two. It is small on purpose: the two designed screens before this
   one are what a person reads; this one is for a parser, and for anyone who
   wants the whole thing at a glance (the .txt is one click away).

   `column-fill: auto` with a definite height is what makes the columns fill
   top to bottom and then spill SIDEWAYS rather than balancing. If a screen is
   too short for even the minimum size, the spill is a third column, and the
   sheet scrolls horizontally rather than losing text.

   Paging mode only. Below it the section grows with its content and the
   document is one ordinary column the page scrolls through. */
.resume__sheet {
  display: grid;
  height: 100%;
  min-height: 0;
  /* Vertical only. Across, the head row and the document each carry the
     inset themselves -- one --space-4, the same gap the card keeps from the
     nav and from the window's right edge (.section's side padding in
     css/layout.css), so the text sits as far inside the card as the card
     sits inside the page. It was the sheet's padding PLUS the document's,
     ~48px, twice that. */
  padding: clamp(var(--space-2), 1.6vh, var(--space-3)) 0;
  overflow: hidden;
}

.doc {
  min-height: 0;
  padding: clamp(var(--space-3), 2.4vh, var(--space-5)) var(--space-4);
  color: var(--card-text-muted);
  font-size: clamp(0.75rem, 0.7rem + 0.15vw, 0.875rem);
  line-height: 1.5;
}

/* The head row already carries the top padding, and at a fitted 7-8px the
   looser 1.5 leading of the one-column version spends height the columns
   need: every tenth of it is roughly a tenth of a column. */
html.fp-active .doc {
  padding-top: var(--space-2);
  padding-bottom: var(--space-3);
  line-height: 1.38;
  height: 100%;
  columns: 2;
  column-gap: clamp(var(--space-5), 3vw, var(--space-7));
  column-rule: 1px dashed var(--card-divider);
  column-fill: auto;
  overflow-x: auto;
  overflow-y: hidden;
}

/* A heading is never the last line of a column, and a job's title, company
   and bullets try to stay in one column together. */
html.fp-active .doc__section,
html.fp-active .doc__job,
html.fp-active .doc__org {
  break-after: avoid;
}

html.fp-active .doc__list li {
  break-inside: avoid;
}

/* The one-column version's air, tightened for the columns. Eight section
   headings at 3em each and ten job blocks at 1.5em were ~40em of pure gap,
   about fifteen lines -- most of a third column on a 1536x864 laptop. The
   rule and the bold uppercase still mark every section; the gaps only have
   to separate, not announce. */
html.fp-active .doc__section {
  margin: 1.1em 0 0.5em;
  padding-top: 0.5em;
}

html.fp-active .doc__job {
  margin-top: 1em;
}

/* Restated, because the rule above outranks the plain `.doc__section +
   .doc__job` one. */
html.fp-active .doc__section + .doc__job {
  margin-top: 0;
}

/* The sheet's head row: name and role on the left, the TXT and PDF downloads
   on the right. OUTSIDE the columns, at ordinary size, so the name reads as the
   page's heading however small the fitted text below it gets -- and the
   button has a row of its own to sit in instead of crowding the top of the
   second column.

   It was tried inside the document with `column-span: all`, and Chrome's
   column balancing after a spanner in a fixed-height box left the second
   column half empty and pushed the rest into a third, even at 7px. */
.resume__sheet {
  grid-template-rows: auto minmax(0, 1fr);
}

/* Top padding kept small: every px of this row is a px off both columns, and
   the button, not the name, sets the row's height. */
.resume__sheet-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-4) 0;
  color: var(--card-text-muted);
  font-size: clamp(0.75rem, 0.7rem + 0.15vw, 0.875rem);
  line-height: 1.5;
}

/* The four contact lines ride in the head row too, two by two beside the
   name, rather than taking the top of the first column: the row is as tall
   as the button whatever sits in it, so they cost nothing here and give the
   columns back six lines. Still after the name in source order, which is
   the order a parser reads. */
.resume__sheet-head .doc__contact {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, max-content));
  column-gap: var(--space-5);
  min-width: 0;
  margin: 0 auto 0 clamp(var(--space-4), 3vw, var(--space-6));
  line-height: 1.5;
}

/* One row in paging mode, always. On a narrow sheet (1280 wide) the three
   pieces did not fit side by side and the button wrapped onto a second row,
   doubling the row's height -- exactly the height the columns needed. The
   contact block is the piece that gives: it shrinks and its lines wrap. */
html.fp-active .resume__sheet-head {
  flex-wrap: nowrap;
}

html.fp-active .resume__sheet-head > .doc__head,
html.fp-active .resume__sheet-head > .resume__downloads {
  flex-shrink: 0;
}

/* TXT and PDF side by side, the two copies of this document in one place.
   The band keeps only Back / Next on this screen. */
.resume__downloads {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin: 0;
}

/* The first heading in the document no longer follows the contact block, so
   it opens the column without the gap and rule meant to end a section. */
.doc > .doc__section:first-child {
  margin-top: 0;
  padding-top: 0;
  border-top: 0;
}

@media (max-width: 560px) {
  .resume__sheet-head .doc__contact {
    grid-template-columns: auto;
    margin: 0;
  }
}

/* The same size as the band's buttons (css/layout.css), so these read as the
   same kind of control as Back / Next and the other screens' Download PDF. */
.resume__sheet-head .btn {
  font-size: clamp(0.8125rem, calc(var(--band-floor) * 0.11), 1.0625rem);
  padding: 0.8em 1.7em;
}

/* `:not([class])` is load-bearing, not tidiness. This rule used to be a bare
   `.doc p`, which at 0-1-1 outranks every `.doc__job` / `.doc__org` below it
   at 0-1-0 -- so the margins written for the job blocks were being dropped
   silently and every paragraph in the document got the same 0 above / 0.7em
   below. The visible result was that a job's title sat tight under the
   PREVIOUS job's bullets and then opened a full paragraph gap before its own
   company line: each heading read as belonging to the block above it.

   Scoping to unclassed paragraphs means the two rulesets no longer overlap at
   all, so ordering and specificity stop mattering. Any classed paragraph in
   here owns its own spacing. */
.doc p:not([class]) {
  margin: 0 0 0.7em;
  text-wrap: pretty;
}

/* Name over role, and nothing else. The downloads are on the section's foot
   line, the same place every Resume screen puts them (css/layout.css). */
.doc__head {
  margin-bottom: 0.2em;
}

.doc__name {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(1.125rem, 1rem + 0.6vw, 1.625rem);
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--card-text);
}

.doc__role {
  margin: 0.1em 0 0;
  color: var(--card-text-accent);
}

.doc__contact {
  margin: 0.8em 0 0;
  line-height: 1.6;
}

/* The dashed rule is the reference's signature and it is doing real work:
   with no colour and no card to separate them, a rule is what tells the eye
   a section has ended. */
.doc__section {
  margin: 1.6em 0 0.7em;
  padding-top: 0.7em;
  border-top: 1px dashed var(--card-divider);
  font-family: var(--font-display);
  font-size: 1em;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--card-text);
}

/* A job block is a unit: clear air ABOVE it to separate it from the previous
   role's bullets, and none below, so the company and location read as the
   second line of the same heading rather than as a new paragraph. */
.doc__job {
  margin: 1.5em 0 0;
  color: var(--card-text);
}

/* The first job under a section heading does not need the full gap -- the
   dashed rule has already done that work. */
.doc__section + .doc__job {
  margin-top: 0;
}

.doc__job strong {
  font-weight: 700;
}

.doc__dates {
  color: var(--card-text-accent);
  font-variant-numeric: tabular-nums;
}

/* Line-height only above, which is what makes it read as a line break under
   the title; the gap it does own is the one before the bullets. */
.doc__org {
  margin: 0.1em 0 0.45em;
  font-size: 0.95em;
}

/* Two org lines in a row -- "Tools:" then "Activities:" under an Areas of
   Expertise heading -- are a pair, not two paragraphs. */
.doc__org + .doc__org {
  margin-top: -0.3em;
}

.doc__list {
  margin: 0 0 0.2em;
  padding-left: 1.15em;
}

.doc__list li {
  margin-bottom: 0.2em;
  text-wrap: pretty;
}

.doc__list li::marker {
  color: var(--card-text-accent);
}



/* ---- Short viewports ----
   Same argument as the case-study block above: at 1280x720 the work-history
   screens are the tallest pages in the deck, and what gives is air and type
   scale, never content. Every bullet on the resume stays on the screen.

   The budget is tighter than it looks: the title band is sized from the wave
   artwork at runtime and measures 130px at 1280x720, not the 86px a plain
   12vh would give, so a 720px screen leaves 549px of content and not 593.
   Anything checked against a probe page that hardcodes the band depth is
   checked against the wrong number -- measure on the real page. */
@media (max-height: 800px) {
  /* The named halves and the toolchain chips cost about 40px a screen, and
     at 1280x720 that is more than there is. It comes out of gaps and card
     padding, as always -- no line of the resume leaves the screen. */
  /* The named halves and the toolchain chips cost about 40px a screen, and at
     1280x720 that is more than there is. Measured on the real page, not on a
     probe: the first screen's three cards came to 590px against a 551px box.
     It comes out of gaps, padding and leading -- no line of the resume leaves
     the screen, and no type size goes down. */
  .tl {
    --tl-row: 0.12rem;
  }

  .tl__card {
    --tl-pad: 0.2rem;
    --tl-gap: 0.08rem;
    padding: var(--tl-pad) 0.55rem;
  }

  .split__label {
    margin-bottom: 0.15em;
  }

  .wins li {
    line-height: 1.38;
  }

  .tl__tools li {
    padding: 0.15em 0.6em;
  }

  .tl__when {
    padding: 0.4em 0.7em;
  }

  .tl__head {
    gap: var(--space-2);
  }

  .tl__when {
    padding: 0.3em 0.55em;
  }

  .wins {
    row-gap: 0.15em;
  }

  /* Chips are what a 720px screen cannot afford here: four areas times two
     groups is eight chip fields, and every row of wrap is 24px. Smaller type
     and tighter padding fit more per row, which removes rows -- cheaper than
     removing tools, and the tools are the content. */
  .chip {
    font-size: 0.6875rem;
    padding: max(0px, calc(0.15em - 2px)) 0.5em;
  }

  .chips {
    gap: calc(0.25em + 3px) 0.25em;
  }

  .skills {
    font-size: 0.75rem;
    line-height: 1.4;
  }

  .tile__group {
    gap: 0.2rem;
  }

  .tile__group + .tile__group {
    margin-top: 0;
    padding-top: 0.2rem;
  }

  /* The "1st" at 70%: the middle stack's last 14px. Still the largest type
     on the screen by a distance. */
  .tile .stat {
    font-size: clamp(1.75rem, 9.1cqw + 0.98vh, 3.85rem);
  }

  /* The tile titles' floor, 15px -> 13.8px. At 1280x720 Product design's
     title sat on the old floor and wrapped to THREE lines, and since the top
     row's heads share the tallest one's height (js/resume.js), that third
     line pushed Strategy and Education down with it and the screen ran 12px
     over. At 13.8px it holds two. Only a title already at the floor gets
     smaller; above it the clamp is unchanged. */
  .tile__name {
    font-size: clamp(0.8625rem, 2.6cqw + 1vh, 1.75rem);
  }

  /* At 720 the chips are at their 11px floor. The middle stack is the one
     that runs long since Strategy became Leadership, Strategy & Ops and took
     a Leadership group (41px over at 1280x720), so it takes width from the
     first column. Swept, not guessed: from 0.75 to 0.77 the first column
     sits at 1px spare and Strategy's tools hold two rows; at 0.78 they wrap
     to three and the stack runs 10px over. The education column keeps its
     share: Honours has 1px. Width alone left it 3px over -- the stat
     numeral and the group dividers below close the rest. */
  .bento {
    --bento-gap: 0.6rem;
    grid-template-columns: minmax(0, 0.76fr) minmax(0, 1.2fr) minmax(0, 1.08fr);
  }

  .tile {
    gap: 0.4rem;
    padding: 0.6rem 0.75rem;
  }

  .tile__label {
    margin-bottom: 0.3em;
  }
}


/* ---- Flow mode ----
   Below 1024px the page scrolls, so nothing has to be squeezed into a row
   height that no longer exists.

   The timeline keeps all three of its columns on a phone -- the dates stay on
   the axis, which is the point of them -- and the indent that marks a
   concurrent role shrinks but survives, because it is the only thing saying
   so. Skills goes to one column, which is what makes it readable at 390px:
   the areas were already rows, so they simply get narrower. */
@media (max-width: 1023.98px) {
  .tl {
    align-content: start;
    --tl-row: var(--space-4);
    --tl-indent: var(--space-3);
    height: auto;
  }

  .tl__when {
    margin-right: 0.5em;
  }

  .wins--two,
  .split {
    grid-template-columns: minmax(0, 1fr);
  }

  /* The two named halves become two labelled blocks, one over the other.
     Left as columns they were 105px wide at 390px -- a gutter, not a
     measure. */
  .split__label {
    margin-top: 0.3em;
  }

  /* The bento keeps two columns on a tablet. Its three column stacks dissolve
     (`display: contents`) so the seven tiles pour into one two-column grid in
     reading order, instead of three stacks squeezed side by side. */
  .bento {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    grid-template-rows: none;
    grid-template-areas: none;
    height: auto;
  }

  .bento > .bento__col {
    display: contents;
  }

  .bento .tile {
    grid-area: auto;
  }

  /* Seven into two leaves one over; the stat tile takes a full row, which
     also stops it being stretched to the height of the education tile. */
  .bento .tile--stat {
    grid-column: 1 / -1;
  }
}

@media (max-width: 640px) {
  .bento {
    grid-template-columns: minmax(0, 1fr);
  }

  /* ---- The date moves off the row and on top of the card ----
     Three columns are the right structure for this timeline and the wrong one
     for a phone. Measured at 375px: the row is 327px wide, the rail takes 44
     and the date chip 78, so the job itself -- the content, the reason the
     screen exists -- was left with 205px, and a concurrent role's indent took
     another 24 off that. Two columns of running text at 205px is not a
     measure; the toolchain chips wrapped one per line and "Head of Product
     Design & UX" broke across three.

     So the chip goes ABOVE the card, in the same column, and reads ACROSS
     instead of down -- "Present | 2019 | 7 yrs" on one line, which is the
     order it already had, simply turned. The card gets the whole 283px.

     THE RAIL STAYS. It is what makes this a timeline rather than a list of
     jobs, and dropping it was the obvious saving and the wrong one: the
     concurrency device (`.tl__item--with`, an indented card and nothing else)
     means nothing without a straight line to be indented FROM. The node now
     sits beside the date rather than beside the card, which is if anything
     truer -- the dot marks the date.

     Two columns, not three, so `.tl`'s own template changes here rather than
     the row being re-placed inside it. Every `.tl__item` is a subgrid of it
     and follows automatically. */
  .tl {
    grid-template-columns: var(--tl-rail) minmax(0, 1fr);
  }

  .tl__item {
    /* The date's row is content-sized; the card takes the rest. Named so the
       three children can be placed by row as well as column -- subgrid only
       inherits the COLUMNS, the rows are this item's own. */
    grid-template-rows: auto auto;
  }

  /* Source order puts the chip first and the node second, which is what the
     three-column layout wanted. Here the node is the first column, so it is
     placed rather than flowed -- and it spans both rows so its connecting
     segment still runs the full height of the row down to the next node. */
  .tl__node {
    grid-column: 1;
    grid-row: 1 / -1;
  }

  /* The dot is positioned from the top of the node, which used to be the top
     of the card; now it is the top of the date chip, which sits a little
     higher. Pulled up to match, so the dot still lands on the chip's first
     line rather than under it. */
  .tl__node::before {
    top: 0.75em;
  }

  .tl__node::after {
    top: 1.6em;
  }

  .tl__item--now .tl__node::before {
    top: 0.6em;
  }

  /* Across, not down. `justify-self: start` undoes the `stretch` the shared
     column needed -- there is no shared column any more, and a chip stretched
     to the card's full width reads as a header bar rather than as a date. */
  .tl__when {
    grid-column: 2;
    grid-row: 1;
    flex-direction: row;
    align-items: center;
    justify-self: start;
    margin: 0 0 0.5em;
    gap: 0.5em;
  }

  /* Turned on its side WITH the chip. Stacked, "through" was a vertical
     stroke because the two years were one above the other and the mark had to
     point the way the reading went. On one line the reading goes across, so
     the mark does too, and a horizontal rule between two dates is the range
     dash every reader already knows.

     A drawn rule rather than an en dash character: at this size the dash is a
     hyphen in most faces, which is why the stacked form was a rule in the
     first place. Same element, same colour, same 45%; only its width and
     height swap. */
  .tl__span {
    width: 0.7em;
    height: 1px;
  }

  /* The tenure was a footer under the two years, separated by a rule ABOVE
     it. Sideways there is no above, so the rule stands up and moves to the
     left of it -- and it should NOT be the same mark as the one between the
     years, which now reads horizontally. One says "2019 through present", the
     other divides that span from the count of it: two different jobs, two
     different marks, which is the whole reason the range mark could be turned.

     On the tenure rather than as a sibling, so it cannot end up on the wrong
     side of the flex gap; it is the chip's own divider and it belongs to the
     thing it divides off. */
  .tl__dur {
    margin-top: 0;
    padding-top: 0;
    border-top: 0;
    align-self: auto;
    display: flex;
    align-items: center;
    gap: 0.5em;
  }

  /* 1.2em of the TENURE's 0.72em type, which lands near the cap height of the
     years beside it -- a rule matched to its own smaller text would have read
     as a tick rather than as a divider. */
  .tl__dur::before {
    content: "";
    width: 1px;
    height: 1.2em;
    background-color: currentColor;
    opacity: 0.45;
  }

  .tl__card {
    grid-column: 2;
    grid-row: 2;
  }

  /* The concurrent role's chip moves with its card.

     In the three-column layout the indent is the card's alone and that is the
     whole device: the date and the node stay on the rail, and only the job
     steps aside. Stacked, the chip is no longer in a shared date column -- it
     is directly above the card, in the card's column -- so a card that steps
     right and a chip that does not reads as the chip belonging to the ROW
     above rather than as one role held inside another. Same variable, so the
     two edges line up exactly however the indent is sized. */
  .tl__item--with .tl__when {
    margin-left: var(--tl-indent);
  }

  /* ---- The sector pill goes up beside the logo ----
     The head is logo / title + employer / sector, in one row on a desktop.
     On a phone that row cannot hold three things, and the sector used to drop
     to a line of its own UNDER the employer -- three stacked blocks where the
     pill, which is one word a skimming reader takes in at a glance, sat at the
     bottom where nothing else was looking.

     It pairs with the logo instead: both are marks rather than text, both are
     read at a glance, and the two of them across the top of the card make one
     row that says what this job was before a word of it is read. The title and
     employer then get the full width underneath, which they wanted anyway.

     Ordered rather than re-nested, because the source order is right for the
     desktop and for a screen reader -- title before sector -- and `order` does
     not change either. `flex-basis: 100%` on the headings is what forces the
     break; without it the three simply redistribute along one line again. */
  .tl__head {
    flex-wrap: wrap;
    align-items: center;
  }

  .tl__head > .logo {
    order: 1;
  }

  .tl__domain {
    order: 2;
    /* Hard right, against the logo's left. `space-between` would do it with
       two items on the line and fail the moment anything else lands there. */
    margin-left: auto;
    /* The desktop pins it to the top of a row whose height is the logo's; here
       it IS beside the logo, so it centres on it -- which is what "aligned
       with the logo" means. */
    align-self: center;
    /* Both marks have to fit one line together, and at the inherited size they
       did not: "CLINICAL HEALTHTECH" measured 178px beside a 109px logo in a
       263px card, so it wrapped straight back to a row of its own and the
       change did nothing. The pill is sized up on a desktop because it is one
       of the few things a skimming reader takes in; sharing a line costs it
       two points, which it can afford -- it is still heavier and wider-tracked
       than the employer line under it. 152px now, and the pair fits with room
       over. */
    font-size: 0.6875rem;
    padding: 0.3em 0.6em;
    letter-spacing: 0.04em;
  }

  /* The other half of the same sum. 7rem was the cap for a logo that had the
     top of the card to itself; sharing it with the pill, the widest wordmarks
     are what push the pair over. 6rem still shows AbbaDox and SimpliLearn
     whole -- `contain` shortens rather than crops -- and buys the 16px that
     closes it.

     Below about 340px the two stop fitting whatever these are, and the pill
     drops to a line of its own again. That is the flex-wrap doing its job,
     not a failure state: it lands above the title rather than below the
     employer, which is where it was going anyway. */
  .tl__head > .logo {
    max-width: 6rem;
  }

  .tl__headings {
    order: 3;
    flex-basis: 100%;
  }
}

@media (max-width: 560px) {
  /* The sector tag used to take a line of its own here, under the employer,
     rather than squeeze the job title into a third of the card. It goes up
     beside the logo instead now -- see the 640px block above -- so the wrap is
     declared there and `order: 3`, which would have put the pill back at the
     bottom, is gone. */

  /* The 7rem cap that used to be here is gone: the 640px block caps the logo
     at 6rem now, so that it and the sector pill fit one line together, and a
     wider cap down here would have overridden it at exactly the widths where
     the pair is tightest. */

}


/* ---- The lock screen ----
   sections/locked.html, only in the deck a visitor without the password gets
   (js/deck-order.js). Two cards side by side: the form for someone who has a
   password, and how to get one for someone who doesn't.

   The unlock card is the wider of the two because its row carries a field
   and a button that both need room; the ask card is prose and a short list.
   Both centre their contents vertically, and the grid stretches them to the
   same height, so their edges line up whichever one runs longer.

   Every colour here is a card token (--card-text*, --card-divider,
   --panel-*), which tools/check-contrast.js already sweeps over every wave
   colour. The one new pair is the coral button, checked there too. */
.lock {
  display: grid;
  grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
  gap: clamp(var(--space-4), 2.4vw, 2.75rem);
  align-items: stretch;
  width: 100%;
  max-width: 72rem;
  margin-inline: auto;
}

.lock__card,
.lock__ask {
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: clamp(var(--space-4), 3.4vh, var(--space-5)) clamp(var(--space-4), 2.6vw, 3rem);
}

.lock__badge {
  display: grid;
  place-items: center;
  width: clamp(2.75rem, 5.5vh, 3.5rem);
  height: clamp(2.75rem, 5.5vh, 3.5rem);
  margin-bottom: clamp(var(--space-2), 2vh, var(--space-4));
  border-radius: 50%;
  background: var(--btn-accent-bg);
  color: var(--btn-accent-text);
  box-shadow: var(--btn-accent-shadow);
}

.lock__badge svg {
  width: 48%;
  height: 48%;
}

.lock__heading {
  margin: 0 0 var(--space-2);
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 1rem + 1.4vw, 2.5rem);
  font-weight: 600;
  line-height: 1.12;
  letter-spacing: -0.02em;
  text-wrap: balance;
  color: var(--card-text);
}

.lock__subheading {
  margin: 0 0 var(--space-2);
  font-family: var(--font-display);
  font-size: clamp(1.25rem, 0.95rem + 0.9vw, 1.875rem);
  font-weight: 600;
  line-height: 1.2;
  color: var(--card-text);
}

.lock__lede,
.lock__ask-copy {
  margin: 0;
  font-size: clamp(1rem, 0.92rem + 0.3vw, 1.25rem);
  line-height: 1.55;
  text-wrap: pretty;
}

.lock__form {
  margin-top: clamp(var(--space-4), 3.4vh, var(--space-5));
}

.lock__label {
  display: block;
  margin-bottom: var(--space-2);
  font-size: var(--fs-small);
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--card-text-accent);
}

.lock__row {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: var(--space-2) var(--space-3);
}

.lock__field {
  position: relative;
  flex: 1 1 14rem;
  min-width: 0;
}

/* The same pill as the buttons beside it, filled like a panel -- the inner
   tier, since it sits inside a card. Room on the right for the eye. */
.lock__input {
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  min-height: 3.25rem;
  padding: 0 3.5rem 0 1.4rem;
  border: 1.5px solid var(--panel-border);
  border-radius: 999px;
  background: linear-gradient(var(--panel-fill-top), var(--panel-fill-bottom));
  color: var(--card-text);
  font: inherit;
  font-size: 1.0625rem;
  letter-spacing: 0.04em;
}

.lock__input:focus-visible {
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
  border-color: var(--card-text-accent);
}

.lock__input[aria-invalid="true"] {
  border-color: var(--card-text-accent);
}

.lock__reveal {
  position: absolute;
  top: 50%;
  right: 0.45rem;
  display: grid;
  place-items: center;
  width: 2.5rem;
  height: 2.5rem;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--card-text-muted);
  cursor: pointer;
  transform: translateY(-50%);
}

.lock__reveal:hover {
  color: var(--card-text);
  background: var(--card-divider);
}

.lock__reveal-icon {
  width: 1.35rem;
  height: 1.35rem;
}

.lock__reveal-icon--hide,
.lock__reveal[aria-pressed="true"] .lock__reveal-icon--show {
  display: none;
}

.lock__reveal[aria-pressed="true"] .lock__reveal-icon--hide {
  display: block;
}

/* Inside the card rather than the band, so the size is a content size, not
   the band-derived one .section__actions gives its buttons. */
.lock .btn {
  font-size: clamp(0.875rem, 0.8rem + 0.25vw, 1.0625rem);
  padding: 0.95em 1.9em;
}

/* Two classes, to outrank `.card p`, which would otherwise make the message
   the muted body tone. An error is the one line here that must be seen. */
.lock .lock__status {
  min-height: 1.5em;
  margin: var(--space-2) 0 0 var(--space-3);
  font-size: var(--fs-small);
  font-weight: 600;
  color: var(--card-text-accent);
}

.lock__projects {
  margin: clamp(var(--space-3), 2.6vh, var(--space-4)) 0;
  padding: 0;
  list-style: none;
  border-top: 1px solid var(--card-divider);
}

.lock__projects li {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0 var(--space-2);
  padding: clamp(0.45rem, 1.2vh, 0.75rem) 0;
  border-bottom: 1px solid var(--card-divider);
}

.lock__project-icon {
  flex: none;
  align-self: center;
  width: 1.05rem;
  height: 1.05rem;
  margin-right: var(--space-1);
  color: var(--card-text-accent);
}

.lock__project-label {
  font-weight: 700;
  color: var(--card-text);
}

.lock__project-sub {
  color: var(--card-text-muted);
}

.lock .lock__cta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3) var(--space-4);
  margin: 0;
}

.lock__email {
  color: var(--card-text-accent);
  font-weight: 600;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.22em;
  overflow-wrap: anywhere;
}

.lock__email:hover {
  text-decoration-thickness: 2px;
}

/* The way into the public Resume, under the password request: quieter than
   both, so it reads as "meanwhile", not as a third call to action. */
.lock .lock__resume {
  margin: var(--space-4) 0 0;
  padding-top: var(--space-3);
  border-top: 1px solid var(--card-divider);
}

.lock__resume a {
  color: var(--card-text-muted);
  font-weight: 600;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.22em;
}

.lock__resume a:hover {
  color: var(--card-text-accent);
  text-decoration-thickness: 2px;
}


/* ---- Contact: "Why Hire Roman?" ----
   sections/contact.html, on the landing page's frame (.about, .about__photo,
   above). The reasons card IS the landing page's credentials card (.creds,
   css/cards.css) with one extra row on top; only the heart that stands in
   for that row's numeral, and the contact card, are new.

   Both size their type in cqw, against .about__body -- the text column, which
   is the container the landing page already declares -- for the landing
   page's reason: the column is the viewport minus a rail minus a portrait,
   and a vw size is wrong on a laptop by the width of both. */
.hire__contact {
  padding: clamp(var(--space-3), 3vh, 2.5rem) clamp(var(--space-4), 4cqw, 3rem);
}

/* The "passionate" row has no figure, so a heart holds the numeral column.
   Sized to the numerals' cap height so the column still reads as one set. */
.hire__heart {
  width: clamp(1.5rem, 8cqw, 2.625rem);
  height: clamp(1.5rem, 8cqw, 2.625rem);
  color: var(--card-text-accent);
}

.hire__links {
  display: grid;
  gap: clamp(var(--space-2), 1.6vh, var(--space-3));
  margin: 0;
  padding: 0;
  list-style: none;
}

.hire__link {
  display: inline-flex;
  align-items: center;
  gap: 0.55em;
  max-width: 100%;
  font-family: var(--font-display);
  font-size: clamp(1.125rem, 5.6cqw, 2.625rem);
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--card-text);
  text-decoration: none;
}

.hire__link span {
  overflow-wrap: anywhere;
  text-decoration: underline;
  text-decoration-color: transparent;
  text-decoration-thickness: 2px;
  text-underline-offset: 0.16em;
  transition: text-decoration-color var(--duration-fast) var(--ease-standard);
}

.hire__link:hover span,
.hire__link:focus-visible span {
  text-decoration-color: var(--card-text-accent);
}

.hire__icon {
  flex: none;
  width: 0.85em;
  height: 0.85em;
  color: var(--card-text-accent);
}


/* Flow mode: one column each. The lock screen's cards stack with the form
   first, since someone holding a password shouldn't have to scroll past the
   explanation meant for everyone else. */
@media (max-width: 1023.98px) {
  .lock {
    grid-template-columns: minmax(0, 1fr);
  }
}

@media (max-width: 560px) {
  .lock .lock__submit {
    flex: 1 1 100%;
  }
}
