/* =============================================================================
   portfolio.css — layout for the reference-driven one-pager (site/index.html).

   This file belongs to ONE page. It is loaded after system/tokens.css and
   system/base.css and re-uses the design system rather than restating it:

     --c-bg / --c-fg   the monochrome pair the reference asks for (#ffffff/#000000),
                       used as declared on every surface — the PROJECTS section
                       included, which was a black band once. See .work
     --c-muted         #767676 — the system's mid grey, used for the intro copy
     --c-disabled      #cacaca — the system's *disabled* grey, and NOT SPENT on
                       this page at the moment: it used to grey the arrow of the
                       one project row with no target, and the arrow went with the
                       section's rebuild. It stays in this list because the rule
                       that will want it is one project away — a row without a case
                       study — and because the page's rule about grey does not
                       move: grey means "not available", never "less important"
     --c-highlight     #cacaca — the page's own "not painted yet" grey: the
                       colour every statement's letters sit at until the scroll
                       reaches them. That is now the same value as --c-disabled
                       and still deliberately not that token: in this language
                       grey means "not available", never "less important", so the
                       two have to be free to move apart
     --bw-hairline     1px, the grid lines
     --s-1 … --s-7     the 4px spacing scale (the project rows' 2rem padding is
                       exactly --s-6, so the reference's value is token-aligned)
     --font            chained behind Funnel Display as the fallback, so a failed
                       font request degrades to Neue Montreal, not to Helvetica
     .container .eyebrow .ds-link .ds-skip
                       layout + shared type from system/base.css and
                       system/components.css

   Funnel Display is SELF-HOSTED (system/fonts/FunnelDisplay-*.woff2), not
   fetched from fonts.googleapis.com. Two reasons: the site keeps its rule of
   zero runtime third-party requests, and the files then fall under the existing
   /system/fonts/(.*) immutable cache header in vercel.json instead of a new
   origin. They are the official latin + latin-ext subsets of the variable font
   (weights 300-800), so one file per subset covers every weight the page uses.
   ========================================================================== */

/* @font-face must sit at the TOP LEVEL, never inside :root: nested it is
   invalid CSS and is dropped silently — no console error, the family just falls
   back. Same rule as system/tokens.css. */
@font-face {
  font-family: "Funnel Display";
  src: url("system/fonts/FunnelDisplay-latin.woff2") format("woff2");
  font-weight: 300 800;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Funnel Display";
  src: url("system/fonts/FunnelDisplay-latin-ext.woff2") format("woff2");
  font-weight: 300 800;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  /* The page face. Chained onto the system's stack, so the fallback is the face
     the rest of the repo already ships. */
  --font-page: "Funnel Display", var(--font);

  /* The statements' starting colour — the reference's light grey, letter by
     letter. Stated here rather than borrowed from the system because it means
     the opposite of --c-disabled, which is the same #cacaca: a statement that has
     not been *read yet* is not a statement that is unavailable. #cacaca on white
     is a 1.6:1 contrast, which is why it is only ever a state the copy passes
     through on its way to --c-fg, never a colour it can rest in: the scroll
     paints each letter black in turn, and a visitor without that paint — no
     script, reduced motion — gets --c-muted instead, which clears AA. */
  --c-highlight: #cacaca;

  /* The project rows' ink AT REST — #555555, the value the owner named, kept as a
     PAGE token rather than borrowed from the system because it is the ONE place
     grey is spent on something that is not disabled (--c-muted #767676 means
     "quiet meta", --c-disabled #cacaca means "unavailable"). It rests on the black
     .work band as a deliberate deviation from the reference, which draws every
     title and arrow in the page's ink, and the owner has now asked for that resting
     state TWICE — once unconditionally, and once with a condition attached. Read the
     condition before spending this token again.

     THE CONDITION IS THE POINTER, NOT THE IMPORTANCE. This token was retired once,
     in the pass where every name was white at all times (read .row__title, where
     both passes are recorded), and it is back because a rest colour is something
     only a visitor WITH A CURSOR can be lifted out of: the unconditional grey was
     shown to phones, which have no hover to lift it with, so the names sat quiet
     exactly where quiet was least readable. It is therefore spent in ONE place — the
     `hover: hover` + `pointer: fine` block under .row__title, and nowhere else — so
     on this page grey means "less important" to a visitor who can chase the white,
     and "not available" (or nothing at all) to a visitor who cannot.

     THE VALUE IS DARK, AND THE OTHER GREYS ARE NOT INTERCHANGEABLE WITH IT:
     --c-disabled is #cacaca and means the opposite thing — light, on white, at the
     end of its usefulness — and --c-muted is #767676 and means "quiet meta". The
     band's own histogram reading counts this one as 85. */
  --c-row-idle: #555555;

  /* --- The PROJECTS band ----------------------------------------------------
     TWO names for the section's own colours, kept apart from the page's
     --c-bg/--c-fg pair so the band can be retargeted — or lifted out again —
     without touching the paper and ink the rest of the page is drawn with. The
     band is a full-bleed BLACK surface, which no supplied frame is (see .work).

     There were THREE until the list lost its rules. --c-work-rule was the rows'
     1px grid line — a third, quieter grey, because against black a --c-work-fg
     line reads as a second headline rather than as a grid line — and a band with
     no rules to draw has no third colour to name. */
  --c-work-bg: #000000;
  --c-work-fg: #ffffff;

  /* THE AIR BETWEEN THE FOUR PROJECT NAMES, AND IT IS NOW THE OWNER'S 40px.

     IT HAS BEEN HERE FOUR TIMES AND THE FOURTH ANSWER IS THE LOOSEST YET — said
     plainly rather than dressed up, because the passes before it went the other way
     and their reasoning is still good: the owner's "1-2rem"
     (clamp(1.25rem, 2.5vw, 2rem), i.e. 20-32px) in the ruled list, then a "hairline"
     ramp (clamp(var(--s-1), 0.55vw, 0.5rem), 4-8px) once the rows became one line
     each, then 0, when even a hairline still read as four separate lines rather than
     as ONE BLOCK OF TYPE. What the 0 was built on is set aside here rather than
     refuted: a gap measures the air between two LINE BOXES, a line box already
     carries leading of its own, so any gap is air added on top of air — and the owner
     has now asked for more space between the names than any pass before this one had.

     A LITERAL px, NOT A STEP OF THE SPACE SCALE, for the reason --work-preview-gap's
     own 20px gives: the ask was that distance exactly, and px rather than rem so that
     this one distance cannot drift with the root font size the way --s-N does. It is
     the owner's number rather than a measurement, and nothing below derives from it.

     THE TERMS LIVE ON .row__title and are measured, not chosen: Funnel Display's own
     cap height (0.675em, read from the shipped family — see there), a 0.85
     line-height, and 0.152em of air left between the caps of two names — 14.4px at a
     1440 frame's 95px type and 14.6px at the 96px ceiling. THAT IS NOW THE SMALLER
     HALF of the distance between two names rather than the whole of it: the clear air
     between two caps is those 14.4px PLUS this gap, ~54.4px, and two baselines stand
     0.85em + the gap apart — 121.6px at the 96px ceiling, where they stood 81.6px
     while this token was 0. (The first build's 1.05 leading with its 20-32px ramp put
     them ~133px apart, so the 40px does not reach back to that.) Nothing else between
     two names has any say: not the list, not the rows (neither has a padding or a
     margin of its own — see .work__item).

     THE TOKEN STAYS, AS THE ONE-LINE KNOB, which is why it is not deleted along with
     its value: it is still the single place the stack's inter-row air is said, on a
     page the owner tunes by hand. 0 is what it held until now, `var(--s-1)` is the
     4px hairline before that, and a clamp of it is the fluid ramp before that.

     IT GIVES BACK WHAT THE 0 COST, AND THAT IS SAID RATHER THAN LEFT TO BE FOUND. A
     row IS the click target, and a phone's target is now the item box PLUS this gap.
     MEASURED on a true 390x844 viewport rather than derived from the line-height: with
     the token at 0 the pitch is 38px — the name's own 0.85 line-height computes to
     34px there, and the row it sits in measures 38px — which is under the 44px
     guidance the note that used to stand here recorded as a trade the owner had
     accepted; with the token at 40px the same pitch is 78px. At a 1440 frame the pitch
     is 81 + 40 = 121px, where it was 81px. Nothing else about the target changes: it
     is still the full width of the window (index.html's note on the row: the words are
     centred on the page's column, the block they are centred in is the window's).

     WHY THE GAP AND NOT A MARGIN ON THE ROWS, which is what a reader reaching for
     more air is most likely to write: a flex `gap` is spent BETWEEN items and NOT
     after the last one, while `margin-block-end` on `.work__item` would add its 40px
     below the LAST name as well — and that is the very edge --work-band-pad exists to
     keep equal to the band's top air (see .work). A margin would therefore have bought
     this space by spending the fix recorded two passes ago, which is a poor trade at
     any size. It is also why the `gap` that spends this carries no `!important`:
     there is ONE definition of this token and ONE spender, both in this file, so there
     is no competing declaration for a stronger weight to beat, and a margin on the row
     would be the wrong mechanism at any weight.

     Stated once, on the list, and not as padding per row, because the rows are one
     line each with no box of their own left to pad. It replaced --row-pad, which
     was the reference's 2rem row padding plus a fluid ramp on top of it — the value
     that made the old ruled rows ~300px tall at a 1440 frame. See .work__list. */
  --work-row-gap: 40px;

  /* ONE frame for the four preview CARDS, and the only size statement about them
     in this file. A card stands beside the name it belongs to rather than in a
     strip between a title and an arrow, so there is no row to fit
     into and no second frame for a mode: the old pair — --row-hover-w, which had
     to fit 266px of empty row at a 1024 frame, and --row-hover-float-w, which was
     a screenshot's size for the cursor to carry — are both gone, and what is left
     is simply "small, and the same wherever it is shown": 176px at the 64rem gate,
     216px at 1440, 256px — the ceiling — from 1707 up. See .row__preview. */
  --work-preview-w: clamp(11rem, 15vw, 16rem);

  /* The stand-off between a card and the name it belongs to — and it is the
     owner's own number: a LITERAL 20px rather than one of the space scale's
     steps, because the ask was that distance exactly, and px rather than rem so
     that this one distance cannot drift with the root font size the way --s-N
     does. It is stated once here and spent by .row__preview--left / --right as a
     margin on the side that faces the words: the card's NEAR EDGE is 20px from
     the name's own box, so the gap holds wherever the column puts the name and
     however long the name is. See .row__preview. */
  --work-preview-gap: 20px;

  /* Headline ramp: the fallback value for a browser without container queries.
     It is superseded by the column-relative cqi value below — see the note
     beside it for why vw is the wrong unit for this headline. */
  --fs-headline: clamp(2.25rem, 7.5vw, 7rem);

  /* --- Hero depth ---------------------------------------------------------
     The hero's two spaces, here rather than inline so the composition can be
     re-tuned in one place.

     --hero-pad-top  the air above the headline (~140px at a 1440 frame in the
                     reference).
     --hero-lead     how far the statements sit below whatever is above them.
                     It is a LEAD-IN, not a gap: it is what keeps them off the
                     first screen, so the scroll the cue asks for is the scroll
                     that reveals them — and the sweep further down this file is
                     the fill of copy that arrives under the fold.
                     Wide it is the ONLY space under the headline, because row 2's
                     row gap is switched off at 64rem; narrow it is added to the
                     row gap that separates the copy from the picture above it.

                     Two terms, and the LARGER one wins:

                       45vh      the owner's ask — significant air rather than a
                                 line of it. This is the spacing on any window
                                 short enough that the fold lands near the copy.
                       calc(100vh - 30rem)
                                 a FLOOR, and the reason "below the fold" is a
                                 fact rather than a probability. The copy's
                                 distance from the top of the page is fixed by the
                                 furniture above it (this padding, the headline,
                                 and on a phone the picture), while the fold moves
                                 with the window: 45vh alone puts the copy 58px
                                 below a 900-tall window's edge but 217px INSIDE a
                                 1400-tall one. Subtracting that furniture as a
                                 constant and adding whatever the screen has left
                                 over puts the copy the same ~50-350px past the
                                 bottom edge at EVERY height. 30rem = 480px is the
                                 narrow layout's furniture (596px at its smallest,
                                 740px at 390x844) less a margin; the 64rem block
                                 below states the wide layout's own, smaller
                                 constant, because there the copy sits under the
                                 HEADLINE rather than under the picture. Re-measure
                                 either one if the headline ramp or the picture
                                 changes size.

                     `vh`, not `svh`: on a phone `vh` is the viewport with its
                     toolbars HIDDEN — the taller one — so a fold stated against it
                     is the conservative one and the copy clears the visible edge
                     whether or not the toolbar is showing. */
  --hero-pad-top: clamp(2rem, 6vw, 6rem);
  --hero-lead: max(45vh, calc(100vh - 30rem));

  /* --- The pin ------------------------------------------------------------
     The hero's scroll-jack as two numbers and the line it stops on.

     --hero-pin-top   WHERE the statements stop: the line, measured from the top
                      of the window, that the stage is held at for the whole of the
                      pin. The stage is a FRAME around the window's middle (see the
                      pin section below), so this is also the margin that frame keeps
                      from the window's top AND bottom edges — and therefore the air
                      the copy is centred in, half of what is left of the window
                      above it and half below. It still has to clear the sticky
                      header, so its floor is the header row's own height
                      (`.header__inner`, 3.5rem): below that the frame's top margin
                      is thinner than the bar, and on a window short enough for the
                      copy to fill the frame the copy's own top edge — level with
                      the frame's, because that is the one case where centring has
                      nowhere left to move it — would slide under the bar. On a tall
                      window it takes 10vh instead (90px at a 900-tall frame, which
                      with a 720px frame leaves the copy 200px of air either side),
                      and 6rem is the ceiling the clamp has always had: 10vh is 140px
                      at a 1400-tall one, a margin worth more of the frame than the
                      copy's own length earns. portfolio.js READS this value rather
                      than restating it, so the line the hold starts at and the frame
                      it holds cannot drift apart.

     --hero-pin-run   HOW LONG it is pinned for: the height of the wrapper the
                      stage sticks inside. What the pin actually owns is this less
                      the stage's own height — and since the stage is now the FRAME
                      (100vh less two --hero-pin-top, 720px at a 900-tall window)
                      rather than a box the copy's own length decides, the travel is
                      250vh less that frame: 150vh + 2 x --hero-pin-top, ~1,530px at
                      a 900-tall window, i.e. ~1.7 screens of scrolling. At 119
                      letters that is a letter every ~13px: fast enough that the
                      sentence reads as one fill, slow enough that the letters land
                      one at a time. German's 160 letters get the same travel and so
                      land one per ~9.6px: the pace is a property of the distance
                      scrolled, not of the sentence, which is why the script divides
                      that distance by a LETTER COUNT rather than stating a distance
                      per letter. */
  --hero-pin-top: clamp(3.5rem, 10vh, 6rem);
  --hero-pin-run: 250vh;

  /* --- Work lead-in -------------------------------------------------------
     The air between the hero's last statement ("I design growth-ready
     websites…") and the PROJECTS heading, which is the first thing after the
     pitch. The owner's read was that the two were crowding each other, so this
     is ADDED to the space that was already there rather than traded against it:
     the hero's own bottom padding, clamp(3rem, 8vw, 9rem), and .work's --s-6 top
     both stay exactly as they were, and the measured copy-to-heading gap goes
     from 80px to ~192px at 320x568 and from 147px to ~309px at a 1440x900
     window.

     Three terms, and the smallest is the brief's:

       7rem        the FLOOR (~112px). A phone is where a vh term is worth least
                   — a 568-tall window would otherwise get 102px and a 667-tall
                   one 120px — and where this is still air rather than a screen
                   of nothing.
       18vh        the brief's ask, "15-20vh or 150-200px": 162px at a 900-tall
                   window and 194px at 1080, so inside the brief at both.
       14rem       the CEILING (224px). Unclamped, 18vh is 252px at 1440x1400 —
                   between a paragraph and a heading that is a scroll of its own
                   rather than breathing room.

     A `vh` term and not a `vw` one, for the same reason the sweep's ends are:
     the gap is read against the SCREEN the heading is scrolled into, not against
     the text measure (a `vw` ramp would make a short-and-wide window the airiest
     and a tall narrow one the tightest, which is backwards).

     Nothing else about the section moves: the heading's own size, the list's
     --s-6 margin under it and the rows' rules are all untouched, so the space
     between PROJECTS and the first rule is exactly what it was. The hero is not
     touched at all either — --hero-lead and the headline's own bottom edge are
     unchanged, so the headline-to-copy spacing this token sits far below is the
     one the owner signed off.

     It is stated on the heading, which lives in a .container that sets
     padding-inline only: the margin collapses out of that container and is
     contained by .work's own top padding, so the space under the hero measures
     --s-6 + this token. If .work's padding is ever restated, re-measure this
     pair rather than trusting the arithmetic. */
  --work-lead: clamp(7rem, 18vh, 14rem);

  /* --- The band's air below the list ----------------------------------------
     THE OWNER'S ASK: the black band ended too closely after the last project
     name — 48px of `--s-7` behind REDESIGN — and the fix is that the air BELOW
     the last name is now the same amount as the air ABOVE the heading, so the
     band reads as one frame around its type instead of a frame that closes early.

     IT IS A SUM, AND THE SUM IS THE POINT. The top's air is TWO things: .work's
     own --s-6 top padding (32px) plus --work-lead (the token above), which the
     heading carries as its own margin — the margin collapses out of its
     .container and is contained by that padding, so the black the heading stands
     in is --s-6 + --work-lead, 194px at a 1440x900 window. The BOTTOM has no
     heading of its own to carry a lead-in: the last thing in the band is a name,
     and nothing after it may declare a margin (the rows are flex items, whose
     margins do not collapse). So the bottom edge states the whole amount itself,
     as its own padding — which is why the two numbers in .work's `padding-block`
     are not equal while the two AIRS are.

     WRITTEN AS THE SAME TWO TERMS AND NOT AS A NUMBER, so it cannot drift: every
     change to --work-lead, or to the band's own top padding, moves both edges
     together. At a 1440x900 window it computes to 194px (a phone at 390x844 gets
     184px, the 7rem floor of --work-lead plus --s-6), and it tops out at 256px
     where --work-lead hits its 14rem ceiling.

     THE NUMBER THE OWNER NAMED was "6-8rem" (96-128px), and this is
     deliberately MORE than that: 6-8rem would have left the band visibly
     top-heavy, since the air above the heading was already 194px at that window.
     Pinning it there is one line if that trade is ever preferred --
     `--work-band-pad: clamp(6rem, 12vh, 8rem)` -- and nothing else in the section
     has to move for it: the token is spent in exactly one declaration. */
  --work-band-pad: calc(var(--s-6) + var(--work-lead));

  /* --- Footer lead-in -----------------------------------------------------
     The air between the page's last line of copy and the footer's own top rule,
     which is the seam the page closes on. THE PREMISE OF THIS TOKEN HAS MOVED
     ONCE, AND THE TOKEN HAS NOT: it was written as the air between the black
     PROJECTS band and the signature, because the owner read those two as crowding
     each other — and a later pass put the CONTACT block between them (see the
     contact block below, which is the section the nav's "contact me" entry now
     lands on). The value, the argument and the spending below are all unchanged,
     at the owner's instruction to leave the footer exactly as it was; what moved is
     what stands at the other end of the seam. It is no longer the whole of that
     seam either: the contact block's own bottom padding is ADDED to this token
     rather than traded against it, and the contact block states the sum.

     The owner read the two as crowding each other, exactly as with --work-lead
     above, and this is ADDED to the space that was already there rather than
     traded against it: the air the closing copy already carried and
     .footer__name's own --s-7 above the caps (48px) both stay exactly as they
     were, so the last-line-to-caps distance is those two plus this token.

     Three terms, and the middle is the brief's "15-20vh":

       6rem        the FLOOR (96px), reached only under a 480px-tall window,
                   where a vh term stops meaning anything — and the footer
                   already brings 96px of its own air on top of it.
       20vh        the brief's ask: 180px at a 900-tall window, 216px at 1080,
                   160px at 1024x800, and 168.8px on a 390x844 phone, where the
                   page already spends 152px of the same kind of air on the
                   same journey, above PROJECTS.
       15rem       the CEILING (240px) — the owner's own number, and the widest
                   this seam is allowed to get on a tall window.

     A `vh` term and not a `vw` one, for --work-lead's own reason: the seam is
     read against the SCREEN it is scrolled into, not against the text measure.

     IT IS STATED ON .footer, AND AS A MARGIN, and both halves of that matter:

       * a margin and not padding, so the footer's own box keeps the height it
         was measured at — the name's 94% share of the band, the band's two
         --s-7 insets and row two's rhythm are all untouched by this token;
       * on .footer and NOT as padding on .work, because .work IS the black
         surface: a pad on the band paints 240px of black rather than opening
         240px of white, and the space the owner asked for is BETWEEN the two,
         which only the footer's side of the seam can open.

     With the air here rather than on the band's edge, the footer's full-bleed
     top rule lands at the END of it: the line that closes the page's content
     now visibly opens the colophon, where against the band it was black on
     black and could not be seen at all. */
  --footer-lead: clamp(6rem, 20vh, 15rem);
}

body,
button,
input,
select,
textarea {
  /* Applied to the whole document, headings and controls included. A bare
     <button> keeps the UA font unless it is told otherwise, which is why the
     controls are named here rather than relying on inheritance alone. */
  font-family: var(--font-page);
}

/* --- Preloader -------------------------------------------------------------
   <div id="preloader"> at the top of index.html's body: one sheet of ink over
   the whole viewport with the number on it, and the first thing the page paints.

   GATED ON .js, the same rule the hamburger and the hero's cue follow and for
   the same reason: a number needs a script to walk it, and the sheet needs one
   to take it away again. Drawn unconditionally, a page whose script never
   arrived — a blocked request, one bad line — would be a black screen with a 000
   on it and no way out of it. So `display: none` is the default and the rule
   that draws the sheet sits behind html.js: inert markup, and that visitor gets
   the page.

   WHAT IT COVERS: `fixed` at all four insets, so it is the viewport rather than
   a 100vw box (a vw box is a scrollbar's width too wide wherever one is still
   reserved), at z-index 9999 — above every layer this page has, which from the
   bottom are the floated project picture (3), the hero's cue (4), the sticky bar
   (5) and the skip link while it has focus (10). The number is centred by the
   grid, and that is the whole composition: no mark, no caption, nothing else on
   the sheet but the number and the copy of it it hands over to (below).

   THE INK IS THE SYSTEM'S PAIR, INVERTED — --c-fg for the sheet and --c-bg for
   the number, which is #000000 under #ffffff: the black-and-white the brief asks
   for, without a hex being written here, and the same inversion .ds-skip wears
   when it takes focus (system/components.css). The page has one answer to "what
   does this look like inside out".

   THE PAGE IS NOT FADED IN UNDER IT. The sheet's own opacity is the only thing
   that moves; the page is already painted and simply stops being covered — the
   cleaner of the two the brief allowed, and the only one that cannot leave the
   hero's own first frame disagreeing with itself about where anything is. */
#preloader {
  display: none;
}

html.js #preloader {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: grid;
  place-items: center;
  background: var(--c-fg);
  color: var(--c-bg);
  /* The fade the script's attribute waits for — 0.6s of ease, on the one
     property that cannot move anything: a sheet that left by translating or
     scaling would take the page with it. */
  transition: opacity 0.6s ease;
}

/* The one state portfolio.js adds to the sheet, and the whole of the fade. An
   ATTRIBUTE rather than a class, because that is how every script-set state on
   this page is written — data-open, data-scrolled, data-retired, data-painted
   — so there is one mechanism here for "the script says so". */
html.js #preloader[data-counted] {
  opacity: 0;
}

/* --- The number, and the pair it takes -------------------------------------
   Two of them, one per span in index.html, which is the only shape a cross-fade
   can be built out of: the number that is leaving and the number that is arriving
   have to be on the sheet at the same time. The roles are not fixed — each step
   writes the next value into whichever one is NOT on screen (portfolio.js's swap)
   — so neither element is named for a part, and the rule below is the whole of
   what they share.

   ONE GRID CELL, BOTH OF THEM: `grid-area: 1 / 1` puts the pair in the same row
   and column, so each is centred on the page's own centre line by the sheet's
   `place-items` rather than sitting beside the other.

   The face is the page's and the size is the composition around it: a straight
   ramp rather than a step at the breakpoint — 100px at the narrowest window the
   page supports, 200px from a 1440 frame up — because nothing else is on the
   sheet and there is nothing for a between-size to crowd.

   tabular-nums because the number is CENTRED: with a proportional set its box
   changes width as its digits change, so the hand-over would nudge the number
   sideways instead of leaving it where the eye last had it. The family is
   variable and may or may not ship a tabular set; where it does not, this costs
   nothing and the shift is a few pixels.

   --fw-bold is 700, a weight the family's own axis draws (300-800 — see the
   @font-face at the top of this file), so nothing here is synthesised.

   THE 0.2s OF OPACITY AND TRANSFORM IS THE HAND-OVER. Both properties move
   together, both at the same length, because the two elements are animating in
   opposite directions at once: the leaving one goes UP and out, the arriving one
   comes up FROM BELOW and in, and for the rest of the step the pair is still.
   That is the rhythm portfolio.js's STEP_MS is spaced around (SWAP_MS + HOLD_MS
   = STEP_MS) — change one and the other has to follow.

   THE PHASES ARE ATTRIBUTES, like every script-set state on this page — data-open,
   data-scrolled, data-retired, data-painted — so there is one
   mechanism here for "the script says so". `below` is a POSITION rather than a
   state: an arriving number is put there and then released, and the release is
   the animation. Setting it with `transition: none` is what keeps that placement
   from being seen — without it the browser would animate the number INTO the
   wings from wherever the previous step left it (transparent, one slide-height
   up) and then, on the release, drop it down into place instead of raising it. */
.loader-number {
  grid-area: 1 / 1;
  font-family: var(--font-page);
  font-weight: var(--fw-bold);
  font-size: clamp(6.25rem, 13.9vw, 12.5rem);
  line-height: 1;
  font-variant-numeric: tabular-nums;
  transition: opacity 0.2s ease, transform 0.2s ease;
}

/* A number that has had its turn: out of the way upwards, and left there — this
   is a state, not an animation, so it stays put until the element is reused. */
.loader-number[data-phase="out"] {
  opacity: 0;
  transform: translateY(-0.18em);
}

/* Where an arriving number waits, one slide-height down and invisible. */
.loader-number[data-phase="below"] {
  opacity: 0;
  transform: translateY(0.18em);
  transition: none;
}

/* No sheet at all where less motion has been asked for: a full-screen black
   flash with a number ticking through it is what that preference is about, and
   system/base.css's blanket flattening of transitions would turn both the fade
   and the hand-over between one number and the next into cuts rather than remove
   them. portfolio.js asks the same query and leaves the sequence, the lock and
   the dismissal alone — so this is not the only half of it that stands down. */
@media (prefers-reduced-motion: reduce) {
  html.js #preloader {
    display: none;
  }
}

/* --- The lock the number is walked behind ----------------------------------
   While the numbers tick the page must not scroll under the sheet: the hero's
   statements are painted BY the scroll, so a wheel that got through in those two
   and a half seconds would spend the hold behind a black screen and hand the
   visitor a half-painted block when it lifted. portfolio.js sets this attribute
   for the sequence and removes it again — BEFORE the fade, never after (see the
   preload block in portfolio.js).

   ON THE ROOT ELEMENT, NEVER ON body. `overflow: hidden` on the root is
   propagated to the viewport (CSS Overflow 3 §3.3), which leaves the root's own
   used overflow `visible` — so the page stops scrolling and NOTHING becomes a
   scroll container, and `position: sticky` on .hero__stage goes on resolving
   against the viewport exactly as it does with the attribute absent. The same
   declaration on `body` — or on `html, body`, which the snippet this usually
   arrives as — turns body into a scroll container and would break that hold for
   good. Do not move it.

   WHAT IT TAKES AWAY AND WHAT IT DOES NOT: `overflow: hidden` takes USER
   scrolling — the wheel, the touch drag, the space bar — and leaves the viewport
   scrollable by script, so a scrollTo() still moves the page under the sheet. That
   is the half that is wanted: what this holds still for two and a half seconds is
   a visitor's own hand on the wheel, and a scripted scroll is not that hand.
   `overflow: clip` would block both, and is not what this is. */
html[data-preload] {
  overflow: hidden;
}

/* --- Header ---------------------------------------------------------------
   The reference: hamburger flush left, name centred, language toggle flush
   right — one hairline-ruled bar, and nothing else in it. `1fr auto 1fr` is
   what centres the name: the outer tracks are forced equal, so the middle
   track sits on the page's centre line whatever the burger and the toggle
   happen to measure.

   The nav is NOT a fourth slot. It is the panel the burger drops out of the
   bar (see .js .nav below), and that is the whole reason the bar can stay one
   row with the name on the centre line at every width — and the reason the
   toggle, which is chrome rather than a destination, is the thing that stayed
   in the bar while the links left it. */

.header {
  position: sticky;
  inset-block-start: 0;
  z-index: 5;
  background: var(--c-bg);
  /* NO RULE UNDER THE BAR, at the owner's explicit request. The hairline that
     used to be reserved here — transparent at rest, `--c-fg` once portfolio.js
     set `data-scrolled` (the scroll edge the README describes) — is removed
     outright, so nothing is drawn under the burger, the name and the EN/DE
     toggle at any scroll position. `border-bottom` is the physical spelling of
     the same edge `border-block-end` names in this file's horizontal writing
     mode, and it is stated `!important` so no other rule can repaint the edge.
     The bar itself is untouched and stays sticky: this is a border, not the box. */
  border-bottom: none !important;
}

/* The scrolled edge that used to live here — `.header[data-scrolled] {
   border-block-end-color: var(--c-fg); }` — is REMOVED, together with the
   border it coloured (see the note in `.header` above). portfolio.js still
   toggles `data-scrolled` on the header; there is simply no border left for it
   to paint, so the attribute is inert from here on. */

.header__inner {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--s-4);
  min-block-size: 3.5rem; /* 56px, one row at every width */
}

.header__name {
  grid-column: 2;
  grid-row: 1;
  justify-self: center;
  /* Regular weight: the reference's name is small and quiet, not a second
     heading competing with the mark beside it. */
  font-size: var(--fs-meta);
  font-weight: var(--fw-regular);
  letter-spacing: 0.02em;
  text-decoration: none;
  white-space: nowrap;
}

/* The toggle is the bar's third slot, and it is here — in the HEADER's layout —
   because placement belongs to the bar a control sits in, exactly as it does on
   the footer bar below. Chrome, not a destination: it holds its slot on a phone
   and on a desktop alike, while the destinations move behind the burger. */
.header__inner .lang {
  grid-column: 3;
  grid-row: 1;
  justify-self: end;
}

/* --- Hamburger ------------------------------------------------------------
   Two horizontal black lines, as the reference draws them. Hidden until
   portfolio.js has run: an inert disclosure is worse than none, and the nav
   stays visible at every width without it (see the no-JS block at the bottom). */

.burger {
  grid-column: 1;
  grid-row: 1;
  justify-self: start;
  display: none;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.625rem; /* the reference's two lines are well separated, not doubled */
  /* Wide enough to hold the 48px bars; still at least the system's 44px hop. */
  inline-size: 3.25rem;
  block-size: 2.75rem;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  cursor: pointer;
}

.js .burger {
  display: flex;
}

.burger__bar {
  display: block;
  inline-size: 3rem; /* 48px at the reference's scale, not a 22px hairline */
  block-size: 3px;
  background: currentColor;
}

/* --- Nav ------------------------------------------------------------------
   The two destinations, and — whenever a script is running — they appear in the
   header nowhere else: the bar itself is three slots and this is the panel the
   burger drops out of it. Same band as the footer's panel, opening the other
   way: white, full-bleed, a hairline on the edge the page behind it meets, and
   a .container inside so the destinations land on the column the burger above
   them sits on rather than against the window's edge.

   `position: absolute` resolves against `.header`, which is sticky and therefore
   already a containing block, so `inset-inline: 0` is the window and
   `inset-block-start: 100%` is the bar's own bottom edge — the panel hangs off
   the bar at every width, not just on narrow screens.

   Hidden until [data-open] is set, and only the script ever sets it. With no
   script the nav is not hidden at all: the no-JS block at the end of this file
   puts it back in the bar's grid, under the name, where its links stay visible
   at every width — an inert disclosure is worse than no disclosure. */

.js .nav {
  display: none;
  position: absolute;
  inset-inline: 0;
  inset-block-start: 100%;
  background: var(--c-bg);
  border-block-end: var(--bw-hairline) solid var(--c-fg);
  padding-block: var(--s-4);
}

.js .nav[data-open] {
  display: block;
}

.nav__list {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  margin: 0;
  padding: 0;
  list-style: none;
}

.nav__link {
  font-size: var(--fs-meta);
  text-decoration: none;
  /* The rule is reserved in the transparent colour so hover cannot shift the
     label by a pixel. */
  border-block-end: 1px solid transparent;
  padding-block-end: 2px;
}

.nav__link:hover {
  border-block-end-color: var(--c-fg);
}

/* --- Language toggle ------------------------------------------------------
   EN/DE as two states of one control: the active language is black, the other
   is grey. That is the reference's own figure/ground logic, it is legible
   without colour, and the state is carried by aria-pressed, so the visual and
   the accessible state cannot disagree. */

.lang {
  display: flex;
  align-items: center;
  gap: var(--s-1);
  font-size: var(--fs-meta);
}

.lang__btn {
  padding: 0 0 2px;
  border: 0;
  border-block-end: 1px solid transparent;
  background: none;
  color: var(--c-muted);
  font-size: var(--fs-meta);
  cursor: pointer;
}

.lang__btn[aria-pressed="true"] {
  color: var(--c-fg);
  font-weight: var(--fw-bold);
  border-block-end-color: var(--c-fg);
}

/* --- Hero -----------------------------------------------------------------
   The reference: headline left, greyscale portrait right, the cue in the
   bottom-right corner of the first screen, and the statements a lead-in BELOW
   that screen (--hero-lead), so the page opens on the headline and the picture
   alone and the copy is what the scroll reveals. Narrow, the source order is a
   single column — headline, picture, cue, statements — which is the order the
   reference's own description reads in; the cue leaves that column for the
   viewport's corner whenever a script is present, and the statements keep that
   lead-in either way. */

.hero {
  /* Airy by design and fluid: the reference's own hero is a tall editorial block
     — measured ~140px above the headline and ~280px below the statements at a
     1440 frame — so both ends are stated fluidly. The top end is the token
     --hero-pad-top; the block is taller than the first screen by design, but its
     height comes from the copy's own lead-in (--hero-lead) rather than from a row
     reserving a screen, so nothing in this rule is measured against the viewport
     — the one viewport-derived number is the lead-in itself. */
  padding-block: var(--hero-pad-top) clamp(3rem, 8vw, 9rem);
}

.hero__grid {
  display: grid;
  /* row gap only on narrow: the wide layout states its own column gap below */
  gap: clamp(1.5rem, 4vw, 4rem) 0;
}

.hero__col {
  /* A grid item defaults to min-content: without this the headline can push the
     column wider than the page instead of being sized by it. */
  min-inline-size: 0;
}

.hero__title {
  font-size: var(--fs-headline);
  /* Funnel Display ships 300-800 as a real axis, so 800 here is a drawn weight,
     not a synthesised one. That is why this page can go heavier than the
     system's Neue Montreal, whose heaviest file is Bold (system/tokens.css). */
  font-weight: 800;
  /* Measured off the reference: its three caps sit one line-height apart with a
     hair of air between them, which is what 1 does for uppercase-only lines.
     Tighter (0.92) and the caps collide; looser and the block loses its stack. */
  line-height: 1;
  letter-spacing: -0.03em; /* the reference's tight tracking */
  text-transform: uppercase; /* the DOM keeps its own case for screen readers */
}

.hero__title span {
  display: block;
}

/* Size the headline against its OWN COLUMN, not the viewport. A vw ramp has to
   be tuned for the narrowest case, where the column is a fraction of the page,
   and then mis-sizes everywhere the container is capped by --measure. cqi makes
   the column the unit, and the factor is MEASURED rather than guessed: "DESIGNER"
   renders 4.91em wide in the real face, so 18.5cqi fills ~91% of the column and
   leaves ~9% slack — the reference fills ~94%, so this matches it while still
   being unable to overflow. (The first attempt used 16cqi on a guessed 5.6em and
   measured only 79% of the column, visibly undersized against the reference.)
   --fs-headline is the fallback for a browser without container queries. */
@supports (container-type: inline-size) {
  .hero__col {
    container-type: inline-size;
  }
  .hero__title {
    font-size: clamp(2.5rem, 18.5cqi, 9rem);
  }

  /* ONE ramp serves both languages, because the headline is the same English in
     both: it is the wordmark, and does not translate (see the note on it in
     index.html). So the German setting measures exactly what the factor above
     was built for and needs no ramp of its own.

     It had one: `html:lang(de) .hero__title` scaled this by the measured ratio
     of "Produktdesigner" (9.61em) to "Designer" (4.91em) while the German
     headline existed. That word has left the site, so the rule has gone with
     it rather than sit here unused. If a German headline is ever wanted back,
     the overflow it prevents is real and the arithmetic is in the README. */
}

.hero__intro {
  display: grid;
  /* Two statements and the air between them. --s-6 rather than the --s-5 this
     block used at 30px: at the size below, 24px between two paragraphs is the
     same order as the gap between two LINES of them, and the pair stops reading
     as two statements. */
  gap: var(--s-6);
  /* The reference's statements are large — ~30px at a 1440 frame — and this
     block has been moved twice on the owner's asks: first up to display size
     (2.5-3rem), then back DOWN to where it is now, ~17% below that, with the
     weight raised to match. It is the page's second voice after the headline
     rather than a paragraph under it, but at the larger size it was reading as
     a headline of its own — two more display lines under three — and the
     sentence wanted to be read, not looked at. 40px is where the two sentences
     read as one statement and the block still outranks the body copy by a
     clear margin (the body sits at 1rem/16px, see --fs-body).
     The ramp is a vw one because the block IS the content column at every width
     it has one: wide it is the grid's left column (65% of the container), narrow
     the whole column, so there is no second measure for a vw ramp to disagree
     with the way the headline's does (see the cqi note above). The floor, 1.25rem,
     is what a phone gets, and the narrow column is what it is pinched against —
     the German copy's longest word is the thing to re-measure if this ramp is
     ever raised (see the README's matrix). Every term is the old ramp (1.5rem /
     4.4vw / 3rem) at ~82%, so the whole curve moved together and the widths at
     which the floor and the ceiling bite are where they were. */
  font-size: clamp(1.25rem, 3.6vw, 2.5rem);
  /* Medium, and a WEIGHT rather than a trick: Funnel Display ships 300-800 as a
     real axis (see the @font-face at the top of this file), so 500 is a drawn
     weight the family has, not 400 smeared with a synthetic bold. Stated as the
     number rather than as a token because the system has no medium ROLE — its
     tokens are --fw-regular (400), --fw-bold (700) and --fw-black (700, mapped
     down to Bold on purpose; see system/tokens.css) — and this is a decision
     about THIS block's copy. The hero headline states its own 800 as a number for the
     same reason.
     Why the weight at all: at the size above the block is display type, and
     display type at 400 on a light page reads thinner than its size, because
     there is more white inside each glyph for the eye to lose the stroke in.
     One step up restores the density the size took away, and 500 is the smallest
     step that does it — 600 and 700 turn a sentence into a label. */
  font-weight: 500;
  /* Tighter than the body's --lh-normal (1.5): leading that is comfortable at
     16px is loose at 48px, and this is the one block on the page set as display
     type. 1.2 is a hair looser than the headline's own 1 — this is sentences,
     not caps. */
  line-height: 1.2;
  max-inline-size: 46ch;
}

/* The reference's grey. --c-muted is the system's only grey that is not the
   disabled colour, and #767676 on white clears WCAG AA at this size. It is also
   the whole colour of this copy for everyone the paint below cannot reach: no
   script, or reduced motion, or a letter the paint never arrived at. */
.hero__intro p {
  color: var(--c-muted);
}

/* The statements are PAINTED IN, letter by letter, as they are scrolled through:
   every character sits at the reference's light grey while the block arrives, and
   the letters turn black in reading order until the sentence has been read — and
   unpainted again if the visitor scrolls back up. The move is the reference's
   own; what is different here is the GRANULARITY, and the granularity is what
   makes it read as writing rather than as a line crossing the block. A line
   uncovers the copy; a letter at a time fills it in.

   The paint is stated HERE and nowhere else — the script only names which letters
   the scroll has reached ([data-painted] on the span, set in portfolio.js) and
   never a colour — so there is one place to change the grey, one place to change
   the black, and one place to change how fast a letter gets there. Nothing about
   the look is assembled in JavaScript.

   Why letters and not a sweep: this block used to be wiped in, by a twice-tall
   `linear-gradient` clipped to the glyphs and slid down behind them. That
   version's boundary was a single hard edge crossing whole lines, and the two
   things wrong with it are the two things a letter-wise paint fixes. A line is a
   coarse step, so the copy never very convincingly filled in as it was read; and
   the edge could only ever sit BETWEEN two glyphs, so at every scroll position
   one line of the copy was half black and half grey. At letter granularity the
   boundary is inside a word, which is where writing happens, and the gradient,
   its `background-clip: text`, its transparent paragraphs and the `--intro-fill`
   number that positioned it are all gone with it.

   Four things keep it honest, the same four the wipe was held to:
     - [data-letters] is set by that script and never by CSS alone. Without it
       there are no spans to paint and the copy keeps the solid grey above —
       which is what this block looked like before any of the effects existed, so
       a page whose script dies is readable.
     - no-preference, so a visitor who has asked for less movement gets the copy,
       not the paint. Stated as a media query rather than as a switch in the
       script precisely so that a preference changed mid-visit is honoured on the
       spot, without a reload.
     - nothing here is `color: transparent` and nothing is clipped to the glyphs,
       so there is no state in which the copy is invisible. The paint moves a
       colour and the fallback is a colour: a letter the paint never reaches is
       the paragraph's own --c-muted, and the worst case is copy that did not fill
       in — never copy that cannot be seen.
     - the letters carry their colour DIRECTLY, so the paragraph's --c-muted
       cannot win by inheritance while the paint is running.

   How far the block travels before it is black — the pin's own scroll distance —
   is deliberately NOT here either: that is `--hero-pin-run` and `--hero-pin-top`,
   up with the rest of the hero's depth, because the distance belongs to the PIN
   and the paint is simply what happens across it. What the paint owns is the grey,
   the black, and the 120ms between the two. */
/* The two spans the split leaves behind — one wrapper per word, one span per
   letter — and the reason the block looks untouched by the split: an inline box
   is not a unit of line breaking, so the sentences still break at the spaces and
   only at the spaces, exactly as the plain text did.

   `display: inline` is the default and is written out anyway because
   `inline-block` is what an effect like this usually reaches for, and it is the
   wrong tool twice over: a word becomes one unbreakable box, so a long one can no
   longer be broken at all and the space after it starts a new line as a box
   rather than as a break; and some assistive technology reads an inline-block
   word out letter by letter, which is the one thing splitting copy for a visual
   effect must not do. As inline spans the letters concatenate back into the same
   sentence for the line breaker and for a screen reader alike — and there is no
   break opportunity between two letters that the plain sentence did not already
   have, so a line cannot break in the middle of a word any more than it could
   before. */
.hero__intro .hero__word,
.hero__intro .hero__letter {
  display: inline;
}

@media (prefers-reduced-motion: no-preference) {
  /* Every letter, in the grey it arrives in. */
  .hero__intro[data-letters] .hero__letter {
    color: var(--c-highlight);
    /* The whole of the smoothness, and short on purpose: the boundary advances a
       letter every few pixels of scroll, so at any real scrolling speed two or
       three letters change in a single frame, and 120ms of colour on each of them
       is what turns those steps into one sweep instead of a row of switches. It is
       LINEAR because the scroll it follows is: an eased colour would arrive before
       or after the position it belongs to, and the paint would stop agreeing with
       the visitor's own thumb. */
    transition: color 120ms linear;
  }

  /* The letters the scroll has reached — the state, and the only thing
     portfolio.js writes. Stated on the span and never inherited, so the
     paragraph's --c-muted cannot win while the paint is running. */
  .hero__intro[data-letters] .hero__letter[data-painted] {
    color: var(--c-fg);
  }
}

/* The lead-in, and it is here rather than on the copy because the copy now has a
   wrapper. This is not a motion rule: it applies at every width, whether or not
   the pin is running, script or no script — the copy stands off the row above it
   by exactly this much, which is what keeps it off the first screen (see
   --hero-lead). A margin rather than a row gap, so it can differ from the air
   between the other rows: wide the row gap is 0 precisely so that this is the
   whole space under the headline. */
.hero__pin {
  margin-block-start: var(--hero-lead);
}

/* --- The pin ---------------------------------------------------------------
   The statements do not scroll past; they STOP, and the letters fill in while the
   page is held. This is the one place on the page where scrolling is spent on
   something other than movement, so it is worth stating exactly what is spent and
   what pays for it.

   Two elements, and the reason there are two is structural: `position: sticky`
   holds a box inside a parent TALLER than itself, so the copy cannot be held
   inside its own box. `.hero__pin` is the tall parent — --hero-pin-run of nothing
   but scroll, the drum the page is spent through while the block is held — and
   `.hero__stage` inside it is the thing held. The stage brings the lead-in with
   it: `margin-block-start: var(--hero-lead)` is on the WRAPPER rather than on the
   copy now, which is geometry-identical to where it was (a margin on a grid item
   does not collapse, and wide the row gap is still 0 so the lead-in is still the
   whole space between the headline's row and this one).

   THE STAGE IS A FRAME, and that is what centres the copy on the screen while it
   is held. A stage the height of its own copy stops where the copy stops, which
   is the top of the window — the copy then reads as copy that has already
   arrived, with the rest of the screen empty below it, and how empty is whatever
   the window happens to be minus one block of text. A frame as tall as the window
   less a --hero-pin-top margin at EACH end is symmetrical about the window's own
   middle, so centring the copy in it puts the copy's centre on the screen's
   centre: the same statement at every window height and for every copy length,
   with nothing tuned to one of them. The frame's top edge is what the sticky rule
   holds, so the hold's own line, and the number portfolio.js reads for it, are
   unchanged. What the frame does cost is that the stage is no longer as short as
   the copy: the pin's travel is the drum less the frame (see --hero-pin-run), and
   the copy's half of the leftover air now sits below it in the document as well
   as on the screen — the air the copy is centred in is the air it keeps on the
   way in and on the way out.

   Nothing here cancels a scroll: the browser scrolls and `sticky` holds, and what
   portfolio.js adds is the reading of WHERE in that travel the visitor is. So
   "only the letters change" is a fact about the hero — nothing in the hero moves
   while the pin holds — and not a claim that the document has stopped.

   Both rules are `.js`, so a page whose script never runs is an ordinary document:
   the wrapper at its natural height, the stage in the flow, the copy exactly where
   the lead-in puts it. Both are also no-preference, for the same reason the paint
   is — a visitor who has asked for less movement gets a hero that scrolls like a
   page, rather than one that swallows two screens of scrolling and gives back a
   colour change. portfolio.js reads that same preference and skips its measurement
   with it. */
@media (prefers-reduced-motion: no-preference) {
  .js .hero__pin {
    /* The drum. Stated taller than the hold it buys because the stage's own height
       is part of what it contains — the pin is this less the stage, and the stage
       is the frame --hero-pin-top defines rather than a box the copy's length
       decides (see --hero-pin-run) — so this is the DISTANCE, not the duration. */
    block-size: var(--hero-pin-run);
  }

  .js .hero__stage {
    position: sticky;
    inset-block-start: var(--hero-pin-top);
    /* THE FRAME, and the reason the copy is centred on the screen while it is
       held. The stage is as tall as the window less a --hero-pin-top margin at
       each end, and the copy is centred in it, so its own centre sits on the
       window's CENTRE: held at the top of the screen instead — which is what a
       box of the copy's own height does — the block reads as copy that has
       finished arriving, with the rest of the screen empty below it. Centring it
       in a frame whose two edges are the same distance from the window's edges is
       what makes "centred" a fact at every window height and every copy length
       rather than a number tuned to one of them: half of what is left of the
       window over is above the copy and half below it, and the sticky rule above
       still holds the frame's own top edge at --hero-pin-top.

       `min-block-size` and not `block-size`: on a window short enough that the
       copy is taller than the frame (a 390x844 phone is not — the copy is 152px
       against a 675.2px frame — but a 1440x300 one is) the frame grows to the copy
       and the copy is simply not centred, rather than a fixed box being
       overrun into the header. (At 40px the copy is 272px, so 1440x420 — the case
       this note used to name — leaves 18px of air a side now, and the window that
       does exercise it is one under ~384px tall: at 1440x300 the copy holds at
       56..328 of the 300-tall window, i.e. 28px of it below the fold.) Either way
       nothing is clipped: no overflow is
       hidden here, and the copy is only ever moved by a box that contains it.

       Flex and not grid: the cross axis of a column leaves the copy its own
       measure (max-inline-size: 46ch) and its own left edge, which is exactly
       what a block box gave it before. portfolio.js reads this height back as the
       stage's own height, so the hold's travel shortens by what the frame adds
       (see --hero-pin-run) — one measurement, both files. */
    min-block-size: calc(100vh - 2 * var(--hero-pin-top));
    display: flex;
    flex-direction: column;
    justify-content: center;
    /* No z-index, and none is needed: the pin is the hero's own top-left cell and
       the header is a positioned element with a z-index of its own (5), which
       outranks it. The cue is the one thing that can reach into it (fixed, z-index
       4) and it belongs there; the pictures cannot, because both are column-2 items
       — the hero's in row 1, the statements' in row 2 of the same column the stage
       has — so neither can share a cell with it and neither needs a z-index or
       gets one. */
  }

  /* The COPY IS NOT THE ONLY THING HELD, and the rule that says so is the
     picture's own, immediately below: the picture that belongs to the statements
     is held with them. It is not the case the note on .hero__media rules out — a
     portrait parked in the right-hand column for the whole scroll to rival the
     copy — because this picture is the COPY'S OWN, in the copy's row, entering
     and leaving with it. See the note on .hero__panel below for the geometry, and
     the markup's own note on why the file is where it is. */
}

/* The statements' picture, held. The stage's rule line for line — sticky on the
   same line, so the two cannot drift apart — and the WIDTH is the half of it that
   has to be said out loud: BESIDE THE COPY IS A WIDE FACT. Narrow the grid is one
   column, so the picture stands in the row after the statements (see the markup's
   own note on its place there) and there is nothing beside it to be held against.
   The same frame there would be a window's worth of air around a picture that is
   centred against nothing — and on a window shorter than the picture, which is any
   phone held sideways, a box SMALLER than its own child, which a centred child
   paints straight out of. So the frame and the sticky line are wide, and what
   narrow keeps is the ordinary figure the base rule gives it: the hero figure's
   own measure, centred in its column, the file's own height.

   THE FRAME, and it is the stage's own number rather than a second one: the
   window less a --hero-pin-top margin at each end, which is symmetrical about the
   window's own middle. The picture's box is that frame and the FILE is centred in
   it, so the picture's centre lands on the screen's centre exactly where the
   copy's does — beside the copy rather than above it. Centring the box's own child
   is what keeps the file's height the FILE's business: nothing here knows it, and
   the grid's column gives it nothing to disagree with.

   `block-size` here rather than the stage's `min-block-size`, for the same reason:
   a box that grows to its content on a window shorter than the frame is the right
   answer for the copy, which must not be clipped, and the wrong one for a picture
   that has to stay centred on the copy's line. That is also why the frame is wide:
   the narrow picture's height follows the column rather than the window, so a
   short window is exactly the case it would break on.

   AND THE TWO WINDOWS ARE ONE, which is the margin on the picture's box rather
   than anything here — the wrapper's own --hero-lead, stated in the grid rules
   below. The row it stands in is a lead-in taller than the wrapper because that
   margin is inside the row, so the picture's own cancels it exactly and its travel
   is the drum less this frame: the hold, term for term. The two enter on the same
   line and leave on the same frame. Without it the picture would pin one
   --hero-lead early, which is a picture's worth of the statements' photograph on
   the FIRST screen — the thing --hero-lead exists to keep off it (see the note on
   the figure's own row). */
@media (prefers-reduced-motion: no-preference) and (min-width: 64rem) {
  .js .hero__panel {
    position: sticky;
    inset-block-start: var(--hero-pin-top);
    block-size: calc(100vh - 2 * var(--hero-pin-top));
    display: flex;
    align-items: center;
  }
}

/* --- The pin needs a beside ------------------------------------------------
   THE HOLD IS A WIDE-SCREEN DEVICE, and this is the width at which that stops being
   true. Above 64rem the statements and their picture are two columns of ONE row (see
   the grid rules below), so the drum has something to hold the copy AGAINST: the
   picture stands beside the copy for the whole of the hold and the letters fill in
   between the two. Below 64rem the grid is one column, so the picture's row comes
   AFTER the copy's — and `block-size: var(--hero-pin-run)` on the wrapper is then a
   drum the visitor scrolls through watching a sentence fill in while the sentence's
   own photograph sits a drum's length below it in the document.

   Measured at 390x844 before this block existed — the rig in the README's own
   section, the real page in a 390x844 frame, ink read off each text node's range
   rather than off boxes: the statements' last line ends at y1,045.2 and me.png starts
   at y3,013.4, 1,968.23px apart, 2.33 screens of scrolling, and the two only come
   level on the hold's LAST frame — 40.25px apart there, all 136 letters painted, with
   the copy already at the top of the screen and about to leave it. At 900x1000 — the
   same one-column layout in a wider frame — the pair stand 2,038.13px apart (2.04
   screens) and the picture's top does not reach the bottom of the screen until
   scrollY 3,080, PAST the end of the hold. The pair the owner reads as one block — the
   sentences, then the photograph of the hands that wrote them — arrive as two, and
   the second one is off the screen for all of the first.

   SO BELOW 64rem THE STATEMENTS ARE NOT HELD. Three rules, and they are one decision.

     .js .hero__pin { block-size: auto }
        THE DRUM GOES. The wrapper is content-height, so it is exactly the stage's own
        height and `run` — the drum less the stage, the one number portfolio.js derives
        the paint from — is 0. That is the same 0 the script already handles for a pin
        it did not build (portfolio.js: `const progress = run > 0 ? … : 0`), so no
        letter is painted, nothing is measured that has no distance in it, and no new
        branch is needed anywhere: the letters rest in the colour CSS gives them.

     .js .hero__stage { min-block-size: 0 }
        THE FRAME GOES WITH IT, and it has to, because it is `min-block-size`: a stage
        left as tall as the window holds the copy CENTRED inside it (that is what the
        frame is for), so half of the frame's air sits above the copy's own top and half
        below its last line — 246.72px each way at 390x844, 310.12px a side at 900x1000,
        493.43px of air in all at the first of them. That air is exactly the distance
        this block exists to remove, only in a different box. The lead-in is
        NOT part of this: it is `margin-block-start` on the WRAPPER (see .hero__pin) and
        stands at every width, so the copy still opens one --hero-lead below whatever is
        above it. The pass that dropped the frame left the drum ON and the letters painting
        across a LONGER journey than the wide layout's — the stage had given 675px back to
        it — and the drum's own rule above is what ends that: with the wrapper at its
        content height there is no journey left to paint across at all.

     .hero__intro[data-letters] .hero__letter { color: var(--c-muted) }
        THE COPY TAKES THE PARAGRAPH'S OWN GREY. The split still happens — [data-letters]
        is the script's, at every width — and without this rule those letters would rest
        in --c-highlight (#cacaca), the grey they ARRIVE in, because the paint that
        carries them to black is the thing with no travel to run over here. --c-muted is
        the colour this page keeps for every visitor the paint cannot reach (see the note
        on .hero__intro p): no script, reduced motion, and now every window under 64rem.
        #767676 on white clears WCAG AA at the statements' 20.8px, and stating it makes
        the three states ONE picture rather than three — measured at 390x844, every
        window in the state, every letter of both statements, `rgb(118, 118, 118)` and
        none of them painted.

   WHAT IS NOT TOUCHED, because it is what the pin is for: the wide layout and every
   number in it (the query above is the complement of the grid's own 64rem, so a window
   at exactly 1024px still gets the two-column hold), the lead-in, the hold's line
   --hero-pin-top, the two elements the script measures, and the `.js`/no-preference
   gates the hold already sits behind. Nothing here is a script switch or a resize
   handler: the CSS decides whether there is a drum, and portfolio.js reads the boxes it
   finds — a resize across 64rem changes the pages's geometry and the reading with it,
   the same way the wide/narrow column does. */
@media (prefers-reduced-motion: no-preference) and (max-width: 63.999rem) {
  .js .hero__pin {
    block-size: auto;
  }

  .js .hero__stage {
    min-block-size: 0;
  }

  .hero__intro[data-letters] .hero__letter {
    color: var(--c-muted);
  }
}

/* The photograph does NOT take part in the pin. Its grid area is its own row and
   no more, so it scrolls up and away with the headline while the statements are
   held, and what the visitor is left with for the whole of the hold is the
   statements, centred, with their own picture beside them and the page's own air
   around both (the picture is held by the rule above; THIS figure is the one that
   leaves, which is the point of this note).

   An earlier pass spanned the figure across the pin's row as well and made it
   `position: sticky`, which kept the portrait in the right-hand column for the
   whole scroll while the copy filled in beside it. That is no longer wanted: a
   held photograph and a held statement are two things asking for the same
   attention, and the scroll the cue asks for belongs to the copy. The figure
   went back to the ordinary thing it is on every other page — a picture in the
   document that scrolls with everything else.

   The `sticky` rule is gone rather than left neutralised, because a sticky box
   travels inside its own containing block and this one is now the height of the
   figure: there is nothing for it to travel through, so the rule would be a
   no-op the next reader would have to prove is one. */

/* The photograph is shown COMPLETE: no aspect-ratio, no object-fit, no crop —
   the reference renders the file at its own ratio (268x346 was the file the
   reference was drawn against). A forced box would
   cut the sitter, which is the one thing a portrait must not do. It is also
   unframed on purpose: the reference draws no rule around it.

   THE TILT is the one piece of play about the picture: at rest the frame hangs
   3deg off plumb, a print propped on a desk rather than filed flat, and the
   cursor STRAIGHTENS it. Settling rather than tipping further is the reading
   that carries something — a photograph that lurched over to the other side
   under the cursor is a wobble with no reason behind it, while coming back to
   plumb is attention being answered — and it is the quieter move of the two as
   well (3deg of travel against 5). 0.3s ease is the same hand-over the project
   names use for their ink in the PROJECTS band, so the page's two hover treatments
   move at one speed.

   The transform is stated on the IMG rather than on the figure, and that is
   what keeps the promise in the markup: transforms are painted, never laid
   out, so the square box the grid places and the space the picture reserves are
   exactly what they were, and nothing else on the page can be moved by the
   rotation. The corners do paint outside that box — 10.2px a side at 1440x900
   and 10.4px at 390x844, measured off the rendered box — which is inside the
   figure's own slack in its column (79% of the column and centred, so it clears
   each side by more than that) and inside the document's: `scrollWidth` equals
   `clientWidth` at both widths, so the tilt costs no horizontal scrollbar. See
   the note on the figure in index.html for the file swap.

   Under `prefers-reduced-motion: reduce` base.css already flattens
   `transition-duration` to 0.01ms for everything, so the hover still
   straightens the picture — it simply arrives as a cut instead of a settle. */
.hero__media {
  inline-size: min(100%, 20rem); /* narrow: a sane cap, centred below the type */
  margin: 0;
  justify-self: center;
}

.hero__media img {
  inline-size: 100%;
  height: auto;
  filter: grayscale(1); /* the reference's greyscale-photography rule */
  transform: rotate(-3deg);
  transition: transform 0.3s ease;
}

/* The picture is the whole of its own figure box, so a cursor anywhere on the
   photograph — not just on a thin edge of it — does this. */
.hero__media img:hover {
  transform: rotate(0deg);
}

/* --- The statements' picture ----------------------------------------------
   `.hero__panel` is me.png beside the copy the pin holds — the one picture on
   this page that is HELD rather than merely shown. It is not a second hero
   portrait: it stands in the statements' own row (row 2, column 2 — see the
   grid's own rules below) and it is held by the pin's rules (see the pin
   section, where that hold's wide-only condition is explained), so it enters
   with the copy and leaves with it.

   The treatment is the hero figure's, stated again rather than shared, because
   the two are separate things that happen to agree: greyscale — the reference's
   rule for photography, and the whole of why the page reads as one object —
   and a 3deg tilt at rest, a print propped on a desk, which the cursor
   STRAIGHTENS (the same 0.3s ease the project names use). Both pictures hanging
   the same way is deliberate: the page's PHOTOGRAPHS tilt in one direction only —
   the PROJECTS band's four cards are the other register and tilt both ways a
   degree or two, because scatter is the thing they are doing. The
   straighten is that treatment's other half rather than an extra — see the note
   on .hero__media for why the move is a return to plumb — and it can be dropped
   by deleting the hover rule below without touching anything else.

   `overflow` and the tilt: transforms paint and never lay out (see the same
   note), so the box the grid places is unchanged, and the corners painting
   outside it — ~8px a side for a picture this size — stay inside the slack 79%
   of the column leaves on either side of it. scrollWidth stays clientWidth. */
.hero__panel {
  inline-size: min(100%, 20rem); /* narrow: the hero figure's own cap, centred */
  margin: 0;
  justify-self: center;
}

.hero__panel img {
  inline-size: 100%;
  height: auto;
  filter: grayscale(1); /* the reference's greyscale-photography rule */
  transform: rotate(-3deg);
  transition: transform 0.3s ease;
}

.hero__panel img:hover {
  transform: rotate(0deg);
}

/* --- The cue ---------------------------------------------------------------
   "scroll to explore" is a signpost for the FIRST SCREEN, so it is pinned to
   that screen's bottom-right corner rather than parked in the copy: `fixed`
   takes it out of the grid and out of the document flow, so it hangs in the
   corner while the page moves underneath it.

   It is pinned only when a script can retire it (portfolio.js does, half a
   screen in) — the same rule the hamburger follows, that nothing is presented
   in a state its script cannot maintain. Without one it keeps the place the
   markup gives it, a grid item beside the statements: a cue that could never
   leave would sit on top of the project list for the rest of the visit. */
.js .hero__cue {
  position: fixed;
  z-index: 4; /* under the header's 5: chrome outranks the page's own hints */
  /* 12vh up from the bottom edge = the reference's 109px at a 900-tall frame,
     and 7rem is the ceiling past which "bottom corner" stops being true (the
     reference's own offset, kept as the maximum). A floor too, so it never sits
     on the edge of a short window. */
  inset-block-end: clamp(var(--s-5), 12vh, 7rem);
  /* Flush with the page's own margin, which is where the reference draws it
     (x1371 of a 1440 frame — 69px in, i.e. on --pad-page, NOT on the narrower
     content column). 100% resolves against the viewport here, so this is
     scrollbar-correct where a 100vw version would sit a scrollbar's width out. */
  inset-inline-end: var(--pad-page);
  /* A hint is never a target: it floats over whatever happens to be at the
     bottom of the screen, and on a short window that can be a project row. */
  pointer-events: none;
  transition: opacity 240ms ease;
}

/* Faded, not removed: it stays in the DOM and in the tab order's path (it holds
   no focusable content), it simply stops being drawn once the visitor has
   scrolled far enough for it to have done its job. */
.js .hero__cue[data-retired] {
  opacity: 0;
}

/* THE CUE INVERTS ITS BACKDROP, with the same one-declaration trick the cursor
   disc uses (see the cursor block, which is where the reasoning lives): the ink
   is white and `mix-blend-mode: difference` subtracts it from what is painted
   behind it, so it reads BLACK on the page's white paper and WHITE over the
   projects band's black. Neither colour is written down twice and the pair cannot
   drift apart, which is what `--c-muted` could not promise: it is a grey that was
   only right while the paper behind it was white.

   IT BLENDS AGAINST THE WHOLE PAGE, not against a box of it. The blend resolves
   in the nearest ANCESTOR stacking context, and nothing between <html> and the
   cue opens one: `main`, .hero and .hero__grid are all static, unblended and
   untransformed at rest, and `.js .hero__cue`'s own `fixed` + `z-index` is the
   blended element's own context rather than an isolating ancestor of it. A
   transform, a filter or `isolation` on any of those three would break that and
   leave the cue white-on-white; the harness in the README's cue section is what
   checks for it rather than this comment being trusted.

   AND IT IS INVISIBLE WHERE THE BLEND IS NOT SUPPORTED, or in a print of the page,
   exactly as the disc is: white ink on white paper. Accepted there for the same
   reason it is accepted for the disc — `mix-blend-mode` is what draws this page's
   over-content ink now, and every browser that runs portfolio.js has it. */
.hero__cue {
  margin: 0;
  font-size: var(--fs-meta);
  color: #ffffff; /* the base difference subtracts from the page */
  mix-blend-mode: difference; /* BLACK on paper, WHITE on the band */
  text-align: end; /* flush with the content's right edge, as in the reference */
}

/* AND IT IS NOT DRAWN BELOW 64rem. The hint belongs to the first screen of the wide
   layout: on a screen that small the corner it hangs in is worth more than the hint,
   and the visitor is about to make the thumb's own scroll regardless.

   THE GATE IS THE PAGE'S OWN 64rem AND NOT A PHONE'S 768px, because "phone and
   tablet" is what every window under the desktop layout means here. 64rem is where
   `.hero__grid` gets its two columns and where the cue is pinned in the first place;
   768-1023px is still the tablet band — the same band the seam block at the foot of
   this file is written for — so a rule at 768px would leave the cue on a tablet. One
   gate, the page's own, and no second breakpoint to keep in step with it.

   `display: none` rather than a visibility or a fading: nothing of it is to be drawn
   and nothing of it to be announced, and there is no blend here worth preserving —
   what sits behind a phone window's bottom-right corner is the browser's own chrome
   as often as it is the page. It is also the one choice that matters on the scriptless
   page, where the cue is a grid item in the hero's own flow rather than a fixed
   corner: `display: none` takes the element out of the grid, so that row is dropped,
   where a `visibility` would have kept the item and left its row and both of its gaps
   standing as a seam. With a script — every page here that runs one — it is already
   `position: fixed`, i.e. outside the flow the rest of the page is measured against,
   and it holds no focusable content and is `pointer-events: none` either way.

   WHAT IT MUST NOT REACH IS THE WIDE LAYOUT, and that is measured rather than
   intended: the rig in `tools/` photographs 1440x900, the same window parked over the
   band and 1024x768 either side of this rule, and the frames are the same file pixel
   for pixel — while the three windows under 64rem lose the cue and move nothing else
   on the page. The `.js` switching above is untouched by it too: portfolio.js still
   retires the cue at half a screen, which is a fact about a page it is drawn on. */
@media (max-width: 63.999rem) {
  .hero__cue {
    display: none;
  }
}

@media (min-width: 64rem) {
  /* This layout's own lead-in floor: same shape as the token, its own constant,
     because the copy sits under the HEADLINE here rather than under the picture
     and the furniture above it is therefore shorter — 424px at the narrowest wide
     frame (1024: 61px of top padding and a 363px headline block) and 553px at
     1440. 22rem = 352px is that 424px less a ~72px margin, so the copy clears the
     fold by 72px at 1024 and 201px at 1440, where 45vh alone would have left it
     58px below the edge at a 900-tall window and 217px INSIDE it at a 1400-tall
     one. See --hero-lead. */
  :root {
    --hero-lead: max(45vh, calc(100vh - 22rem));
  }

  .hero__grid {
    /* 65:35, measured off the reference: the headline's column runs to ~65% of
       the content width and the picture's column holds the remaining third. */
    grid-template-columns: minmax(0, 13fr) minmax(0, 7fr);
    column-gap: var(--s-7);
    /* No row gap: --hero-lead on the pin's wrapper IS the lead-in between the
       headline and the copy, and a gap here would add to it. */
    row-gap: 0;
    align-items: start;
  }

  /* Row 1 is content-height — whichever of the headline and the portrait is
     taller — and row 2 follows it, one --hero-lead below. The screen of height
     that used to come from row 1 reserving it (`grid-template-rows:
     minmax(var(--hero-screen), auto) auto`, JS-gated so a scriptless page fell
     back to content height) is not back: the reveal is the spacing on the copy
     itself, a lead-in the copy stands off by, stated once, in CSS only, and the
     same whether a script runs or not. Nothing here depends on what row 1
     measures — `.js .hero__cue` pins the cue to the viewport's corner either way.
     Row 2 is the pin's drum (--hero-pin-run), which is the one thing in this grid
     that is not content-height and the reason the hero is three screens tall
     instead of one. */
  .hero__col {
    grid-column: 1;
    grid-row: 1;
  }
  .hero__media {
    grid-column: 2;
    /* Row 1 only — the headline's screen — and NOT the pin's drum below it. The
       figure's area is therefore as tall as its own row, which is what makes it
       scroll away like any other picture (see the note on the figure above in
       this file). It costs the rows nothing either way: the figure is shorter than
       the column's content at every wide width — 302.8 against the headline's
       305.9 at 1024, 405.6 against 409.8 at 1366 and up, i.e. within 1% of it at
       both ends of the ramp, because both are the reference's own proportions —
       so a one-row item never sets the row's height, and this one never did. */
    grid-row: 1;
    /* 79% of its column: the reference's picture is ~312px of a ~397px content
       column at a 1440 frame, i.e. it clears each edge of the column instead of
       filling it. */
    inline-size: 79%;
  }
  /* Row 2: the statements directly under the headline (row 1 is content-height —
     see the note on the row template above), one --hero-lead below it. The grid
     places the pin's WRAPPER now rather than the copy itself, because the copy is
     inside it (a stage held in a drum — see the pin section above); the lead-in
     travels with it. The cue is pinned to the viewport when a script is present, so
     this placement in the picture's column is what the page falls back to without
     one, which is the reference's own spot for it: level with the first
     statement. */
  .hero__pin {
    grid-column: 1;
    grid-row: 2;
    align-self: start;
  }
  .hero__cue {
    grid-column: 2;
    grid-row: 2;
    align-self: start;
    /* The seat the cue takes with no script: the row's own top rather than the
       first statement's, which is one --hero-lead below it. The reference draws
       the cue and the copy on one line, so it takes that same gap. With a script
       the cue is `position: fixed` and this does nothing. */
    margin-block-start: var(--hero-lead);
  }

  /* The statements' picture, in the statements' own row: the RIGHT-hand column
     again, one row down from the hero's portrait, so the two pictures stand in
     the same column at two points in the page and the copy keeps the column it
     had. Its measure is the hero figure's own — 79% of this column, centred —
     which is what makes the two read as one object rather than two sizes.

     `.js` because without a script there is nothing to hold it with, and row 2's
     second column is already spoken for: it is the CUE's seat in that case (see
     .hero__cue above). So a page with no script leaves the picture in the flow
     where the markup puts it — after the statements — and a page with one gets
     the picture beside them. Nothing else about the figure is JS-dependent.

     The pin is `.js` AND wide (see the note on it in the pin section): narrow the
     picture keeps this seat — the same column, the same row, the same measure — it
     is simply not held there, because there is no beside to be held against. */
  .js .hero__panel {
    grid-column: 2;
    grid-row: 2;
    align-self: start;
    inline-size: 79%;
    justify-self: center;
    /* The wrapper's own lead-in, ON THE PICTURE'S BOX, and it is the whole of why
       the picture is beside the copy for exactly the hold rather than beside it
       early: this margin is inside the row (see the pin's note on .hero__pin)
       exactly as the wrapper's is, so the picture's place in the row is the
       copy's place in it — one lead-in below the row's top, which is where the
       copy stands once the row has travelled. Without it the picture would sit at
       the row's own top and pin one --hero-lead ahead of the hold, which puts a
       statement-sized picture on the FIRST screen — the thing --hero-lead exists
       to keep off it (see the note on .hero__media). */
    margin-block-start: var(--hero-lead);
  }
}

/* --- Projects -------------------------------------------------------------
   FOUR NAMES AND FOUR PICTURES, and the section is TYPE rather than a table: one
   line of very large uppercase type per project, centred in the black band, grey
   at rest and WHITE under the cursor, with that project's own picture arriving as
   a card BESIDE THE NAME IT BELONGS TO, 20px from the words.

   WHAT THE REBUILD TOOK OUT, listed because every one of these was a deliberate
   device with a note beside it, and a reader who finds the note should find the
   reason it is history now:

     * the RULES. Each row was a full-bleed hairline block — the top border of
       `.work__item`, plus one closing rule on the last item — so the list read as
       a ruled table. There is no border in this section any more, which is what
       retired --c-work-rule in :root.
     * the ARROWS. `.row__arrow` and the -45deg turn a hover gave it are gone at
       the owner's request, and --row-arrow-w went with them: it sized that glyph
       AND anchored the old preview's right edge to its left one. This is the only
       part of the page with no <svg> left in it.
     * the INDENT. --row-indent pushed each title past the column edge because the
       reference ranged its titles left. Centred type has no start edge to push
       from, so the token and the `padding-inline-start` that spent it are gone.
     * the ROW PADDING. --row-pad (the reference's 2rem, plus a fluid ramp on top
       of it) is what made the old rows ~300px tall at a 1440 frame. The rows are
       one line each now, and the air between them is the list's own gap —
       --work-row-gap — stated once on the list because the rows have no box of
       their own left to pad. It has been through four passes at the owner's
       request: down to a 4-8px hairline when the four names were first read as one
       block of type rather than as four lines, then to 0 when that hairline still
       read as a gap — because a gap is air added on top of a line box's own leading
       — and now back UP to the owner's 40px, the loosest value it has held. The
       stack's rhythm is therefore the 0.85 line-height on .row__title PLUS that gap;
       see :root for both, for the reversal, and for what the 0 cost that the 40px
       gives back.
     * the CURSOR-FOLLOWING PICTURE, and the whole `data-float` mechanism with it.
       The pictures are cards beside their own names now, so the script that used
       to carry them was DELETED rather than rewritten — see the note where it
       stood at the foot of portfolio.js. Nothing places a preview from a script
       any more, and that is a simplification rather than a loss: the state that
       draws a card is the row's own `:hover`, which this stylesheet already knew
       how to answer.

   THREE THINGS ABOUT THE BAND ARE UNCHANGED, and they are the ones the owner asked
   for: it is still BLACK, the heading is still white and still at the left, and
   the section is still full-bleed with its air inside that width — --s-6 above the
   heading (plus --work-lead on the heading itself) and --work-band-pad under the
   list, so the list never crowds the heading, the hero above it or the contact
   block below it. THE TWO EDGES OF THAT AIR NOW HOLD THE SAME AMOUNT: the bottom
   states the top's sum as its own padding, because the last name has no lead-in of
   its own to carry — read --work-band-pad in :root for why that is the honest way
   to say "as much room below as above".

   --c-bg/--c-fg are still re-pointed for the length of the section, so everything
   inside that reads the page's pair follows the band on its own: the keyboard's
   focus ring on .row__inner, ::selection, and — new with the cards — their
   elevation, because --shadow is drawn in --c-fg and is therefore a WHITE offset
   inside this band rather than the black one it would be anywhere else on the page.
   THE NAMES' INK NO LONGER RIDES ON THIS PAIR: they were the one thing in the band
   that read --c-fg dynamically — grey at rest, white under the cursor — and they now
   name their two colours directly instead of asking what --c-fg currently is:
   --c-work-fg for the white and --c-row-idle for the wide screen's rest, both stated
   on .row__title along with the condition that decides which is in force (read the
   block under that rule). The re-pointing stands on its other three spends — the
   ring, the selection and the shadow — and those are reasons enough. */

.work {
  --c-bg: var(--c-work-bg);
  --c-fg: var(--c-work-fg);
  background: var(--c-work-bg) !important;
  color: var(--c-work-fg) !important;
  /* THE BAND'S OWN AIR, and the two edges are not the same NUMBER because they are
     not the same THING: the top's air is this --s-6 padding PLUS the heading's own
     --work-lead (the heading's margin collapses out of its container and is
     contained by this padding), while the bottom has no heading of its own to carry
     a lead-in, so it states that whole amount itself. --work-band-pad IS that sum,
     which is what makes the black above the heading and the black below the last
     name the same amount rather than two guesses. The owner's read was that the band
     ended too closely after REDESIGN — 48px of `--s-7` before this — and a band whose
     bottom air equals its top air is the correction; see :root for the token, its
     arithmetic and the flat 6-8rem alternative. */
  padding-block: var(--s-6) var(--work-band-pad);
  /* NO `position` HERE ANY MORE, and its absence is deliberate rather than tidy: this
     box was the CONTAINING BLOCK FOR THE FOUR CARDS while they stood in the band's
     corners. A card stands beside its own name now — `.row__title-wrap` is the
     nearest positioned box on purpose, because the 20px is measured from the WORDS —
     so a `position` here would have nothing to place. What the cards DO still need
     from this box is that it never clips: `overflow` stays at its default `visible`,
     or a card standing outside the column would be cut off by the band it is drawn
     in. */
}

.work__title {
  /* The lead-in under the hero's statements — see --work-lead in :root. Stated
     as a margin on the heading rather than as more padding on .work so it reads
     as the air ABOVE the first thing in the section, and it is ADDED to the
     section's own --s-6 top padding, not substituted for it. */
  margin-block-start: var(--work-lead);
  /* Not the .eyebrow treatment: the reference's section title is a real heading
     step (~25px at a 1440 frame), not a 14px meta label. */
  font-size: var(--fs-h2);
  font-weight: var(--fw-black);
  letter-spacing: 0.01em;
  text-transform: uppercase;
  /* WHITE at all times, on the band's black. Stated here rather than left to
     inherit from .work so that nothing sitting between the section and this h2 can
     repaint the section title, and !important at the owner's explicit request for
     the same guarantee. This heading is the ONE piece of type in the section that
     never changes state: the names below it are the ink that moves. */
  color: var(--c-work-fg) !important;
}

.work__list {
  /* The heading's own lead-in, and it is HALF what it was: --s-4 (16px) where the
     block of type used to start 32px under PROJECTS. The owner read the four names
     as hanging too low under the heading, and this is the whole of that distance —
     --work-lead above is the HERO's seam rather than this list's, and is untouched
     (see :root).

     A margin here rather than a padding on .work, for the reason the banner gives
     for the whole band: .work IS the black surface, so a padding would paint more
     band, and this is space the heading opens under itself. */
  margin: var(--s-4) 0 0;
  padding: 0;
  list-style: none;
  /* One line per project, stacked, and the air between them is this gap — stated
     once, here, rather than as padding on each row: the rows have no box of their
     own any more, so the space can only be said in one place. --work-row-gap is the
     owner's 40px now, after three values that came down to 0; the token, that history
     and what the 40px adds to the line-height's own air are in :root.

     THIS GAP AND THE LINE-HEIGHT ON .row__title ARE THE STACK, and both are now doing
     work: while this gap was 0 the line-height was the whole of the rhythm, and at
     40px the gap is the larger half of it. `gap` stays declared and keeps reading the
     token, so the one-line knob still works from here — a change to either one is half
     a change to the stack.

     A flex gap and not a margin on the rows, for the reason :root gives: this one is
     spent BETWEEN items, so the last name keeps its own distance to the band's bottom
     edge and --work-band-pad's equal-air claim (see .work) stands untouched. */
  display: flex;
  flex-direction: column;
  gap: var(--work-row-gap);
}

/* `.work__item` carries NO DECLARATIONS any more, and the class stays in the
   markup for one reason: it is a project's slot in the list, and the parent of the
   picture that project owns. It was the section's only border — a top hairline per
   row, plus one closing rule on the last — and a list with no rules to draw has
   nothing to spend here. Nothing is missing because of it: the air those rules sat
   in is the list's gap above, and the state a rule used to report is the name's
   own ink below.

   NO MARGIN AND NO PADDING EITHER, and that is worth saying here because it is the
   first thing to suspect when a stack of names will not close up or will not open
   out: the two boxes between two names — this <li> and the `.row` link inside it —
   are both bare. The margin is the reset's (`* { margin: 0 }`, system/base.css)
   rather than anything this file took away, and the padding was --row-pad's, gone
   with the ruled rows (see the banner above). So the ONLY space between two names is
   the list's gap — the owner's 40px, set there and not here — and the title's
   line-height, one place each. NEITHER OF THESE TWO BOXES IS WHERE TO CHANGE IT: a
   `margin-block-end` on this <li> would also put 40px under the LAST name, which is
   the band's bottom air (--work-band-pad) rather than the stack's — see :root. */

/* The row is the anchor itself — or the inert div for a project with no target —
   and it spans the full width, so the whole line is the click target, not just the
   words in it, at whatever width the window takes. */
.row {
  display: block;
  color: inherit;
  /* NO PROJECT NAME IS EVER UNDERLINED — the resting half of that promise, and the
     load-bearing half. The state rule below states the same thing beside the ink,
     but it has to be stated HERE, on the link, and not only on .row__title:
     `text-decoration` PROPAGATES from the box that declares it down to the inline
     boxes inside it, so a line on `.row` would run under the words whatever
     `.row__title` said about itself — a DESCENDANT cannot cancel a decoration its
     ANCESTOR paints, which is why the ancestor is the only place the line can be
     ruled out. The `!important` is what the owner asked for, and it is also the
     honest flag for this one: it puts the declaration out of reach of anything
     loaded after this stylesheet, which is exactly the reach a stray underline
     has. */
  text-decoration: none !important;
}

.row__inner {
  /* CENTRED — and as `text-align` rather than as a centred flex item, which is the
     one thing about this box that has to be said out loud. A name long enough to
     wrap has to centre its OWN second line too, and a centred flex item would put
     the box in the middle of the page and leave the words ranged left inside it.
     None of the four names the owner shipped is long enough to wrap any more — see
     .row__title — and this choice is not conditional on that: it is the right way
     to centre type that may wrap, in this list or in the next one.

     The box is still the page's column — .container's max-width and --pad-page —
     so the names sit on the same grid as the heading above them and every other
     line on the page, and the keyboard's ring below still outlines a column rather
     than the whole window.

     AND NO `position` HERE, which is the same decision this rule used to make for
     the opposite reason: it carried `relative` while a preview stood in the band's
     corners, and the nearer positioned box has to be the NAME's own box now — see
     .row__title-wrap, which is inside this one. A `position` here would be inert at
     best, and at worst it would become the containing block a card is measured from
     if that rule were ever restated, which is exactly the accident this note exists
     to prevent. */
  text-align: center;
}

/* The name and ITS OWN PICTURE, wrapped together in one inline box — and the box a
   card is measured from, which is the whole reason this element exists.

   WHY IT IS HERE AND NOT ON .row__inner: a card stands 20px from the WORDS, on the
   side the owner gave that project, and the words are centred in the page's own
   column. Measured from the column, `inset-inline-end: 100%` would put a card 20px
   from the COLUMN's edge — half a screen away from the name it belongs to — so the
   box a card hangs off has to BE the name's box, and this is it. (An absolutely
   positioned box is measured from its NEAREST positioned ancestor: this is the
   nearest one to a card, and nothing between the two takes a `position`.)

   `display: inline-block` IS LOAD-BEARING rather than tidiness, and it is the one
   declaration here that must not be removed. The card is centred on the name with
   `inset-block-start: 50%` plus `translateY(-50%)`, and a `relative` INLINE box's
   containing block is the union of its inline boxes' CONTENT AREAS — font metrics,
   i.e. the 1.25em em box rather than the 0.85em line box this stack is built on —
   while an inline-block's is its padding box, which is that line box to the pixel.
   The difference is ~19px at the 96px ceiling, i.e. the whole of whether the card
   lands on the name's type or a fifth of a line above it.

   IT DOES NOT MOVE THE STACK: an inline-block's baseline is the baseline of its
   last line box, so the row's own line box still measures what it measured before
   (read the arithmetic on .row__title), and it stays centred by the `text-align`
   on .row__inner above. */
.row__title-wrap {
  display: inline-block;
  position: relative;
}

.row__title {
  /* THE OWNER'S 96px, stated as the CEILING of a fluid ramp rather than as a fixed
     size, the way every display size on this page is: 6rem is reached at a ~1455px
     window, 6.6vw carries the mid-desktop frames just under it (95px at 1440), and
     the floor is the owner's own 40px on a phone — 2.5rem binds below a ~606px
     window, so a 390px phone gets exactly 40px.

     ALL FOUR NAMES ARE ONE LINE EACH AT THIS SIZE, and that is the owner's doing
     rather than this rule's: the four names in the list are the SHORT forms of the
     projects (index.html, and `work.rowN.short` in portfolio.js), so the longest of
     them is "Hairdresser" at 11 characters. The arithmetic is kept, because the
     96px ceiling still has to be safe for whatever the owner names next: size is
     not the only term in a line — the column is — and the column's content box is
     1184px at a 1440 frame, against a face that sets about 0.63em a character (the
     hero's own note measures "DESIGNER" at 4.91em). 11 characters is ~6.9em, i.e.
     ~665px at 96px, so the longest name uses a little over half the line and there
     is room for a good deal more.
     THE PREVIOUS NAMES DID NOT FIT, and that is why the names got shorter rather
     than the size: the fuller titles the four project pages still carry, set at the
     same 96px, wrapped three of the four onto a second centred line — "ECOnudge
     sustainable tool" is 26 characters, ~16.4em, i.e. ~1570px — and putting them on
     one line each would have meant trading the owner's 96px for something near
     72px. That trade was declined rather than missed: the names were shortened. */
  font-size: clamp(2.5rem, 6.6vw, 6rem);
  font-weight: var(--fw-black);
  /* 0.85, AND IT IS MEASURED RATHER THAN CHOSEN — the number comes out of Funnel
     Display's own cap height, read from the shipped family's `OS/2` table: 810 of
     1200 units, i.e. 0.675em. The round caps overshoot that, and all four names use
     one — S, O, C and U stand at 0.685em above the baseline and dip 0.0133em below
     it — so the ink one name draws is 0.698em tall, and the air between two of them
     is this line-height MINUS that ink: 0.85 − 0.698 = 0.152em, which is 14.4px
     between the caps of two 95px names at a 1440 frame and 14.6px at the 96px
     ceiling. With --work-row-gap back up at the owner's 40px, THAT 0.152em IS NOW THE
     SMALLER HALF OF THE SPACE between two names: the clear air between two caps is
     those 14.4px plus the gap, ~54.4px at a 1440 frame, and two baselines stand 0.85em
     plus the gap apart — 121.6px at the 95px type, where they stood 81.6px while the
     gap was 0. That is still short of the first build's 1.05 leading with its 20-32px
     ramp (~133px at the ceiling), so the 40px is a loosening inside the tight
     composition rather than a return to the ruled list's height.

     HOW TO TUNE IT, AND WHICH KNOB TO TURN FIRST: this line-height still says how much
     of a name's own leading is kept — 0.80 is the tightest value that clears the
     overshoot (0.102em of air, 9.7px at 95px type) and 0.90 gives back 0.202em
     (19.2px) — but it can no longer close the stack up on its own, because the
     --work-row-gap spent between the line boxes is the larger half of the distance.
     The air BETWEEN two names is the gap's to set; the air INSIDE a line is this
     number's. Read the two as a pair, in :root.

     NOTHING OVERFLOWS IT, which is why the pitch is exactly this line-height and
     nothing more. The family's ascent + descent is 1.25em, so a 0.85em line box has
     NEGATIVE leading — that is what pulls two names together instead of pushing the
     ink out: the cap top sits 0.115em inside the box and the round caps' 0.0133em
     overshoot sits 0.037em inside it. This box declares no `overflow` and the band
     clips nothing either (see .work). A name that DID wrap would let its two lines
     overlap — which is one more reason the names are kept to one line each (see the
     arithmetic above) rather than a reason to raise this. */
  line-height: 0.85;
  letter-spacing: -0.01em;
  /* UPPERCASE IN THE STYLE, not in the markup: the DOM keeps the owner's own
     sentence case, so a screen reader hears "ECOnudge" rather than "ECONUDGE". */
  text-transform: uppercase;
  /* WHITE HERE, AND THE GREY IS THE WIDE SCREEN'S EXCEPTION — so read this line
     together with the `hover: hover` block below it, which is the only rule that
     overrides it, and with :root, which holds the grey and the condition it is
     spent under. This declaration is the MOBILE AND TOUCH ANSWER: a phone, a
     tablet, a narrow window, a visitor with no cursor — every one of them gets the
     band's own white the whole time, and there is no state to wait for because
     there is nothing to hover with.
     WHY THE ANSWER IS THE BASE RULE AND NOT A `max-width: 768px` QUERY: a base rule
     covers every case a width cannot see — a touch screen 810px wide is not a phone
     by any width test, and a query written at 768px would have left it grey with no
     cursor to lift the grey with — and a rule stated as the base leaves no seam for
     a 768.5px viewport to fall between two queries into.
     THE OWNER HAS ASKED BOTH WAYS IN TURN, and both passes are kept in view: the
     four names rested in the grey, then were made white at all times (which retired
     --c-row-idle and the `color 0.3s ease` transition that softened the step), and
     now the grey is back with the condition the first arrangement lacked. Nothing
     about the name's box, its line box or the section's height is involved in any of
     it — colour is not a layout property, in either direction.

     STATED ON THE TITLE rather than left to inherit from .row (which keeps `color:
     inherit`), so that nothing between the link and the words can repaint them, and
     on the band's OWN token — --c-work-fg — for the reason .work__title above
     spends the same one: this is ink the band owns rather than the page's pair
     re-pointed. `!important` is kept from the grey rule, where it was the owner's
     explicit request for a forced cascade, and it is now the SHARED WEIGHT OF THREE
     DECLARATIONS — this one, the grey below, and the hover white in the same block —
     so that which of them wins is decided by order and specificity alone rather than
     by one of them quietly carrying the heavier weight. */
  color: var(--c-work-fg) !important;
}

/* THE HOVER'S TWO HALVES, and they are conditional in different ways on purpose.
   THIS rule is the DECORATION half, and it is UNCONDITIONAL: nothing is underlined
   in this list at any width, with any input device, at rest or under the cursor.
   It used to underline the words — `text-decoration: underline`, 2px, with a 0.08em
   offset — and that was REMOVED at the owner's request: a line under a project name
   read as a defect rather than as emphasis. DELETE NOTHING MORE: `none` is written
   here rather than the line simply deleted so the decision stays legible in the file
   it is a decision about, and with `!important` it cannot be undone by anything
   loaded after it. The resting half of the same promise is on `.row` above — read
   the note there for why `text-decoration` has to be stated on the ANCESTOR and not
   only on the words.
   THE COLOUR HALF IS THE BLOCK BELOW, and it is conditional — which is the whole
   difference between the two: a missing underline is missing for everybody, while an
   ink that lifts under a cursor can only be promised to a visitor who has a cursor.
   `:active` is the TOUCH case there (a tap is a press while it lasts) and
   `:focus-visible` brings the keyboard in on the mouse's terms; the phone answers
   both by starting white. No layout property is spent here or there, so the name's
   box, its line box and the section's height are identical in every state and away
   from all of them. */
a.row:hover .row__title,
a.row:focus-visible .row__title,
a.row:active .row__title {
  text-decoration: none !important;
}

/* THE WIDE SCREEN'S ANSWER — the owner's grey at rest, their white under the
   cursor, and ONLY the row the cursor is on: `.row__title` is stated per name, and
   this state selects the hovered LINK's own title, so the other three names cannot
   be reached by it at all. There is no script in any of this and none is needed:
   the three selectors are the row's own `a.row:hover` / `:focus-visible` /
   `:active`, the same three the preview cards answer to.

   WHY THE POINTER TERMS COME WITH THE WIDTH. `hover: hover` + `pointer: fine` is the
   page's own phrase for "a wide screen with a pointer" — the pair the cards' query
   and the custom cursor both use — and it is the half of this query that keeps the
   promise honest: a touch screen has no hover, so a grey rest would be a colour it
   could never leave, which is the one flaw in the build this rule's grey first came
   from. The width is the owner's: 48.0625rem is 769px, the first width past the
   768px they named, so the grey governs screens WIDER than 768px exactly as asked.

   WHAT IS NOT HERE IS THE CARDS' 64rem, and that is deliberate rather than an
   oversight: a card waits for the width it needs to have room in, while the ink
   needs only a cursor — so between 769px and 1024px a hover recolours the name and
   draws nothing, which is exactly what the rows did before the cards moved beside
   them. If the two gates are ever wanted to coincide, change this width to 64rem;
   delete nothing else.

   `transition: color 0.3s ease` IS BACK WITH THE GREY, and it belongs to this block
   rather than to the base rule: it is the wide screen's step, and a transition on a
   colour that never changes is only a promise the file cannot keep. Under
   `prefers-reduced-motion: reduce` system/base.css flattens it to 0.01ms and the name
   still changes colour — at once.

   ORDER IS LOAD-BEARING, AND THIS BLOCK HAS TO STAY BELOW THE BASE `.row__title`
   RULE. Both rules select one class and both carry `!important`, so the LATER one
   wins: the grey is the resting colour only because it is written here. Moved above
   the base rule, this block would lose to it and the names would go white at rest
   with nothing in the file to say why. */
@media (hover: hover) and (pointer: fine) and (min-width: 48.0625rem) {
  .row__title {
    color: var(--c-row-idle) !important;
    transition: color 0.3s ease;
  }

  a.row:hover .row__title,
  a.row:focus-visible .row__title,
  a.row:active .row__title {
    color: var(--c-work-fg) !important;
  }
}

/* The row spans the viewport, so the global focus ring would draw a box around the
   whole page. Re-pointed at the content instead: same ring, right box. */
a.row:focus-visible {
  outline: 0;
}

a.row:focus-visible .row__inner {
  outline: var(--bw-base) solid var(--c-fg);
  outline-offset: var(--s-2);
}

/* --- The projects' previews: one card beside its own name -------------------
   Every project carries a picture of itself — the file is in the markup,
   `img.row__preview`, one per item — and on a wide screen with a pointer, hovering
   (or tabbing to) a name brings that project's picture in as a CARD 20px from the
   WORDS, on the side the owner gave that project. Two things follow from "beside
   its own name", and between them they are the whole design:

     * NOTHING FOLLOWS THE POINTER. The card that arrives is the one the owner gave
       that project, standing on the side the owner gave it, so the four stand either
       side of the stack rather than under the cursor. It is also why the SIDE is a
       modifier ON THE <img> — `--left`, `--right` — rather than a position counted
       from the row's place in the list: the side belongs to the PROJECT, so a
       project that moves up the list takes its side with it.
     * THE PICTURE IS STILL THE PROJECT'S OWN, and it is now the name's OWN SIBLING
       inside the name's own box: both live in `.row__title-wrap` (read that rule
       before this one — it is the box a card is measured from), so a picture and
       the name it belongs to are paired by the markup and cannot drift apart. The
       wrapping is what makes the 20px a real 20px: the distance is spent between
       the name's own box and the card, not between the page's column and the card.

   THE FOUR CORNERS ARE HISTORY — `--upper-right`, `--left-centre`, `--lower-left`
   and `--lower-right` are gone, replaced by `.row__preview--left` and
   `.row__preview--right`. The owner's ask was the DISTANCE: a card sits immediately
   beside its own project's text, 20px away, instead of in a fixed corner of the
   screen — Smart Home's on the left, Hairdresser's on the right, ECOnudge's on the
   left, Redesign's on the right. WHAT THAT COSTS IS SAID RATHER THAN HIDDEN: the
   stack's pitch is the 0.85 line box on .row__title PLUS --work-row-gap — 81.6 + 40 =
   121.6px at the 96px ceiling — and a card is 2:3 of --work-preview-w (216x144 at a
   1440 frame), so a card standing beside its name is still taller than the row it
   belongs to and still OVERLAPS the names above and below it: by about 11.2px each,
   the owner's 40px gap having cut that from the ~31px it was while the gap was 0. That
   is the composition the ask produces — a card laid ON the block of type, which is
   what its elevation is for — and the knob if it ever reads as crowding is
   `--work-preview-w`, never the 20px.

   NO SCRIPT, AND THAT IS THE POINT. The state that draws a card is the row's own
   `a.row:hover` / `a.row:focus-visible` — the same state that inks that name white,
   in a query of its own lower down (see .row__title): a hover on a wide screen now
   does TWO things rather than one, and both of them are states this stylesheet
   answers rather than states a script reports. The card is a DESCENDANT of the link
   now rather than its sibling —
   it stands inside the name's own box, which is the whole of how it knows where the
   name is — so one rule in this file is the whole mechanism and there is nothing
   left for portfolio.js to place. Read with the script off, the section is
   complete: the right picture arrives beside the right name, which is what the four
   files are for.

   POINTER AND WIDTH, BOTH. `hover: hover` + `pointer: fine` because a touch screen
   has no hover to respond to (and the sticky hover a tap leaves behind would strand
   a card on the page), and 64rem because below it the names need the width more
   than the band needs cards standing in it. THE TWO POINTER TERMS ARE THE NAME'S INK
   GATE AS WELL, and the width there is deliberately NOT this one: a card waits for
   the 64rem it needs to have room in, while the ink follows the owner's 768px — read
   the block under .row__title for that argument and for what a hover does at a width
   between the two. The base state is
   `display: none`, and because the files are `loading="lazy"` and have no box to
   draw in below that query, a phone never fetches them at all — the four files cost
   a phone nothing, which is the second reason the hidden state is a box rather than
   an opacity.

   THE CARD ITSELF is a 3:2 frame, like the old one, and one frame serves all four
   files: the exports are not one shape (900x645, 900x645, 901x645, 900x645) and
   `object-fit: cover` fills the frame instead of distorting the file. It carries
   the system's own elevation — --shadow, the hard offset drawn in --c-fg, which the
   band re-points to WHITE — and a small rotation. Those two are what make it read
   as a card laid ON the type rather than as a hole in it: the rotation is the
   owner's ask, and the offset is what survives on black, where a normal blurred
   shadow has nothing to darken. */
.row__preview {
  display: none;
}

/* The query the comment above describes, and the only place its three terms are
   written down. */
@media (hover: hover) and (pointer: fine) and (min-width: 64rem) {
  .row__preview {
    display: block;
    /* Measured from the NAME'S OWN BOX — `.row__title-wrap`, its nearest positioned
       ancestor and the box the 20px is measured from. Nothing between the two takes
       a `position`. */
    position: absolute;
    inline-size: var(--work-preview-w);
    aspect-ratio: 3 / 2;
    object-fit: cover;
    object-position: top center;
    /* One elevation step, in the band's own ink — see the banner above. */
    box-shadow: var(--shadow);
    opacity: 0;
    visibility: hidden;
    /* Never the thing a click lands on, and never announced: the name beside it is
       already the project's name. This matters more here than it did when the
       picture sat under the cursor — a card that covers a word of the NEXT
       project's name must not swallow the click aimed at that word. */
    pointer-events: none;
    /* Over the type it stands beside, and still under the page's chrome (the
       sticky bar at 5, the hero's cue at 4) — the order the cursor-following build
       used, kept because it is the page's order rather than that build's. */
    z-index: 3;
    /* The owner's 0.4s fade, with `visibility` delayed by the same amount so the
       card fades OUT where it stands instead of being cut off on the first frame;
       the arriving state zeroes that delay so it is there at once. Under
       `prefers-reduced-motion: reduce` system/base.css flattens both to 0.01ms and
       the card still arrives — at once. The rotation is not a transition at all, so
       there is nothing there to flatten: a tilted card is a tilted card. */
    transition: opacity 0.4s ease, visibility 0s linear 0.4s;
    /* THE CARD'S CENTRE IS THE NAME'S CENTRE, and this is the half of that which is
       common to both sides. `inset-block-start: 50%` puts the card's top edge on the
       middle of the name's own line box — the containing block above, which is why
       `.row__title-wrap` is an inline-block and not a bare inline — and the
       `translateY(-50%)` in each side rule below pulls it up by half its OWN height,
       which is what centres it rather than hanging it. The translation lives with
       the rotation because they are one transform on one element, stated once.
       A card is taller than the row it belongs to (see the banner above), so it is
       always the NAME that decides where its card sits, never the other way round. */
    inset-block-start: 50%;
  }

  /* --- The two sides --------------------------------------------------------
     Each is stated as the HORIZONTAL inset plus the stand-off, and nothing else: a
     card's size is said once (--work-preview-w), its vertical placement is said
     once (the 50% + translateY pair, above and below), and the 20px is said once
     (--work-preview-gap). What is left per side is which edge of the NAME the card
     hangs off.

     100% OF THE NAME'S BOX, not of the column — `.row__title-wrap` is the
     containing block, so `inset-inline-end: 100%` puts the card's right MARGIN edge
     on the left edge of the WORDS and `inset-inline-start: 100%` puts its left
     margin edge on their right edge. The margin then spends --work-preview-gap in
     the direction that faces away from the words, so the distance is 20px at every
     width, for every name, however long the name is and wherever the column puts
     it — which is exactly what a corner of the screen could not do.

     The tilt is a degree or two and MIRRORED — the card on the left leans into the
     type, the one on the right leans away from it — which is what keeps four cards
     in two seats reading as a pair of gestures rather than as four placements that
     all missed. (The old build's "no two corners the same way" went with the
     corners: a side is a side, and there are two.) */
  .row__preview--left {
    inset-inline-end: 100%;
    margin-inline-end: var(--work-preview-gap);
    transform: translateY(-50%) rotate(2.5deg);
  }

  .row__preview--right {
    inset-inline-start: 100%;
    margin-inline-start: var(--work-preview-gap);
    transform: translateY(-50%) rotate(-2.5deg);
  }

  /* The disclosure: the link's own state, reaching its own card. THE DESCENDANT
     COMBINATOR IS THE RIGHT ONE NOW, because the card stands INSIDE the name's own
     box rather than beside the link — read with the sense of it: the card cannot
     leave the row it belongs to, because it is *in* that row, which is the same
     pairing the sibling combinator used to keep and a stricter one.
     `.row--soon` IS STILL SAFE: this selector asks for an `a.row`, and a row with
     no destination is a plain <div>, so a row that advertises nothing still draws
     nothing. */
  a.row:hover .row__preview,
  a.row:focus-visible .row__preview {
    opacity: 1;
    visibility: visible;
    /* Both transitions, not just the fade: visibility has to switch on the first
       frame or the fade in would have nothing to fade in. */
    transition-delay: 0s;
  }
}

/* A row with no target behind it. It is not an <a>, so nothing offers it as a link,
   the state rule above matches nothing inside it, and it takes the default cursor
   rather than the pointer. It has no arrow to grey out any more either: the one
   rule that used to state the system's DISABLED grey for this state spent it on
   `.row--soon .row__arrow`, and that glyph is gone from the section — so `cursor` is
   the whole of what is left to say. No row on the page is written this way at the
   moment (all four have a case study), but the state stays: the next project
   without one needs it, and a row that offers itself as a link before its case study
   exists is exactly what it prevents. */
.row--soon {
  cursor: default;
}

/* --- Contact ---------------------------------------------------------------

   THE PAGE CLOSES WITH AN INVITATION instead of with the colophon. This section is
   the invitation — the headline, the arrow that points at the address, the address
   itself, and the owner's GIF beside them — and it is why the nav's "contact me"
   entry is the one destination on the page that MOVED in this pass: it names
   `id="contact"`, the anchor the FOOTER used to carry. index.html's note on the
   block has the whole of that change; what matters here is that no rule in this
   file is keyed on the id — there is no `:target` selector anywhere on the site —
   so moving it cost one attribute and nothing else moved in either direction.

   WHITE, STATED; THE INK IS NOT. `.work` re-points --c-bg/--c-fg for the length of
   its own section, and this block sits outside that scope, so the pair in force
   here is the page's own (#ffffff paper, #000000 ink). The paper is written down
   for .footer's own reason: the white is a token, and a band that leaves its
   background to chance is one nested background away from a grey one. The ink is
   NOT re-declared — it is already the page's #000000 outside the work band — so the
   headline, the arrow and the address all read black by saying nothing, which is
   what the owner asked for.

   THAT IS ALSO WHAT KEEPS THE CURSOR DISC BLACK OVER THIS BAND, and it is worth
   stating because nothing in the section says it: the disc is white with
   `mix-blend-mode: difference`, so it takes its difference against what is painted
   behind it, and white against this section's white IS black (see the cursor block,
   which is where the reasoning lives). The blend resolves in the disc's own
   stacking context — the root one — so a white section is all it needs; what would
   have broken it is a transform, a filter or `isolation` on an ANCESTOR of the
   disc, and this block adds none of those. It does contain one thing: .contact__col
   takes `container-type: inline-size` for the headline ramp below, which is layout
   containment and therefore a stacking context of its own. That is harmless for the
   same reason — it nests inside this section, under the layer the disc is painted
   at, rather than over it.

   NO SCRIPT IS INVOLVED, and none should be added: the arrow is markup, the address
   is an anchor, the two lines of the headline are [data-en]/[data-de] pairs like
   every other string on the page (index.html's mechanism, which rewrites them by
   textContent on load and on the toggle), and nothing here reads a scroll or a
   pointer. portfolio.js has no block for this section. */

.contact {
  background: var(--c-bg);

  /* THE HEADLINE'S SIZE, IN TWO PIECES, and this is the FALLBACK of the two: the
     ramp a browser without container queries gets. It is the hero's arrangement,
     for the hero's reason — a `vw` term is tuned at one width and then mis-sizes
     everywhere the container is capped by --measure (82rem), which is why the
     `@supports` block lower down hands the job to `cqi` where it can.

     Where the two AGREE is one band, around 1400px, where 5vw and 9.4cqi both compute
     72.0px — not quite where the column is widest, which is 768px at a 1280 window, but
     the two facts sit inside one another: the picture's share is 4vw, so it is narrowest
     against the container at 1280. Below that band the fallback is the SMALLER
     of the two (64.0px against 72.2px at 1280) and above it the LARGER (76px pinned
     against 71.4px from 1520 on), and the two directions are not equally costly:

       BELOW, small type costs nothing: the `@supports` branch is the one drawing the
       composition, and this branch only ever runs in a browser that cannot do
       container queries at all.
       ABOVE, the fallback is the pinned one, and it is the CEILING'S job to keep it
       harmless: 9.795em x 4.75rem = 744px, which the 760px the column has from a
       1600 window on still holds — so even a ceiling-pinned line neither wraps nor
       overflows.

     The CEILING is the owner's own "4-6rem" and 4.75rem sits inside it, but it is a
     net rather than a working number on both branches: the widest column this page
     has is 768px, whose 9.4cqi is 72.2px (the measured maximum, at a 1280 window — a
     1440 window's 766.4px is 72.0), so it never binds where `cqi` is in charge,
     and on the fallback branch — 5vw reaches 76px at a 1520 window and would keep
     climbing — it is what stops the first line wrapping above that. The FLOOR is a
     net in the same way — 2.25rem binds only under a ~430px window, where the first
     line wraps rather than overflows. */
  --fs-contact: clamp(2.25rem, 5vw, 4.75rem);

  /* THE BAND'S OWN AIR, top and bottom: the owner's "generous, 6-8rem, so the
     section breathes" — 96px under a 900-tall window, 108px at 900, 128px from a
     1067-tall window up. A `vh` term and not a `vw` one, for --work-lead's reason:
     the air is read against the SCREEN the section is scrolled into, not against
     the text measure, which the container below already sets.

     WHAT IT ADDS UP TO, because this air is ADDED TO the two seams it now sits
     between rather than traded against them — the owner asked for this padding AND
     for the footer to be left exactly as it was:

       above   --work-band-pad + this padding, measured at a 1440x900 window as
               194px (the value README.md's own band bullet records — the token only
               reaches its 256px ceiling from a ~1245-tall window, where 18vh passes
               --work-lead's 14rem cap; measured 255.92px at 1244 and 256.00 at 1245)
               + 108px = 302px
               from the band's last content to the headline, of which 108.0px is
               measured from the band's bottom EDGE at all ten widths the page was
               swept at. --work-band-pad is untouched on purpose: it is the band's
               closing edge, and it still has to equal the air above the band's own
               heading (see .work).
       below   this padding + --footer-lead (180px at a 900-tall window) = 288px
               between the address and the signature, where README.md recorded 180px
               for that seam before this section stood in it. Measured: 288.0px at
               1024 and above, and 496.7px under the 64rem gate, where the picture
               joins the stack between the copy and the footer and brings its own
               168.8px plus the 40px gap (481.0px at 320, where the picture is 153px).

     Both sums are larger than the numbers README.md carries for those two seams,
     and both are this declaration's doing rather than a correction of them. If
     either reads as too much air, this one line is the lever, and the two seams
     above and below stay exactly as measured. */
  padding-block: clamp(6rem, 12vh, 8rem);
}

/* TWO COLUMNS, ONE OF THEM THE PICTURE, and the type takes what is left: the tracks
   are `minmax(0, 1fr) auto`, so the second is exactly the picture's width and the
   first is everything else in the container. The `0` in the minmax is what stops a
   long word widening the first track past its share — a grid item's min-content is a
   floor — which is the same guard, for the same reason, as .hero__grid's.

   THE GATE IS THE PAGE'S OWN 64rem, the one .hero__grid, .footer__info-row and
   every other two-column arrangement here stacks at. Under it this section is ONE
   column, which puts the picture BELOW the copy in source order — the order the owner
   asked for — and centres both; over it they stand side by side with the type first.
   `align-items: center` is what holds the picture against the middle of the copy
   rather than against its top edge, since the two columns are never the same height. */
.contact__grid {
  display: grid;
  gap: clamp(2.5rem, 4vw, 4rem);
  align-items: center;
  justify-items: center;
}

/* THE COLUMN FILLS ITS TRACK, AND THAT IS A FIX RATHER THAN A PREFERENCE. The base
   `.contact__grid` centres its items, which makes each of them shrink-to-fit — and a
   shrink-to-fit box whose inline size is CONTAINED, which is exactly what
   `container-type: inline-size` below applies, has no contents it may size itself
   from: it collapses to ZERO. That is what it did. Measured at 320, 360, 390 and 480,
   the column was 0px wide with the copy centred on its own left edge and the ADDRESS
   48px past the right edge of the window — the section's ONLY horizontal overflow,
   and `justify-self` is the one declaration that removes it: the item's own say, so
   it overrides the grid's `justify-items: center`, and `stretch` takes the track's
   definite width instead of asking for the contents'. The column then measures the
   track (272px at a 320 window, 432px at 480), the address fits with 24px to spare,
   and the copy stays centred because `text-align` is what centres it, not the box.
   NO SECOND RULE FOR THE NARROW CASE: `stretch` is what the 64rem block's
   `justify-items: normal` was already giving the column there, so this line only
   states for the base case what the wide case did by accident. `min-inline-size: 0`
   stays as the guard against a long word, as on .hero__col. */
.contact__col {
  justify-self: stretch;
  min-inline-size: 0;
  text-align: center;
}

/* TWO LINES, AND THE BREAK IS THE MARKUP'S: each line is a <span> set to a block, so
   the composition cannot move when a word gets a pixel wider (index.html's note on
   the block carries the argument, and the hero's three lines are the same
   arrangement). `line-height: 1` because the lines are the composition and nothing
   else is in them; the tracking is the hero's -0.03em, since this is display type in
   a face drawn for it.

   THE WEIGHT IS 500 AT THE OWNER'S REQUEST, where it was 800 — the face's own
   maximum, and what "bold" resolved to on this page (the hero and the signature are
   still 800). Medium reads as an invitation rather than an announcement, and it is
   this headline ALONE: the hero stays at 800, the PROJECTS band at --fw-black and the
   case-study titles at --fw-bold, and this rule cannot reach any of them, because
   `.contact__title` is one <h2> on one page — index.html's, and the only element in
   the document that carries the class.

   A WEIGHT THE FILE DRAWS RATHER THAN ONE THE BROWSER FAKES: Funnel Display ships a
   real `wght` axis (300-800 — the @font-face at the top of this file, and the shipped
   woff2 carries fvar, HVAR, MVAR, avar and STAT), and the advance width of "Let's
   build something" at 100px walks 994.17 / 1003.84 / 1016.73 / 1032.84 / 1042.50 for
   300 / 400 / 500 / 700 / 800 — five distinct stops, so 500 is a position on that axis
   rather than 400 smeared with a synthetic bold. Written as the number rather than as
   a token for the reason .hero__intro gives above: the system has no medium ROLE.

   WHAT IT COSTS IS 2.5 POINTS OF THE COLUMN AND NOTHING ELSE, measured on the built
   page at 1440: the first line 92.1% → 89.6%, the second 79.6% → 77.4%, the two-line
   break unmoved, and every box in the section identical — the arrow, the address, the
   picture, the column and the section all measure delta 0.00px, and `scrollWidth ==
   clientWidth` holds at all ten widths in both languages. The ramp's note below carries
   the sweep. */
.contact__title {
  margin: 0;
  font-family: var(--font-page);
  font-size: var(--fs-contact);
  font-weight: 500;
  line-height: 1;
  letter-spacing: -0.03em;
}

.contact__title span {
  display: block;
}

/* WHERE THE BROWSER CAN, THE SIZE IS THE COLUMN'S OWN SHARE INSTEAD — the arrangement
   .hero__title already carries, and for the argument stated there: the container is
   capped by --measure (82rem), so a viewport unit stops describing it the moment the
   window passes 1312px, while `cqi` keeps describing it at every width.

   9.4cqi IS MEASURED, NOT PICKED. The widest line in either language is English
   "Let's build something", 9.795em in Funnel Display at weight 800 with the tracking
   above — measured in the self-hosted face at 100px and divided by 100, one line at a
   time ("Let's build something" 9.795, "amazing together!" 8.465, German "Lass uns
   gemeinsam" 9.785, "etwas Großartiges bauen!" 11.8884). A line that fills 92% of
   its column leaves the composition an edge on both sides without ever wrapping:

     100 / 9.795 x 0.92 = 9.39cqi

   THE TABLE IS THE WEIGHT-800 MEASUREMENT, AND IT IS KEPT AS MEASURED rather than
   re-derived from the weight the page now ships: at 500 the same strings are narrower
   — the widest English line is 9.537em rather than 9.795, i.e. 687.06px of the same
   766.41px column at 1440 — so the ramp draws its lines ~2.5 points short of the 92%
   this formula aims at. 100 / 9.537 x 0.92 = 9.65cqi is the ramp that puts 500 back at
   92% — measured rather than just divided: swapped in for one run at 1440 it draws 92.0%
   and 79.5% in English and 91.8% / 79.3% + 30.2% in German, with the three-line shape and
   the zero overflow where they were. It is NOT shipped: the owner asked for the weight,
   not the size.

   AND THE MEASUREMENT HAS TO BE TAKEN AFTER THE FACE HAS ARRIVED, which is not a
   detail of the method but the thing that decides the number: a probe that asks for
   it in the same task as the font request gets the SERIF fallback's metrics instead
   (8.5942, 7.2377, 8.1837, 10.2616 — the table this block carried first), because
   nothing has yet told the browser to fetch the file. That ramp, 10.7cqi, is 104.8%
   of this column, and the first line wrapped at every width over the 64rem gate.
   `document.fonts.load("800 100px ...")`, awaited, and only then the measure.

   WHICH IS 72.0px (4.50rem) at a 1440 window: the 82rem container less its two 4rem
   insets is 1184px, less the picture's 360px ceiling and the 4vw gap (57.6px at 1440,
   its own 4rem ceiling from a 1600 window on) leaves the type 766px — and 768px at a
   1280 window, where the same gap is only 51.2px, which is the widest this column gets
   anywhere (72.2px). That is inside the owner's "4-6rem on desktop", and it is also why the
   picture stops at 360px — the ceiling belongs to the FALLBACK, whose size knows
   nothing of the column, and it is the column that has to carry it: a 400px picture
   would leave the type 723px at a 1440 window, where that branch sets its 72px line
   at 705.6px, i.e. 97.6% of the column and no edge left on either side. Under `cqi`
   the same 400px picture would be harmless — a share of the column always leaves a
   share of the column — which is why 360 is a ceiling on the picture rather than a
   rule about the headline.

   THE GERMAN IS LONGER THAN THE ENGLISH BY 21%, and this is where that is paid: at
   9.4cqi the German's second line is 11.8884em, 856px against the same 766px
   column, so it wraps once — "etwas Großartiges" / "bauen!" — and the German
   headline sets in THREE lines where the English sets in two ("Lass uns gemeinsam" /
   "etwas Großartiges" / "bauen!").

   AND THE THIRD OF THOSE IS ONE WORD ON ITS OWN, which the sentence that stood here
   said the opposite of: "no line of one word, which is what the break was chosen to
   keep true" is FALSE in German, and the ten-width sweep is what replaced it. Measured
   at a 1440 window — the window this block's 766.4px column belongs to — the third line
   is 233.2px, 30.4% of that column, against 92.0% for the first line and 79.4% for the
   second, and the same three shares hold at every width over the 60rem gate, because
   each of them is the ramp measuring its own column. WHAT PUTS THE WORD THERE IS GREEDY
   LINE-BREAKING RATHER THAN A WRAP THE RAMP ASKED FOR: the column at 9.4cqi is 10.64em,
   so the first line takes "etwas Großartiges" (8.447em) and "bauen!" is what is left
   over. A two-line arrangement does exist — "etwas" / "Großartiges bauen!", 2.80em and
   8.89em, both inside the 10.64 — but no browser picks it, and it would not be better:
   a line holding "etwas" is the same weakness one line up.

   THE LEVER IF THE OWNER WOULD RATHER HAVE NEITHER THE THIRD LINE NOR ITS ONE WORD is
   the single number below: 7.7cqi fits the German's 11.8884em line inside 92% of the
   column as well (100 / 11.8884 x 0.92 = 7.74, and that is the top of the range — 8.4cqi
   would still fit the line, at 99.9% of the column and no edge left on either side), at
   59.0px (3.69rem), and costs the English 9.795em x 59.0 = 578px, i.e. 75% of the column
   instead of 92%. It is the only arrangement in which the German has no line of one word
   — both languages set in two lines there, four lines in all — and English's 92% is what
   pays for it. 9.4cqi IS WHAT THE PAGE SHIPS, AND THAT IS A DECISION RATHER THAN A
   DEFAULT: both states were rendered and photographed at 1024x900 and at 390x844 — the
   7.7 one sets the German in two clean lines at 75.3% and 91.5% of its column and the
   English at 75.4%, the one below keeps the English at 92.1% and the German's one-word
   third line — and the owner chose this one with the alternative in hand.

   MEASURED ON THE BUILT PAGE at ten widths — 320, 360, 390, 480, 768, 1024, 1280, 1440,
   1920 and 2560 — this ramp reports what the number above claims: ZERO elements past the
   viewport's right edge and `scrollWidth == clientWidth` at all ten, the English one line
   per span from 480 up, at 92.1% of the column and 79.6% for the second, and the German
   three lines FROM 480 UP rather than from 1024: "Lass uns gemeinsam" at 92.0% of the
   column, "etwas Großartiges" at 79.4% and `bauen!` alone at 30.4%, the same three shares
   at all seven widths from 480, because each of them is this ramp in its own column. THE
   FIRST READING OF THIS SWEEP RAN THE GERMAN AT FOUR WIDTHS ONLY — 320, 390, 1024 and
   1440 — which is where "from 1024 up" came from: 480 and 768 were never asked, and both
   are three lines, because the first span is 9.785em and fits one line from 480 up.
   Below them the German is four lines at 360 and at 390 ("Lass uns" / "gemeinsam" /
   "etwas Großartiges" / "bauen!") and five at 320, where the second span breaks twice
   ("etwas" / "Großartiges" / "bauen!"). The computed size is 40.608px at 480, 60.16 at
   768, 52.17 at 1024, 72.04 at 1440 and 71.44 from 1920 on, where the ceiling of 4.75rem
   never binds, because the widest column this page has is 768px. The three-line German is
   therefore a reading rather than an estimate — including its third line being one word,
   which is the correction the paragraph above records — and that paragraph is also what
   the two-line alternative would cost in English.

   THE SAME SWEEP RUN AT THE WEIGHT THE PAGE NOW SHIPS returns those arrangements with
   narrower ink: zero elements past the viewport's right edge and `scrollWidth ==
   clientWidth` at the same ten widths in both languages; the English one line per span
   from 480 up at 89.6% and 77.4% (89.7% at the 768, 1920 and 2560 columns, which are 640
   and 760 rather than 766.41) and the same 2+2 at 320, 2+1 at 360 and 390, where the
   tightest line on the page is 95.1% rather than 97.7%; the German still three lines
   from 480 up with `bauen!` alone on the third — the word belongs to the arrangement
   rather than to the weight — at 89.4%, 77.2% and 29.5%, and its five lines at 320 and
   four at 360 and 390 unmoved bar the ink, at 71.1% and 62.0% / 56.5%. The computed size
   is unchanged at every width, because a share of the column knows nothing of weight. */
@supports (container-type: inline-size) {
  .contact__col {
    container-type: inline-size;
  }

  .contact__title {
    font-size: clamp(2.25rem, 9.4cqi, 4.75rem);
  }
}

/* THE ARROW IS TWO HAIRLINES, AND WHAT MATTERS IS THEIR THICKNESS — 2px at every
   size the section draws them at, which takes one extra declaration to arrange: a
   `stroke-width` inside a viewBox scales WITH the box, so the 2 this drawing is
   written at would be 4.5px at its ceiling and 2.8px on a phone. `vector-effect:
   non-scaling-stroke` on the two paths is what pins it, and it has to be on the
   PATHS: `vector-effect` is not an inherited property, while `stroke` and
   `stroke-width` both are, which is why the two of those sit on the <svg> and only
   the effect is repeated below. The colour is the page's ink via `currentColor`, so
   the arrow reads black here without a rule saying black, and `--bw-base` is the
   page's own weight for a line that is meant to be seen (the row rules use it).

   THE HEIGHT IS THE SIZE, and the viewBox is the shape: 24x32 is the box, the stem
   runs down 29 of those 32 units and the head is a 21-unit chevron meeting it 11
   units above the tip, so a replaced box that is told its `block-size` reads its
   own width off that ratio — 54px at the 72px ceiling. The ramp puts it at 40px on
   a phone and 72px from a 1440 window up, i.e. under the headline's own scale
   wherever the two are read together. `margin-inline: auto` is the NARROW case
   (the column is centred there); the 64rem block below takes it back to 0, because
   an auto end margin in a 766px column would centre a 54px drawing in the middle
   of type that is no longer centred. */
.contact__arrow {
  display: block;
  block-size: clamp(2.5rem, 5vw, 4.5rem);
  inline-size: auto;
  margin-block-start: var(--s-6);
  margin-inline: auto;
  stroke: currentColor;
  stroke-width: var(--bw-base);
}

.contact__arrow path {
  vector-effect: non-scaling-stroke;
}

/* THE ADDRESS IS A `.ds-link`, the page's own treatment for a link in running copy:
   it inherits the ink, so it is black here without being told, it carries the
   hairline underline, and the underline thickens under the pointer. The footer's
   own email uses it, and this is the same address — the two are deliberately not
   merged into one element: the footer is the signature's row and this is the
   invitation's, and neither should move because the other did.

   THE SIZE IS THE OWNER'S "medium", read against this section: 18px on a phone,
   24px from a ~1090px window up, between the address's neighbours — the headline
   over it and nothing under it. It is not larger because of its WIDTH: the address
   is 13.79em in the face the page sets it in, which is 331px at this block's own
   24px ceiling and 248px at the 18px a phone gets, against the 272px of column a
   320px window has. The ceiling is what that arithmetic constrains, and it binds
   only from a ~1090px window up, where the column is 635px wide. */
.contact__mail {
  margin: var(--s-6) 0 0;
  font-size: clamp(1.125rem, 2.2vw, 1.5rem);
}

/* AND THE UNDERLINE COMES OFF UNDER THE POINTER, which is the inverse of what a
   `.ds-link` does everywhere else on this page — the hairline at rest, THICKENED
   under the cursor — and it is the owner's request for this one address: underlined
   at rest, line gone while hovered.

   IT IS SCOPED TO THE INVITATION rather than written as `a[href^="mailto:"]`, which
   the request carried: that attribute is true of four other addresses on this site —
   the footer's, the two on the impressum and the one on the privacy statement — and
   those four are `.ds-link`s standing in running prose, where the underline is the
   only thing marking them as links. All four keep it. This rule cannot reach them
   whatever they load, because `.contact__mail` exists on this page alone, and that
   matters: impressum.html and datenschutz.html load THIS file, after
   system/components.css, so an attribute selector here would have restyled their
   emails too.

   THE CASCADE IS DECIDED RATHER THAN HOPED. `.ds-link:hover` in system/components.css
   raises `text-decoration-thickness` and is (0,2,0); this rule is (0,3,0) on the same
   element, in the stylesheet loaded after it, so it wins without `!important` — and the
   shorthand resets the thickness that rule raises, which costs nothing once there is no
   line left to thicken. Nothing else in either file states a `text-decoration` this
   could lose to: the page's `!important` declarations are on the work band, the project
   rows and the footer's borders.

   NO TRANSITION IS DECLARED HERE, AND THAT IS A MEASUREMENT RATHER THAN AN OMISSION.
   The request carried `transition: text-decoration 0.2s ease`, and it does NOT fade the
   line. Measured in a headless Chrome on exactly that pair of declarations: the
   declaration is live — `transition-property` computes to `text-decoration` at 0.2s, so
   it is not being dropped as invalid — but ZERO transition objects are created
   (`getAnimations()` is empty) and the computed `text-decoration-line` reads `none` at
   0, 25, 50, 75, 100, 125, 150, 175, 200, 300 and 400ms. The line goes in one step.
   `text-decoration-line` is a DISCRETE property, and a discrete property has no
   interpolable middle state for a transition to run through — the same reason the
   `text-decoration-thickness` transition `.ds-link` carries in system/components.css
   can exist at all while this one cannot.

   IF THE FADE IS WANTED ANYWAY, THE PROPERTY THAT CAN CARRY IT IS THE LINE'S OWN
   COLOUR — that one is interpolable. Swap in these two declarations and delete the
   `none` below:

     .contact__mail .ds-link {
       transition: text-decoration-color 0.2s ease;
     }
     .contact__mail .ds-link:hover {
       text-decoration-color: transparent;
     }

   It is real, measured the same way: the browser builds a 200ms
   `text-decoration-color` transition, and driving its `currentTime` by hand walks the
   alpha down the `ease` curve — 1 at 0ms, 0.592 at 50, 0.196 at 100, 0.04 at 150, 0 at
   200 — while `line`, `thickness` and `offset` hold at rest's values throughout
   (`underline`, the hairline, and the 0.2em offset, which is 3.2px in the probe's 16px
   type and 4.8px at this block's 24px ceiling), so the address cannot move by a pixel
   as the line dissolves. The cost is that `text-decoration-line` still computes to
   `underline` under the cursor — the line is invisible rather than absent. */
.contact__mail .ds-link:hover {
  text-decoration: none;
}

/* THE PICTURE: 300px to 360px, the owner's "300-400, without dominating" — and the
   ceiling is the headline's need rather than a taste (see the cqi note above).
   `max-inline-size: 100%` is what lets it come in under a 300px column at the very
   narrow end without a second ramp, and `block-size: auto` is the half of that which
   is CSS's: the picture keeps the file's own proportion.

   NO WIDTH/HEIGHT ATTRIBUTES, on the owner's instruction to reference the file by
   path alone. The cost is stated rather than hidden — the box has no shape until the
   file arrives, so the section grows by the picture's height when it does and the
   footer moves down with it — and index.html's note on the block carries the two
   lines that would reserve it. */
.contact__gif {
  display: block;
  inline-size: clamp(18.75rem, 26vw, 22.5rem);
  max-inline-size: 100%;
  block-size: auto;
}

/* FROM THE PAGE'S OWN 64rem THE TWO COLUMNS STAND SIDE BY SIDE: the type first, the
   picture in an `auto` track of exactly its own width. `justify-items: normal` is
   the base rule's `center` taken back — in a grid that resolves to `stretch`, which
   is what lets the copy column fill its track — and the copy inside it is set flush
   left, which is how every other two-column block on this page reads. */
@media (min-width: 64rem) {
  .contact__grid {
    grid-template-columns: minmax(0, 1fr) auto;
    justify-items: normal;
  }

  .contact__col {
    text-align: start;
  }

  /* The picture is the right-hand column and sits flush with the container's own
     right edge: the auto track is exactly its width, so `end` names the intent
     rather than a distance.

     `align-self: center` IS THE VERTICAL HALF OF THE SAME STATEMENT, AT THE OWNER'S
     REQUEST, AND IT MOVES NO PIXEL: the base rule's `align-items: center` already
     resolves this item's `auto` to `center`, so the picture was centred on the row
     before this line existed. Across 16 widths in both languages the ONLY thing a
     sweep sees change is the computed value itself, `align-self: auto` -> `center`:
     `gif.centre - col.centre` 0.00px at every width from 1024 up, and every other
     box — the copy, the picture, the section, the footer — at the same coordinates to
     the hundredth of a pixel. It is stated on the ITEM rather than left to the
     container for the reason `.contact__col`'s `justify-self` gives above: the item's
     own say, so a later restatement of the container's alignment cannot drop the
     picture out of the middle without this rule changing too.

     WHAT IT IS CENTRED ON IS THE COPY, WHICH CARRIES THE ARROW AND THE ADDRESS, AND
     THE COPY IS ~150-172px TALLER THAN THE HEADLINE — so the picture's middle sits
     exactly HALF THAT TAIL below the headline's own middle: 74.49px at 1024, 77.50 at
     1100, 78.80 at 1152, 82.00 at 1280, 83.99 at 1360 and 86.00 from 1440 up,
     identical in both languages. The tail is that half doubled —
     2 * --s-6 + the arrow's ramp + the address's line box: 172px at 1440 (64 + 72 +
     36), 148.99 at 1024 (64 + 51.2 + 33.79) — which is why the distance is a calc()
     rather than a number. IF THE PICTURE SHOULD SIT ON THE HEADLINE'S MIDDLE INSTEAD,
     that half-tail is the whole of the change and one declaration carries it:

       transform: translateY(calc(-0.5 * (2 * var(--s-6) + clamp(2.5rem, 5vw, 4.5rem)
         + var(--lh-normal) * clamp(1.125rem, 2.2vw, 1.5rem))));

     A `translateY` AND NOT A BOTTOM MARGIN OF THE SAME HEIGHT, which lands the middles
     on each other just as exactly (measured 0.00px from 1024 up, both languages) but
     pays for it in LAYOUT: at 1440 that margin makes the row taller by the tail, so the
     section is +58.41px, the copy 29.2px lower and the footer 58.41px lower. The
     `translateY` is paint only — with it in place the section, the copy's own top and
     the footer's own top are identical to the readings above and the picture is the
     only thing in the document that moves. The German three-line headline needs no
     separate value: the lift is a share of the TAIL rather than of the type. */
  .contact__gif {
    justify-self: end;
    align-self: center;
  }

  .contact__arrow {
    margin-inline: 0;
  }
}

/* --- Footer ---------------------------------------------------------------

   TWO ROWS, both on the page's own white: the drawer in the middle of the page
   and the header bar above it all sit on that white, and a footer that leaves its
   own background to chance is one nested background away from a grey band. The
   rule on the top edge is full-bleed, like every other rule here: it is the
   page's closing line, not a line of copy.

   ROW ONE IS THE SIGN-OFF — a maker's mark now, "100% HUMAN-MADE", where the
   owner's name used to be — and it is a MARQUEE: ONE line of type, repeated along a
   track that slides ONE HALF OF ITSELF to the right and starts again, forever, in ONE
   colour, the page's ink. The direction is the owner's ask, and it is a reversal rather
   than a redesign: the keyframes below are the same two ends of the same distance played
   the other way round, so the period, the coverage and the seam are all unchanged (see
   .footer__track). The two-tone the mark first carried was
   removed at the owner's request, so both halves of the phrase are the same solid
   black in both language settings, the German included, which reads the same words
   (see .footer__name). The travel is BACK on this row, as the owner then asked: it is
   a loop rather than the arrival below, and it is CSS's rather than a script's (see
   .footer__track, which carries the arithmetic, the speed and the state that stops
   it). The footer used to close with a gesture: the name arrived as TWO words, one
   at each window edge, which the
   scroll that revealed the footer carried together in the middle while painting
   them from --c-highlight into --c-fg. That was REMOVED at the owner's request —
   and with it the script, because portfolio.js has no footer block any more: nothing
   on this page reads this row's box and nothing writes to it. What is below is the
   whole of the mark in every state, and the ONE state that changes anything here is
   prefers-reduced-motion, which stops the track and leaves the band the composition
   the removal left it (see the media block under .footer__copy). There is no
   transition here to soften and no inline style to
   override, which is why the rules that used to carry the two words' travel and
   their arrival grey do not exist either. The two-tone the mark first arrived with
   outlived that removal by one rule, which painted the tail and nothing else; that
   rule is gone too, so there is no colour in this row at all beyond the page's ink.

   THE SIZE IS THE FIT, AND THE FIT IS NOW THE BAND'S OWN BOX. The band is the
   window's own width (see .footer__name), so the mark spans the page the way every
   rule here does — but a phrase does not reach the edges of its band by being
   full-bleed: it reaches them because its TYPE was sized to reach them, and one
   number cannot do that for two strings 8% apart. The owner asked for the mark edge
   to edge with "minimal padding, 2-3vw at each side", so the band pads itself
   --signoff-bleed at each edge and the string is fitted to what is left: the calc
   below divides the band's content box by the string's own width in ems.

   THERE IS ONE STRING AGAIN, so there is one number. The phrase is no longer
   translated — the owner asked for "100% HUMAN-MADE" to stand in German as well (see
   index.html) — so the German string, its em row and its divisor are GONE rather
   than parked at the English number, and the html:lang(de) rule that carried it went
   with them, its twin inside the @supports below included: a language selector that
   no longer selects anything is a rule the next reader has to disprove before
   trusting the rest. What the German's numbers WERE, for the record, since the
   argument above still turns on them: "100% HANDGEMACHT" measured 10.6134em in Funnel
   Display and 10.6522em in Neue Montreal, against the English's 9.8417 and 9.7264.

   THE EMS ARE NOT ASSUMED. The string set at 100px in the page face, weight 800 and
   the -0.02em tracking stated below (so the tracking is part of the number):
     · "100% HUMAN-MADE"    9.8417em in Funnel Display, 9.7264em in Neue Montreal
   A fresh sweep of the same string in the same conditions read Funnel Display at
   9.84 — the table above, to a tenth of a percent — and Neue Montreal at 9.85, which
   is 1.3% WIDER than the table's English. That disagreement is taken as the width
   rather than resolved: the divisor is the widest reading less 0.5% of slack —
   9.85/0.995 = 9.9 — so neither table can beat the number. The face usually drawn
   then lands at 97.4% of the band, i.e. 1.3vw of air at each edge, 18.7px at 1440;
   a face drawing as narrow as this table's Neue Montreal would sit at 96.3%, 1.9vw,
   which is the whole price of a number that cannot overflow. (Both readings carried
   2.78vw and 3.3vw of air when the owner's 2-3vw margin was the value of
   --signoff-bleed; that margin is 1vw in the same request that asked for the mark
   bigger, and both readings move with it because it is a share of the band rather
   than a distance.)

   THE SHARE IS STILL WHAT IS STATED, with ONE ceiling on top of it. The type, its
   tracking and the air around it are shares of the band, so the composition is the
   same at 320 as at 2560 and there is nothing for a breakpoint to correct — and that
   is still the whole of the rule below the cap. What the owner asked for after living
   with it is a target for a desktop screen, "around 158px", so the fit is wrapped in
   a min() (see the rule below) and 158px is the one number on this row that is a
   DISTANCE rather than a share. It is spent above a ~1596px band and idle below it:
   under that width the fit is the smaller term and the mark fills the band exactly as
   it always did, over it the mark is 158px and the air grows with the window (502px
   at each side of a 2560 band). The cap can never bind where the fit would NOT fit
   the string whole — the fit is the smaller term exactly where 158px is too wide for
   the box — and THAT is what keeps the phrase uncropped: 158px of this string is
   1555px of ink, so the size it names only exists inside a band ~1596px wide or
   wider. The clip below is still a net rather than a fix — the worst reading above
   leaves 0.25% of the band unspent — and it stays, because a promise about the page's
   horizontal axis should not rest on a font.

   THE MARK RAN ON A DIAGONAL AND DOES NOT ANY MORE, and the ask is what changed: the
   phrase was rotate(-3deg) on the ONE band it runs in — every copy turned with it, so
   the loop travelled UNDER the diagonal and there were two motions on this row — and
   the owner asked for the mark to run STRAIGHT instead. So the rule carries no
   transform at all now, and one line of type sits on the page's own horizontal axis
   (see .footer__name, which carries the fit and the removal). That leaves ONE motion
   here rather than two: the track moves (see .footer__track) and the box it moves under
   is flat, so the phrase both sits and travels horizontally at 320 and 2560 alike.
   WHAT THE TILT LEFT BEHIND, because the numbers below were read with it on: the pivot
   was the box's own centre (CSS's own default), which is what made this read as a line
   drawn on a slight diagonal rather than as two ends pushed in opposite directions; the
   angle was a FIXED -3deg rather than a share, the same at 320 as at 2560, so the
   diagonal was a feature of the composition rather than of the window; and it was
   STATIC — no transition on it, nothing about it changed under prefers-reduced-motion,
   and it was never the thing that moves here. It is also why the air above and below
   the caps is generous rather than tight: the ink's lowest corner sat 31.6px inside the
   box at 1440 while the tilt was on, so a diagonal this shallow could never reach row
   two or the band's own top edge — and with the line flat there is that much more
   clearance still.

   IT IS ONE NUMBER AGAIN, and the toggle is why twice over. It was one number sized
   for the German, which left the English at 86.6% of the band, 6.7vw of air at each
   edge; it then became one number per language, each fitted to itself, so that both
   reached the edges; and it is one number now because the German string is gone — the
   phrase reads the same in either setting (see the note above). What the number has
   never been is a step: it is the string's own fit, so the composition is identical
   at 320 and at 2560, and identical in German.

   THE UNIT IS THE BAND AND NOT THE VIEWPORT, which is why `.footer` takes
   container-type: inline-size in the @supports below. A vw fit is measured against
   100vw while the band is 100% of the layout width, and on a platform that still
   reserves a scrollbar those differ by the scrollbar's own width: the phrase would
   be ~15px wider than its box at every size and the clip would take half of that
   off each end. cqi is a share of the band's box whatever the scrollbar does. The
   vw pair stated first is the whole thing for a browser without container queries —
   the same fit against the viewport, where the band is that scrollbar narrower and
   the clip earns its keep. Nothing measures this row at runtime (portfolio.js has
   no footer block), so the fit has to be CSS's, and this is it.

   AND THE SEAM ABOVE THE BLOCK IS WHITE AIR, NOT BAND. --footer-lead (see :root)
   holds this element's side of it, and it is a MARGIN here rather than padding on
   .work for the reason stated with the token: the band IS a black surface, so a pad
   on it paints black where the owner asked for air, and a margin on the footer
   leaves the page's white to do the work. It also means the footer's own box keeps
   the height its own fit gives it — the mark's line box and the two --s-7 insets are
   the ones README > How this page was checked records.

   WHAT STANDS ABOVE THAT SEAM IS NO LONGER THE BAND: the CONTACT block is between
   them now (see that block, and index.html's note on it), so the air the owner asked
   for is the contact block's own bottom padding PLUS this margin rather than this
   margin alone. Both terms are argumented where they are spent, and the sum is
   recorded in README rather than left to be rediscovered here.

   AND THE TWO HAIRLINES THE BLOCK USED TO DRAW ARE GONE, in that order. The gap
   above this band had made the block's own top rule visible for the first time —
   against the black band it was black on black — and the owner, seeing it at that
   distance, asked for it AND for the rule that opened row two to go with it: two
   full-bleed lines boxing the signature read as a frame around the name rather
   than as the page closing. So the band carries no rules of its own now, and the
   white above it — the contact block's padding and then --footer-lead — is the only
   thing separating the page's closing copy from the name. The
   box is 2px shorter for it at every width, exactly: the two rules were its height
   too. README > How this page was checked carries the re-measured box and ink. */

.footer {
  background: var(--c-bg);
  /* THE BAND CLIPS, and what it clips is the MARQUEE'S WINDOW: this is the box's own
     `overflow: hidden`, the mark's box is the window's own width, and the track the
     phrase runs on is far wider than that and travelling — 11793px against a 1440 band —
     so the band is the window the phrase is seen through. IT WAS NEVER THE TILT'S CLIP
     ALONE: the mark ran on -3deg until the owner asked for it straight, and a rotate()
     swings the two ENDS of a box this wide up and down — and swings its corners OUT:
     -3deg put them ~5px wider than a 1440 band, which is a horizontal scrollbar on the
     page unless something clips them. So for that round this rule was a net around the
     overhang as well, and with the line flat there is no overhang left to catch: the
     probe's own `footerClipX` — the pixels the band's clip was holding back — reads 0
     at 1440 and at 500, where it read 5 and 3 (README > How this page was checked).
     What keeps the clip needed now is the loop itself rather than a rotation: the track
     is 11793px against the band's 1440 (see .footer__track). The ink was always clear of
     that overhang anyway — the fit is measured on the string INSIDE the box, and the ink
     stays 31.6px clear of this box's own edge at 1440 — so what the corners swung out by
     was empty box rather than the phrase (see .footer__name, which carries the fit and
     the removal). Row two is inside this box as well, and it is ordinary blocks in this
     band's flow — nothing in the band overflows it but the track the phrase runs on. */
  overflow: hidden;
  /* THE RULE THAT CLOSED THE PAGE IS GONE, at the owner's explicit request: at the
     distance --footer-lead had just opened, the top rule read as the box around the
     signature rather than as the page's closing line, so it and the rule that opened
     row two are both removed.
     Written in the PHYSICAL spelling the owner asked for: in this page's writing
     mode `border-top` and `border-block-start` name the same edge, and both edges are
     cleared so no rule here can come back by inheritance or by a later shorthand.
     `!important` for the reason --footer-lead carries it — no later rule should be
     able to put the line back without someone having decided to.
     WHAT IT COSTS, for the record: the band measured 1440x324 and 390x260 WITH its
     two hairlines and measures 2px less without them; the ink share and the box
     height in README > How this page was checked are re-measured to match.
     THOSE ABSOLUTE BOXES ARE THE FIT OF THAT PASS, and that fit is the 94% one the
     README records (133.056px at a 1440 band): --signoff-bleed came down to the 1vw
     this file spends below, so the same arithmetic on today's 142.545px name gives
     331.55 at a 1440 band and 260.6 at 390 (the pixels round those to 331 and 261), and
     README > How this page was checked re-measures both off the raster rather than off
     this arithmetic. What the 2px is claiming is the
     REMOVAL and not the size, and that is exact at every width: the rules were their
     own height and nothing else moved. */
  border-top: none !important;
  border-bottom: none !important;
  /* THE SEAM UNDER PROJECTS — the generous air the owner asked for, stated on
     this element because this is the side of the seam that can open it: the
     white page shows through a margin here, where a padding on .work would
     paint more black. --footer-lead in :root holds the value and the argument
     for it; what belongs here is that the footer's OWN box is not touched by
     it, so every number this file was measured against still holds.
     !important at the owner's explicit request, and for the reason the band's
     colour pair carries one: no later rule should be able to take this gap
     away without someone deciding to. Nothing else on the page sets a margin
     on .footer, so it overrides nothing. */
  margin-block-start: var(--footer-lead) !important;
  /* The face, stated rather than inherited: `body` already sets it, and the footer
     should not be one change to `body` away from closing the page in another voice.
     Both rows are the page face, which is what the owner asked for. */
  font-family: var(--font-page);
}

/* The band the sign-off is set in. FULL-BLEED — it is the window's own width, the
   way every rule on this page is — and it CLIPS, so the phrase can never put a
   horizontal scrollbar on the page whatever face is drawing it. That clip is the
   MARQUEE'S WINDOW as well as a net (see .footer__track): it is this box's border box,
   which is the window's own edge, so a phrase enters and leaves exactly at the page's
   sides, and what --signoff-bleed insets is the content box — where the track starts
   and where the STATIC composition is centred — rather than where the travel is cut.
   The air is the band's own, and it is symmetric: --s-7 above the
   caps and --s-7 below them, and --signoff-bleed at each edge, where the owner's
   "minimal padding" is the whole of the mark's margin (see the note above, which
   carries the fit that spends it) — and that inset was the TILT's margin as well while
   the phrase sat on a -3deg diagonal: the rotation was on this box, this box is the
   window's own width, so the phrase's ends rose and fell by ~37px at 1440 and the
   corner room at each edge was half of what kept that diagonal off the page's
   horizontal axis. THE TILT IS REMOVED at the owner's request (see the note on the
   rule below), so the same 1vw is the mark's margin alone now: spent on one thing
   instead of two, and nothing else about the band moves for it. */
.footer__name {
  /* THE MARK'S WHOLE MARGIN, and it is stated once because it is spent twice: the
     band pads itself by this at each edge, and both font-size calcs below divide
     what is left. It was the "2-3vw at each side" the owner asked for, and it came
     DOWN to 1vw in the same request that asked for the mark bigger — the fit IS the
     band divided by the string, so every vw the margin gives up is size the phrase
     takes: 2.5vw to 1vw is the 3.2% between 138.18px and 142.55px on a 1440 band. It
     is still a SHARE of the band rather than a distance, so the composition holds at
     every width, and what it leaves is still a real gutter: 1.3vw of air, 18.7px at
     1440, which WAS the room the tilt turned in as well as the mark's margin — the
     tilt is removed now (see the note on the rule below) and the value did not come
     with it: 1vw is the owner's own 2.5vw-to-1vw cut rather than a number the
     rotation asked for. The cqi
     spelling inside the @supports below is the same 1% of the same box, measured
     against the band rather than against the window. */
  --signoff-bleed: 1vw;

  /* THE OWNER'S DESKTOP SIZE. The one number on this row that is a distance, and the
     only cap in this block: the mark stops growing here and fills the band below it.
     158px is what the owner asked for, and the arithmetic that makes it safe is in
     the calc below — at this string's 9.84em it is 1555px of ink, so a band narrower
     than ~1596px never reaches it. */
  --signoff-mark-max: 158px;

  /* THE MARQUEE'S OWN NUMBERS, and there are three of them because the loop needs
     exactly three: how many copies are in ONE HALF of the track (which is what -50% is
     worth, so it is half of the eight index.html carries), how long ONE copy takes to
     cross, and the air between two copies. None of them is a distance in px — the count
     and the time are what they are, and the gap is in em (see .footer__copy) — so the
     whole pattern is a share of the composition rather than a set of sizes that would
     have to be re-measured at every width. --signoff-half and --signoff-step are spent
     once each, together, in the track's animation below. */
  --signoff-half: 4;
  --signoff-step: 9s;
  --signoff-gap: 0.5em;

  text-align: center;
  overflow: hidden;
  padding-block: var(--s-7);
  padding-inline: var(--signoff-bleed);
  /* Fitted to the string inside the band's own box and CAPPED at the owner's 158px:
     the fit is the smaller term wherever 158px would not fit whole — this string is
     9.84em wide, so 158px of it is 1555px of ink, and the band holds that only from
     ~1596px up — and the cap is the binding term above that. Below the cap nothing is
     cropped: what is NOT here is a floor, and its absence is deliberate. A clamp's
     lower bound would be a size the phrase keeps when the band can no longer hold it,
     which for a string this long is a cut-off glyph rather than a small phrase: 3rem
     is 472px of ink, i.e. a phrase that stops fitting a band under ~490px wide, and
     the mark's whole point here is that it fills whatever width it is given. ONE
     string, one number, both language settings (see the note above). */
  font-size: min(var(--signoff-mark-max), calc((100vw - 2 * var(--signoff-bleed)) / 9.9));
  /* THE WHOLE LINE'S ink, and there is only one of it: --c-fg is #000000, so every
     word of the phrase — the "100%" and the tail after it alike — is solid black.
     The per-half rule that used to tint the tail grey left with the two-tone (see
     the note on .footer), and the rule below holds the two halves on this same
     colour even if a class is ever hung on one of them. */
  color: var(--c-fg);
  /* A drawn weight, not a synthesised one: Funnel Display ships 300-800 as a real
     axis — see the note on .hero__title, which is where this page already goes
     heavier than the system's Neue Montreal can. */
  font-weight: 800;
  /* ONE line, always: the mark is the page's closing statement, and a wrapped one is
     two lines. The size is a fit rather than a ramp, so the only face that could
     ever exceed the band is one wider than every reading the divisor was built from
     (see the note above), and the clip above is what answers that rather than a
     wrap. */
  white-space: nowrap;
  /* An uppercase-only line, so 1 is this page's own display leading (see
     .hero__title), and the tracking is the -0.02em every width above was measured
     WITH — see the note above. */
  line-height: 1;
  letter-spacing: -0.02em;
  text-transform: uppercase; /* the DOM keeps its own case for screen readers */
  /* THE TILT IS GONE, at the owner's explicit request. The phrase sat on a -3deg
     diagonal — `transform: rotate(-3deg)` here, on THIS box, because a rotation turns
     the whole line as one unit and there has never been a per-letter rule on this row —
     and the owner asked for the mark to run STRAIGHT instead: no rotation, no tilt, a
     flat horizontal line of type at every width. So the rule that carried the tilt
     carries no transform at all now, and that is the shape the loop is written in: the
     keyframes move the TRACK under this box (`translateX(0)` to `translateX(-50%)`, see
     @keyframes signoff-marquee) and were never rotated themselves, so with this box on
     the page's own axis the phrase both SITS and TRAVELS horizontally — one line, one
     axis, at 320 and 2560 alike.
     WHAT LEAVES WITH THE ROTATION IS PAINT AND NOT LAYOUT: a rotation is a paint
     property, so the fit, this box's own height, the footer's box, the page's length and
     every share and period README records are the numbers they were; what the removal
     shows up in is the ink's own box and the band's clip, and README > How this page was
     checked carries those readings, taken with the band's own probe at 1440, 500 and
     2000. The clip below is the MARQUEE'S WINDOW tilt or no tilt — it is this box's own
     `overflow: hidden`, and it was never the rotation's alone (see its note) — and the
     track and all eight copies it carries are plain spans with no `transform` of their
     own (see .footer__track), so there is one line of type here and it is horizontal. */
}

/* EVERY WORD OF THE MARK IS THE PAGE'S INK. This rule replaces the one that painted
   the tail of the phrase `--c-highlight`: the owner asked for the mark in one colour
   after living with the two-tone, so both halves of "100% HUMAN-MADE" are solid
   black — in BOTH language settings, since the phrase reads the same in either.

   IT IS STATED ON THE SPANS RATHER THAN LEFT TO INHERITANCE, because inheritance is
   what let one half be tinted for a class alone. .footer__name already draws this
   colour and the spans would inherit it anyway; naming it here as well costs one rule
   and means a `.grey`-shaped COLOUR rule hung on either half later loses to this line
   (a class plus an element out-specifies a bare class) instead of quietly going
   through. The selector matches the marquee's own boxes as well — the track and every
   copy in it — and that is harmless rather than incidental: each of them is a span
   inside this element, so all of them take the same ink the phrase takes, and there is
   still one colour on this row in every state. The two spans inside a copy exist for
   the TOGGLE — each carries its own data-en / data-de, equal to each other because the
   phrase is no longer translated, and applyLang writes `el.textContent` per element
   (see index.html) — and NOT for a colour split. The value is the page's ink, --c-fg =
   #000000 (system/tokens.css), so
   it is the same black as the rest of the page and not a second one. There is no
   opacity, filter or greyscale rule anywhere on this row to remove. */
.footer__name span {
  color: var(--c-fg);
}

/* THERE IS NO GERMAN RULE HERE, and no German-sized number anywhere in this block:
   the phrase stays English in German (see index.html), so both settings read the same
   string at the same size and a lang selector would have nothing to select. The
   html:lang(de) rule that fitted "100% HANDGEMACHT" — and its twin inside the
   @supports below — was REMOVED with the German words rather than parked at the
   English number, so .footer__name has exactly ONE size in every state of the page.
   The German's measurements, and the reason they left, are in the note above. */

/* THE BAND IS THE UNIT THE FIT IS MEASURED IN, and this is where that is set up:
   container-type makes .footer a query container, so the cqi in the rules here is a
   share of the band's own content box rather than of the viewport. The band is the
   window's own width, so the two agree everywhere except where a scrollbar is
   reserved — which is the whole reason the pair exists (see the note above).

   NOTHING IS SIZED OFF THIS CONTAINER BUT THE MARK, and taking it moves no pixel:
   .footer is a block box in normal flow, so its inline size comes from its parent
   and inline-size containment cannot touch that. The zero width that needed
   `justify-self: stretch` on the contact column belongs to a box sized by its own
   contents (see that rule), and this one has none of that to lose. The containment
   does make a stacking context, which the cursor's own note already covers for the
   same reason: this is inside the page, under the disc.

   THE BLOCK RESTATES THE WHOLE FIT, not just the band: `html:lang(de)` used to
   out-specify a bare `.footer__name` whatever the source order, which is why the
   German's size was repeated in here as well as above. There is no German size to
   repeat any more — one string, one number, both settings (see the note above and
   index.html) — so the block holds the band and the fit, and the language rule that
   was its third declaration went with the German words. The fit it restates is the
   same expression as the one above, min() and cap included: --signoff-mark-max is a
   custom property on the element, so the one spelling of it reaches both, and the
   only thing this block changes is the unit the band is measured in. */
@supports (container-type: inline-size) {
  .footer {
    container-type: inline-size;
  }

  .footer__name {
    --signoff-bleed: 1cqi;
    font-size: min(var(--signoff-mark-max), calc((100cqi - 2 * var(--signoff-bleed)) / 9.9));
  }
}

/* THE TRACK THE MARK RUNS ON, and the whole of the marquee. The owner asked for the
   phrase to travel across its band and start again, so index.html carries EIGHT
   identical copies of it and this element is the strip they sit on: it slides ONE HALF
   OF ITSELF — translateX(-50%), the keyframes at the end of this block — to the RIGHT,
   and starts again. The direction is the owner's ask and the whole of it is the order of
   the two ends: the track opens a half of itself to the LEFT of its own start and
   travels back to the start, so the pattern crosses the band left to right rather than
   right to left. Everything below is a property of the two ends — the distance, the
   count of copies, the coverage and the seam — so none of it moved with the direction,
   and the ends of a period are one picture either way round.

   THAT IS SEAMLESS, AND THE ARITHMETIC IS WHY RATHER THAN A HOPE. Every copy advances
   the pattern by its own width plus the gap that follows it (see .footer__copy), the
   copies are identical to the character, and half of this track is a WHOLE NUMBER of
   those advances — four of them, which is --signoff-half on .footer__name and half of
   the eight copies in the markup — so the frame at the end of the period is the frame
   at the start of it: the pattern lands on itself instead of jumping back. There is
   nothing at either edge of this row to hide a seam with, either: a fade, a mask or a
   second track would still be showing the wrong thing at the end of a band this wide,
   so the loop is built to need none of them, and the travel is the whole
   implementation.

   IT IS SIZED BY ITS CONTENT (inline-size: max-content), because -50% is a share of
   THIS box: a track the width of the band would move by half a band, i.e. bring the
   gap between two copies into the middle of the window. max-content is also what the
   thing IS — one flex line whose items are the copies, each as wide as its own phrase
   (white-space: nowrap, inherited, see .footer__name) plus its gap — and the auto
   margins are what centres the STATIC composition under reduced motion, where this
   track is one copy wide (see the media block below). Against the travelling track
   they are zero: its width is greater than the band's, so there is no free space for
   an auto margin to take, and the loop starts at the band's own content edge.

   THE COUNT OF COPIES IS THE COVERAGE, and it is counted rather than guessed. What the
   clip exposes is the track's RIGHT end, so the loop can only show a whole window
   while half the track is at least the band — the requirement is `half ≥ band`, and
   half the track is four advances:
     · below the cap, an advance is the phrase plus the gap, which the fit makes about
       10.34/9.9 of the band — so half the track is ~4.1 bands, over four times what the
       promise needs, and it is that ratio at EVERY width rather than at one, because
       both numbers in it are shares of the same box;
     · above the cap the type stops growing (--signoff-mark-max), so an advance is
       fixed — 1555px of ink plus a 79px gap — and half the track is a measured 6536px,
       which is the widest window this marquee fills whole: past the 6016px of the widest
       display Apple sells and past every 5K panel.
   Eight copies is what makes the travel cover any window that exists; the number is in
   the markup and as --signoff-half above because those two have to agree, and a copy
   added or removed without the other is a loop that jumps (index.html says the same
   thing from its side).

   AND THE SPEED IS ONE SHARE TOO. --signoff-step is the time ONE copy takes to pass,
   and the period is its product with the count above — 36s — so an advance takes 9s at
   320 and at 2560 alike: the phrase crosses a phone in the same three breaths as a
   desktop, because the band is a share of the window and so is the pattern on it. It is
   the ONE number to turn for speed, and nothing has to be re-measured with it: the
   period is long or short in exact proportion to the copies the loop moves through.
   LINEAR, because a marquee that eases is a marquee that stops and starts — the phrase
   has to be at the same speed in the middle of the window as at its edge, or every
   arrival at the edge reads as the machine coming to rest.

   IT MOVES NOTHING BUT PAINT. `transform` is a paint property, so this band's box, its
   height, the footer's height and the page's scroll length are IDENTICAL at every
   moment of the period: the travelling track is clipped by a band that has already
   sized itself (see .footer__name), and the only pixel that changes is the pattern's
   place inside it. That is also why there is no `will-change` here — a transform
   animation is promoted without being told — and why nothing in this rule can shift
   layout on the first frame of the loop or the last. */
.footer__track {
  display: flex;
  inline-size: max-content;
  margin-inline: auto;
  animation: signoff-marquee calc(var(--signoff-half) * var(--signoff-step)) linear infinite;
}

/* One phrase, and the air that follows it. THE GAP IS PADDING ON THE COPY rather than a
   `gap` on the track, and that is not a preference: a flex `gap` is not drawn after the
   last item, so half the track would stop being a whole number of advances and the loop
   would jump by half a gap at the end of every period. Padding rides on every copy,
   including the one the track's half ends on, which is what keeps the arithmetic above
   true.

   THE DISTANCE IS IN EM — a share of the mark's own size — so the pattern is the same
   composition at every width the way the type is: 0.5em is 71px on a 1440 band, 19px at
   390 and 79px once the type is capped, rather than a fixed hole in a line that grows.
   It is the owner's 4rem read as that share (64px at the 1440 the request was made at),
   and it is a SEPARATOR rather than a margin: wide enough to say where one phrase ends
   and the next begins — about two word spaces at this weight — and no wider, because
   everything between the phrases is a moment with no mark in it. */
.footer__copy {
  padding-inline-end: var(--signoff-gap);
}

/* THE STATIC COMPOSITION, which is the band exactly as the removal left it: ONE phrase,
   CENTRED, edge to edge, with no travel at all. prefers-reduced-motion is therefore a
   STATE of this row rather than a flattening of it. base.css's own block would already
   take the animation to one frame at 0.01ms — and that frame is the loop's own seam, so
   the picture would come out right — but this is the honest spelling: the track does not
   run, and the copies that exist for the travel are not rendered rather than merely
   hidden behind the visible one. THE GAP GOES WITH THEM, and it has to: it is the copy's
   own padding, so a single centred copy would otherwise sit half a gap left of centre in
   a track that is one copy wide.

   NOTHING ELSE ON THIS ROW CHANGES between the two states — the fit, the ink,
   the band's box and the phrase's place in it are the same, and the track's auto margins
   are what put the single copy where the static composition has always had it. The two
   states are the same picture, and that was checked rather than assumed: the README
   section on this band holds the capture that proves it. */
@media (prefers-reduced-motion: reduce) {
  .footer__track {
    animation: none;
  }

  .footer__copy {
    padding-inline-end: 0;
  }

  .footer__copy ~ .footer__copy {
    display: none;
  }
}

/* The loop itself: from half of this track's width to its own start. Written as the two
   ends of one distance rather than as angles or steps, and left as `-50%` and `0` rather
   than as px, because the track is sized by its content at every width — the share is
   what lets the ONE keyframe pair serve 320 and 2560 alike, and the count of copies is
   what makes the start land on the end (see the arithmetic on .footer__track).

   THE PAIR IS THE SAME TWO ENDS, READ THE OTHER WAY, and that is the whole of the
   direction: `from` is the frame a period begins on, so the track now begins a half of
   itself to the LEFT of its own start and travels back to the start — which draws the
   pattern left to right — where it used to begin at its start and travel left. The
   distance, the period, the coverage and the seam are untouched: they are properties of
   the two ends rather than of the order they are played in, and the note above proves
   the ends are one picture whichever one the loop opens on. */
@keyframes signoff-marquee {
  from {
    transform: translateX(-50%);
  }

  to {
    transform: translateX(0);
  }
}

/* Row two. Its top edge carries NO RULE any more — that line was the second of the
   two hairlines the owner asked for, the one that used to separate the signature
   from the email and the two legal links; it is cleared in the physical spelling the
   owner asked for and in both edges, with the note on .footer above holding the
   reason and the arithmetic. Everything else about the row stands: the copy inside
   it is on the page's column, and the name is still the ONE thing here that meets
   the window's edges. */
.footer__info {
  border-top: none !important;
  border-bottom: none !important;
}

.footer__info-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--s-3) var(--s-5);
  padding-block: var(--s-5) var(--s-7);
}

/* The two groups, each a row of small black text with even air between its items
   rather than a literal space, so an item that wraps on a phone keeps the same gap
   the unwrapped row has. --fs-meta is the system's small-text role: this page
   spends it on every meta row, and this row is one. The left group holds ONE item
   now — the email — since the phone number and the city beside it were removed
   with the footer's animation; what is left of the gap is what holds the two legal
   links apart. */
.footer__contact,
.footer__legal {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--s-1) var(--s-4);
  margin: 0;
  font-size: var(--fs-meta);
}

/* A phone stacks the two groups rather than holding them apart, and it is
   arithmetic rather than taste: the email's own box is 193.11px and the two legal
   links come to 174.52px at --fs-meta in this face, so the row needs 391.63px with
   the --s-5 gap between the groups where a 390px window leaves the column 342.00px
   (390 less the page's own two 24px insets). It would therefore wrap on its own —
   and a wrapped `space-between` row puts a second line's only item at the LEFT edge,
   which would read as a third thing rather than as the second group. Two groups, a
   line each. */
@media (max-width: 47.999rem) {
  .footer__info-row {
    flex-direction: column;
    align-items: flex-start;
  }
}

/* --- The custom cursor -----------------------------------------------------
   The reference's pointing device, and the page's own: a small filled disc that
   INVERTS whatever is behind it — BLACK on the page's white, WHITE over the
   projects band's black. The disc is white and `mix-blend-mode: difference`
   subtracts it from its backdrop, so the pair is one declaration rather than two
   and neither colour is written down. The cue carries the same declaration at the
   other end of the page, for the same reason: see the cue block above.

   IT REPLACES THE SYSTEM CURSOR RATHER THAN JOINING IT. Every `cursor:` the
   system would otherwise draw is turned off — including the `pointer` this page's
   links and buttons carry (system/components.css) and the `default` a row with no
   target carries — because a disc beside an arrow is two pointers. That hiding is
   SCOPED to `html[data-cursor]`, which portfolio.js sets when it finds the disc
   and only then: this file is the frame all five pages share and the four project
   pages carry no disc, so an unscoped `cursor: none` would take their pointer away
   and give them nothing back. The same promise as `html.js` — nothing here happens
   on a page without the markup or without a script to drive it.

   POINTER-ONLY. The disc is a mouse's cursor; a finger has none. So it is drawn,
   and the system cursor hidden, inside the same `hover`/`pointer` query the project
   cards use — the one place on this page that says what "a pointer" means — and
   nowhere else. There is no width term: a cursor is a cursor at every viewport,
   unlike a card, which also needs the band to be wide enough to carry it.

   ABOVE EVERYTHING, AND BLENDED WITH EVERYTHING. z-index 99999 puts the disc over
   every layer this page has (the preloader's 9999, the header's 5, the cue's 4, a
   project card's 3), and because nothing between <html> and the disc opens a
   stacking context or isolates, the backdrop the blend reads is the whole page
   rather than a box of it. `position: fixed`, with the viewport's top-left origin,
   is what lets portfolio.js's translate() be the pointer's own clientX/clientY; the
   disc is centred on that point by the second translate in the same write, so this
   file leaves `transform` alone and only says how big the disc is.

   INVISIBLE UNTIL THE POINTER IS SEEN. `opacity: 0` here, flipped to 1 by the
   script the first time the mouse moves: a disc parked at the origin before then
   would be a quarter-circle stuck in the window's corner, and the fade in and out
   is the `opacity` transition declared below. Growing over something clickable is
   `[data-hover]` — an attribute rather than a class, because that is how this page
   hands state to CSS (data-open, data-scrolled, data-retired, data-painted); the
   script only says when, this file says how much. */
.custom-cursor {
  /* Nothing is drawn anywhere until a pointer-bearing script arms this — see the
     block in portfolio.js, which reads this answer back rather than restating the
     query below. The same shape as .row__preview's own base rule. */
  display: none;
}

/* The one place "a pointer" is written down, and the reason the disc exists at
   all. The project cards' query uses the same two terms (with a width added); here
   they are the whole of it. */
@media (hover: hover) and (pointer: fine) {
  .custom-cursor {
    display: block;
    position: fixed;
    top: 0;
    left: 0;
    width: 14px;
    height: 14px;
    border-radius: 50%;
    background: #ffffff; /* the base difference subtracts from the page */
    mix-blend-mode: difference; /* BLACK on white, WHITE on black */
    pointer-events: none; /* never the thing a click lands on */
    z-index: 99999; /* over every layer this page has */
    transform: translate(-50%, -50%);
    opacity: 0; /* drawn only once the pointer has been seen */
    transition: width 0.2s ease, height 0.2s ease, opacity 0.2s ease;
    will-change: transform;
  }

  /* The promise the disc makes, kept only on the pages that can see it: no system
     cursor anywhere, so the disc is the only pointer. Stated on <html> and its
     descendants rather than on body/a/button/* so the four project pages — which
     never get the attribute — keep theirs. */
  html[data-cursor],
  html[data-cursor] * {
    cursor: none !important;
  }

  /* Shown by the script on the pointer's first move, and again whenever the
     pointer comes back in the window (it fades out on the way to another window —
     see the cursor block in portfolio.js). */
  .custom-cursor[data-visible] {
    opacity: 1;
  }

  /* Over an <a> or a <button> — the two things here that go somewhere or do
     something — the disc grows, and the destination announces itself under the
     pointer. White stays white; difference still does the colour. */
  .custom-cursor[data-hover] {
    width: 40px;
    height: 40px;
  }
}

/* A touch screen has no pointer to replace: nothing to hide and nothing to draw.
   The query above already excludes it — this is the same answer stated as a net,
   in the spelling the row previews use, so a later change to the draw query cannot
   leave a disc drawn on a finger. */
@media (hover: none) {
  .custom-cursor {
    display: none !important;
  }
}

/* --- Narrow screens -------------------------------------------------------
   A drawer's own treatment, and the only thing left that is width-dependent: the
   same list stacked in a column with comfortable targets, which is what a menu
   wants on a phone. Written against the list rather than against either menu,
   because both menus are the same drawer opening a different way — the header's
   drops down off the bar, the footer bar's rises over it — so nothing here
   depends on which is which. */

@media (max-width: 47.999rem) {
  .js .nav__list {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--s-3);
  }

  .js .nav__link {
    font-size: 1.25rem; /* comfortable targets inside a drawer */
  }
}

/* --- A phone's own spacing ------------------------------------------------
   THE NARROW SHAPE GETS ITS OWN NUMBERS, and they are the owner's, taken from a
   read of the page on a phone rather than from the reference's desktop frame.
   The wide layout's leads are shares of the WINDOW (`vh`) or of the headline —
   the right shape for pushing copy past a 1440 frame's own furniture — and at
   390x844 the same terms land far apart instead of proportional. Measured before
   this block existed, at 390x844:

     the portrait -> the statements ........ 652.39px   (the pinned stage's frame)
     the statements -> me.png ............ 1708.41px   (the drum's own length)
     the band's top edge -> PROJECTS ....... 181.91px   (--work-lead at 18vh)
     PROJECTS -> SMART HOME ................. 6.39px   (the list's --s-4)
     ECONUDGE -> the band's bottom edge .... 179.91px   (--work-band-pad)
     the contact block's own padding ... 101.28/101.28px (12vh of the window)

   Two of those read as mistakes rather than as air — a screen of empty page
   between the portrait and the sentences, and a black band that is mostly paper —
   and the other four are the owner's own bands, named below at each rule.
   Everything here is SPACING: no type ramp, no colour, no width of the page's
   own column. The headline's ramp, the rows' one-name-per-line stack (measured
   `1111` at 320 / 360 / 390 / 414 / 600 / 768 / 1024 / 1440 — already true and
   untouched, in German too), the band's --s-7 insets and the footer's seam are
   all exactly what they were, and the wide layout is untouched: the query is the
   same 47.999rem the footer row and the drawer use, and the block sits at the END
   of the file so same-specificity overrides win by order. */

@media (max-width: 47.999rem) {
  /* The two lead tokens the hero's and the band's own rules read, restated at
     their FLOOR rather than left fluid:

       --hero-lead  max(45vh, calc(100vh - 30rem)) — a share of the window,
                    because that is what a wide screen needs to push the copy past
                    a 1440 frame's furniture (see :root). On a phone that
                    furniture is the pinned stage's FRAME, which the hold's own
                    narrow rules drop below 64rem (see "The pin needs a beside"),
                    so the lead-in has nothing left to clear: 4rem is the
                    owner's number, and with the grid's own 2.5rem row gap it puts
                    the statements 104px under the portrait.

       --work-lead  clamp(7rem, 18vh, 14rem) is 142px at 320 (its 7rem floor) and
                    181.91 at 390. The black above the heading is this token PLUS
                    the band's own --s-6 (32px), and --work-band-pad is that same
                    sum — so one number sets both edges, which is the whole point
                    of the token. 5rem + 2rem = 7rem, i.e. the clamp's own floor:
                    the phone gets 112px of band air top and bottom, inside the
                    owner's 6-8rem. */
  :root {
    --hero-lead: 4rem;
    --work-lead: 5rem;
  }

  /* The hero's own tail: clamp(3rem, 8vw, 9rem) is 48px at 320-600 (8vw does not
     reach 3rem until a 600px window), where the owner reads 4-6rem. 4rem is the
     low end, and it made the picture-to-band seam 176px in total (this 64 plus
     the band's 112 above) rather than 230. THE SEAM IS LONGER THAN THIS NUMBER
     NOW: the block at the very END of this file opens a further 4-6rem of white
     above the band below the 64rem gate, so the seam reads 240px at 390x844 and
     176px here plus 82 at 1023. This declaration is still the number it states —
     the air the hero's own tail contributes — and the seam's end-to-end figure
     lives with the block that finishes it. */
  .hero {
    padding-block-end: 4rem;
  }

  /* The row gap, and on a narrow screen it is the space between the hero's parts:
     the base clamp(1.5rem, 4vw, 4rem) is 2.25rem at 900 and 1.5rem at a phone width
     — 4vw is 15.6px at 390 and 12.8px at 320, under the clamp's floor — where
     the owner reads the portrait and the statements as hanging too close. The
     owner's 2.5rem, and with --hero-lead above it the seam the owner SET here is
     the portrait -> statements one: 104px. Stated on the narrow block rather than
     by moving the clamp, because the clamp's 4rem end is the wide layout's own
     COLUMN gap — a different space with a different job. The seam on the OTHER
     side of the statements is not this gap's and is not 2.5rem: it is stated on
     the picture's own box, immediately after this rule. */
  .hero__grid {
    gap: 2.5rem 0;
  }
  /* THE SEAM THE OWNER SET BETWEEN THE STATEMENTS AND THE PICTURE, and the one
     place on a phone where the space that matters is stated on the element below
     rather than on the row gap above it. Above 64rem the pair are two columns of
     ONE row, so no row gap runs between them at all; narrow, the row gap above
     this box is the portrait's seam — 2.5rem, the owner's number — and the same
     2.5rem under the statements is 40px between a sentence and the photograph
     that answers it, which is the pair the owner reads as ONE unit and as too
     far apart. 1.75rem is that pair's own number, so the rule states the seam and
     the gap it answers rather than restating the gap: 1.75rem less the grid's
     2.5rem, i.e. 12px off this box's top edge.

     A negative start margin looks like a trick and is not: in a grid an item's
     row is sized by the item's MARGIN box, so the row is 12px shorter than this
     box and the box begins 12px above the row's own start — the seam becomes the
     28px and nothing else about the picture moves (its height is untouched: a
     stretched item with a start margin keeps its own bottom edge, and `min(88vw,
     100%)` still sets its width). The other 2.5rem above it is deliberately left
     alone: it is the seam the portrait hangs on, and a gap rewritten to 1.75rem
     would drag the portrait's own air down with it. */
  .hero__panel {
    margin-block-start: calc(1.75rem - 2.5rem);
  }

  /* The statements at the owner's 1.3-1.5rem, where the base ramp's floor is
     1.25rem (20px — its smallest value anywhere on the page, and a phone is
     exactly where it takes it). The same `3.6vw` term, so the two curves meet
     rather than step: 20.8px at 320-390, 21.6 at 600, and this declaration stops
     mattering at 667, where 3.6vw reaches 1.5rem. The 500 weight, the 1.2
     line-height and the 46ch measure are the base rule's and stay. */
  .hero__intro {
    font-size: clamp(1.3rem, 3.6vw, 1.5rem);
  }
  /* Both photographs, at a share of the WINDOW rather than at the token cap:
     min(100%, 20rem) is 320px from a 390px window up, which is 82% of the window
     at 390, 77% at 414 and — once the cap is the binding term rather than the
     column — 53% at a 600-wide phone-shaped one, so the smaller phone gets the
     SMALLER share of the page. The owner's 85-90% is stated as min(88vw, 100%):
     88vw is 343px at 390 and 364 at 414, and the `100%` term is what keeps the two
     widths whose column is narrower than 88vw (272px at 320, 312 at 360) from
     overflowing — they take the column, which is 85-87% of those windows
     themselves. `justify-self: center` (the base rules') still centres both, and
     the 3deg tilt still paints inside the slack: scrollWidth == clientWidth at
     every width in the sweep, in both languages. */
  .hero__media,
  .hero__panel {
    inline-size: min(88vw, 100%);
  }

  /* The heading's own lead-in — --s-4 (16px) where the owner reads the four names
     hanging too close under PROJECTS (measured 6.39px of ink). Their 6-8rem, at
     the low end, which is deliberately the same size as the air the band opens
     with ABOVE the heading (--work-lead + --s-6 = 7rem): the heading then sits in
     the middle of its own black rather than at the bottom of it. The list's own
     --work-row-gap (28px of ink between two names) is the base rule's and is
     untouched — that is the tight stack the owner asked to KEEP, and every name is
     one line at every width and in both languages. */
  .work__list {
    margin-block-start: 6rem;
  }

  /* The contact block's own padding: clamp(6rem, 12vh, 8rem) is 101.28px at
     390x844, where the owner reads 5rem. The seam BELOW the block is white and
     untouched — --footer-lead (20vh = 168.80px at 844) stands at every width — so
     the contact-to-caps distance on a phone is this 80px plus that 168.80. */
  .contact {
    padding-block: 5rem;
  }
}

/* --- Without a script -----------------------------------------------------
   `html.js` is set inline in index.html, before the first paint, so nothing
   above that hangs off it applies without a script: the burger is never drawn
   (an inert disclosure is worse than none), the nav is never a drawer, and its
   two destinations keep a visible place in the bar instead. The page loses the
   panel, not the navigation — and it is why the nav is inside the header at all
   rather than being assembled a second time by the burger's script.

   The nav takes its own ROW rather than the bar's third slot: two links between
   the name and the toggle would push the name off the centre line on a wide
   screen and overflow a 320px one. Spanning the row, the list wraps inside the
   column the name is already on. */

html:not(.js) .nav {
  grid-column: 1 / -1;
  grid-row: 2;
  min-inline-size: 0;
}

/* The band's container is neutralised here, and only here: with no script the
   nav sits INSIDE .header__inner, which is already a container, so the padding
   that puts the destinations on the burger's column while the nav is a
   full-bleed band would indent them twice and off the name's column instead. */
html:not(.js) .nav .container {
  padding-inline: 0;
}

html:not(.js) .nav__list {
  flex-wrap: wrap;
}

html:not(.js) .header__inner {
  row-gap: var(--s-2);
  padding-block: var(--s-2);
}

/* --- The two seams the owner read as cramped, widened below the gate -------
   THE ASK, and it is two seams rather than one: on a tablet or a phone — anything
   under the desktop layout — the hero wants more air between its photograph and
   the words under it, and more air between the statements' own picture and the
   black PROJECTS band below that. Both seams were measured and set in earlier
   passes; at these widths the owner now reads both as too tight.

   ADDITIONS, NOT RESTATEMENTS, and that is the whole of why neither seam's own
   numbers move: the air below is spent ON TOP of what is already there — the
   grid's row gap plus --hero-lead under the portrait, and .hero's own tail
   (clamp(3rem, 8vw, 9rem), 4rem below 48rem) under me.png. No font, no colour, no
   picture's size, no token above this block and no rule inside the band moves;
   in particular THE BAND'S OWN PADDING-BLOCK IS NOT TOUCHED. The new space above
   the black is white page, which is a margin on .work's own side of the seam: a
   padding there would paint 64-82px of extra black instead (the same distinction
   --work-band-pad is stated on the band for; see :root).

   ONE NUMBER, SPENT TWICE, so the two seams cannot drift apart the way two
   hand-written values would. It is the owner's 4-6rem, with the clamp doing the
   work at both ends of that range:

     clamp(4rem, 8vw, 6rem)   4rem from 320 up to 800 — 8vw does not reach the
                              floor until an 800px window, so the narrowest phone
                              gets 64px and no less, which is the FLOOR of the
                              owner's range — then 4.5rem at 900 and 5.12rem at the
                              last width the block reaches (1023.98). The 6rem
                              CEILING is never spent here and is stated so that a
                              later change to the gate cannot blow past the
                              owner's top end.

   NOT A `vh` TERM, which is --hero-lead's own argument read backwards: a share of
   the WINDOW is the right term where a wide screen has to push copy past a 1440
   frame's furniture, but these two seams live inside the page's own column, and
   the window a phone has is SHORT — a `vh` term would take the air away exactly
   where the page is tightest. `vw` tracks the column instead.

   THE MECHANISM IS THE OWNER'S OWN, chosen from what this structure offers:

     .hero__intro  the description's own `padding-block-start`, and PADDING rather
                   than a margin is not pedantry: the copy sits in a grid row with
                   me.png in the row BELOW it, so a start MARGIN would grow that
                   row and drag the picture away from the sentences it answers —
                   the 1.75rem pair the narrow block sets on .hero__panel. Padding
                   opens the air inside the copy's own box, so the picture travels
                   down WITH the copy and the statements-to-picture seam is
                   exactly what it was.

     .work         a start MARGIN, because .work IS the black surface (see .work):
                   its padding-block is the owner's own and stays untouched, and
                   only the band's own start margin can open white above it.

   THE GATE IS 63.999rem AND NOT 1024px, deliberately: 64rem is the page's own
   desktop gate — .hero__grid's two columns, the pin's wide-only rules, the
   footer's info row — so the desktop layout is what applies AT 1024px, and this
   block must not reach it. Written in rem to match every other gate in the file,
   and placed after the phone block so that where the two overlap (under 48rem)
   this one wins by order, which is how that block's own note says narrow
   overrides are meant to land.

   MEASURED, at the sizes this block is for — the air the block ADDS, and then the
   seam end to end (what was already there plus the addition):

                         375x667      768x1024     1023x800
     portrait -> copy   +64 → 167     +64 → 638    +82 → 482     (to the copy's
                                                                    first LINE)
     me.png -> the band +64 → 128     +64 → 125    +82 → 164

   and at 1024x800 and 1440x900 every one of these reads exactly what it read
   before the block existed (622.50 / 621.50 / 1441.91 and 752.27 / 751.27 /
   1645.19), with scrollWidth == clientWidth at all seven widths in the sweep. */

@media (max-width: 63.999rem) {
  :root {
    --narrow-seam-air: clamp(4rem, 8vw, 6rem);
  }

  /* The portrait -> the description. The copy's own box opens here; the lead-in
     above it (--hero-lead, restated at 4rem under 48rem) and the row gap above
     that are untouched, so this is air added to the seam rather than a seam
     restated. */
  .hero__intro {
    padding-block-start: var(--narrow-seam-air);
  }

  /* me.png -> the black band. White page above the surface, and the band's own
     PADDING-BLOCK keeps its two values: this is the seam between the two
     sections, measured from the picture's box to the band's top edge. */
  .work {
    margin-block-start: var(--narrow-seam-air);
  }
}
