/* ═══════════════════════════════════════════════════════════════════════
   mateus.design — shared stylesheet. See design.md for intent.

   One typeface, one size, so hierarchy runs on position, space and colour.
   Three colour roles: --text statements, --dim labels, --link actionable.
   --plate and --plate-dark are surfaces, not voices. Four colours, one set,
   every page — a page sets no colour value at all.

   Markup lives in the HTML; anything two pages would share lives here.
   ═══════════════════════════════════════════════════════════════════════ */


/* ── Tokens ──────────────────────────────────────────────────────────── */

:root {
    /* FOUR COLOURS, WRITTEN OUT. There was a generative system here: one hue
       per project, paper and ink spread ±40° either side of it, the accent
       carrying the most chroma its own hue could hold, and every value on the
       page falling out of two numbers. These four are given instead, and no
       arithmetic over them reproduces the old set — so the values are literal
       and the reasoning below is measurement, not derivation.

       The shape of the old set survives by coincidence and is worth keeping:
       the ink is L 0.300 and the paper L 0.973, within a hair of the
       lightnesses the formulas were solved for. What actually changed is
       chroma. Paper, ink and grey are near-neutral now — C 0.002, 0.007 and
       0.039 — where they used to carry the page's hue, so all the colour on
       a page is in the accent and nowhere else. And the accent moved up:
       L 0.668 against the old 0.555, which is where the contrast note below
       comes from. */
    --bg:   #F5F6F7;
    --text: #2E2D31;

    /* THE CONTRAST BUDGET, which --link and --dim both spend. Everything is
       700 weight, so WCAG's large-text line is 18.66px and both values below
       are measured against 3:1 rather than 4.5:1. That holds wherever --type
       is at or over 1.1667rem, which is a viewport of 583px and up — above
       the fold of any laptop, and the width this budget was set at. Under
       583px --type is on its 1rem floor, body copy is normal text by WCAG,
       and the accents owe a 4.5:1 they do not have. That shortfall is older
       than these colours and was accepted deliberately when the type came
       down; it is wider now.

       Against the paper: ink 12.64:1, grey 3.61:1, accent 3.08:1.

       THE ACCENT HAS 0.08 OF MARGIN. It passes 3:1 and nothing more. The
       previous set ran 3.94–4.13 here, so this is the tightest the page has
       been, and it is tight because the colour is given rather than solved —
       #FF4B33 sits at L 0.668 where the old accent sat at 0.555, and
       lightness is most of contrast. Anything that lifts the paper or
       lightens this red breaks the floor outright. Measure, do not estimate:
       0.08 is inside the error of judging it by eye.

       The grey has more room than the number suggests — 3.61 against a 3:1
       floor — but it is also the value the two surface greys below are
       matched to, so moving it moves three things. */
    --link: #FF4B33;

    /* Not derived any more. It was 58% of the way from the paper to the ink,
       which kept the palette a closed set: move the paper and the grey came
       with it. That is over. This grey is bluer than either value it used to
       sit between — C 0.039 at hue 259, against 0.002 and 0.007 — so no mix
       of them reaches it. Move the paper or the ink and this has to be
       re-measured by hand, along with --dim-on-dark below. */
    --dim:  #738299;

    /* Light plate: a wash of the page's own ink laid over the paper, not a
       colour mixed from it. Translucent on purpose — one alpha holds the same
       step whatever is behind it, where a mixed colour is a second value to
       keep true. It lands on #ECEDEE over the paper.

       --text rather than black, so the surface carries the ink's cast rather
       than introducing a third. Dark plate: true black, a field rather than a
       surface, so bright video has no colour behind it. */
    --plate: #2e2d310b;                                 /* fallback */
    --plate: color-mix(in srgb, var(--text) 4.5%, transparent);
    --plate-dark: #000;

    /* The grey voice against black instead of paper. This one runs the
       other way from the plate's: --dim lifted toward the paper, not sunk
       toward the ink, to 7:1.

       It was solved to 3.61 once, to recede exactly as far as the grey on
       paper, and then to 4.5. Both read as too faint on black: the eye takes
       a dark ground's grey as dimmer than the same ratio on a light one, and
       everything grey on black is small type — captions and callout
       qualifiers. 81% of --dim into the paper gives 7.1, still well short of
       the white beside it at ~18.

       Matched on ratio, not on colour: black does not move when the paper
       does. Move --dim or --bg and re-solve this. */
    --dim-on-dark: #8a97ab;                             /* fallback */
    --dim-on-dark: color-mix(in oklab, var(--dim) 81%, var(--bg));

    /* And the same voice against the light plate, #ECEDEE. This one was
       dropped when the palettes went — a 4.5% tint looked too small a step to
       deserve its own value, plain --dim measuring 3.33 on it against 3.61 on
       paper. Measurement put it back: table heads, light callouts and their
       qualifiers all sit on the plate, and 0.28 is not nothing at 3:1.
       93.4% re-solves it to 3.611. Captions on the plate used to wear this
       too, and now take --caption-on-plate below, solved to 4.5.

       Same rule as the second: each surface gets the voice solved against
       it, so grey recedes by the same amount on paper and plate — 3.61 and
       3.61. Chained to --plate's alpha: change one and re-solve this. */
    --dim-on-plate: #6e7c92;                            /* fallback */
    --dim-on-plate: color-mix(in oklab, var(--dim) 93.4%, var(--text));

    /* Which of the three greys is in force, set by the surface rather than by
       the consumer. Paper is the default and every plate overrides it, so a
       caption, a table head or a bullet asks for "the grey voice here" and
       gets the one solved against what it is actually sitting on. The
       alternative is the same colour written twice per component, once per
       surface, which is how the two of them drift apart. */
    --dim-local: var(--dim);

    /* Captions have their own grey. A caption is --type-caption, a fixed 16px,
       so it never reaches the large-text line and owes 4.5:1, not the 3:1 the
       rest of the grey voice is held to. Same blue-grey, pulled toward the ink
       until it clears that line on each surface: 82% of --dim gives 4.55 on
       paper, 76% gives 4.53 on the plate. On black the dark grey is already
       past 4.5, at 7, so it serves for both.

       Chosen by the surface through --caption-local, the way --dim-local is.
       Chained to --dim, --text and --plate's alpha: move any and re-solve. */
    --caption: #657184;                                 /* fallback */
    --caption: color-mix(in oklab, var(--dim) 82%, var(--text));
    --caption-on-plate: #626c7e;                        /* fallback */
    --caption-on-plate: color-mix(in oklab, var(--dim) 76%, var(--text));
    --caption-local: var(--caption);

    /* ── Space ───────────────────────────────────────────────────────
         S    M / 2.5   inside a thing
         M    M         the working rhythm — paragraphs, plate to plate
         L    M × 2.5   one remove from the text — sub-heads, callouts
         XL   M × 5     between text and an image
         XXL  M × 10    between sections

       M is the page margin and the rest are multiples of it: one clamp in the
       system, no pixel values anywhere, each step far enough from the next
       that a gap either matches another or is plainly different. Typographic
       measures stay in em, off this scale — they belong to their text.

       This clamp is the type clamp's old ceiling scaled by 0.6, the same
       factor --type came down by, because the rhythm is only harmonic while
       the gaps stay in proportion to the text they separate. Left alone it
       would have read as a section break of ten lines where it used to be
       six: at 1280px, M measures 15.4px and XXL 153.6px against 24px type,
       which is the ratio the page had before. Move --type and this moves. */
    --space-m: clamp(0.6rem, 1.2vw, 1.5rem);
    --space-s: calc(var(--space-m) / 2.5);
    --space-l: calc(var(--space-m) * 2.5);
    --space-xl: calc(var(--space-m) * 5);
    --space-xxl: calc(var(--space-m) * 10);

    --type: clamp(1rem, 3.2vw, 1.5rem);

    /* The one exception to a single size: a caption labels an object rather
       than addressing the reader. The ceiling is --type's floor, so the two
       scales interlock on a number the system already holds. Floor and
       ceiling are the same number, so a caption is a fixed 16px and it is
       body copy that moves: they read as one size on a phone, where the grey
       is what separates them, and diverge as the viewport grows.

       Tied to --type's floor rather than written out, because a caption that
       stays put while body copy shrinks ends up the larger of the two. That
       is exactly what happened when --type's floor went 1.25rem → 1rem and
       this stayed at 1.25rem: under a 625px viewport the caption outsized the
       paragraph above it. Follow the floor down, or the hierarchy inverts. */
    --type-caption: clamp(1rem, 1.2vw, 1rem);

    /* How long a line may get. Fixed on purpose: a comfortable line is a
       character count, and since the type has a ceiling so does the line
       carrying it. Measured in Chrome at the 1.5rem ceiling this holds 54.8
       characters. It is a character count, so it is not independent of
       --type: at the old 2.5rem ceiling the same 1105px held 54.2, and left
       at 1105px against 24px type it measured 90.3 — a line no one finishes.
       Re-measure rather than re-derive. Images ignore this and run to the
       page edge.

       Two meanings now, one value. Here it is the text column, as described.
       On a case study above 1200px it is two fields of the column grid — half
       the text column rather than all of it — and the grid's page cap is
       solved from it as 3 × --measure + 4 × --space-m. The line there is a
       range, ~40 characters to 54.8, not the fixed count above. See Column
       grid at the foot of this file, and design.md. */
    --measure: 663px;
}


/* There were seven palettes here — one hue per project, set with an attribute
   on <html>, and hovering a row of the work list re-tinted the whole of home
   to that project's colours. It is gone. One set now dresses every page, so
   there is no attribute to read and nothing for a page to declare.

   The transition below is what is left of it. It used to be scoped to
   `*, *::before, *::after` because a cross-fade has to put every element that
   paints a colour on one clock, and its comment specifically rejected
   narrowing to `a` — links would be the last thing still moving after the
   page had arrived, two events from one gesture. That objection died with the
   cross-fade: there is no page-wide change left to be last after. On `*` it
   would now ease nothing at all, and deleted outright it would snap the one
   colour change that remains, which is a link under the pointer. */
@media (prefers-reduced-motion: no-preference) {
    a { transition: color 0.35s ease, background-color 0.35s ease; }
}


/* ── Base ────────────────────────────────────────────────────────────── */
* { box-sizing: border-box; }

/* The rail is sticky, so a hard anchor jump swaps the column beside it with
   nothing to say the page moved. Gated — a long smooth scroll is squarely
   what the reduced-motion setting exists to switch off. */
@media (prefers-reduced-motion: no-preference) {
    html { scroll-behavior: smooth; }
}

body {
    margin: 0;

    /* Paper is flat. It was a fade once — the hue at the head of the viewport
       spent to white at its foot, anchored to the viewport because a case
       study runs past 30,000px and a document-sized gradient arrives as a flat
       tint anyway. It is gone, and what it took with it is worth recording:
       the foot of every page used to read at higher contrast than the head, so
       the numbers in the contrast budget above were the worst case. Now they
       are the only case, which is what makes them worth measuring once.

       Flat also means background-color and nothing over it. That mattered
       most when the palettes cross-faded, since a gradient is uninterpolable
       and would have snapped the paper while the type eased. There is no
       cross-fade left, so it is a constraint on anything added here later
       rather than a description of what is running. */
    background-color: var(--bg);

    color: var(--text);
    font-family: "neue-haas-grotesk-display", sans-serif;
    font-weight: 700;
    font-style: normal;
    font-size: var(--type);

    /* Unitless, and that is load-bearing: a ratio is re-resolved against each
       element's own font-size, where 1.25em would compute to a length here and
       be inherited as that length everywhere. Same 30px on body copy either
       way — the difference only shows on the second scale. The caption-size
       things that matter set their own 1.35, but .compare-tabs button does
       not, and at 1.25em it took a 30px line box on 16px text instead of
       20px. Anything added later at caption size would inherit the same bug
       silently, which is the argument for the ratio over the length. */
    line-height: 1.25;
    -webkit-font-smoothing: antialiased;
    text-rendering: optimizeLegibility;
}

/* one size means one size — no negotiating with the UA stylesheet */
h1, h2, h3, p, ul, li, dl, dt, dd, figure, figcaption, blockquote {
    margin: 0;
    padding: 0;
    font: inherit;
}

ul { list-style: none; }

/* Both levels are labels, so both are grey. What tells them apart is the
   room they get: a section name arrives after XXL and stands clear of its
   content at L; a sub-head arrives after L and binds at M, because it
   belongs to the text under it rather than introducing a new part. */
h2, h3 { color: var(--dim); }

h2 + * { margin-top: var(--space-l); }

h3 { margin-top: var(--space-l); }
h3 + * { margin-top: var(--space-m); }

.sr-only {
    position: absolute;
    width: 1px; height: 1px;
    margin: -1px; padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}


/* ── Links and selection ─────────────────────────────────────────────── */

/* setting color here too, so links stay legible inside a selection */
::selection {
    background-color: var(--link);
    color: var(--text);
}

/* .action rides along: a button that acts on the page instead of taking you
   somewhere is still the part you can act on. Chrome stripped below — a UA
   button frame at this size is a box the width of the column. No transition
   here; the shared one above already carries colour. */
a, .action {
    color: var(--link);
    text-decoration: none;
}

/* Two layers, both derived from --link so neither hardcodes the accent:
   color-mix lands first, relative oklch overrides where supported.

   Chroma comes down with lightness by the same factor. Holding chroma while
   darkening pushes the colour out of sRGB and what returns is a different hue
   — 6.2 degrees off for the red when this was measured across seven accents,
   6.1 for the blue. Scaling both kept every one of them inside 1.2 degrees, so
   a hover is the accent darkened rather than a neighbour. One accent now, but
   the rule is the reason it is written as a scale rather than a literal. */
a:hover, .action:hover {
    color: color-mix(in oklab, var(--link) 80%, black);
    color: oklch(from var(--link) calc(l * 0.8) calc(c * 0.8) h);
}

a:active, .action:active {
    color: color-mix(in oklab, var(--link) 60%, black);
    color: oklch(from var(--link) calc(l * 0.6) calc(c * 0.6) h);
}

.action {
    font: inherit;
    background: none;
    border: 0;
    padding: 0;
    cursor: pointer;
}


/* ── Layout ──────────────────────────────────────────────────────────── */

/* This is home, about and the blog at every width, and a case study below
   1200px. Above that a case study is on six fields instead — see Column grid
   at the foot of this file, which overrides the two tracks below.

   Two constraints, and one number cannot serve both. Text stops at --measure;
   images stop at twice it, so the two widths in the page are one number and
   its double and an image reads as exactly two columns of type. Both caps are
   off --measure, so moving the line length moves the whole composition.

   The second track is the cap: everything in the column inherits it, so an
   image runs to the edge of the column rather than the edge of the glass. Left
   uncapped a figure measured 2323px against 663px of text on a 2560px display
   — three and a half columns, which is a different page, not a wider one.

   Left, not centred: past the cap the tracks stop growing and the leftover
   becomes margin, and it all goes on the right. The rail is the page's left
   edge and stays there at every width — centring would float the whole
   composition and break the one alignment the page has held since the top of
   the stylesheet. Written out rather than left to the initial value, which
   resolves to stretch and only packs left because the tracks cannot stretch.
   Only visibly different past 1563px, where the tracks stop filling the
   viewport. */
.page {
    display: grid;
    grid-template-columns: max-content minmax(0, calc(var(--measure) * 2));
    justify-content: start;
    column-gap: var(--space-m);
    padding: var(--space-m);
    align-items: start;
}

/* figures and figure rows written as the exception, so anything added later
   is capped by default rather than silently running full width. A row is a
   div, so it needs naming here or it clamps to the measure while the single
   figures beside it run to the page edge.

   .fields is transparent here, and named twice for it. It exists so a case
   study's section is one grid row tall and its heading can pin (see Column
   grid); it is not a thing to be capped. Capped, it would take every figure
   inside it down to the measure at the widths below the grid's breakpoint —
   which is exactly where these rules are the whole layout. */
.statement > *:not(figure),
.study > * > *:not(figure, .figure-row, .fields),
.study .fields > *:not(figure, .figure-row) {
    max-width: var(--measure);
}

/* Stops spaced down the full viewport, sticky for the whole scroll: who,
   where you can go, where I am. The home page has no nav and carries the
   first and last only. */
.rail {
    position: sticky;
    top: var(--space-m);
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    height: calc(100dvh - 2 * var(--space-m));
}

/* nowrap because the rail is a max-content column: with spaces in the
   wordmark it could shrink to "Mateus •" and break the name in two */
.brand {
    display: inline-block;
    width: fit-content;
    white-space: nowrap;
}

.rail-nav ul {
    display: flex;
    flex-direction: column;
}

/* Named but not built: grey and deliberately not a link, which is better than
   one that looks live and goes nowhere, and keeps the hover scramble firing
   only on things that take you somewhere. A work-list row has no <a> to be
   its tile, so the <li> takes that role, as a role-list row does; a click on
   it pops a .tip over the title to say why nothing happened. */
.rail-nav .soon,
.work-list .soon { color: var(--dim); }

.location {
    font-style: normal;
    margin: 0;
}

/* direct children only — the reveal's per-character spans must not be
   forced to block */
.location > * { display: block; }

/* the colon sits low against lining figures — raise to optical centre */
.clock-colon {
    display: inline-block;
    transform: translateY(-0.07em);
}

.location .wx-line {
    display: flex;
    align-items: center;
    gap: 0.3em;
}

.wx-icon {
    font-family: "Material Symbols Sharp";
    font-weight: normal;
    font-variation-settings: "FILL" 1, "wght" 500, "GRAD" 0, "opsz" 40;
    font-size: 1.1em;
    line-height: 1;
    letter-spacing: normal;
}


/* ── Home ────────────────────────────────────────────────────────────── */
.statement p { margin: 0; }

.statement p + p { margin-top: var(--space-m); }

/* One step under the XXL a case study's sections take. A case study earns that
   break because it runs past 30,000px and the reader needs telling a new part
   has started; home is a statement and two short lists, where the same gap
   left each section stranded on its own screen. */
.statement > section { margin-top: var(--space-xl); }

/* home opens on a headingless section where it opened on a paragraph, and
   the rule above gives every section the interval. At the top of the page
   there is nothing to be spaced from — at any width, or the statement drops
   below the line every other page starts its main on. */
.statement > section:first-child { margin-top: 0; }

.work {
    scroll-margin-top: var(--space-m);   /* land clear of the top edge */
    padding-top: var(--space-xxl);
}

/* Two lists of one-line rows: the work, then the roles behind it. The whole
   work row is the link, so the target is the full line and the hover scramble
   churns title, company and year as one object. */
.work-list li + li,
.role-list li + li { margin-top: var(--space-s); }

/* A row is one colour, in both lists. The credit is a qualifier and would
   ordinarily take --dim, but a row here is read as a single object — company,
   title and years are one statement, not a payload with a footnote — and two
   colours would arrive as two things. The work list used to have a stronger
   version of the same reason, that its subject was the palette itself and a
   row busy changing colour had to change as one; the palettes are gone and the
   first reason carries it alone. Hovering still darkens the work row via the
   ordinary a:hover, which is now the only sign the row is the one being
   pointed at. */
.work-list .credit,
.role-list .credit { color: inherit; }

/* Home's two lists are grids of cards, at every width — a project or a role
   is name, qualifier and date stacked in its own tile, rather than a line
   with its facts run together. Only the column count changes with the viewport;
   everything else about the tile is one rule, unconditional, so the same
   shape holds from four across down to one.

   The work list's tile is its <a> — the whole card is one link, so the flex
   column has to be the element that is one. Most role-list rows have nothing
   to link to, so the <li> is the tile directly, no wrapper needed — except
   the last, "Learn about me," which is a plain <a> in a single-child <li> and
   reads as the set's own call to action, in --link rather than ink. */
.statement .fields > :is(.work-list, .role-list) {
    display: grid;
    column-gap: var(--space-m);
    row-gap: var(--space-l);
    max-width: none;
}

.statement .fields > :is(.work-list, .role-list) li { margin-top: 0; }

.statement .fields > .work-list li > a,
.statement .fields > .work-list li.soon,
.statement .fields > .role-list li {
    display: flex;
    flex-direction: column;
}

/* The blog's list (the only .work-list inside a .study) sets its date apart
   by space alone, no bullet. On the grid the date has its own column, and
   the margin is taken back there. */
.study .work-list .credit { margin-left: var(--space-m); }

/* Two columns at every width below the desktop tier — measured down to
   320px before this was settled on: cards narrow to 146px there and nothing
   overflows, "Senior to Staff Product Designer" wraps to more lines rather
   than breaking anything. No media query needed; this is the default the
   four-column tier overrides upward, not a tier of its own with a floor. */
.statement .fields > :is(.work-list, .role-list) {
    grid-template-columns: repeat(2, minmax(0, 1fr));
}

/* A pill that pops above an element to say what a click just did: "Copied!"
   on the email link, "Coming soon" on a .soon work row. The words are the
   element's own data-tip, and JS adds .is-tipped for a moment and takes it
   away, so a new use is markup only. Absolutely positioned, so it never
   reflows the sentence it sits in, and set flush with the element's left
   edge, the edge everything on the page aligns to. */
.tip { position: relative; }

.tip::after {
    content: attr(data-tip);
    position: absolute;
    bottom: 100%;
    left: 0;
    margin-bottom: 0.2em;
    padding: 0.15em 0.5em;
    border-radius: 999px;
    background-color: var(--link);
    color: var(--text);
    line-height: 1.2;
    white-space: nowrap;
    pointer-events: none;
    opacity: 0;
    transform: translateY(0.35em);
    transition: opacity 0.18s ease, transform 0.18s cubic-bezier(0.2, 0, 0.2, 1.4);
}

.tip.is-tipped::after {
    opacity: 1;
    transform: none;
}

@media (prefers-reduced-motion: reduce) {
    .tip::after { transition: opacity 0.1s ease; }
}


/* ── Case study ──────────────────────────────────────────────────────── */
.study p + p,
.study p + dl,
.study p + ul,
.study ul + p,
.study dl + p { margin-top: var(--space-m); }

.study > section { margin-top: var(--space-xxl); }

.hero .lede { color: var(--dim); }

/* A page whose text opens beside its title, not under a section head
   (about-me.html). Stacked, it takes the L a head gives what follows it. */
.hero > .fields { margin-top: var(--space-l); }

/* Ends the case study, so it takes the larger break. Scoped to .study to
   outrank the plate-to-text rule below — a bare `.next` loses to it. */
.study .next { margin-top: var(--space-xl); }

/* ── Paired lines ─────────────────────────────────────────────────────
   A second texture at the same size: terms align, so the block reads as a
   table rather than prose. Short terms only.

   minmax(0, max-content), not plain max-content: an over-long term then
   shrinks and wraps instead of widening the track past the container and
   squeezing the value column to nothing. */
.pairs {
    display: grid;
    grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
    column-gap: var(--space-l);
}

.pairs dt { color: var(--dim-local); }
.pairs dd { grid-column: 2; }

/* reversed: here the term is the payload and the line after it qualifies */
.pairs.lead dt { color: var(--text); }
.pairs.lead dd { color: var(--dim-local); }

/* A pairs inside a callout states its own layout rather than inheriting the
   two-column default or the page grid's subgrid — a block's dt is a label
   over content that does not fit beside it (a paragraph, a list), so it
   stacks at every width rather than only once mobile runs out of room. The
   values are the mobile-only rule's own, made unconditional: the gap between
   term and detail is S, "rows of a pair" in the space table; a further pair
   in the same block takes L above its term, same as a new list item would. */
.block .pairs {
    grid-template-columns: minmax(0, 1fr);
}

.block .pairs dd {
    grid-column: 1;
    margin-top: var(--space-s);
}

.block .pairs dt:not(:first-of-type) { margin-top: var(--space-l); }

/* dt and dd share a grid row, so their top margins must move together or
   the term drifts out of line with the detail beside it */
.pairs dt:not(:first-of-type),
.pairs dt:not(:first-of-type) + dd { margin-top: var(--space-s); }

/* separate observations, not two rows of one table */
.pairs.lead dt:not(:first-of-type),
.pairs.lead dt:not(:first-of-type) + dd { margin-top: var(--space-l); }

/* ── Tables ───────────────────────────────────────────────────────────
   .pairs is this idea at two columns: it reads as a table because the terms
   align and the term is grey, not because anything is drawn around it. A
   table is the same means at three columns or more — alignment and colour
   doing the work a rule would do in another system. There are no borders
   anywhere here and a head rule would be the first one.

   Caption scale, which is the one second size the system already carries, so
   this adds none. It is also the right voice: a table is scanned and referred
   back to rather than addressed to the reader, which is what a caption is.

   The wrapper holds the plate and the scrolling; the table holds the type.
   That split is what lets a wide table scroll inside its own object instead
   of widening the page — a table is the second thing here that cannot wrap,
   after the blog's code blocks, and at a plain 1fr it would size the column
   to itself and take the rail with it. */
.table {
    --dim-local: var(--dim-on-plate);
    margin-top: var(--space-l);
    padding: var(--space-m);
    background: var(--plate);
    overflow-x: auto;
}

.table table {
    width: 100%;
    border-collapse: collapse;
    font-size: var(--type-caption);
    line-height: 1.35;
}

/* The head is a row of labels, so it takes the grey voice — the same job,
   and the same colour, .pairs gives its dt. Weight is inherited because
   there is only one: colour is what separates the head from the body. */
.table th {
    font-weight: inherit;
    text-align: left;
    color: var(--dim-local);

    /* A head is a label, so it stays on one line — "Held by" broken across
       two rows reads as two heads. Same demand .pairs makes of its terms:
       keep them short. A long one widens the table, and the wrapper's scroll
       is what stops that reaching the page. */
    white-space: nowrap;
}

.table td { color: var(--text); }

.table :is(th, td) { vertical-align: top; }

/* The gutter and the row step are .pairs' own two values, so a table and a
   pair of lines space identically. Padding rather than border-spacing, which
   would also inset the plate's own edge and break the alignment with every
   other block on the page. */
.table :is(th, td) { padding: 0 var(--space-l) var(--space-s) 0; }

.table :is(th, td):last-child { padding-right: 0; }
.table tr:last-child td { padding-bottom: 0; }

/* Head to body is one remove, the step a sub-head takes above itself. */
.table thead th { padding-bottom: var(--space-m); }

/* Not added to the scroll reveal's target list in common.js on purpose.
   A figure doesn't churn either — only its caption does — so a plate-borne
   object arriving whole is the behaviour the page already has. Churning
   every cell would also make one object flicker in dozens of pieces. */

/* ── Card sort ────────────────────────────────────────────────────────
   A research artefact that argues rather than illustrates: the same
   twenty-six items grouped three ways, so you watch a hierarchy fail to
   agree with itself and then resolve. A screenshot of it would be a
   picture of text; this is the text.

   Everything here is .table's reasoning applied to a grid instead of a
   table. Caption scale, because this is scanned and referred back to
   rather than addressed to the reader — the second size the system
   already carries, and no third one. Alignment and colour do the work a
   border would do elsewhere. The frame holds the plate's scroll so seven
   columns of a merchant's own sort can run past the measure without
   taking the page with them.

   Deliberately NOT monospaced, unlike the blog's code blocks. That
   exception is bought by a slashed zero and a distinguishable 1 and l, in
   running text where the reader has to tell them apart. YAML's structure
   is carried by indent depth, which is padding here rather than a count
   of spaces, so the second family would buy nothing and cost the one
   typeface. */
/* The tabs are .compare-tabs, borrowed whole rather than restyled: this
   site has one way of putting a set of views behind a row of words, and a
   second one that merely looked similar would be the thing worth avoiding.
   Above the object, where that component already puts them — you should
   know the figure moves before it moves.

   The slug sits under them and names the sitting you are looking at: which
   of the seven merchants, or which file. Centred, because it belongs to the
   tabs above rather than to the columns below. */
.sort-slug {
    margin-bottom: var(--space-m);
    font-size: var(--type-caption);
    line-height: 1.35;
    text-align: center;
    color: var(--dim-local);
}

/* The positioning context the exit animation measures against: a cell
   being removed is taken out of flow and flown back into the copy it
   split from, and it has to be placed against something that isn't the
   viewport, or the frame's own scroll would drag it. */
/* overflow-y is hidden rather than left to follow overflow-x's auto: the
   frame is pinned to an explicit height while an act changes, and an auto
   y would put a scrollbar on the figure for the length of every
   transition. Nothing here ever wants to scroll downward anyway. */
.sort-frame {
    position: relative;
    margin-top: var(--space-m);
    overflow-x: auto;
    overflow-y: hidden;
}

/* auto-flow column, not a fixed count: an act has four columns, six or
   seven depending on how the merchant in front of it that day decided to
   carve the deck up. 8.5rem is the floor at which the longest label —
   "Yoco balance transaction history" — wraps to three lines and stops. */
.sort-grid {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: minmax(8.5rem, 1fr);
    column-gap: var(--space-l);
    align-items: start;
    font-size: var(--type-caption);
    line-height: 1.35;
}

/* A column head is a label, so it takes the grey voice .table gives its
   th and .pairs its dt. The kind line above it is the YAML act's
   `- type: tab`, empty and collapsed in the other two. */
.sort-head {
    margin-bottom: var(--space-m);
    color: var(--dim-local);
}


.sort-grid ul {
    margin: 0;
    padding: 0;
    list-style: none;
}

.sort-grid li + li { margin-top: var(--space-s); }

/* How many of the eight testers filed this item here, carried on the ink
   rather than on a six-step blue ramp as the original matrix had it —
   the same information in a system with three colour roles and no
   spare.

   The obvious version of this — mix straight from --dim to --text —
   was built first and is too quiet to read. Those two are a voice apart,
   not a volume apart: six steps across them is 0.05 lightness each, and
   the middle four are indistinguishable at caption size. So the ramp runs
   past both ends of the pair, a step lighter than --dim and a step darker
   than --text, which roughly doubles the gap and lets six counts read as
   six. Both ends are still mixed from the system's own values rather than
   written out, so the ramp moves if --dim or --text ever do.

   The dark end is under --text, which is the one place on this site that
   deliberately goes past the ink. It is a data ramp: the extremes are
   read against each other, not against the paper, and the values that
   matter for contrast — 4, 5, 6 — all sit at or above --text. */
.sort-grid {
    --sort-faint: color-mix(in oklab, var(--dim-local) 52%, var(--bg));
    --sort-solid: color-mix(in oklab, var(--text) 78%, #000);
}

.sort-grid li {
    --n: 6;
    color: color-mix(in oklab,
        var(--sort-solid) calc((var(--n) - 1) / 5 * 100%), var(--sort-faint));
}

.sort-grid li[data-n="1"] { --n: 1; }
.sort-grid li[data-n="2"] { --n: 2; }
.sort-grid li[data-n="3"] { --n: 3; }
.sort-grid li[data-n="4"] { --n: 4; }
.sort-grid li[data-n="5"] { --n: 5; }

/* The leftover pile — the group a merchant swept what they had no use for
   into. Named by them, so it keeps its name and loses its weight. */
.sort-col[data-rest] .sort-head,
.sort-col[data-rest] li { color: var(--dim-local); }

/* The live act is read against the closed sort it just replaced, so it
   keeps that shape and those words rather than taking on the notation the
   tree is actually stored in. An earlier build rendered it as the YAML
   itself — `- type: anchor`, `ref: sales.new_sale` — which was accurate
   and unreadable: you had to translate every line back into the card you
   had been watching to see where it had gone.

   So the only things that change are the ink and one mark. An anchor is
   the page's single home and keeps full weight; a link is one of its
   other doors, so it recedes and takes an arrow. That is the whole
   finding — one home, many doors — with nothing to decode. */
.sort-grid.is-live li[data-kind="link"] { --n: 3; }

.sort-kind:not(:empty) { margin-right: 0.25em; }

/* A hanging indent, so a label too long for the column wraps clear of the
   arrow instead of running back underneath it and reading as its own
   entry. Applied to every cell in the act rather than only the linked
   ones, or the two kinds would no longer share a left edge. */
.sort-grid.is-live li {
    padding-left: 1em;
    text-indent: -1em;
}

/* The tabs are hidden until the module claims them: with no JavaScript the
   closed sort in the markup is the whole figure, and three tabs that
   switch to nothing would be a lie about what the page can do. */

/* The caption belongs to the act and changes with it, but it is still a
   caption — it keeps the interval and the centring every other one has. */

/* Reduced motion keeps all three acts and drops only the travel between
   them: the steps still work, the module simply cuts. Auto-play is off
   too — an animation that starts itself is the thing the setting is
   asking about. */
@media (prefers-reduced-motion: reduce) {
    .sort-grid li { transition: none !important; }
}

/* Print gets the live tree, which is the one act that is a conclusion
   rather than evidence for one. The steps are furniture on paper. */
@media print {
    .sort .compare-tabs { display: none; }
    .sort-frame { overflow-x: visible; }
}


/* ── Lists ────────────────────────────────────────────────────────────
   Marker hangs in the margin so the text keeps a straight left edge. Same
   bullet the wordmark and work list already use — a mark the page has
   established, not a new shape for lists alone. */
.points li {
    position: relative;
    padding-left: 0.8em;
}

.points li::before {
    content: "•";
    position: absolute;
    left: 0;
    color: var(--dim-local);
}

.points li + li { margin-top: var(--space-s); }

/* An ordered list is the same object with a different marker — the counter
   replaces the bullet and hangs off the same left edge. Wider indent
   because a number is wider than a dot, and it has a stop after it. */
.points.numbered { counter-reset: point; }

.points.numbered li {
    counter-increment: point;
    padding-left: 1.4em;
}

.points.numbered li::before { content: counter(point) "."; }

/* ── Blocks ───────────────────────────────────────────────────────────
   A raised claim, on the same plate the artwork sits on, so emphasis and
   evidence read as the same kind of object. Both plates are available here,
   so both set --dim-local: the supporting voice follows whichever surface
   the block is wearing, without duplicating the markup. */
.block {
    --dim-local: var(--dim-on-plate);
    margin-top: var(--space-l);
    padding: var(--space-m);
    background: var(--plate);
}

.block.dark {
    --dim-local: var(--dim-on-dark);
    background: var(--plate-dark);
    color: var(--bg);
}

/* One rule for every plate pairing, so the interval can't drift between
   combinations. M, not the larger step used against prose: consecutive
   plates are a set, and a plate's own edge already does the separating. */
:is(figure, .figure-row, .block, .block-row, .table) + :is(figure, .figure-row, .block, .block-row, .table) {
    margin-top: var(--space-m);
}

/* ── Block rows ───────────────────────────────────────────────────────
   Parallel call-outs run across rather than down — three peers stacked look
   like three steps. Equal height is what makes a row read as a set.
   A row or a stack, nothing between: auto-fit passes through a two-up state
   on the way down and strands the third card on its own line. */
.block-row {
    display: grid;
    gap: var(--space-m);
    margin-top: var(--space-l);
}

/* only once there is genuinely room — below this, columns barely ten
   characters wide */
@media (min-width: 1080px) {
    .block-row {
        grid-auto-flow: column;
        grid-auto-columns: 1fr;
    }
}

.block-row .block { margin-top: 0; }   /* inside a row the gap does it */

/* Leaving a plate mirrors arriving at one. One rule per direction, covering
   every plate-and-text combination: split into partial rules, each looks right
   alone and the set quietly misses a case. */
:is(figure, .figure-row) + :is(p, ul, dl, h2, h3) {
    margin-top: var(--space-xl);
}

/* A callout sits a step closer to the text than an image: it is still the
   body's own voice, where an image is a change of medium. A table is in that
   same voice — set in the page's own words, not a picture of some. */
:is(.block, .block-row, .table) + :is(p, ul, dl, h2, h3) {
    margin-top: var(--space-l);
}

.block > * + * { margin-top: var(--space-s); }

.block .support,
.block .points,
.block .pairs.lead dd { color: var(--dim-local); }

.block .pairs.lead dt { color: inherit; }

/* ── Figures ──────────────────────────────────────────────────────────
   The caption lives inside the plate, so the whole thing is one object
   rather than an image with a line loose underneath. */
figure {
    --dim-local: var(--dim-on-plate);
    --caption-local: var(--caption-on-plate);
    margin-top: var(--space-xl);
    padding: var(--space-m);
    background: var(--plate);
}

figure img,
figure video {
    display: block;
    width: 100%;
    height: auto;
}

/* A working prototype rather than a picture of one. Its height is the
   prototype's own — the dialog and the state picker below it need ~680px at
   any width — so it is set as an attribute in the markup, like an image's,
   and only the width follows the figure. The live one brings its own canvas,
   so it goes in a .bare figure: a plate around it would frame it twice. A
   frozen state (?static) drops its canvas instead and sits on the plate. */
figure iframe {
    display: block;
    width: 100%;
    border: 0;
}

/* On a plate the frame runs out over the plate's padding to its top and side
   edges. An iframe clips at its own box, so a shadow drawn inside it stops
   at the frame, not at the plate — inset by the padding, the panel's shadow
   was cut off a plate's padding short of the plate's edge. The frame carries
   its own air instead, enough for the whole shadow. */
figure:not(.bare) > iframe {
    width: calc(100% + 2 * var(--space-m));
    margin: calc(-1 * var(--space-m)) calc(-1 * var(--space-m)) 0;
}

/* Centring only works because the caption is small — at full size it would
   compete with prose, which all hangs off one left edge. Measure capped so a
   long caption stacks tidily instead of running the plate's full width. */
figcaption {
    margin-top: var(--space-m);
    margin-inline: auto;
    max-width: 56ch;
    font-size: var(--type-caption);
    line-height: 1.35;
    text-align: center;
    color: var(--caption-local);
}

/* The plate is for artwork assembled for the page — a screenshot or a
   diagram, which needs a surface to sit on. A real photograph or a piece of
   footage already reads as an object; putting it on a plate frames it twice. */
figure.bare {
    --dim-local: var(--dim);
    --caption-local: var(--caption);
    padding: 0;
    background: none;
}

/* Figures are the one thing exempt from --measure; this opts back in, for a
   specimen that full width would oversell, or for artwork the prose is
   arguing about rather than illustrating beside. --fig-w caps it tighter — a
   2x export goes soft past half its own pixel width. .compare makes the same
   opt-in for itself, without needing the class. */
figure.inset { max-width: var(--fig-w, var(--measure)); }

/* A wider inset sits closer to artwork that carries its own frame. .inset
   wants it because the plate has narrowed around the artwork; .strip wants it
   because the plate has not, and at page-edge width the outer screens were
   landing against its edge. Different reasons, one declaration — the two were
   written independently on separate branches and arrived saying the same
   thing twice. */
figure:is(.inset, .strip):not(.bare) { padding-inline: var(--space-l); }

/* Artwork flattened before export puts a white rectangle on the plate, leaving
   the tint showing as a frame around it. */
figure.paper { background: #fff; }

/* The dark plate .block.dark and .clip already wear, for artwork that wants
   black behind it. On black the dark grey serves as the caption grey too. */
figure.dark {
    --dim-local: var(--dim-on-dark);
    --caption-local: var(--dim-on-dark);
    background: var(--plate-dark);
}

/* A transparent cut-out that runs off its own canvas — a hand cropped at the
   wrist — wants the plate to be its edge. Inset, the crop lands mid-plate and
   reads as damage; flush, it reads as bleed. --flush-x keeps the sides inset
   when only the top and bottom are cropped, so artwork that stops short of
   its own side edges doesn't meet the plate's. */
figure.flush { padding: 0 var(--flush-x, 0); }

/* The plate-to-plate M assumes each plate carries its own inset. A flush one
   has none — its artwork runs into the gap — so the next figure steps back to
   XL, the interval a figure gets from prose. The :is() is there for weight:
   the plate-pairing rule's own :is() carries two classes, and a plain
   `figure.flush + figure` loses to it silently. */
figure.flush + :is(figure, .figure-row) { margin-top: var(--space-xl); }

/* ── Figure rows ─────────────────────────────────────────────────────────
   A pair of photos from the same moment reads as one beat, not two — same
   logic as .block-row, applied to figures instead of callouts. */
.figure-row {
    display: grid;
    gap: var(--space-m);
    margin-top: var(--space-xl);
}

/* Grid stretches both plates to a shared height, so the shorter artwork
   leaves a void under its caption. Centre the image in the space above the
   caption and let the caption sit at the bottom, and the pair reads as two
   halves of one object. Photographs are .bare — no plate to expose the gap,
   and side-by-side shots want their top edges aligned — so they keep the
   default. */
.figure-row > figure:not(.bare) {
    display: flex;
    flex-direction: column;
}

.figure-row > figure:not(.bare) img {
    margin-block: auto;
}

/* A frame takes the void below itself only, not either side: its top margin
   is the bleed over the plate's padding, and two frozen states read as one
   screen in two hands when their panels start on the same line. The captions
   still land together at the foot. */
.figure-row > figure:not(.bare) > iframe {
    margin-bottom: auto;
}

@media (min-width: 1080px) {
    .figure-row {
        grid-auto-flow: column;
        grid-auto-columns: 1fr;
    }

    /* Two per line instead of one line of N, so an odd item wraps at the
       same size as the rest rather than stretching to the full column —
       three come out [a][b] / [c].

       This is the stranded third the traps note warns about, chosen on
       purpose rather than arrived at by auto-fit. The difference is that
       the column count is stated: at two, a set of three always breaks the
       same way, where auto-fit picks a count from the viewport and strands
       the third only at some widths. */
    .figure-row.two-up {
        grid-auto-flow: row;
        grid-template-columns: repeat(2, 1fr);
    }
}

/* Two figures side by side are still siblings, so the adjacency rule above
   fires between them too. :is(figure, .bare) matches the same elements as a
   bare `figure` but carries a class's specificity — enough to tie that rule
   and win on source order. Plain `figure` loses the tie and the gap returns. */
.figure-row :is(figure, .bare) { margin-top: 0; }

/* ── Screen strips ────────────────────────────────────────────────────
   A row of phone screens runs to the page edge like any other figure, and
   on a wide display that is what it wants. On a handset the same row is
   four slivers, and the page padding is taking width the screens need — so
   below the breakpoint the plate breaks out of that padding and spans the
   whole viewport. Roughly 20% more width, which is the difference between
   reading the section names and not. The caption keeps its inset so it
   still reads as a caption. The wider inset it carries above the breakpoint
   is shared with .inset, up beside that rule — and that rule's :not(.bare)
   gives it a class more weight than a bare figure.strip, so this one has to
   carry the same :not() to win. Without it the padding stayed and the strip
   broke out of the page padding only to be inset again by its own. */
@media (max-width: 720px) {
    figure.strip:not(.bare) {
        margin-inline: calc(var(--space-m) * -1);
        padding-inline: 0;
    }

    figure.strip figcaption {
        padding-inline: var(--space-m);
    }
}

/* gifts.html's artwork carries transparent margin in the file and floats on
   its plate; a strip exported tighter sits close to the plate's edge. .air
   gives it room in CSS rather than in the export. The space under the
   artwork goes above the caption, not below it, so the caption sits at the
   foot of the plate and the artwork is centred in what's left — the gifts
   arrangement.
   The sides keep the strip's own inset unless --air-x widens them: a row
   of small screens can't spare the width and loses legibility before it
   gains air. --space-xxl there lands on the 171px gifts measures at 1440,
   about 15% of the plate at any width the tokens scale over.
   Above the breakpoint only — below it the strip goes full-bleed on
   purpose. Comes after the shared .inset/.strip rule, which it ties on
   specificity. */
@media (min-width: 721px) {
    figure.strip.air {
        padding: var(--space-xl) var(--air-x, var(--space-l)) var(--space-m);
    }

    figure.strip.air figcaption {
        margin-top: var(--space-xl);
    }
}

/* ── Screen recordings ────────────────────────────────────────────────
   Bare bright portrait captures with no device frame: black gives them an
   edge to stop against, and they're held to about the size a handset reads
   at in the stills beside them. */
.clip {
    --dim-local: var(--dim-on-dark);
    --caption-local: var(--dim-on-dark);
    background: var(--plate-dark);
}

.clip video {
    width: 30%;
    margin-inline: auto;
}



/* ── Compare ─────────────────────────────────────────────────────────
   Two states of one screen, one plate — a toggle rather than a pair.
   For a before/after where the two shots are different shapes (a card
   list is taller than the table it became), a figure-row puts them side
   by side at mismatched heights, which reads as two unrelated images
   rather than one screen changing. Showing one at a time, in the same
   frame, is the more honest comparison.

   Colour doesn't carry "selected" here — a marker does. Both tabs are
   --link, since the whole bar is the interactive part and there's
   nothing to distinguish by acting-vs-not; a dot under the current one
   is a dedicated signal instead of overloading colour with a second job.
   Two things were tried first and both read backwards: --link on the tab
   you'd click reads as "this one's on" (it's the colourful one), and
   swapping the pairing so --link means "current" instead means every
   tab looks clickable and only one truly needs to be. A separate marker
   sidesteps the collision rather than picking a side of it.

   The tabs live inside the plate, not above it — a segmented control
   at the top of the card they belong to, not a caption for it. Centred
   and close together, because they're one control with two settings,
   not two separate labels.

   A figure is written as the exception to --measure so a screenshot can
   run to the page edge (see Layout). This one opts back in: it's read
   with its tabs, in line with the argument rather than beside it, so it
   sits in the text column like a paragraph does — inset, not full
   width. No margin-inline: auto — that would centre it independent of
   the column; leaving it out keeps it flush with the prose's own left
   edge. */
.compare {
    max-width: var(--measure);
    margin-top: var(--space-xl);
}

/* No plate and no padding of its own: .compare is a figure, so it already
   carries both. Giving the frame a second set stacked two identical plates
   and put the artwork 2×--space-m from the outer edge while the caption
   sat at 1×, which is the inconsistency that reads as a wonky inset. The
   frame stays as the grouping element the tabs and stage share. */
/* Wraps because a set can be more than two: four tabs at caption size
   overflow a phone's column, and a segmented control that scrolls off the
   plate is worse than one that takes a second line. */
.compare-tabs {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--space-s) var(--space-m);
    margin-bottom: var(--space-m);
}

/* Column layout so the dot sits under the label without its own
   absolute positioning — reserved space either way, so nothing shifts
   width when a tab gains or loses its marker. */
.compare-tabs button {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.35em; /* the dot belongs to its own label, not the layout grid —
                    same exception .points' bullet indent already makes */
    font: inherit;
    font-size: var(--type-caption);
    background: none;
    border: 0;
    padding: 0;
    color: var(--link);
    cursor: pointer;
}

.compare-tabs button.is-active {
    cursor: default;
}

.compare-tabs button::after {
    content: "";
    width: 6px;
    height: 6px;
    border-radius: 999px;
    background: var(--link);
    opacity: 0;
}

.compare-tabs button.is-active::after {
    opacity: 1;
}

/* The stage holds one aspect ratio for the whole set (set per instance,
   inline — each set is its own shape); the images stack in it and swap by
   opacity, so switching is a cross-fade inside a box that never resizes.

   object-fit: contain, because a set is not guaranteed to be one size. Take
   the ratio from the tallest member and the rest sit inside it untouched —
   a shorter one shows a little plate above and below rather than being
   stretched to fit, and nothing has to be re-cut to be shown together. */
.compare-stage {
    position: relative;
}

.compare-stage img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: contain;
    display: block;
    opacity: 0;
    transition: opacity .4s ease;
}

.compare-stage img.is-active {
    opacity: 1;
}

/* .air on a comparison, for the reason it exists on a strip (see Screen
   strips): a set exported flush top and bottom puts its screens a --space-m
   from the tabs and the caption both, and reads as packed in. The room goes
   either side of the stage rather than into the figure's padding, so the
   tabs stay at the head of the plate they control and the caption at its
   foot. Same breakpoint as the strip's, for the same reason. */
@media (min-width: 721px) {
    figure.compare.air .compare-tabs {
        margin-bottom: var(--space-xl);
    }

    figure.compare.air figcaption {
        margin-top: var(--space-xl);
    }
}


/* ── Résumé ───────────────────────────────────────────────────────────
   One entry: date, name and role in three columns, the same idea Education
   uses at two — every field short enough to be a label except the role,
   which is left to wrap. Then the prose.

   The only place an h3 isn't grey. It earns it by not being a label: here
   the heading is the proper noun being scanned for, sitting in its own
   column rather than above the row. */
.role h3 {
    color: var(--text);
    margin-top: 0;   /* the row carries the space, not its first line */
}

/* Three fixed-ish columns don't fit the prose measure this sits in — date,
   name and role together need close to 840px at --type's ceiling, half again
   .role's own --prose-w. Bled right the same amount a figure bleeds left:
   two fields and their gaps, so the row reads as an object (the four-field
   width every table and compare figure already takes) while the paragraphs
   after it stay at the prose width they need for reading. */
.role .when {
    display: grid;
    column-gap: var(--space-l);
    margin-right: calc(-2 * (var(--module) + var(--space-m)));
}

/* Each row is its own grid, so nothing but a fixed track ties the ten
   together — .pairs' lesson from the two-column version still applies at
   three. Date fixed to the longest in the list, "May 2017 – Mar 2020"
   (226px); name fixed to the longest, "Ativa Corretora" (165px); role the
   flexible remainder, free to wrap — "Senior to Staff Product Designer"
   does, at two lines. */
.role .when { grid-template-columns: 14.5rem 11rem minmax(0, 1fr); }

.role .when .date { color: var(--dim-local); }
.role .when .title { color: var(--text); }

/* h3's own top margin, and the generic h3 + * after it, both move a grid
   item down within its own row rather than in the flow — the same trap the
   column-grid rhythm work hit. Both zeroed; the row's gap from the h3 above
   it carries the space instead. */
.role .when h3 + * { margin-top: 0; }

/* .when was a <dl> and matched .study's own dl + p; a plain <div> needs its
   own rule for the same gap before the prose starts. */
.role .when + p { margin-top: var(--space-m); }

/* The date is a margin note, not the start of the sentence: the prose reads
   as continuing from the name, so it lines up under Yoco rather than under
   Jul 2022. Shifted by the date column and its gap — the same distance
   .when's first two tracks take — and given its own width rather than
   inheriting one from the shift, since a margin-left alone would just steal
   that much of --prose-w back from the line length the measure was set for. */
.role > p {
    margin-left: calc(14.5rem + var(--space-l));
    width: var(--prose-w);
}

/* set solid — one two-line block; beats the bare `h3 + *`, which gives M */
.role h3 + .when { margin-top: 0; }

/* an entry is one object, so two of them take the larger step */
.role + .role { margin-top: var(--space-xl); }

/* what the sheet carries and the page doesn't. Mirrored by .screen-only,
   declared in the print sheet — the only place it does anything. */
.print-only { display: none; }


/* ── Column grid ──────────────────────────────────────────────────────
   Six fields of equal width, and one grid-template-columns in the whole
   stylesheet: .study, each section and .fields all subgrid off it, so the
   rail, a pinned heading and the last word of a paragraph sit on the same
   lines because there is only one set of lines.

   The cap is derived, not chosen. Two fields plus a gutter must equal
   --measure; solve six fields, five gutters and two page margins for that and
   the page width falls out as 3 × --measure + 4 × --space-m. So the page still
   has one number in it. What changes is what --measure means: it stops being
   the text column and becomes two fields, which is half of one.

   The module is fluid, so the measure is a range — ~40 characters at 1512px up
   to 54.8 at the cap. A fixed module would hold 54.8 everywhere and show four
   fields on a laptop, which is a different page rather than a narrower one:
   the paired composition would never appear below a 2085px display.

   Everything here is scoped under .grid, and .grid is only ever on the seven
   case studies. about.html and every blog post share main.study, .hero and
   .pairs.meta — an unscoped rule reaches the résumé and its print sheet.

   The whole block is min-width, so below 1200px not one of these rules applies
   and the page is exactly what it was. There is no "grid off" state to
   maintain, because off is the absence of this block rather than an undo of
   it. 1200 is set by the naming column, not by the measure: at 1024px a module
   is 156px and "Design what to design" wraps to three lines. */
@media (min-width: 1200px) {
    .page.grid {
        grid-template-columns: repeat(6, minmax(0, 1fr));
        max-width: calc(3 * var(--measure) + 4 * var(--space-m));

        /* so --module below can be a real length. An element cannot query its
           own container, which is why --module is declared on .study. */
        container-type: inline-size;
    }

    /* cqw is the page's content box, so this is the track width exactly, with
       no scrollbar in it — the reason for a container unit over vw. */
    .grid :is(.study, .statement) {
        --module: calc((100cqw - 5 * var(--space-m)) / 6);

        /* How wide the text column is. Three fields under 1400, where two
           would hold only 32 characters at 1200 and 37 at 1400 — under the
           line this type wants. Objects and artwork do not follow it; only
           the page's own voice does.

           Declared here beside --module and not on .page.grid: it is built out
           of --module, and --module cannot exist any higher up because an
           element may not query its own container. Defined on the page, the
           var() resolves to nothing, the whole value is invalid, and max-width
           silently falls back to none. */
        --prose-w: calc(3 * var(--module) + 2 * var(--space-m));
    }

    .grid :is(.study, .statement) {
        grid-column: 2 / -1;
        display: grid;
        grid-template-columns: subgrid;
    }

    /* the hero and every section: five fields, page 2 to 6 */
    .grid :is(.study, .statement) > * {
        grid-column: 1 / -1;
        display: grid;
        grid-template-columns: subgrid;
    }

    /* Four fields, page 3 to 6 — the width objects get. Prose takes two of
       them and the last two are left as air.

       Three widths, and they are a hierarchy rather than a grid being filled:
       the page speaks in a two-field column, objects that need room to be read
       across take four, and artwork takes five. Fields 5 and 6 hold nothing but
       the right-hand end of artwork, and that is the composition.

       Two columns of prose were built here first, both as paired blocks and as
       a magazine flow, and the content will not support either. Figures
       interrupt prose so often that a run between them is short: the median run
       is 5 lines on this page, 6 on the app, and 3 on Uber and Nubank. Even
       poured as one continuous flow and split in half, 8 of 15 runs here and 12
       of 14 on Uber come out with columns under three lines. Two columns would
       therefore appear on a handful of runs and nowhere else, which reads as a
       mistake rather than as a system. Measured before it was abandoned; do not
       reintroduce it without measuring the run lengths again. */
    .grid .fields { grid-column: 2 / -1; }

    /* Prose stops here; objects state their own width below. The grid does the
       capping now, so the measure cap from Layout is cleared first. */
    .grid :is(.study, .statement) .fields > *,
    .grid .hero > * { max-width: none; }

    /* The blog's list still reads as columns rather than one line with the
       date run on after the title: you scan down the titles, or down the dates, instead of parsing
       two facts out of one string. The columns are the page's own fields, so
       a date lands on the same line across every row — alignment comes from
       the grid or it is not alignment. */
    .grid .study .fields > .work-list {
        display: grid;
        column-gap: var(--space-m);
        max-width: none;
    }

    .grid .study .work-list .credit { margin-left: 0; }

    .grid .study .work-list > li,
    .grid .study .work-list > li > a {
        display: grid;
        grid-template-columns: subgrid;
        grid-column: 1 / -1;
    }

    .grid .study .work-list {
        grid-template-columns: calc(3 * var(--module) + 2 * var(--space-m)) var(--module);
    }

    /* Four across once the naming column exists to hold them apart from
       everything else — the card idiom itself (display:grid, no bullet, the
       tile stacking) is unconditional now and lives with the base rules, not
       here. Only the column count is width-specific.

       max-width still has to be restated at this weight: the general prose
       cap two rules up matches any ul inside .fields, and carries a type
       selector from its own :is(p, ul, …) — three classes and a type beats
       three classes and none, tie or no tie, which is exactly the weight the
       base card rule sits at outside this block. ".fields >" in the
       selector is what gets it to four classes. Measured before this was
       caught: the grid applied, but max-width still computed to --prose-w,
       and every card sat inside a column half the width intended. */
    .grid .statement .fields > .work-list,
    .grid .statement .fields > .role-list {
        grid-template-columns: repeat(4, minmax(0, 1fr));
        max-width: none;
    }

    /* home caps its main's children directly where the others cap their
       grandchildren, so its sections carry the measure themselves and would
       come out two fields wide instead of five. */
    .grid .statement > * { max-width: none; }

    /* h2, ol and hr are here for the blog: a post arrives from Markdown as one
       flat stream, so its headings and rules sit inside .fields where a case
       study's h2 never does, and they are text rather than objects. A
       blockquote stays at four fields — it comes through as .block, and a
       raised claim breaks wider than the prose on a case study too. */
    .grid :is(.study, .statement) .fields > :is(p, ul, ol, h2, h3, hr) { max-width: var(--prose-w); }

    /* A post's h2 belongs in the naming column like any other section head.
       It cannot get there the way a case study's does: that one is a grid item
       placed in field 2, and a post arrives from Markdown as a flat stream with
       no section around each heading to place it in. So it hangs into the
       column instead — floated out of the text and one field to the left, the
       same move the printed résumé makes.

       Floating takes it out of flow, so the section break it used to carry has
       to be restated on the element under it, or the two run together. Both get
       XXL and the head lands level with the first line of its section — or
       both get none, when the post opens on it.

       What this does not get is the pin. Sticky needs a containing block the
       height of the section, which is exactly what the flat stream does not
       provide — that would mean teaching the build to wrap each heading and its
       content in a section, the way the table wrapper is already done in
       eleventy.config.js. Worth doing if posts ever lean on h2; today one of
       the two published posts has none. */
    .grid .prose .fields > h2 {
        float: left;
        width: var(--module);
        margin-left: calc(-1 * (var(--module) + var(--space-m)));
    }

    .grid .prose .fields > h2 + * { margin-top: var(--space-xxl); }

    /* A post that opens on a heading has had that heading's XXL cleared by
       the :first-child rule under Blog prose, so the element under it clears
       its own too, or the text drops a whole interval below its head. */
    .grid .prose .fields > h2:first-child + * { margin-top: 0; }

    /* The naming column. h1 names the page and leaves with the hero; each h2
       names a section and pins for the length of it, so the column always says
       what you are reading. One level of naming — h3 stays in the text.

       The head and .fields share row 1, which is what makes the pin work.
       Sticky moves an element inside its containing block, and a grid item's
       containing block is its own grid area — a head in a multi-row section
       would pin for the height of one paragraph and then leave. One row means
       the head's area is the section's full height. That is the whole reason
       .fields exists.

       Pinned at --space-m, the offset the rail already sticks at, so the head
       and the brand share one top line.

       Above the artwork, not behind it: field 2 is the head's column without
       exception. Only figures reach it — .block, .block-row and .table are
       body-voice and stay at 3–6, so a dark plate never passes under a head.
       A figure wears --plate, a 4.5% ink tint, which grey sits on.

       Audited: of 45 wide figures across the seven case studies, 14 are dark
       at the left edge — 12 on truckflow, 2 on yoco-app at a marginal 102
       luminance. On those the head is poorly legible, which is accepted here
       and resolved when truckflow is rebuilt with artwork chosen against this
       grid. Sample the artwork composited over its own plate, not bare: on a
       bare canvas the transparent left edges of the Uber shots read as black
       and the list comes back at 29 rather than 14.

       mix-blend-mode: difference was tried and rejected. It does fix the dark
       case, but difference blends against the paper too, so it recolours every
       pinned head on every page — the Yoco head came out olive, no longer
       matching the h3 directly above it. The version that works is a colour
       set to paper-minus-dim, which returns exactly --dim over paper and a
       light colour over black; it needs seven hand-computed constants, which
       is the one thing a palette is not allowed to carry. */
    .grid .hero > :not(h1) { grid-column: 2 / -1; }
    .grid .hero > h1 { grid-column: 1; }

    /* The hero's rows come from the gap for the same reason a section's do:
       h1 and the deck now share row 1, so a margin between them would push
       one down and not the other. In the flow they sat at 0 and the deck
       carried M down to the meta; here that M is the row. */
    .grid .hero { row-gap: var(--space-m); }

    /* beside the title now, so the stacked L would only push it off row 1 */
    .grid .hero > .fields { margin-top: 0; }

    .grid :is(.study, .statement) > section > h2 {
        grid-column: 1;
        grid-row: 1;
        position: sticky;
        top: var(--space-m);
        align-self: start;
        z-index: 1;
    }

    .grid :is(.study, .statement) > section > .fields {
        grid-row: 1;

        /* h2 + * gives L in the flow; here the head is beside its section
           rather than above it, and a margin would push the whole section down
           instead. The section's own XXL does the separating. */
        margin-top: 0;
    }

    /* Artwork is one field wider than the text on its left, so an image breaks
       out of the text block into the naming column rather than aligning with
       it. The figure lives in .fields, which starts at field 3, so it grows a
       module to the left rather than being placed at field 2 — one derived
       declaration instead of a second copy of the grid. .fields is block flow,
       so a negative margin on one side widens the block by that much. */
    .grid .fields > figure,
    .grid .fields > .figure-row {
        margin-left: calc(-1 * (var(--module) + var(--space-m)));
    }

    /* Both already opt back into --measure, for the same reason: they are read
       in line with the argument rather than beside it. Two fields is that
       opt-in expressed in the grid's own terms.

       The text column, whatever it currently is: a comparison carries tabs and
       a caption and an inset is artwork the prose is arguing about, so both are
       read after the sentence that introduces them rather than beside it. They
       track --prose-w rather than stating two fields, or they would come out
       narrower than the prose under 1400 where the column takes three. The one
       kind of artwork that does not break left. */
    .grid .fields > figure.inset,
    .grid .fields > figure.compare {
        max-width: var(--prose-w);
        margin-left: 0;
    }

    /* Any direct child of .fields can state how many fields it takes, when
       none of the defaults is right — a figure, but also a .block or a
       .block-row that wants to read as a pull-quote rather than an object.
       Counted in fields so it lands on the grid's own lines.

       margin-left: 0 undoes a figure's left bleed; on a .block, which never
       bleeds, it is a no-op rather than a special case to guard against.

       Four is the object width, the same .table and .block-row take by
       default: for artwork that is read across rather than looked at, or a
       callout meant to be scanned like the row it sits beside. Two is the
       text column, for a quote meant to be read as a sentence.

       Two branches, not one, and this is the second time that has mattered.
       figure.compare and figure.inset carry their own max-width at the same
       three-class weight this rule has — tied only because the figure type
       selector is repeated on both sides. Drop it, as a single bare
       [data-fields] once did, and the count loses outright rather than
       winning the tie by coming later in the file: a .compare given
       data-fields="4" measured unchanged at two fields until this was found.
       The plain branch stays for elements with nothing to out-rank, .block
       among them.

       One rule per count because attr() cannot be used inside calc() yet. Five
       is a figure's own default and needs no value here. */
    .grid .fields > figure[data-fields],
    .grid .fields > [data-fields] {
        max-width: calc(var(--fields) * var(--module) + (var(--fields) - 1) * var(--space-m));
        margin-left: 0;
    }

    .grid .fields > [data-fields="2"] { --fields: 2; }
    .grid .fields > [data-fields="3"] { --fields: 3; }
    .grid .fields > [data-fields="4"] { --fields: 4; }

    /* Alignment comes from the page grid or it is not alignment: the term
       lands on field 3 and its detail on field 4, on the same lines the prose
       above them uses. Replaces .pairs' own two-track grid and its L gutter —
       the page's gutter is the gutter. Stated rather than inherited, because a
       subgrid that declares no gap is the safe reading of a spec that has two;
       the value is the parent's either way, so both readings align. */
    /* .hero really is a subgrid — matched by ".study > *", it inherits
       .study's own five tracks — so .pairs.meta inside it can subgrid too and
       land on the same lines the prose beside it uses.

       .fields is a different animal, and this used to conflate the two.
       ".grid .fields" below sets only grid-column: 2 / -1 — no display:grid,
       no subgrid. .fields is a block box occupying a column span on its
       parent section, the same reason a figure reaches its width with a
       negative margin instead of grid placement rather than one. subgrid on
       a .pairs inside it has no grid parent to inherit from and silently
       computes to a single collapsed column — which is what was happening
       to about.html's Education and Skills dates, unnoticed until measured
       directly: dt and dd stacked instead of sitting side by side. Pre-dates
       any of today's edits; this just found it. */
    .grid .hero > .pairs {
        grid-template-columns: subgrid;
        column-gap: var(--space-m);
    }

    .grid .hero > .pairs dt { grid-column: 1; }
    .grid .hero > .pairs dd { grid-column: 2 / -1; }

    /* Explicit tracks off --module instead, since there is no grid to
       subgrid from. One field for the term, matching "the term lands on
       field 3" — the rest for the detail, as a single 1fr rather than a
       second subgrid it has no sub-columns to need. */
    .grid .fields > .pairs {
        grid-template-columns: var(--module) minmax(0, 1fr);
        column-gap: var(--space-m);
    }

    .grid .fields > .pairs dt { grid-column: 1; }
    .grid .fields > .pairs dd { grid-column: 2; }

    /* A deck, not a paragraph: three fields keeps it a line rather than a
       block, and stops it running to 111 characters at the cap. */
    .grid .hero > .lede { grid-column: 2 / span 3; }

    /* about only — .role exists nowhere else. A résumé entry is prose with a
       company name over it, so it reads at the text column like any other
       paragraph. Capped on the article rather than its children, because the
       h3 inside it is the one h3 in the system that is not a grey label and
       must not be caught by the prose rule above.

       Named down to .fields to clear the max-width: none reset above, which
       carries three classes — the same trap --prose-w fell into. */
    .grid :is(.study, .statement) .fields > .role { max-width: var(--prose-w); }

    /* ── Rhythm ──────────────────────────────────────────────────────
       Nothing of its own. .fields is ordinary block flow, so every interval in
       the table in design.md comes from the sibling margins the rest of the
       site already uses, unchanged and unduplicated.

       One exception, because the section's heading is no longer in the flow
       above its text: h2 + * gave the first element L, and here the head sits
       beside its section rather than above it, so the section's own XXL does
       that separating instead. */
    .grid .fields > :first-child { margin-top: 0; }
}

/* Two fields once they hold a line — 37 characters at this threshold, ~40 at
   1512, and 54.8 at the cap, where two fields are exactly --measure and the
   column is the one the rest of the site reads at. Below here the same single
   column takes three fields to keep 48–56, and field 6 joins 5 as air. */
@media (min-width: 1400px) {
    .grid :is(.study, .statement) { --prose-w: calc(2 * var(--module) + var(--space-m)); }
}


/* ── Small screens ───────────────────────────────────────────────────── */

/* The rail becomes a header that scrolls away. Three stops won't fit across
   375px, so brand and clock share the top line and the nav drops below.

   Cells are placed explicitly: in the markup the nav sits between brand and
   clock (the order the rail wants on desktop), so flow order alone would
   push the clock onto a third row. Safe to diverge because the clock holds
   no links — the two focusable stops stay in DOM order, so tab order still
   follows the page. */
@media (max-width: 720px) {
    /* minmax(0, …) for the same reason the two-column rule uses it: a plain
       1fr takes an auto minimum, so one unwrappable child sizes the track to
       itself. A code block is the one thing here that cannot wrap, and at a
       plain 1fr it sizes the column well past the viewport and takes the rail
       with it. Capped, the pre scrolls inside its own plate. */
    .page {
        grid-template-columns: minmax(0, 1fr);
        row-gap: var(--space-l);
    }

    .rail {
        position: relative;
        top: auto;
        height: auto;
        display: grid;
        grid-template-columns: auto auto;
        justify-content: space-between;
        align-items: start;
        column-gap: var(--space-m);
        row-gap: var(--space-m);
    }

    /* The clock spans both rows so the nav can sit on its last line rather
       than clearing the whole block: row one is the brand's line, row two is
       what is left of the clock, and a nav aligned to the end of it lands
       level with the weather. */
    .location { grid-row: 1 / span 2; grid-column: 2; }

    .rail-nav {
        grid-row: 2;
        grid-column: 1;
        align-self: end;
    }

    .rail-nav ul {
        flex-direction: row;
        gap: var(--space-m);
    }

    /* No room for two columns. Term length isn't what decides it — the
       résumé's are "2015" and its values still run four lines in a column
       this narrow, so this applies to every .pairs, not just those in cards.
       .meta keeps its columns, being short on both sides. */
    .pairs:not(.meta) {
        grid-template-columns: minmax(0, 1fr);
    }

    .pairs:not(.meta) dd {
        grid-column: 1;
        margin-top: var(--space-s);
    }

    .pairs:not(.meta) dt:not(:first-of-type) { margin-top: var(--space-l); }

    /* Same call as .pairs above: three columns have even less room than two,
       so the row stacks — date, then name, then role, each its own line.
       None of the three carry an explicit grid-column, so one track is
       enough to put each on its own row; no per-child override needed. */
    .role .when {
        grid-template-columns: minmax(0, 1fr);
        row-gap: var(--space-s);
    }

    /* The prose indent lines paragraphs up under the name at 720px and
       above, where .when is three columns wide. Stacked, the name starts at
       the row's own left edge again, so the indent has to go with it. */
    .role > p {
        margin-left: 0;
        width: auto;
    }

    /* the column is already about one phone wide down here */
    .clip video { width: 66%; }
}


/* ── Blog prose ───────────────────────────────────────────────────────
   A post is generated from Markdown, so its markup arrives as one flat stream
   rather than the hand-built sections a case study uses. Most of it is already
   covered: .study describes long-form prose, and the renderer emits .points
   and .block for lists and quotations (see eleventy.config.js). What follows
   is only what Markdown emits and no hand-built page uses. */

/* A hand-written page takes the section interval from its <section>
   wrapper. Here the heading is the only thing marking the boundary, so it
   carries that interval itself. */
.prose h2 { margin-top: var(--space-xxl); }

/* .fields is the first child once a post is on the grid, so the interval has
   to be cleared on what is inside it as well — a post opening on a heading
   would otherwise take the XXL above. */
.prose > :first-child,
.prose .fields > :first-child { margin-top: 0; }

.prose p + ol,
.prose ol + p { margin-top: var(--space-m); }

/* The one place a second family is unavoidable: a monospaced 1 and l, a 0
   that is not an O. Sits on the text scale, not the caption scale — a command
   is read rather than labelled, and at caption size it reads as a stray line
   marooned on a large plate. 0.9em is where monospace matches the display
   face beside it. */
.prose code {
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 0.9em;
}

/* A block of it is a plate, like a figure or a callout — same fill, same
   corner, and the step from text a callout takes. A long line scrolls
   inside the plate rather than widening the page. */
.prose pre {
    margin-top: var(--space-l);
    padding: var(--space-m);
    background: var(--plate);
    font-size: 0.9em;
    line-height: 1.35;
    overflow-x: auto;
}

.prose pre code { font-size: inherit; }

.prose pre + :is(p, ul, ol, h2, h3) { margin-top: var(--space-l); }

/* A break inside prose. The mark the wordmark and lists already use, centred
   and grey — the system draws no lines and this is not the place to start.
   L each side: more than the M between paragraphs, less than the XXL between
   sections. line-height 1 so the mark's box is the mark, not a body line's
   worth of air on top of the margins. */
.prose hr {
    margin: var(--space-l) 0;
    border: 0;
    line-height: 1;
    text-align: center;
    color: var(--dim);
}

.prose hr::before { content: "•"; }


/* ── Print ───────────────────────────────────────────────────────────
   The résumé has to leave as a document. Same system re-pointed at paper:
   setting the one space clamp and one type clamp in absolute units carries
   all five steps and the caption scale, so no component needs a print rule.

   THE GREY IS SET BY HAND HERE, and it did not used to be. --dim was a mix of
   --text and --bg, so re-pointing those two at black and white re-computed it
   on its own and it printed #4D4D4D at 8.45:1. It is a literal now and would
   print #738299 — a blue-grey at 3.90:1, on a sheet where every other value
   is black. #4d4d4d is that old computed value written out, so the sheet
   prints exactly as it did. --link is set by hand for the older reason: the
   accent has to stop meaning anything when there is no clicking.

   --plate, --dim-on-plate, --dim-on-dark and the two caption greys still
   derive, off --dim and --text, and so still re-compute here. All come out
   darker than --dim,
   which is harmless — the plate fills are dropped below, so they print on
   white and only gain contrast.

   Must stay last: :root against :root, winning on order alone. */
@media print {
    :root {
        --bg: #fff;
        --text: #000;
        --link: #000;
        --dim: #4d4d4d;

        --type: 10pt;
        --type-caption: 8pt;
        --space-m: 6pt;

        /* a printed column is already measured by the page margin */
        --measure: none;
    }

    @page { margin: 14mm 16mm; }

    /* 1.2 is a display leading, set for 40px type; at 10pt it closes up */
    body { line-height: 1.4; }

    /* site furniture — a live clock says nothing on paper, and the print
       button would print itself */
    .rail,
    .actions { display: none; }

    .print-only { display: block; }

    /* scrolling is free and paper isn't: the early internships stay on the
       page and off the sheet */
    .screen-only { display: none; }

    .page {
        display: block;
        padding: 0;
    }

    /* a plate is a screen device — on paper the fill is toner buying what
       the space around it already does. The dark plate inverts back. The
       figure modifiers are named because a bare `figure` loses to them. */
    figure,
    figure.paper,
    figure.inset:not(.bare),
    figure.flush,
    figure.dark,
    .block,
    .clip,
    .prose pre { background: none; padding: 0; }

    .block.dark { --dim-local: var(--dim); color: var(--text); }
    .clip figcaption,
    figure.dark figcaption { color: var(--dim); }

    /* a company split across a break reads as two jobs, and half a command
       is not a command */
    .role,
    .block,
    figure,
    .prose pre { break-inside: avoid; }

    h2, h3 { break-after: avoid; }

    /* --space-xxl is a pause in a scroll; on a sheet it is a sixth of the
       page left white. Halved, it is what lets About print and the résumé
       still close on page two — measured. */
    .resume .study > section { margin-top: var(--space-xl); }

    /* Education and Skills side by side, as a résumé conventionally sets
       them. Not load-bearing — two pages fits stacked too — so it is safe
       to drop. Floated, not inline-block: no tag whitespace to account for,
       and nothing follows that would need clearing. */
    .study > section.brief {
        float: left;
        width: 48%;
    }

    .study > section.brief ~ section.brief { float: right; }

    /* ── The naming column, carried onto paper ────────────────────────
       The screen puts the title and every section head in a column beside the
       text. The sheet does the same, and it is the difference between a résumé
       that reads as typed and one that reads as composed.

       34mm of head and a 6mm gutter leaves 138mm of text. There is room for
       it: the sheet holds two pages down to 130mm of content and only breaks
       at 120, measured by printing it at six widths. This spends about a third
       of that slack.

       Floated into the sheet's own padding rather than a grid, because a grid
       container that fragments across a page break is the one thing here that
       Chrome does not do reliably, and the two floats above already establish
       the idiom. The head simply stays on the page its section started on,
       which is what a running head does on paper.

       The padding is on .study rather than on each section, so Education and
       Skills keep their left edge with everything above them — padded per
       section and excluded from the head column, they hung 40mm out into the
       margin on their own.

       .brief keeps its heads inline and its float: both want the same left
       strip and only one can have it, and stacking them to gain the column
       costs a third page — measured. The long sections get the column; the two
       short ones stay as a résumé conventionally sets them.

       .resume, not .study: a case study is not a document, and printing one at
       this width would cost it pages for a sheet nobody asks for. */
    .resume .study { padding-left: 40mm; }

    .resume .hero > h1,
    .resume .study > section:not(.brief) > h2 {
        float: left;
        width: 34mm;
        margin-left: -40mm;
    }

    /* The fixed columns are sized against --type's 24px screen ceiling, and
       rem does not follow --type down to 10pt here — left alone they would
       eat most of the 138mm the naming column leaves, wrapping the role on
       nearly every entry and costing the sheet its third page. max-content
       both: still one row, just sized to the paper's own, much smaller
       type instead of a screen measurement. */
    .role .when { grid-template-columns: max-content max-content minmax(0, 1fr); }

    /* Same reasoning as the column widths: the indent is a fixed screen
       measurement and has no meaning at 10pt. Paper keeps the prose at the
       row's own left edge. */
    .role > p {
        margin-left: 0;
        width: auto;
    }
}
