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; } }