blog.git

blog

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

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>