blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git

src/styles/base.css (45188B)

      1 /* Baseline typography, the pieces every route shares (the shell, the header and footer
      2    bars), and the code plates and diagrams any Markdown page can carry. Page layout lives
      3    with each template. */
      4 *, *::before, *::after { box-sizing: border-box; }
      5 
      6 /* `scrollbar-gutter: stable` is load-bearing, not a polish detail: it is half of what
      7    makes the header sit in the same place on every route.
      8 
      9    The header pads itself with max(--root-pad, (100% - --w-page) / 2), and that `100%` is
     10    the body's content width, which a classic scrollbar narrows by 15px. /papers/, /talk/
     11    and /404 are short enough never to scroll; /writing/ and the long articles scroll at
     12    any common window height; /, /code/ and the short articles scroll or not depending on
     13    the window's height. Without the reserved gutter the
     14    bar centres against two different widths and lands 7.5px apart between a scrolling
     15    page and a non-scrolling one — 338 against 330.5 of padding at a 1700px window, which
     16    is visible the moment you click from /papers/ to /writing/. Reserving the gutter
     17    everywhere makes the two cases one case: every page lays out in the same 1685px,
     18    scrollbar painted or not.
     19 
     20    What it costs. A short page gives up 15px of usable width and its content steps 7.5px
     21    left — which is the point, since that is the step that puts it on the same edge as
     22    everything else. It reserves nothing where the scrollbar is an overlay (every touch
     23    browser, and macOS unless "always show" is set), so no mobile width pays for it.
     24    And it cannot create horizontal overflow: it narrows the ICB, and every box on
     25    this site is sized from that width rather than from 100vw — verified 0px of
     26    documentElement.scrollWidth overhang on all seven routes at 320 through 2560.
     27 
     28    On `html` rather than `body` because the viewport takes its scrollbar-gutter from the
     29    root element's box; declared on body it would apply to body's own scroll container,
     30    which is not the one painting the bar. */
     31 html { -webkit-text-size-adjust: 100%; scrollbar-gutter: stable; }
     32 
     33 body {
     34   margin: 0;
     35   background: var(--page-bg);
     36   color: var(--page-fg);
     37   font-family: var(--font-body);
     38   font-size: var(--fs-body);
     39   line-height: var(--lh-large);
     40   /* No font-optical-sizing: neither Lora nor Pretendard has an opsz axis, so it would be
     41      inert - and `auto` is the initial value in any case. Left out rather than implying an
     42      axis exists. */
     43   -webkit-font-smoothing: antialiased;
     44 }
     45 
     46 h1, h2, h3, h4, h5, h6 {
     47   font-family: var(--font-display);
     48   font-weight: 500;
     49   color: var(--black);          /* headlines stay pure black */
     50   text-wrap: balance;
     51   margin: 0;
     52 }
     53 h1 { font-size: var(--fs-h1); line-height: var(--lh-tight); letter-spacing: -0.015em; }
     54 h2 { font-size: var(--fs-h2); line-height: var(--lh-snug); }
     55 h3 { font-size: var(--fs-h3); line-height: var(--lh-snug); }
     56 h4 { font-size: var(--fs-h4); line-height: var(--lh-close); }
     57 h5 { font-size: var(--fs-h5); line-height: var(--lh-medium); }
     58 h6 { font-size: var(--fs-h6); line-height: var(--lh-medium); }
     59 
     60 p { margin: 0; }
     61 /* See --sp-para in tokens.css: 0.7em, the reference's own paragraph gap, rather than the
     62    24px of --sp-24. Written as a top margin on the second of a pair rather than as a
     63    bottom margin on every paragraph, so a paragraph that ends a block contributes nothing
     64    to the gap below it and the block rhythm stays the one thing that sets it. */
     65 p + p { margin-block-start: var(--sp-para); }
     66 
     67 /* Links are crimson at the weight of the text around them, and carry no rule at rest or on
     68    hover — the reference's own treatment, measured on its article pages: rgb(160,32,32),
     69    weight 400 against a 400 paragraph, text-decoration none. Weight 700 plus an underline
     70    would make a link a different piece of type from the sentence holding it.
     71 
     72    One thing to know about this, stated rather than discovered later. Colour alone is a
     73    weaker signal than an underline, and WCAG's technique for colour-only links wants 3:1
     74    between the link and the text beside it. --crimson on this site's black body measures
     75    2.81:1 — under that line, though the same crimson is 7.25:1 against the page and so
     76    never hard to read. The reference is further under it at 2.06:1. Nothing here closes
     77    the gap at rest, by design: the hover step below lifts a hovered prose link to 3.84:1,
     78    and keyboard users get the global :focus-visible ring near the top of this file, which
     79    rings these links like every other control on the site. The 2.81:1 at rest is the
     80    accepted cost for a reader who only looks.
     81 
     82    Because `font-weight` is `inherit`, a link in a heading takes the heading's own 500
     83    with no rule of its own. */
     84 a { color: var(--crimson); font-weight: inherit; text-decoration: none; }
     85 
     86 /* Hover is a colour step, the reference's own: driven over CDP on its article page, its
     87    body links move rgb(160,32,32) -> rgb(192,48,48), with no rule and no background.
     88    --crimson-lift is the same step applied to --crimson (see tokens.css).
     89 
     90    So: colour only, and no transition — the step is instant, as the header nav's colour
     91    step is. Gated on a real pointer, because a bare :hover latches after a tap and would
     92    leave the last link touched sitting in the hover colour. Keyboard users get the global
     93    :focus-visible ring rather than this, which is the stronger signal of the two. */
     94 @media (hover: hover) {
     95   :is(.prose, .about) a:hover { color: var(--crimson-lift); }
     96 }
     97 
     98 :focus-visible { outline: 3px solid var(--ppc-ui); outline-offset: 2px; }
     99 
    100 .skip { position: absolute; left: -9999px; }
    101 .skip:focus { left: var(--root-pad); top: var(--root-pad); z-index: 10;
    102   background: var(--page-bg); padding: var(--sp-30) var(--sp-40);
    103   border: 2px solid var(--ppc-ui); }
    104 
    105 /* Read by assistive tech, drawn nowhere. The callers: the <h1> of each top-level page
    106    (About, Papers, Talk, Code, Writing); the comma between the byline's name and date;
    107    the live region through which the code plate's copy control announces its result;
    108    and the <h2 class="sr-only" id="footnote-label">Footnotes</h2> the Markdown
    109    processor emits above the footnote list, which without this rule would draw as a
    110    visible English headline in the middle of a Korean article. `clip-path` alone would do it in
    111    every engine this site targets; the width, height and margin are the belt that keeps
    112    the box from being a focus-scroll target. Not `display: none`, which would take it out
    113    of the accessibility tree as well. */
    114 .sr-only {
    115   position: absolute;
    116   inline-size: 1px;
    117   block-size: 1px;
    118   margin: -1px;
    119   padding: 0;
    120   border: 0;
    121   overflow: hidden;
    122   white-space: nowrap;
    123   clip-path: inset(50%);
    124 }
    125 
    126 /* Shell */
    127 /* The shell is sized to the page box (rail + gap + measure) with the padding added on top
    128    of it. box-sizing is border-box, so a max-width of the page box alone would take
    129    --root-pad out of the measure; added on top, --root-pad cannot shrink the measure however
    130    it is tuned — and, by the same token, cannot shrink the side gutter either: it lives
    131    inside this max-width, so it moves only the edge of main's own box, which paints
    132    nothing, not where the text starts. See the --w-content note in tokens.css for the
    133    cancellation. */
    134 main {
    135   max-width: calc(var(--w-page) + 2 * var(--root-pad));
    136   margin-inline: auto;
    137   padding-inline: var(--root-pad);
    138 }
    139 
    140 /* The header is full-bleed, `main` is not.
    141    Capped at main's width, the header's bottom rule would float in from each edge — a
    142    short line across the middle of the screen rather than a boundary. The box spans the
    143    viewport so the rule and the shadow reach both edges, while the padding reproduces
    144    main's max-width: above `--w-page` + 2 × `--root-pad` it resolves to
    145    (100% − --w-page) / 2 and centres a --w-page content box, below it falls back to
    146    --root-pad.
    147 
    148    THE CONTRACT IS THAT THIS BAR NEVER MOVES. Same left edge, same right edge, symmetric
    149    padding, on every route and at every width — that is what a reader crossing from
    150    /papers/ to an article is entitled to, and it is the only thing this expression owes.
    151    Two things can break it, and both are handled:
    152 
    153      - the scrollbar. `100%` is the body's content width, so without a reserved gutter
    154        the bar centres against 1700 on a short page and 1685 on a scrolling one.
    155        `scrollbar-gutter: stable` at the top of this file reserves the gutter on every
    156        page, so there is one width.
    157      - a page restating this padding. The article template must not restate it against
    158        its own --page-left: that slides the whole bar ~50px right and makes it asymmetric
    159        (280 left / 181 right at 1500) inside the 1440–1584 band, on article pages only.
    160 
    161    What the bar does not promise is that the wordmark always sits on `main`'s left edge.
    162    It cannot, because between 1440 and 1584 an article pushes `main` right to seat the
    163    contents rail. Only `main` moves; the bar stays where it is, and the two edges part by
    164    79.5px at a 1440px window, 49.5 at 1500, 7.5 at 1584, and 0.00 from a 1599px window up
    165    and below 1440. That is the trade the fixed bar was chosen over the shared edge for.
    166    See the "which presentation" block in src/pages/writing/[year]/[slug].astro. */
    167 /* Height follows the reference: 16px of padding above and below a single ~27px row, for a
    168    60px bar. Padded 48px above instead of 16 it would pass 90px, which reads as a section
    169    of the page rather than a rule across the top of it.
    170 
    171    That row is ONE line box shared by two type sizes, and the line-height below is what
    172    makes it one. The reference declares `line-height: 170%` on body, which computes to a
    173    fixed 27.2px at the declaring element and inherits as a LENGTH, so its 24px title and
    174    its 19.2px nav sit in the same 27.2px row and the bar is 16 + 27.2 + 16 + 1 = 60.2px.
    175    Our --lh-large is a ratio: it resolves per element, the larger slot then sets the row,
    176    and a 1.25x title would push this bar to about 71px. calc() against --fs-nav
    177    reproduces the reference's inheritance instead — 19.07 x 1.4 = 26.70px at a 1440px
    178    window, for a 59.70px bar.
    179 
    180    align-items is centre rather than baseline, and that is deliberate: both slots have
    181    the same line box, so centre, start and stretch are the same pixel, while baseline
    182    would step one of them off the row. The reference does not baseline-align either — its
    183    title and nav are floats in a shared line box, and their baselines are measurably 1.5px
    184    apart.
    185 
    186    row-gap is smaller than column-gap because the wrapped state is a real state here
    187    (below 450px the nav always takes a row of its own; see the max-width: 449px query
    188    below) and it should not inherit the desktop gap. See the note on the nav gap below.
    189 
    190    The rule itself is the Gazette's, measured off news.harvard.edu/gazette: 1px solid
    191    #a51c30 — the same crimson this site already carries as its brand, and the Gazette's
    192    masthead ground is rgb(255,251,244), the identical cream to --page-bg here. It is
    193    --crimson rather than --ppc-ui on purpose: --ppc-ui is the per-section token, and the
    194    Gazette's rule does not change colour by section. Nor would --ppc-ui vary here the way
    195    its name suggests: only the article template passes `section`, so every other route
    196    renders is-slate and --ppc-ui there is slate, which is also the resting nav colour. Even
    197    if those pages did pass one, a header accent that changes per section is the thing this
    198    line is avoiding. The hover rule further down follows the same reasoning. */
    199 body > header { padding-block: var(--sp-40);
    200   padding-inline: max(var(--root-pad), calc((100% - var(--w-page)) / 2));
    201   border-bottom: 1px solid var(--crimson);
    202   box-shadow: 0 1px 12px rgb(0 0 0 / 0.07);
    203   line-height: calc(var(--fs-nav) * var(--lh-medium));
    204   display: flex; flex-wrap: wrap;
    205   column-gap: var(--sp-40); row-gap: var(--sp-30);
    206   align-items: center; justify-content: space-between; }
    207 main { padding-block: var(--sp-64) var(--sp-120); }
    208 
    209 /* Site footer. No rule above it and no pushing to the bottom of the viewport.
    210 
    211    Neither works here. A rule would repeat a mark the crimson line under the header
    212    already owns, on a copyright notice that does not carry that weight. Pinning it to the
    213    bottom needs `body` to be a flex column, and that breaks /writing/ outright: `main`
    214    carries `margin-inline: auto`, an auto cross-axis margin cancels `align-items: stretch`,
    215    so `main` collapses to fit-content — and `.index` inside it is a `container-type:
    216    inline-size` box, which may not size from its contents, so it computes to width 0 and
    217    the entry titles wrap one word per line. On a short page the notice simply follows the
    218    content, which is what the reference does too.
    219 
    220    Padding is --sp-40 rather than --sp-48: one line of --fs-small needs air, not a band. */
    221 .site-foot {
    222   display: flex;
    223   flex-wrap: wrap;
    224   align-items: baseline;
    225   justify-content: space-between;
    226   column-gap: var(--sp-48);
    227   row-gap: var(--sp-30);
    228   padding-inline: max(var(--root-pad), calc((100% - var(--w-page)) / 2));
    229   padding-block: var(--sp-40);
    230   font-size: var(--fs-small);
    231   line-height: var(--lh-base);
    232   letter-spacing: 0.02em;
    233   color: var(--page-meta);
    234 }
    235 
    236 /* Copyright left, profiles right — the header's own arrangement, so the two bars that
    237    bracket the page read as a pair rather than as two unrelated strips. The gap is the
    238    header nav's, derived from the same token so the two rows of links stay in step. */
    239 .site-foot p { margin: 0; }
    240 .site-foot__social { display: flex; flex-wrap: wrap; column-gap: calc(var(--fs-nav) * 0.8); }
    241 /* Size and case come back from .site-foot, not from the global `nav a` rule in the
    242    page-name and navigation section below. That selector is unscoped — it reaches every
    243    <nav> on the site — so without this these links would arrive at --fs-nav (19.2px) in
    244    small-caps beside a 14px copyright line. Each nav it reaches restates what it needs:
    245    these links here, and .toc-rail__link in the article template. small-caps is dropped
    246    with the size: at one shared font-size its lowercase would still draw shorter than the
    247    line it is meant to match. */
    248 .site-foot__social a {
    249   color: inherit;
    250   font-size: inherit;
    251   font-variant-caps: normal;
    252   letter-spacing: inherit;
    253   font-weight: 400;
    254   text-decoration: none;
    255 }
    256 /* The rule under these is the header nav's gesture, drawn by the wipe rule further down
    257    (see "The wipe"). --wipe-y lifts it one pixel, written as 0.07em so it tracks
    258    --fs-small's clamp instead of drifting against it down to 11px. Measured at 1700, the
    259    lifted rule's top row is still two pixels under the row a 1px auto underline would
    260    take: these links are flex items, so the box the rule hangs from ends at the bottom of
    261    the line box rather than at the bottom of the glyphs.
    262    The colour step sits inside the pointer gate together with the rule: this is the
    263    header's "the item takes the accent" gesture, and a colour that latches after a tap on
    264    a touch screen is exactly the defect the gate exists for. */
    265 .site-foot__social a { --wipe-y: calc(100% - 0.07em); }
    266 .site-foot__social a:focus-visible { color: var(--crimson-dark); background-size: 100% 2px; }
    267 
    268 @media (hover: hover) {
    269   .site-foot__social a:hover { color: var(--crimson-dark); background-size: 100% 2px; }
    270 }
    271 
    272 /* Page name and navigation.
    273    One family, one weight, one small-caps texture — and TWO sizes. The reference does not
    274    set its title and its nav identically: measured, the title is 24px with .15em (3.6px)
    275    of tracking and the nav is 19.2px with none — a 1.25x step, with the tracking on the
    276    title alone. Flattening the two would remove the only size contrast in the bar and
    277    leave the left slot at 42px of ink against the reference's 108; a quarter of that
    278    reference width is letter-spacing. 1.25x of --fs-nav lands 23.8px at a 1440px window
    279    and 20.2px on a 375px phone, against the reference's flat 24 at both.
    280 
    281    The tracked size is written as calc(--fs-nav * 1.25) rather than picked off the
    282    heading ladder: the ladder's steps are not parallel to --fs-nav, so no step holds
    283    1.25x across widths — --fs-h3 is 1.28x of it at 1440 and 1.31x at 375, --fs-h4 1.15x
    284    and 1.21x. The relationship is the thing being matched, not a number.
    285 
    286    Lora ships no `smcp`, so the browser synthesises these. That is not a reason to avoid
    287    them: synthesis is how small-caps reach the screen on most faces, and the stroke holds
    288    up — at 400 Lora's 'o' is 2.69 contrast with a .0380em thin stroke, which at 19px is a
    289    0.72px hairline, and the reference ships exactly this - synthesised Lora small-caps -
    290    at exactly this size. Lora has no opsz axis, so one measurement covers every size
    291    here. */
    292 body > header > .page-name, nav a { font-family: var(--font-body); font-size: var(--fs-nav);
    293   font-variant-caps: small-caps; letter-spacing: normal; font-weight: 400;
    294   text-transform: none; text-decoration: none; }
    295 body > header > .page-name { font-size: calc(var(--fs-nav) * 1.25); letter-spacing: 0.15em; }
    296 nav { display: flex; gap: var(--sp-50); flex-wrap: wrap; }
    297 /* .8em is the reference's own nav spacing (`div.links a { margin-right: .8em }`), and as
    298    an em it tracks the type instead of drifting against it: 15.3px at a 1440px window,
    299    12.9px at 375, where the flat 24px gap of `nav` would be 1.26em of this type at 1440
    300    and 1.49em at 375 — loosest exactly where the row is tightest. Scoped to this header
    301    rather than edited into the rule above, because `nav` is an element selector and it
    302    reaches the other navs too: the footer's .site-foot__social, and on an article the
    303    .toc-rail, which escapes it with display: block. */
    304 /* The reference's own spacing, .8em, but resolved against the size it is .8em OF.
    305    Written as `0.8em` here it would resolve against <nav>'s inherited font-size, which is
    306    body's 16px because --fs-nav is set on `nav a` and not on `nav` — a 12.8px gap beside
    307    19px links, 0.67em of their type, which reads as cramped. The reference puts its .8em
    308    on the link itself (`div.links a { margin-right: .8em }`) at 19.2px, which is 15.36px;
    309    calc against the token keeps that ratio at every width. */
    310 body > header nav { column-gap: calc(var(--fs-nav) * 0.8); row-gap: calc(var(--fs-nav) * 0.4); }
    311 
    312 /* The bar's height must not depend on which page is open, and between 375 and 430px,
    313    left to wrap on its own, it does. The page name and the nav are two flex items in one
    314    wrapping row, so whether the nav folds to a second line is decided by how wide the name
    315    is — and the name changes per route ("Code" against "Hyukjin An" is 6 characters and
    316    about 80px). Measured across all seven routes without this query: identical two-row
    317    bars at 320 and 360, identical one-row bars from 450 up, and a 375-430 band where the
    318    short names fit beside the nav and the long ones do not — 55.55px against 88.81px
    319    depending on the page.
    320 
    321    Below the width where the LONGEST name still fits, every route stacks. The nav taking a
    322    full row is not a fallback here; it is the narrow layout, and applying it to all seven is
    323    what makes the bar the same object everywhere. 450px is where the sweep showed the last
    324    route stop folding, so the stack ends one pixel under it. */
    325 @media (max-width: 449px) {
    326   body > header nav { flex-basis: 100%; }
    327 }
    328 nav a { color: var(--page-meta); }
    329 
    330 /* The current page is named in the left slot, so the nav does not mark it a second time.
    331    A painted mark — a 4px rule in --ppc-ui plus a colour change, laid down transparent on
    332    every item — fails on four counts. It duplicates a signal the slot already carries; the
    333    reference marks nothing in its nav for exactly that reason (all five of its links
    334    measure identical on the page they point at). It takes the bottom edge the hover rule
    335    needs, so the current item would grow a 2px rule on top of a 4px one. Its 4px + 2px of
    336    padding is 6px of header height on every item on every page, enough to put this bar
    337    over 60px. And --ppc-ui resolves to slate on every route but the articles, i.e. it
    338    would draw the "you are here" mark in precisely the colour of the links that are not
    339    current. aria-current stays in the markup: it is the programmatic state, and it is not
    340    the same question as whether the state is painted twice. */
    341 
    342 /* Hover and focus: a 2px rule that grows out of the centre — the reference's one piece of
    343    motion, and the detail that reads first in a side-by-side. transform-origin is left at
    344    its 50% default, which is what opens it outward instead of wiping it in from one side.
    345    The reference's scaleX(.99) is an anti-aliasing dodge worth 0.29px a side, not a design
    346    decision, so this goes to a full 1.
    347 
    348    It costs nothing in layout: absolutely positioned against the padding box, which with
    349    the shared line box above puts its top edge 4.7px under the baseline at a 1440px
    350    window — the reference's is 4px under, crossing the descenders of "Writing" by about a
    351    pixel. It is a rule through the type, not a clearance underline, and both are that on
    352    purpose.
    353 
    354    --crimson rather than --ppc-ui, for the reason given above the header rule: this bar's
    355    accent is the brand crimson at every route, and --ppc-ui here would be the resting nav
    356    grey on every route but the articles. The reference's own rule (#c44) is likewise a
    357    step off its link red, not the link colour itself.
    358 
    359    Reduced motion needs nothing here. The guard at the foot of this file already selects
    360    *, *::before, *::after — the transition degrades to an instant appear, which is the
    361    right outcome. Do not add a second query.
    362 
    363    Focus keeps the global 3px outline as well; the rule is additive, not a replacement.
    364    On its own it would be about 128px² of indicator on "Writing", roughly a third of the
    365    area WCAG 2.4.13 asks for, and a keyboard user could not tell focus from hover. */
    366 body > header nav a { position: relative; }
    367 body > header nav a::before {
    368   content: ''; position: absolute; inset-inline: 0; bottom: 0; block-size: 2px;
    369   background: var(--crimson); transform: scaleX(0);
    370   transition: transform 0.3s ease; }
    371 body > header nav a:focus-visible::before { transform: scaleX(1); }
    372 body > header nav a:focus-visible { color: var(--crimson-dark); }
    373 /* A bare :hover latches after a tap on a touch screen and the item stays marked until the
    374    next tap elsewhere, so the pointer states are gated on a pointer that can actually
    375    hover. :focus-visible above is not gated — that is the keyboard path.
    376    The colour moves toward the accent, which on this ground means DOWN in lightness and up
    377    in contrast (6.63:1 -> 10.80:1). The reference brightens instead, 7.71:1 -> 5.67:1,
    378    because it starts from a saturated red with headroom to lose; ours starts from a
    379    neutral, and lifting it would make the hovered item recede and fail AAA. The gesture
    380    being matched is "the item takes the accent", not the direction of the luminance step. */
    381 @media (hover: hover) {
    382   body > header nav a:hover { color: var(--crimson-dark); }
    383   body > header nav a:hover::before { transform: scaleX(1); }
    384 }
    385 
    386 /* --- The wipe: the header's rule, drawn as a background -----------------------------
    387    The footer's profile links take the header nav's gesture -- a 2px crimson rule opening
    388    from the centre over 0.3s -- without taking the header's technique. They are the only
    389    links outside the header that take this animated rule: body links step colour instead
    390    (see the `a` rules near the top of this file), the way the reference's body links do.
    391    `text-decoration: underline` cannot do this, because it has no in-between state to
    392    animate: the rule is either absent or whole.
    393 
    394    The header draws its rule with an absolutely positioned ::before at `bottom: 0`, which
    395    is one rectangle spanning the element's whole padding box. That is right for a nav
    396    item and wrong for any inline link that wraps: the rectangle lands under the last line
    397    only, at the width of the widest, and reads as a bottom border rather than an
    398    underline. A background gradient sized 0% -> 100% fails the same way by default,
    399    because the background positioning area of an inline box is the union of its
    400    fragments; `box-decoration-break: clone` hands every line fragment its own copy of the
    401    background, each opening from its own centre. The footer's links are flex items, so
    402    each is one block box and the clone has nothing to split, but it keeps the rule right
    403    on any inline link that takes it.
    404 
    405    On a single-line link the two techniques are pixel-identical, which is worth knowing
    406    before anyone tries to unify them: the absolute bar's containing block and the
    407    background positioning area are both the padding box. So the header keeps its
    408    ::before rather than being folded in here: converting it would trade a
    409    compositor-driven transform for a repainting background animation to save one
    410    declaration, and the header is the reference everything else is being matched to.
    411 
    412    The cost, stated plainly: a rule you draw yourself cannot skip ink. Where
    413    `text-decoration` breaks around a descender this runs straight through it -- the same
    414    look as `text-decoration-skip-ink: none`. That is the price of the gesture, and there is
    415    no third option: skip-ink belongs to text-decoration, and text-decoration has nothing
    416    to animate.
    417 
    418    There are two knobs and they are not interchangeable, which is a fact about
    419    `background-clip` rather than a matter of taste. --wipe-y moves the rule UP: the
    420    background is clipped to the border box, so a position past 100% is not drawn at all.
    421    Measured at 14px: `calc(100% + 0.14em)` renders nothing, and `calc(100% + 0.07em)`
    422    renders the rule 1px thick instead of 2px, silently clipped. To sit LOWER, a link adds
    423    `padding-block-end`, which moves the bottom of the box the background is clipped to.
    424 
    425    On an inline box that is free, and "free" is an A/B measurement rather than a claim
    426    about the spec: with the padding removed, every containing block, every sibling and
    427    document.scrollHeight come back identical to three decimals, and the single number that
    428    changes is the link's own border box. Vertical padding on an inline box does not enter
    429    its line box, so nothing around it moves; it grows only the box the background is
    430    clipped to, and incidentally the link's own hit target by a pixel or two downward. On a
    431    BLOCK box the same padding would move everything below it, which is why the footer
    432    links -- block-level, as flex items -- are tuned with --wipe-y instead.
    433 
    434    Where there is no exact position to match, the rule goes a pixel lower rather than a
    435    pixel higher: it cannot skip ink, so the extra pixel is the one that keeps it off a
    436    descender.
    437 
    438    No local reduced-motion query. The guard at the foot of this file already selects
    439    *, *::before and *::after, the transition degrades to an instant appear, and a second
    440    query here would be dead code. */
    441 .site-foot__social a {
    442   background-image: linear-gradient(var(--crimson), var(--crimson));
    443   background-repeat: no-repeat;
    444   background-position: 50% var(--wipe-y, 100%);
    445   background-size: 0% 2px;
    446   -webkit-box-decoration-break: clone;
    447           box-decoration-break: clone;
    448   transition: background-size 0.3s ease;
    449 }
    450 
    451 /* The one mode the background rule cannot reach, handed back.
    452 
    453    Forced colours drop `background-image` outright -- with forced-colors emulated,
    454    `background-image` computes to `none` -- so the rule that marks a hovered link is simply
    455    not painted. A colour step goes with it: `color` is forced to LinkText at rest and on
    456    hover alike. `text-decoration: underline` is what survives the mode, painted by the UA
    457    in the forced text colour, so it comes back here for hover and focus. Without it a High
    458    Contrast reader gets no hover affordance on these links at all, and no focus mark
    459    beyond the ring.
    460 
    461    The same trade [slug].astro makes for the task-list checkbox, the :target footnote and
    462    the rail's hairline: what is a background elsewhere is handed back as something the
    463    mode still paints.
    464 
    465    Only the footer pair lives in this file. The other forced-colours underlines -- on the
    466    Writing index's entry titles, the article's kicker, contents list and rail, and the
    467    project names on /code/ -- are written beside their own rules in the page that owns
    468    them, because an Astro page scopes a rule by adding its
    469    attribute to EVERY compound -- `.project__name[cid] a[cid]` -- which ties with a
    470    selector of the same shape written here and then wins on stylesheet order. Written
    471    here, the .project__name and .entry__title underlines compute
    472    `text-decoration-line: none` on hover in this mode. Inside the page the same scope
    473    lands on both rules and the pseudo-class settles it. .site-foot__social a has its rest
    474    rule in this file, above this block, so its pair belongs here.
    475 
    476    Two queries rather than one nested pair: the pointer half keeps the (hover: hover)
    477    gate, because a latched underline after a tap is the same defect in this mode as in
    478    any other.
    479 
    480    Not applied to the header nav, which shares the blind spot: its bar is a
    481    pseudo-element background and disappears here too. Changing the reference is a
    482    separate decision from matching it. */
    483 @media (forced-colors: active) {
    484   .site-foot__social a:focus-visible { text-decoration: underline; }
    485 }
    486 @media (forced-colors: active) and (hover: hover) {
    487   .site-foot__social a:hover { text-decoration: underline; }
    488 }
    489 
    490 /* ── Code blocks ──────────────────────────────────────────────────────────────
    491    DOM contract, emitted by the `sqzer:code-block` transformer in astro.config.mjs:
    492      figure.code[data-language][data-wrap?]
    493        > figcaption.code__lang           (absent when data-language is "plaintext")
    494        > pre.astro-code[tabindex=0][role=group][aria-label]
    495            > code > span.line > span[style="color:var(--astro-code-token-*)"]
    496 
    497    The `pre` carries an inline style (background-color / color / overflow-x) that no
    498    stylesheet can outrank without !important, so it is steered through the
    499    --astro-code-* custom properties in tokens.css instead of being overridden.
    500 
    501    Two things the plate must not do:
    502    1. Keep the UA margin. The UA sheet gives `figure` `margin-inline: 40px`; left
    503       unreset, every code block sits 40px right of the one left edge the whole page is
    504       built on.
    505    2. Bleed. The shell is sized to the 1024px page box (see `main` above), so the prose
    506       column really is 928px and the block is already the full measure. Bleeding
    507       -root-pad on top of that gives a 976px box that lines up with nothing —
    508       neither the rail's left edge nor the page box's right edge — and pushes the
    509       printed width to 944px. Flush is both correct and simpler. */
    510 .code {
    511   /* Containing block for the copy control, and the control's own box. See the copy
    512      block below for why the size is declared up here rather than beside the button. */
    513   position: relative;
    514   --copy-size: 2rem;
    515 
    516   margin-block: var(--sp-48);
    517   margin-inline: 0;                  /* reset the UA figure margin */
    518   padding-block: var(--sp-40);
    519   background: var(--sand);
    520 }
    521 /* No figcaption to hold the control, so the plate reserves its row instead. The selector
    522    is the transformer's contract, not a guess: a figcaption is emitted for every language
    523    except plaintext, which is what a fence with no language becomes. Measured on the built
    524    output: 52 code plates, 7 of them uncaptioned (6 fences carry no language and one is
    525    written ```plaintext). The three terms are the same three the captioned path spends
    526    (padding, row, caption margin), so both kinds of block open their first line of code at
    527    exactly the same depth. */
    528 .code[data-language='plaintext'] {
    529   padding-block-start: calc(var(--sp-40) + var(--copy-size) + var(--sp-40));
    530 }
    531 :is(h2, h3, h4, h5, h6) + .code { margin-block-start: var(--sp-32); }
    532 
    533 /* The language label reuses the kicker idiom and the 4px x 3rem rule rather than
    534    inventing a second type role for it. */
    535 .code__lang {
    536   display: flex;
    537   align-items: center;
    538   gap: var(--sp-30);
    539   /* The control sits at the right end of this row. align-items is center, so the label
    540      stays optically centred and the row grows around it. */
    541   min-block-size: var(--copy-size);
    542   padding-inline: var(--code-pad);
    543   margin-block-end: var(--sp-40);
    544   font-family: var(--font-body);
    545   font-size: var(--fs-small);
    546   line-height: var(--lh-medium);
    547   text-transform: uppercase;
    548   letter-spacing: 0.105rem;
    549   font-weight: 700;
    550   color: var(--ppc-text);
    551 }
    552 .code__lang::before {
    553   content: '';
    554   flex: none;
    555   inline-size: 3rem;
    556   block-size: 4px;
    557   background: var(--ppc-ui);
    558 }
    559 
    560 .code pre {
    561   margin: 0;
    562   padding-inline: var(--code-pad);   /* rule's left end = first character's left end */
    563   font-family: var(--font-mono);
    564   font-size: var(--fs-code);
    565   line-height: var(--lh-base);       /* 1.5 x --fs-code: 21.6px at 14.4 */
    566   font-variant-ligatures: none;
    567   tab-size: 2;
    568 
    569   /* Horizontal scroll shadow, no JS. The two `local` layers are plate-coloured masks
    570      that scroll with the content; the two `scroll` layers are shadows fixed to the box.
    571      At either end the mask covers the shadow, so a shadow shows only where there is
    572      more to see. Modern engines interpolate gradients with premultiplied alpha, so
    573      `transparent` no longer produces a grey midpoint — the colour does not have to be
    574      restated as an rgba literal, and --sand stays the single source of truth. */
    575   background-image:
    576     linear-gradient(to right, var(--sand), transparent),
    577     linear-gradient(to left,  var(--sand), transparent),
    578     linear-gradient(to right, rgb(0 0 0 / 0.16), rgb(0 0 0 / 0)),
    579     linear-gradient(to left,  rgb(0 0 0 / 0.16), rgb(0 0 0 / 0));
    580   background-position: left center, right center, left center, right center;
    581   background-repeat: no-repeat;
    582   background-size: 2.5rem 100%, 2.5rem 100%, 0.75rem 100%, 0.75rem 100%;
    583   background-attachment: local, local, scroll, scroll;
    584 
    585   scrollbar-width: thin;
    586   scrollbar-color: var(--sand-dark) transparent;
    587 }
    588 .code pre::-webkit-scrollbar { block-size: 6px; }
    589 .code pre::-webkit-scrollbar-thumb { background: var(--sand-dark); }
    590 .code pre::-webkit-scrollbar-track { background: transparent; }
    591 
    592 /* The `<code>` inside the plate carries the text, and the UA sheet gives it its own
    593    `font-family: monospace` — which beats what it would inherit from the rule above, so
    594    without this --font-mono would never reach a single character of code. That is
    595    invisible in English: Chrome resolves the `monospace` generic per the element's
    596    language, and an article whose prose is Korean is <article lang="ko">, where the
    597    generic is the Korean fixed default (GulimChe on Windows) rather than Consolas.
    598    Measured on bind9's first line, "sudo yum update -y" at 16px: 144.00px as lang=ko,
    599    158.34px with this rule (= Consolas, and = what the same line measures under
    600    lang=en). Inherit rather than restate --font-mono, so the plate keeps one source of
    601    truth. */
    602 .code pre code { font-family: inherit; }
    603 
    604 /* The default outline-offset would draw the ring outside the plate. */
    605 .code pre:focus-visible { outline: 3px solid var(--ppc-ui); outline-offset: -3px; }
    606 
    607 /* ```<lang> wrap — for blocks whose lines cannot be preserved. Longest line measured
    608    in the corpus is 1,639 characters (one raw sysmon XML event). */
    609 .code[data-wrap] pre { white-space: pre-wrap; overflow-wrap: anywhere; }
    610 
    611 /* ── Per-language retuning ────────────────────────────────────────────────────
    612    Custom properties inherit, so declaring one on the figure resolves the inline
    613    `color: var(--astro-code-token-*)` on every span inside it. That is the reason
    614    for choosing the css-variables theme: a transformer cannot do this.
    615 
    616    Measured on the built DOM, not assumed. A punctuation override on bash, sh or
    617    shellsession as well would be wrong on all three counts:
    618      bash         punctuation = 1 span in 30 blocks, and it is ' &&' — a conjunction,
    619                   which the override would paint the colour reserved for "command
    620                   starts here". bash's prompt is already token-function ($ x22 of 59).
    621      sh           punctuation = 0 spans.
    622      shellsession never appears: the corpus writes ```console, and data-language keeps
    623                   the author's word, so that selector would match no element.
    624    Only console earns it: punctuation = 17, of which 16 are prompt glyphs ($ x13, # x3). */
    625 .code[data-language='console'] { --astro-code-token-punctuation: var(--crimson-dark); }
    626 
    627 /* bash keyword = 150 spans, of which 136 are `<` and `>` from XML and log output
    628    pasted into a ```bash fence, not redirects. 136 purple angle brackets is not a
    629    reading of the code. The 14 real pipes are structural too, so slate is right for both. */
    630 .code[data-language='bash'],
    631 .code[data-language='sh'] { --astro-code-token-keyword: var(--slate); }
    632 
    633 /* Inline code. 52 spans across 2 documents, median 13.5 characters — sparse and short.
    634    No corner radius: this page's vocabulary is a 4px rule and a 1px hairline, not curves. */
    635 :not(pre) > code {
    636   font-family: var(--font-mono);
    637   font-size: 0.9em;
    638   background: var(--sand);
    639   padding: 0.1em 0.3em;
    640 }
    641 
    642 /* ── Copy control ───────────────────────────────────────────────
    643    One icon button per code plate, built by the script in the article template. Nothing
    644    is emitted for it server-side, so a reader with no JS is never handed a control that
    645    cannot work — and nothing here is conditional on the script having run, so a plate
    646    looks the same before, during and after it does.
    647 
    648    That is the whole reason the row is reserved in CSS above rather than made by the
    649    script: the button is taken out of flow and dropped into a band that already exists,
    650    so no code block reflows when it appears. The band is also why the control cannot end
    651    up over code at any width. It is above the scroller, not inside it, on a plate whose
    652    width the reader never has to share with it.
    653 
    654    DOM contract, from the script:
    655      button.code__copy[data-state=idle|done|fail][aria-label]
    656        > svg > g[data-glyph=copy|done|fail]
    657 
    658    Colour: there is no green in this palette, so success is not signalled by hue — the
    659    glyph changes shape, and the accent is the same accent every other active control on
    660    the page takes. Failure is told honestly instead of being coloured: the script selects
    661    the block's text, which is a far louder signal than a red button, and says so through
    662    the live region.
    663 
    664    No focus rule. The global :focus-visible ring at the top of this file is what the rest
    665    of the site's controls wear, and outline-offset: 2px lands it inside the plate.
    666    No reduced-motion rule either: the blanket query at the foot of this file already
    667    drops these transitions with !important. */
    668 .code__copy {
    669   position: absolute;
    670   inset-block-start: var(--sp-40);
    671   /* Right edge in by the same --code-pad the first character is in by on the left. */
    672   inset-inline-end: var(--code-pad);
    673   display: grid;
    674   place-items: center;
    675   inline-size: var(--copy-size);
    676   block-size: var(--copy-size);     /* 32px square, over the 24px WCAG 2.2 target floor */
    677   padding: 0;
    678   border: 1px solid var(--sand-dark);
    679   background: none;
    680   color: var(--page-meta);
    681   cursor: pointer;
    682   transition: color 120ms, border-color 120ms, background-color 120ms;
    683 }
    684 .code__copy > svg { display: block; }
    685 
    686 /* All three glyphs ship in one <svg> and data-state picks the one that paints. The button
    687    never rewrites its own markup, so it cannot change size between states and there is no
    688    second request for an icon. */
    689 .code__copy [data-glyph] { display: none; }
    690 .code__copy[data-state='idle'] [data-glyph='copy'],
    691 .code__copy[data-state='done'] [data-glyph='done'],
    692 .code__copy[data-state='fail'] [data-glyph='fail'] { display: inline; }
    693 
    694 /* Gated, like the nav's hover: on a touch device :hover sticks after the tap, which would
    695    leave the control reading as active on the last block touched. */
    696 @media (hover: hover) {
    697   .code__copy:hover { color: var(--ppc-text); border-color: var(--ppc-ui); background: var(--page-bg); }
    698 }
    699 .code__copy[data-state='done'] { color: var(--ppc-text); border-color: var(--ppc-ui); }
    700 .code__copy[data-state='fail'] { color: var(--ppc-text); border-color: var(--ppc-ui); background: var(--ppc-wash); }
    701 
    702 @media print {
    703   .code { background: none; break-inside: avoid; }
    704   /* Paper has no clipboard. The control goes, and so does the row that was reserved for
    705      it -- on plaintext blocks that is 48px of empty plate at the head of every fence. */
    706   .code__copy { display: none; }
    707   .code[data-language='plaintext'] { padding-block-start: var(--sp-40); }
    708   .code__lang { min-block-size: 0; }
    709   /* overflow-x is inline on the pre, so this is the one place !important is required;
    710      without it everything past the scroll edge is cut off the page. */
    711   .code pre {
    712     overflow-x: visible !important;
    713     white-space: pre-wrap;
    714     background-image: none;
    715   }
    716 }
    717 
    718 /* ── Diagrams ─────────────────────────────────────────────────────────────────
    719    DOM contract, emitted by src/lib/satteri-diagram.mjs:
    720      figure.diagram[data-diagram][tabindex=0][role=group][aria-label]
    721        > svg[width][height][viewBox]     (baked by scripts/render-diagrams.mjs)
    722 
    723    The SVG carries its own <style>, scoped to its own #mmd-<hash> id and holding the palette
    724    this file's tokens resolved to at bake time, so nothing here paints the picture. What is
    725    left is the frame, and the one thing the picture cannot do for itself: fit.
    726 
    727    A diagram is baked at intrinsic size and scaled down here to whatever width the column
    728    has. Keeping every drawing at 100% and scrolling the frame would keep the labels at full
    729    size, but a picture you have to drag sideways inside a page you are scrolling down is not
    730    a picture you read, and it hides a diagram's whole point - its shape - behind a 928px
    731    slot.
    732 
    733    So fitting is two-sided, and only half of it is here. The other half is in
    734    src/lib/diagrams.mjs: the sequence diagrams are baked with `sequence.wrap`, which breaks
    735    long message labels instead of letting one label set the distance between two lifelines.
    736    That is what makes fitting affordable - one comes out 660x780 (1,103x660 unwrapped) and
    737    needs no scaling at all, the other 1,194x958 (1,540x874 unwrapped) and scales 0.75, so
    738    its 16px labels land at 12px rather than 9.3px. The flowchart is the one that does not
    739    come down: its width is four boxes in a row, not one long line, so it fits at 0.59 and
    740    its 15px labels land at 8.8px. It is a four-box pipeline whose shape survives that; its
    741    fine print does not, and the honest answer there is the reader's own pinch-zoom, which
    742    on vector art costs nothing.
    743 
    744    `max-inline-size: 100%` with `block-size: auto` is the replaced-element idiom: the root's
    745    width/height attributes give the intrinsic ratio, so the height follows the width and the
    746    picture never grows past what mermaid measured. Nothing scrolls, so the frame needs no
    747    scroll shadows, no thin scrollbar and no print override for `overflow-x`: the five rules
    748    this block compiles to measure 387 B raw in the built sheet, 176 B brotli.
    749 
    750    Global rather than in the article template, because the article template is not the only
    751    page that renders Markdown: src/pages/index.astro puts about.md through this pipeline too.
    752    A reader who never opens a page with a diagram pays for this block and nothing else: no JS,
    753    no request, no font. */
    754 .diagram {
    755   margin-block: var(--sp-48);
    756   margin-inline: 0;                  /* reset the UA figure margin */
    757   padding: var(--code-pad);
    758   border: 1px solid var(--sand-dark);
    759 }
    760 :is(h2, h3, h4, h5, h6) + .diagram { margin-block-start: var(--sp-32); }
    761 /* Inside the border, like .code pre, so the ring is not drawn over the prose beside it.
    762    The figure keeps its tabindex: it is not a scroll region, but it is still the one thing
    763    on the page a keyboard reader may want to land on before zooming into it. */
    764 .diagram:focus-visible { outline: 3px solid var(--ppc-ui); outline-offset: -3px; }
    765 
    766 /* `display: block` also drops the inline baseline gap under the frame; `margin-inline: auto`
    767    centres a drawing narrower than the column, as the 660px sequence diagram is. */
    768 .diagram > svg {
    769   display: block;
    770   margin-inline: auto;
    771   inline-size: auto;
    772   max-inline-size: 100%;
    773   block-size: auto;
    774 }
    775 
    776 @media print { .diagram { break-inside: avoid; } }
    777 
    778 @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }