src/pages/writing/index.astro (15603B)
1 ---
2 import Base from '../../layouts/Base.astro';
3 import { getCollection } from 'astro:content';
4 import { byHugoOrder, postUrl } from '../../lib/order';
5 import { hasHangul, langOf } from '../../lib/hangul';
6
7 const posts = (await getCollection('writing'))
8 .filter((p) => !p.data.draft)
9 .sort(byHugoOrder);
10
11 // Grouped by year, matching the axis already present in the URL.
12 const years = [...new Set(posts.map((p) => p.data.date.slice(0, 4)))].sort().reverse();
13
14 /** Rail date. The year is stated by the section kicker, so only MM.DD is repeated. */
15 const md = (iso: string) => `${iso.slice(5, 7)}.${iso.slice(8, 10)}`;
16
17 const span = years.length ? `${years[years.length - 1]}–${years[0]}` : '';
18
19 /* This page is an English document - heading, year kickers - that sets Korean titles,
20 several of them above the fold. Deciding the font preload from the document
21 language would skip the Korean face on the site's densest page and leave those titles
22 in fallback until a third-wave request lands. Ask the strings that will be painted. */
23 const ko = posts.some((p) => hasHangul(p.data.title, p.data.deck));
24 ---
25 <Base
26 title="Writing :: debuman"
27 description={span ? `Essays and technical notes, ${span}.` : 'Essays and technical notes.'}
28 ko={ko}
29 >
30 <div class="index">
31 {/* The reference carries no page title on its section pages: the bar's left slot
32 already prints the page name, so an <h1> repeating it is the same word twice.
33 Kept in the document as .sr-only rather than deleted — the page still needs one
34 top-level heading for assistive tech and for the document outline, and the bar's
35 slot is a <span> precisely because it is not one. */}
36 <h1 class="index__title sr-only">Writing</h1>
37
38 {posts.length === 0 && <p class="index__empty">Nothing here yet.</p>}
39
40 {years.map((y) => (
41 <section class="year">
42 <h2 class="year__label" id={y}>{y}</h2>
43 <ol class="year__list">
44 {posts.filter((p) => p.data.date.slice(0, 4) === y).map((p) => (
45 <li class="entry" data-type={p.data.type}>
46 <time class="entry__date" datetime={p.data.date}>{md(p.data.date)}</time>
47 <h3 class="entry__title" lang={langOf(p.data.title, 'en')}>
48 <a href={postUrl(p)}>{p.data.title}</a>
49 </h3>
50 {p.data.deck && (
51 <p class="entry__deck" lang={hasHangul(p.data.deck) ? 'ko' : undefined}>{p.data.deck}</p>
52 )}
53 </li>
54 ))}
55 </ol>
56 </section>
57 ))}
58 </div>
59 </Base>
60
61 <style>
62 /* ────────────────────────────────────────────────────────────────
63 Page-local tokens.
64 Most are var(hook, literal): the literal is the stand-alone value measured from the
65 Gazette, and the hook is where the global token set can override it. Only some hooks
66 are set. tokens.css sets --w-content, --wp-h1, --wp-h5 and --wp-large, and fonts.css
67 sets --font-sans and --font-serif. Nothing defines --wp-small, --wp-medium,
68 --wp-kicker or the --desk-* and --color-* hooks, so those resolve to the literals
69 here. The colour literals equal tokens.css's --crimson-dark, --crimson,
70 --crimson-wash, --black, --slate, --grey and --sand-light today, but a change there
71 does not reach these page-local tokens. (The row hover and the focused title read
72 --crimson directly, so that one does reach this page.)
73 ──────────────────────────────────────────────────────────────── */
74 .index {
75 /* Grid */
76 /* Must equal --w-content in src/styles/tokens.css: this is what puts an entry title
77 on the same left edge as the <h1> of the article it links to. Moving one without
78 the other silently splits that edge - see the note beside --w-content.
79
80 Bound through var() rather than re-stated, and the unit is why. The article caps
81 at an absolute --w-content (928px); a rem literal here only matches it at a 16px
82 root. Chrome's font-size setting is one click from 12px, and .index has
83 margin-inline: auto, so the moment its cap falls below the shell the box centres
84 and the two left edges part. Measured with a 58rem literal at a 12px root: the
85 article <h1> landed 116px left of the entry title at 1488 and 1920, and 103.5px
86 left at 1024; at 14px, 58px and 38.6px. Through var() it is 0.00px at 12 / 14 /
87 16 / 20 / 24px roots, because both pages then cap on the same absolute length.
88 The 58rem fallback is the stand-alone value, used only if Base ships no tokens -
89 the same var(global, measured-literal) shape as the type scale below.
90
91 One exception to the shared edge, and it is not this page's doing nor fixable from
92 here: every article pushes its own page box right between 1440 and 1584px, where a
93 centred gutter cannot seat the contents rail's 240px floor. The article reserves
94 that gutter whether or not it paints a rail in it. This page has no rail and
95 nothing to make room for, so it deliberately does not follow - shifting it in
96 sympathy would buy an empty 280px left gutter at 1440 for a page with nothing to
97 put in it. Measured delta, article title minus entry title, at a 16px root: 72px at
98 1440, 48 at 1488, 24 at 1536, and 0.00px from 1584 up and at 1439 and below. Those
99 are layout-viewport widths, the same basis the Geometry note uses; a classic 15px
100 scrollbar puts the band at 1440-1598 in window widths and the worst case at 79.5px,
101 measured at a 1440px window. See the ══ Geometry ══ note in
102 src/pages/writing/[year]/[slug].astro for why it was traded. */
103 --content-w: var(--w-content, 58rem); /* 928px, = --w-content */
104 --rail-w: 4.5rem; /* 72px date rail */
105 --rail-gap: 1.5rem; /* 24px */
106
107 /* Type scale. The literals are as measured. tokens.css sets three of the hooks, which
108 replace their literals; the trailing notes on those three give both values. */
109 --fs-small: var(--wp-small, clamp(0.6875rem, 0.688rem + ((1vw - 0.2rem) * 0.256), 0.875rem)); /* 11→14 */
110 --fs-medium: var(--wp-medium, clamp(0.75rem, 0.75rem + ((1vw - 0.2rem) * 0.342), 1rem)); /* 12→16 */
111 --fs-large: var(--wp-large, clamp(1rem, 1rem + ((1vw - 0.2rem) * 0.342), 1.25rem)); /* literal 16→20; --wp-large 16→19 */
112 --fs-h5: var(--wp-h5, clamp(1.25rem, 1.25rem + ((1vw - 0.2rem) * 0.342), 1.5rem)); /* literal 20→24; --wp-h5 18→20 */
113 --fs-h1: var(--wp-h1, clamp(2.125rem, 2.125rem + ((1vw - 0.2rem) * 2.568), 4rem)); /* literal 34→64; --wp-h1 27→38 */
114 --fs-kicker: var(--wp-kicker, clamp(0.63rem, 0.36vw + 0.54rem, 0.88rem)); /* 10.08→14.08 */
115
116 --lh-tight: 1.1;
117 --lh-medium: 1.4;
118 --lh-base: 1.5;
119
120 --gap-kicker: clamp(1rem, 0.72vw + 0.83rem, 1.5rem); /* 16→24 */
121 --entry-pad: clamp(0.75rem, 0.75rem + ((1vw - 0.2rem) * 0.342), 1rem); /* 12→16 */
122 --gap-section: clamp(2rem, 1.8rem + 1vw, 3rem); /* spacing-48 */
123
124 /* Colour: desk = crimson */
125 --ppc-text: var(--desk-text, #771120); /* dark — kickers */
126 --ppc-ui: var(--desk-ui, #a51c30); /* base — 4px rules, focus ring */
127 --ppc-wash: var(--desk-wash, #f7ebeb); /* wash — row hover */
128 --ink: var(--color-black, #000);
129 --meta: var(--color-slate, #605a4e);
130 --hairline: var(--color-grey, #b9b8ad);
131 --paper: var(--color-sand-light, #fffbf4);
132
133 --font-body: var(--font-sans, "Inter", "Pretendard", system-ui, sans-serif);
134 --font-display: var(--font-serif, "Newsreader", "Pretendard", Georgia, serif);
135 /* The one switch for the entry titles' face: var(--font-display) or var(--font-body).
136 fonts.css maps --font-serif and --font-sans to the same Lora stack today, so the
137 choice only shows if the two ever differ. */
138 --index-title-font: var(--font-display);
139
140 container: index / inline-size;
141 max-width: min(100%, calc(var(--rail-w) + var(--rail-gap) + var(--content-w))); /* 1024px */
142 margin-inline: auto;
143 font-family: var(--font-body);
144 color: var(--ink);
145 }
146
147 /* Masthead.
148 The h1 is .sr-only (see the note beside it in the markup), so none of this paints.
149 It is flush with the page box, NOT indented onto the entry column. The 96px indent
150 belongs to what hangs off the 72px date rail: the year kickers and the entry titles,
151 the column that is the page's spine. The masthead is not in that column — it is the
152 page's own name, addressed to the shell, not a row in the list — so it takes the
153 shell's edge, the edge /papers/, /talk/ and /code/ start from. */
154
155 .index__title {
156 margin: 0;
157 font-family: var(--font-display);
158 font-size: var(--fs-h1);
159 font-weight: 500;
160 line-height: var(--lh-tight);
161 letter-spacing: -0.01em;
162 color: var(--ink);
163 }
164
165
166 .index__empty {
167 padding-left: calc(var(--rail-w) + var(--rail-gap));
168 font-size: var(--fs-large);
169 line-height: 1.6;
170 color: var(--ink);
171 }
172
173 /* Year as section kicker, ported from .featured-articles__category */
174 .year__label {
175 position: sticky;
176 top: var(--index-sticky-top, 0px);
177 z-index: 2;
178 margin: 0;
179 padding: var(--gap-kicker) 0 var(--gap-kicker) calc(var(--rail-w) + var(--rail-gap));
180 background: var(--paper);
181 scroll-margin-top: var(--index-sticky-top, 0px);
182
183 font-family: var(--font-body);
184 font-size: var(--fs-kicker);
185 font-weight: 700;
186 line-height: var(--lh-medium);
187 letter-spacing: 0.105rem; /* 1.68px */
188 text-transform: uppercase;
189 font-variant-numeric: tabular-nums;
190 color: var(--ppc-text);
191 }
192
193 /* Signature motif, used once per year heading. */
194 .year__label::before {
195 content: "";
196 display: block;
197 width: 3rem;
198 border-top: 4px solid var(--ppc-ui);
199 margin-bottom: var(--gap-kicker);
200 }
201
202 .year__list {
203 list-style: none;
204 margin: 0;
205 padding: 0;
206 }
207
208 /* Entry */
209 .entry {
210 position: relative;
211 display: grid;
212 grid-template-columns: var(--rail-w) minmax(0, 1fr);
213 column-gap: var(--rail-gap);
214 align-items: baseline;
215 padding-block: var(--entry-pad);
216 border-bottom: 1px solid var(--hairline);
217 transition: background-color 0.12s linear;
218 }
219
220 .entry:last-child { border-bottom: none; }
221
222 .entry__date {
223 grid-column: 1;
224 grid-row: 1;
225 justify-self: end; /* right-aligned: the vertical spine of the page */
226 font-size: var(--fs-small);
227 line-height: var(--lh-base);
228 font-variant-numeric: tabular-nums;
229 font-feature-settings: "tnum" 1;
230 letter-spacing: 0.02em;
231 color: var(--meta);
232 white-space: nowrap;
233 }
234
235 .entry__title {
236 grid-column: 2;
237 grid-row: 1;
238 margin: 0;
239 font-family: var(--index-title-font);
240 font-size: var(--fs-h5);
241 font-weight: 500;
242 line-height: var(--lh-medium);
243 color: var(--ink);
244 word-break: keep-all; /* stops Korean titles breaking mid-word */
245 text-wrap: balance;
246 }
247
248 /* Focus moving backwards (Shift+Tab) scrolls the link only just into view, where the
249 sticky year label above would cover it and the row's focus ring completely. The margin
250 is the label's own height (padding, rule, rule's gap, one line) plus the row's padding,
251 plus 1rem for the ring (3px offset, 2px outline) and for the link's inline box sitting
252 a little inside its taller line; with only the ring's 5px, the ring's top would still
253 touch the label. Below 36rem the date sits above the title in the row, and the container
254 rule adds its line. */
255 .entry__title a {
256 color: inherit;
257 text-decoration: none;
258 scroll-margin-top: calc(
259 3 * var(--gap-kicker) + 4px + var(--fs-kicker) * var(--lh-medium) + var(--entry-pad) + 1rem
260 );
261 }
262
263 /* The whole row is the hit area, ported from .featured-article__title a:before */
264 .entry__title a::before {
265 content: "";
266 position: absolute;
267 inset: 0;
268 }
269
270 /* The row is the trigger, through the full-row hit box on `.entry__title a::before`:
271 hovering anywhere on it washes the row and turns the title from --ink to --crimson,
272 which measures 6.42:1 on the wash.
273 Hover is a colour step, not a rule. The reference draws its animated underline in one
274 place only - the header nav - and every link in the body of a page, its writing index,
275 its code list and even its year rail, simply lifts from rgb(160,32,32) to
276 rgb(192,48,48) with no decoration at all. Measured over CDP on all three. */
277 @media (hover: hover) {
278 /* The wash and the title's colour are one gesture, so they share one gate. On a touch
279 screen :hover latches after a tap, and either half left outside the gate would stay
280 on a tapped row until the next tap elsewhere while the other half did not. */
281 .entry:hover { background-color: var(--ppc-wash); }
282 .entry:hover .entry__title a { color: var(--crimson); }
283 }
284 /* Keyboard parity: the outline on `.entry__title a:focus-visible::before` marks the
285 row, and this marks the title the same way the pointer does. Outside the gate on
286 purpose: it is the keyboard path. */
287 .entry__title a:focus-visible { color: var(--crimson); }
288
289 /* Forced colours force `color` to the system colours at rest and on hover alike, so
290 the colour step above does not show in that mode. An underline is what the mode can
291 still draw, so hover and focus get one here and only here. Written in this file
292 rather than in base.css because Astro scopes a page rule on every compound, which
293 outranks the same selector written globally. */
294 @media (forced-colors: active) {
295 .entry__title a:focus-visible { text-decoration: underline; text-underline-offset: 0.14em; }
296 }
297 @media (forced-colors: active) and (hover: hover) {
298 .entry:hover .entry__title a { text-decoration: underline; text-underline-offset: 0.14em; }
299 }
300
301 .entry__title a:focus-visible { outline: 0; }
302 .entry__title a:focus-visible::before {
303 outline: 2px solid var(--ppc-ui);
304 outline-offset: 3px;
305 }
306
307 /* Deck. None exist yet; when backfilled it lands on row 2 with no layout change. */
308 .entry__deck {
309 grid-column: 2;
310 margin: 0.375rem 0 0;
311 font-family: var(--font-body);
312 font-size: var(--fs-medium);
313 line-height: var(--lh-base);
314 color: var(--ink);
315 word-break: keep-all;
316 text-wrap: pretty;
317 }
318
319 /* Folds when the rail no longer fits.
320 The page title is .sr-only and carries no indent, so it has nothing to un-indent here. */
321 @container index (max-width: 36rem) {
322 .index__empty,
323 .year__label { padding-left: 0; }
324
325 .entry { grid-template-columns: minmax(0, 1fr); }
326
327 .entry__date {
328 grid-column: 1;
329 grid-row: 1;
330 justify-self: start;
331 margin-bottom: 0.25rem;
332 }
333
334 .entry__title { grid-column: 1; grid-row: 2; }
335 .entry__title a {
336 scroll-margin-top: calc(
337 3 * var(--gap-kicker) + 4px + var(--fs-kicker) * var(--lh-medium) + var(--entry-pad) + 1rem
338 + var(--fs-small) * var(--lh-base) + 0.25rem
339 );
340 }
341 .entry__deck { grid-column: 1; }
342 }
343
344 @media (prefers-reduced-motion: reduce) {
345 .entry { transition: none; }
346 }
347 </style>