/* ==========================================================================
   Layout
   --------------------------------------------------------------------------
   One stylesheet for every theme. Nothing here branches on which theme is
   loaded, and there is no theme class or attribute anywhere in this file.

   The method: write each property unconditionally and let the token decide
   whether it is visible. A section always has a rule; in a theme that draws
   with surface instead, --section-rule-width is none. A figure always has a
   background and a shadow; in a theme that draws with outlines, those are
   transparent and flat. This is what makes the theme switch verifiable in
   dev tools: swap the token file and nothing in this file changes.

   Reads semantic and component tokens only. Never a primitive, never a raw
   value.
   ========================================================================== */

/* --------------------------------------------------------------------------
   The page. A column at least as tall as the viewport, with main taking the
   slack, so on a short page the footer sits at the bottom instead of floating
   halfway down with empty space under it. On a long page the column is taller
   than the viewport anyway, main has no slack to take, and the footer scrolls
   away like everything else. Not fixed, not sticky.

   dvh is the viewport as it currently is, so a mobile browser's toolbar does
   not push a short page's footer below the fold. vh first, for anything that
   does not know dvh yet.

   Nothing below depends on body being block. The case study's grid is on the
   article inside main, main stretches to the full width as a column flex
   item, and the skip link is absolutely positioned, so it takes no part in
   the flex layout.
   -------------------------------------------------------------------------- */

body {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  min-height: 100dvh;
}

main {
  flex-grow: 1;
}

/* --------------------------------------------------------------------------
   Sticky header, from 48rem up, so theme, mode and navigation are reachable at
   any scroll position. Below 48rem the header wraps to two rows and would take
   a large share of a phone's height, so it scrolls away there as before.

   Sticky fails silently if an ancestor has overflow, a transform or a filter.
   The header's only ancestors are body and html, and neither has any of those.

   The background is the page surface, so content scrolling underneath is
   hidden rather than showing through, and it fades with the body on a theme
   change instead of snapping. The layer puts it above the compare figure's
   chips and divider, and below the skip link.

   Anything an in-page link can land on is given a scroll margin the height of
   the header, so it lands below the header rather than under it. Today that
   is only #main, the skip link's target; [id] covers any anchor added later.
   The header's height is not set anywhere, it falls out of these same tokens:
   the switcher's icon box and its block inset, the header's block inset, and
   its bottom rule. Change how the header is built and change this with it.
   -------------------------------------------------------------------------- */

@media (min-width: 48rem) {
  .site-header {
    position: sticky;
    top: var(--space-none);
    z-index: var(--layer-sticky);
    background-color: var(--surface-page);
    transition: background-color var(--motion-duration-ui) var(--motion-ease-ui);
  }

  [id] {
    scroll-margin-top: calc(
      var(--switcher-icon-box) + var(--switcher-inset-block) * 2 +
      var(--space-inset-sm) * 2 + var(--border-width-hairline)
    );
  }
}

/* --------------------------------------------------------------------------
   Sticky header elevation. At the top of the page nothing is under the
   header, so it is flat. Once the page scrolls, content passes beneath it and
   it takes --elevation-sticky. That is a state, not a look, so the rule is
   shared and each theme answers it with its own shadow. Slate keeps its
   divider under the shadow; in Sand, whose divider is transparent, the shadow
   is the only thing separating the header from what is under it.

   Driven by the page's own scroll position, not by script: the animation runs
   on a scroll timeline, from flat at scroll 0 to the full shadow once the page
   has moved by the header's own block inset. With no JavaScript it still
   works. A browser without scroll timelines skips this whole block and gets
   a flat header, which is the right way to fail. Without the @supports guard
   it would run the animation on the ordinary clock instead, finish instantly
   and leave the header permanently shadowed.

   Reduced motion keeps the state and drops the fade: the shadow arrives in
   one step at the end of the same range instead of growing over it.
   -------------------------------------------------------------------------- */

@keyframes site-header-lift {
  from { box-shadow: var(--elevation-flat); }
  to   { box-shadow: var(--elevation-sticky); }
}

@supports (animation-timeline: scroll()) {
  @media (min-width: 48rem) {
    .site-header {
      animation-name: site-header-lift;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: scroll(root block);
      animation-range: var(--space-none) var(--space-inset-sm);
    }
  }

  @media (min-width: 48rem) and (prefers-reduced-motion: reduce) {
    .site-header {
      animation-timing-function: step-end;
    }
  }
}

/* --------------------------------------------------------------------------
   The breakout grid. Content column, and a full row that a breakout element
   spans and then constrains itself within.
   -------------------------------------------------------------------------- */

.case-study {
  display: grid;
  grid-template-columns:
    [full-start] minmax(var(--space-gutter), 1fr)
    [content-start] min(var(--layout-content-max), 100% - var(--space-gutter) * 2) [content-end]
    minmax(var(--space-gutter), 1fr) [full-end];
  row-gap: var(--space-none);
}

.case-study > * {
  grid-column: content;
}

/* The header and footer sit in .container, the case study in the grid above.
   They have to agree on where the page's edges are, so .container's width is
   the grid's content track, written the same way from the same two tokens:
   the content measure, and the small gutter as a floor when the viewport
   cannot hold it. The gutter lives outside the measure in both, never inside.

   Until Sep 11 .container took the full measure as a max-width and then
   subtracted --space-gutter-lg as padding inside it, so its content was
   narrower than the column by twice the large gutter: 48px each side in
   Slate, 64 in Sand. Change one of these two and change the other. */
.container {
  width: min(var(--layout-content-max), 100% - var(--space-gutter) * 2);
  margin-inline: auto;
}

.case-study > .breakout {
  grid-column: full;
  justify-self: center;
  width: 100%;
  /* Three bounds. The theme's breakout width is the cap. Under it, the large
     gutter is kept when the viewport can spare it, so the element gives up
     width rather than the gutter. And whatever the gutter asks for, the
     element is never narrower than the text column it breaks out of.

     Without the gutter, a theme whose breakout is wider than the viewport
     clamps to 100% and the grid's minmax(gutter, 1fr) tracks collapse to
     zero, which puts the widest elements on the page hard against both
     screen edges. That happens to Slate at 1280, the most common laptop
     width, because its breakout is 82.5rem. Capping the breakout to the
     narrower theme's width would have fixed it by erasing a real difference
     between the two.

     Without the text column as a floor, the large gutter is a fixed amount
     taken from a viewport that is not, and on a phone it took more than the
     text column's own margin: until Sep 14, 2026 every breakout figure at
     320px was 192px wide in Sand and 224px in Slate, beside 272px of text,
     and a screen recording inside one played at 128px. The floor is the text
     column written exactly as the grid writes it, so the two cannot disagree
     about where it is. */
  max-width: min(
    var(--layout-breakout-max),
    max(
      100% - var(--space-gutter-lg) * 2,
      min(var(--layout-content-max), 100% - var(--space-gutter) * 2)
    )
  );
}

/* --------------------------------------------------------------------------
   Sections. Separated by a rule, by space, or by both, depending entirely on
   --section-rule-width and --section-gap.
   -------------------------------------------------------------------------- */

.section {
  margin-block-start: var(--section-gap);
  padding-block-start: var(--section-rule-gap);
  border-block-start: var(--section-rule-width) solid var(--divider-color);
}

.section__title {
  margin-block: var(--space-none) var(--space-prose-heading-after);
  /* A theme that runs a large display face wants its headings held to a
     measure; a theme at a smaller heading size does not, and holding it
     anyway forces wraps that were never in the design. */
  max-width: var(--section-title-measure);
  font-family: var(--type-heading-font);
  font-size: var(--type-heading-size);
  font-weight: var(--type-heading-weight);
  line-height: var(--type-heading-leading);
  letter-spacing: var(--type-heading-tracking);
  text-transform: var(--type-heading-transform);
  text-wrap: balance;
  color: var(--text-primary);
}

/* The only rule that sets vertical space between paragraphs. The reset in
   base.css already zeroes paragraph margins, so nothing below should set a
   margin on .section__body p: a margin shorthand there outranks this rule
   on specificity and silently removes every gap. It did, from Sep 9 to
   Sep 11, on every page. */
.section__body > * + * {
  margin-block-start: var(--space-prose-paragraph);
}

.section__body p {
  max-width: var(--measure-prose);
  font-family: var(--type-body-font);
  font-size: var(--type-body-size);
  font-weight: var(--type-body-weight);
  line-height: var(--type-body-leading);
  letter-spacing: var(--type-body-tracking);
  color: var(--text-secondary);
}

.section__body strong {
  font-weight: var(--type-emphasis-weight);
  color: var(--text-primary);
}

/* A list can sit inside a section, under its title, as on Home. There the
   section already spaces it: the title's own bottom margin above, the next
   section's gap below. The entry list's usual margins are for when it stands
   alone in the article, and inside a section they would open a full section
   gap between a heading and the list it heads. */
.section > .entry-list {
  margin-block: var(--space-none);
}

/* Under an introduction rather than straight under the title, the list is set
   off from it the way a following paragraph would be. */
.section__body + .entry-list {
  margin-block-start: var(--space-prose-paragraph);
}

/* And prose that picks up again after the list, as on Unified Protections,
   where the copy runs paragraph, quotes, paragraph. The same gap in the other
   direction; without it the paragraph sits hard against the last quote. */
.entry-list + .section__body {
  margin-block-start: var(--space-prose-paragraph);
}

/* A link inside prose. Until Unified Protections no page had one, so it fell
   to the browser's own link colours, which are not tokens and fail contrast in
   the dark modes. Underlined, so colour is not the only thing marking it. */
.section__body a {
  color: var(--text-link);
  text-decoration: underline;
  text-underline-offset: var(--space-inline-2xs);
}

.section__body a:hover { color: var(--text-link-hover); }

/* The link that follows a section's list to the page holding all of it:
   "All nine projects", "All eleven". Its meaning comes from the section title
   above it, which is why it can be that short. */
.section__more {
  margin-block-start: var(--space-stack-lg);
  font-family: var(--type-ui-font);
  font-size: var(--type-ui-size);
  font-weight: var(--type-ui-weight);
  line-height: var(--type-ui-leading);
  letter-spacing: var(--type-ui-tracking);
}

.section__more a {
  color: var(--text-link);
  text-decoration: underline;
  text-underline-offset: var(--space-inline-2xs);
}

.section__more a:hover { color: var(--text-link-hover); }

/* --------------------------------------------------------------------------
   Figures. Framed, or padded and lifted. Same declarations either way.
   -------------------------------------------------------------------------- */

.figure {
  margin-block: var(--section-gap);
  padding: var(--figure-inset);
  border-radius: var(--figure-radius);
  background-color: var(--figure-surface);
  box-shadow: var(--figure-elevation);
}

.figure__media {
  /* Its own border colour rather than the container's. --border-container is
     what the figure panel is drawn with, so reusing it here asks the media to
     separate from a surface using that surface's own edge colour. */
  border: var(--figure-media-border) solid var(--figure-media-border-color);
  border-radius: var(--radius-media);
}

.figure__caption {
  margin-block-start: var(--caption-gap);
  /* A block in the figure, so it starts at the figure's content-box edge on
     its own: flush with the media in a theme with no figure inset, and inset
     with it in a theme that has one. The cap is the only thing it needs.

     width and max-width compose: the prose measure is the ceiling either way,
     and a theme that wants its captions narrower still gets that through
     --caption-measure. Expressing it as min(--caption-measure, --measure-prose)
     is not possible, because a theme is allowed to set that token to none and
     none is not a length. */
  width: min(var(--measure-prose), 100%);
  max-width: var(--caption-measure);
  padding-block: var(--caption-inset-block);
  padding-inline: var(--caption-inset-inline);
  border-inline: var(--caption-border) solid var(--border-container);
  border-block-end: var(--caption-border) solid var(--border-container);
  background-color: var(--caption-surface);
}

/* --------------------------------------------------------------------------
   Stats. One treatment, not two.

   The four directions had this as either a ruled table or a card grid, which
   are different enough that expressing both would have meant tokenising
   flex-direction. That is the point where a token system turns into a
   stylesheet written in variables. So the structure is fixed here (a row of
   cells that wraps, label above value) and the theme decides the column
   count, the rules and the surfaces.

   --stats-columns is the most cells a row holds, not a count every row is
   forced into. Each cell starts at that share of the row, less the gap, and
   can never shrink below its own content. Where a value or label would not
   fit beside its neighbour, the cell takes the next row instead of painting
   outside its box. A theme that asks for two gets two wherever two fit; a
   theme that asks for one stacks at every width.

   This was a fixed column count until the Density page broke at 320px in
   Sand: two cells about 60px wide, and "1,443" needing 124px. It is fluid
   rather than a breakpoint because the content decides where a row splits,
   so it holds at widths nobody tested.
   -------------------------------------------------------------------------- */

.stats {
  margin-block: var(--section-gap);
  display: flex;
  flex-wrap: wrap;
  gap: var(--stats-gap);
  border-block: var(--stats-edge-width) solid var(--divider-color);
}

.stat {
  flex: 1 1 calc((100% + var(--stats-gap)) / var(--stats-columns) - var(--stats-gap));
  padding: var(--stat-inset);
  border-radius: var(--stat-radius);
  border-block-start: var(--stat-rule-width) solid var(--divider-color);
  background-color: var(--stat-surface);
  box-shadow: var(--stat-elevation);
}

.stat:first-child {
  border-block-start-width: var(--border-width-none);
}

/* --------------------------------------------------------------------------
   Entry list. The same shape as stats: a fixed structure, and the theme
   decides the rules, the surfaces and the gap between entries. One theme
   reads it as a ruled table, the other as a stack of cards.
   -------------------------------------------------------------------------- */

.entry-list {
  margin-block: var(--section-gap);
  display: grid;
  gap: var(--entry-list-gap);
  border-block: var(--entry-list-edge-width) solid var(--divider-color);
}

.entry {
  padding-block: var(--entry-inset-block);
  padding-inline: var(--entry-inset-inline);
  border-radius: var(--entry-radius);
  border-block-start: var(--entry-rule-width) solid var(--divider-color);
  background-color: var(--entry-surface);
  box-shadow: var(--entry-elevation);
}

.entry:first-child {
  border-block-start-width: var(--border-width-none);
}

/* --------------------------------------------------------------------------
   Pull quote. Ruled top and bottom, or set on a filled block.
   -------------------------------------------------------------------------- */

.pullquote {
  margin-block: var(--section-gap);
  padding-block: var(--pullquote-inset-block);
  padding-inline: var(--pullquote-inset-inline);
  border-block: var(--pullquote-rule-width) solid var(--divider-color);
  border-radius: var(--pullquote-radius);
  background-color: var(--pullquote-surface);
}

.pullquote p {
  max-width: var(--measure-narrow);
}

/* --------------------------------------------------------------------------
   The compare component takes the figure treatment, because it is one.
   -------------------------------------------------------------------------- */

.compare {
  margin-block: var(--section-gap);
  padding: var(--figure-inset);
  border-radius: var(--figure-radius);
  background-color: var(--figure-surface);
  box-shadow: var(--figure-elevation);
}

/* --------------------------------------------------------------------------
   Resume. The one page with a sidebar, and the one page wider than the
   header and footer: it reads the wide measure rather than the content
   measure, because two columns inside 720px leave the sidebar about 220px.
   Written the same way as .container, from the same gutter, so it narrows
   the same way when the viewport cannot hold the measure.

   The header and the body share one split, so the contact block above
   lines up with the sidebar below it. One column until 48rem, the width the
   rest of the site already treats as the step up from a phone.
   -------------------------------------------------------------------------- */

.resume {
  width: min(var(--layout-wide-max), 100% - var(--space-gutter) * 2);
  margin-inline: auto;
}

.resume__header,
.resume__columns {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  column-gap: var(--space-inline-xl);
  align-items: start;
}

.resume__header {
  row-gap: var(--space-stack-lg);
}

/* The portrait is a header photo beside the name, and only reads as one when
   there is a column for it to sit in. Stacked, it would land on its own below
   the phone number, so it is hidden until the split exists. display: none also
   takes it out of the accessibility tree, which loses nothing: its alt
   repeats the h1. */
.resume__portrait {
  display: none;
}

@media (min-width: 48rem) {
  .resume__header,
  .resume__columns {
    grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
  }

  .resume__portrait {
    display: block;
  }
}
