/* Copied from Quire Ink src/web/*.css.ts by tools/extract.ts, less the IDE chrome,
   which is lifted whole into quireink-ide.css. */
/* MOTION TOKENS. Three durations, and no easing token. docs/conventions/motion.md promised these
   since the frozen tree and 2.0 had none, so every duration in islands.css.ts was a literal.
   Counted before deciding: twelve motion declarations across three files, and FOUR values for
   three intents — .13s and .15s are the same idea written twice, which is exactly the drift a
   token set exists to stop. So the tiers are named and .13s becomes .15s.

   --ease is DELIBERATELY not here. Its value would have been the keyword ease, which is
   indirection with no payload; and the three scroll-driven animations must stay linear, since
   easing a timeline a reader is scrubbing with their thumb is wrong rather than slower.
   Introduce one when a real curve is chosen, not to complete a set.

   Static, so they live in the immutable sheet rather than the per-page inline half: they are
   not derived from any setting. data-motion=off and prefers-reduced-motion still switch all of
   it off in one rule each, at the foot of islands.css.ts. NOTE the sign-in page does not get
   this sheet — see the comment in login.css.ts. */
:root{--dur-fast:.15s;--dur-base:.2s;--dur-slow:.5s}
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
/* The hidden ATTRIBUTE means hidden, and it takes an !important to say so.
   Its own rule is a UA default of display:none, which any class setting a display value
   outranks - so setting .hidden = true in script on anything carrying .icon-btn
   (display:flex) did nothing at all. That shipped: the grid toggle hides itself on a page
   with no list, and it was visible on every article, every /search and every 404 on the
   demo, aria-pressed and all. This is the SECOND time the bug was found; the first was
   patched as .theme-menu[hidden] in islands.css, one component at a time. A general rule
   is the only version that covers the component nobody has written yet. */
[hidden]{display:none!important}
/* TWO font handles, and which one is the DEFAULT matters more than it looks. --font-sans
   is the system chrome face: header, footer, rail, dates, reading times, everything that
   is not the reader's own words. --font-reading is the reader's words, and it is opted
   INTO by .prose and .reading-font. This said --font-reading, so the whole site rendered
   in the article face and the owner's chrome font was never seen anywhere. */
body{
  margin:0;background:var(--c-bg);color:var(--c-text);
  font-family:var(--font-sans);font-size:var(--fs-body);line-height:var(--lh-body);
  letter-spacing:var(--ls-body);font-optical-sizing:auto;
}
/* Block margins to zero, and let the components state their own spacing.
   The frozen tree got this from Tailwind's preflight and its layout is built on top of it:
   the listing card sets .mt-2 / .mt-3 and expects nothing from the browser. Without the
   reset the card's first paragraph carried a 1em default margin, it collapsed out through
   the card, and the whole feed sat 14px lower than the rail beside it - which is the
   three-columns-not-level the owner spotted. Rhythm INSIDE the body copy is restored by
   .prose > * + * in prose.css.ts, exactly as the frozen tree restores it. */
h1,h2,h3,h4,h5,h6,p,figure,blockquote,ol,ul,dl,dd,pre{margin:0}
/* The SECOND thing the frozen tree got free from Tailwind's preflight, and the second one
   to go missing. A form control does not inherit its font: left alone, every button on the
   site paints in the browser's UI face at the browser's size, not in the owner's typeface
   at a --fs- token. Measured before this rule existed: "Sao chép" on every code block and
   "Lên đầu trang" on every article rendered in Arial at 12px/normal, and the theme and menu
   buttons at 13.33px/normal - four controls on a site whose own rule is one typeface and no
   hardcoded sizes. The shorthand is deliberate: font:inherit carries line-height too, which
   font-family alone would leave at the UA's "normal". */
button,input,select,textarea,optgroup{font:inherit;color:inherit;letter-spacing:inherit}
img,video,iframe{max-width:100%;height:auto}
a{color:var(--c-link);text-decoration:underline;text-underline-offset:.15em}
a:hover{color:var(--c-heading)}
/* ONE focus signature for the whole public site, the ring the sign-in page already used.
   Everything out here relied on the browser default, except the sign-up field, which
   replaced the outline with a 1px border-colour change: on a dark theme that is invisible,
   so a reader navigating by keyboard lost the cursor entirely. :focus-visible, not :focus,
   so a mouse click does not draw it. */
:focus-visible{outline:2px solid var(--c-accent);outline-offset:2px}
hr{border:0;border-top:1px solid var(--c-rule);margin:2.5rem 0}

/* WHAT A SELECTION LOOKS LIKE. Owner's call, 2026-08-24: black on a light page, grey on a
   dark one, in place of the browser's blue.
   The blue is the one colour on the whole reading page that came from nowhere: this site
   sets its palette down to the hairline and then hands the most physical gesture a reader
   makes — dragging across a sentence — to the operating system's accent. Ink on paper is
   what the rest of the page already is, and inverted it reads like a marker.
   Dark mode takes --c-meta rather than --c-heading on purpose: heading is near-white in
   every dark palette, and a white block over dark text is a flashbulb in the middle of a
   paragraph. The mid-grey reads as a mark; the text on it stays the page's own ground.
   Three selectors and not one, for the same reason content/themes.ts writes three: the
   island sets .dark and data-scheme, and before it runs, a reader whose SYSTEM is dark
   is on the dark palette with neither attribute set. Without the media query that reader
   gets a black block on a black page for one paint, which is a selection they cannot see. */
::selection{background:var(--c-heading);color:var(--c-bg)}
html.dark ::selection{background:var(--c-meta);color:var(--c-bg)}
@media (prefers-color-scheme:dark){
  html:not([data-scheme]) ::selection{background:var(--c-meta);color:var(--c-bg)}
}

/* The column width is --shell-w, not a constant: the layout sets it from the owner's
   contentWidth and a two-rail listing overrides it with something narrower. It read
   --content-width, which nothing ever set, so every page has been 42rem wide regardless
   of the setting.

   The 2rem gutter is the same at EVERY width, which the frozen tree's markup does not
   admit: it says "px-8 sm:px-5", but no .sm\:px-5 rule was ever compiled into its
   stylesheet, so 2rem is what actually shipped. Measured off the rendered page, not read
   off the class list — the two disagreed by 24px of column, which is one word per line. */
/* dvh after vh, not instead of it: iOS counts the URL bar inside 100vh, so a full-height
   shell overshoots the visible page by the height of the bar and the footer sits under it.
   The vh line stays as the fallback for an engine that does not know dvh. */
.wrap{max-width:var(--shell-w,42rem);margin:0 auto;padding:0 2rem;
  display:flex;min-height:100vh;min-height:100dvh;flex-direction:column}
/* The rail is absolutely placed against THIS box, not the page, so it never displaces the
   reading column and the column stays centred exactly as it does with no rail at all. It
   wraps the content and not the header, which is what puts the rail's first line level
   with the article's first line. */
.with-rail{position:relative;display:flex;flex:1;flex-direction:column}
main{flex:1;padding:3rem 0 1rem}

/* Off-screen until it takes focus, then a real control at the top left of the page. Moved
   rather than sized to nothing: a zero-size element is skipped by some screen readers, and
   display:none would take it out of the tab order entirely, which is the one thing it must
   never be. */
.skip-link{position:absolute;left:-9999px;top:0;z-index:60}
.skip-link:focus{left:.5rem;top:.5rem;padding:.5rem .75rem;background:var(--c-bg);
  border:1px solid var(--c-rule);border-radius:var(--radius,.5rem);color:var(--c-heading);
  text-decoration:none}

header.site{padding:1.75rem 0}
header.site .title{font-family:var(--font-sans);font-weight:var(--fw-heading,600);color:var(--c-heading);
  text-decoration:none;font-size:var(--fs-h4);line-height:var(--lh-h4);
  letter-spacing:var(--ls-h4)}
/* width+height on the tag reserve the space, so the header does not jump when the logo
   arrives; the CSS width keeps it responsive and height:auto keeps the ratio. */
header.site .logo{display:block;height:auto}
/* When the owner has uploaded a dark twin both are in the markup and exactly one shows.
   The :has() test keeps the light one visible on a site with no dark logo, where
   .logo-dark is simply absent.

   :not(.logo-dark) is load-bearing. The dark twin is class="logo logo-dark", so without it
   the hide rule matched BOTH marks - and at (0,6,2) against the show rule's (0,3,2) it won,
   so a site with a dark logo had no logo at all in dark mode. It shipped that way because no
   instance had ever set one: found on the first live site that did. */
header.site .logo-dark{display:none}
html.dark header.site .title:has(.logo-dark) .logo:not(.logo-dark){display:none}
html.dark header.site .logo-dark{display:block}
/* Tight to the wordmark: the two are one lockup, and at .75rem the tagline floated far
   enough from the logo to read as a separate element. */
header.site .tagline{color:var(--c-meta);font-size:var(--fs-small);
  line-height:var(--lh-small);letter-spacing:var(--ls-small);margin:.35rem 0 0}

/* The reading face for .prose is in prose.css.ts, with the rest of the .prose rules, so the
   editor gets it too. There is deliberately no "article h1" rule: an article IS also the
   listing card, and a bare element selector here silently restyled every card title. Sizes
   come from the type-role classes. */
article > header h1{color:var(--c-heading);margin:0}
article > header .t-small{margin:0}
/* Standfirst: the excerpt, so a long read opens on a sentence rather than a wall.
   It is the AUTHOR'S words — the same string a list card prints — so it is set in the
   reading face. It was not: with no family of its own it fell to --font-sans, so the same
   excerpt rendered in Literata on the home page and in JetBrains Mono under the title of
   the post it belongs to. A book serif headline with a terminal subtitle under it, and the
   one seam on the page where the two faces touch. Same class as the comment body, which
   lost its reading face in the port for the same reason. */
.deck{margin:1rem 0 0;color:var(--c-meta);font-family:var(--font-reading);
  font-size:var(--fs-h4);line-height:var(--lh-h4);letter-spacing:var(--ls-h4)}
#post-body{margin-top:2.5rem}
/* Tags and categories over a rule: the rule is where the article ends. Without it the
   taxonomy reads as one more paragraph. */
.post-taxo p{margin:0 0 .25rem}
/* The end-of-article anchors the contents list jumps to. Their own empty elements, because
   the taxonomy they used to sit on is hidden in the wide layout and an anchor with no box
   cannot be scrolled to. Zero height, so they cost the flow nothing at either width. */
.anchor{display:block;height:0;scroll-margin-top:6rem}
/* The right gutter of an article: the same facts as the meta line and the taxonomy, one per
   line, placed by singleRailCss above the rail breakpoint. Absent by default — below that
   breakpoint there is no gutter, and the in-flow originals are what the reader gets. */
.post-info{display:none}
.post-info p{margin:0}
.post-info p + p{margin-top:.35rem}
/* One even rhythm through the facts — the tags and categories used to be set apart and the
   owner asked for them level with the rest. The ACTION is the only thing set apart, because
   it is the one row that is not a fact: it does something. */
.post-info .info-action{margin-top:1.25rem;color:var(--c-heading);font-weight:500}
/* The VALUES are a step darker than the words around them, the same ink the contents list
   uses for the row you are on. The panel is the only place a desktop reader sees the date
   and the length, so it carries the whole hierarchy on its own. */
.post-info time,.post-info .num{color:var(--c-heading)}
/* End-of-article furniture, and it needs its own scale rather than the page's. The link had
   NO size rule at all, so a related title inherited the BODY size: the quietest thing on the
   page (a list of "you might also read") was set as large as the writing, and in the chrome
   face, which on a monospace setting is visibly wider again. h5 is the title role that sits
   below body. The whole block is now ONE size: at h5 the titles still read as headings under
   a chrome font that is monospace on this site, and the owner asked for them smaller again.
   So the label, the titles and the dates are all --fs-small and nothing here competes with
   the article; weight and colour do the separating, which is how a book sets its back
   matter. */
/* One pointer forward at the article's end. The label whispers like .related's heading;
   the title is the only thing at reading size, because the title is the offer. */
.read-next-label{font-size:var(--fs-small);line-height:var(--lh-small);
  letter-spacing:var(--ls-small);font-weight:var(--fw-heading,600);color:var(--c-meta);margin:0 0 .5rem}
.read-next-title{margin:0;font-size:var(--fs-h3);line-height:var(--lh-h3);letter-spacing:var(--ls-h3)}
.read-next-title a{font-weight:var(--fw-heading,600);color:var(--c-heading)}
.related{font-size:var(--fs-small);line-height:var(--lh-small);
  letter-spacing:var(--ls-small)}
/* The size is stated even though the block already sets it: an h2 carries a UA default of
   1.5em, so leaving it to inherit made the quiet label the largest thing in the block. */
.related h2{font-size:var(--fs-small);line-height:var(--lh-small);
  letter-spacing:var(--ls-small);font-weight:var(--fw-heading,600);color:var(--c-meta);margin:0 0 1.25rem}
.related ul{list-style:none;padding:0;margin:0}
.related li + li{margin-top:1rem}
.related a{font-weight:var(--fw-heading,600);color:var(--c-heading)}
.related p{margin:.125rem 0 0}
article + .subscribe-card,article + #comments{margin-top:2.5rem}

/* The body's rhythm is ONE rule: every sibling gets the same lead, and the headings then
   buy themselves a little more. Margins were bottom-side and fixed in rem here, which does
   not scale with the reader's type size and left headings floating between paragraphs. */
/* The reading face belongs HERE, not in the public sheet, because the editor is a .prose
   surface too and what you type has to be set in the face it will be published in. It lived
   in public.css.ts, so the writing surface fell back to the chrome font and a post drafted
   in JetBrains Mono was published in Literata. */
.prose{font-family:var(--font-reading);
  font-size:var(--fs-body);line-height:var(--lh-body);
  letter-spacing:var(--ls-body);color:var(--c-text);
  hanging-punctuation:first last}
/* Hanging punctuation is how a set book treats an opening quote: the mark hangs into the
   margin so the TEXT edge stays optically straight. Safari only, for now; everyone else
   ignores the property and keeps today's edge, which is exactly what a progressive
   enhancement is allowed to do. It inherits, and a code block is the one place it would
   be wrong — a line starting with a quoted string would slip its indent — so pre opts out. */
.prose pre{hanging-punctuation:none}
.prose > * + *{margin-top:1.4em}
.prose h1,.prose h2,.prose h3,.prose h4,.prose h5{color:var(--c-heading);font-weight:var(--fw-heading,600);
  scroll-margin-top:2rem}
.prose h1{font-size:var(--fs-h1);line-height:var(--lh-h1);
  letter-spacing:var(--ls-h1);margin-top:1.9em}
.prose h2{font-size:var(--fs-h2);line-height:var(--lh-h2);
  letter-spacing:var(--ls-h2);margin-top:1.85em}
.prose h3{font-size:var(--fs-h3);line-height:var(--lh-h3);
  letter-spacing:var(--ls-h3);margin-top:1.8em}
.prose h4{font-size:var(--fs-h4);line-height:var(--lh-h4);
  letter-spacing:var(--ls-h4);margin-top:1.75em}
.prose h5{font-size:var(--fs-h5);line-height:var(--lh-h5);
  letter-spacing:var(--ls-h5);margin-top:1.7em}
/* A heading belongs to what comes AFTER it. Left to the shared 1.4em lead, the space below
   a heading is a fixed 1.4 x the BODY size while the space above is a multiple of the
   HEADING's own size — so the smaller the heading, the closer the two get, and at h5 they
   inverted: 22px above, 25px below. Measured on the specimen page, all four levels:

     h2  44 above / 22 below      h3  32 / 25      h4  28 / 25      h5  22 / 25

   The space below is therefore stated here, in the FOLLOWING element's em (it is that
   paragraph's lead, not the heading's), and graded so every level keeps a clear
   above-beats-below ratio while the absolute space still shrinks with the level. */
.prose > :is(h1,h2) + *{margin-top:.75em}
.prose > :is(h3,h4,h5) + *{margin-top:.6em}
/* Bold is EMPHASIS in the body colour. A book serif's 700 is blacker than the 600 of the
   headings, so a preset can dial it back through --reading-bold. */
.prose strong,.prose b{font-weight:var(--reading-bold, 700)}
/* The word keeps the palette's colour; the mark under it is the pen's graphite, because a
   hex baked into an SVG cannot read a CSS variable and this is the trade ADR 0018 already
   made for the pigments. Measured across all six palettes in both modes: 4.97:1 to 7.01:1
   against the paper, where the hairline it replaces sat at 1.16-1.33:1.
   
   repeat-x, and a size in em: the dashes are a physical thing the hand made, so a long link
   gets MORE of them rather than longer ones. box-decoration-break makes a link that wraps
   start its run again on the next line, exactly as the pen does for a highlight.
   
   The bottom padding is load-bearing for the same reason it is on the u element: an inline background
   clips at the font's descent, and this line lives just under the baseline. */
.prose a{color:var(--c-link);text-decoration:none;
  background-repeat:repeat-x;background-size:4.6em .3em;background-position:0 1.22em;
  -webkit-box-decoration-break:clone;box-decoration-break:clone;
  padding-bottom:.42em;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 240 16' preserveAspectRatio='none'%3E%3Cpath d='M1.3,8.8 C4.5,8.7 7.8,9.7 11,9.6 C14.3,10.2 17.5,9.5 20.8,10.1 L20.8,12.8 C17.5,12.4 14.3,13 11,12.4 C7.8,12.7 4.5,11.9 1.3,12.2 Z' fill='%235b574f' opacity='.73'/%3E%3Cpath d='M35.1,7.7 C39.3,7.1 43.4,8 47.6,7.7 C51.8,8.2 56,7 60.2,7.7 L60.2,9.9 C56,9.8 51.8,10.6 47.6,10.5 C43.4,10.9 39.3,10.3 35.1,10.9 Z' fill='%235b574f' opacity='.89'/%3E%3Cpath d='M88.3,6.4 C91.8,5.9 95.3,6.8 98.9,6.2 C102.4,6.9 105.9,5.9 109.5,6.3 L109.5,8.8 C105.9,8.6 102.4,9.4 98.9,8.7 C95.3,9.1 91.8,8.6 88.3,8.9 Z' fill='%235b574f' opacity='.89'/%3E%3Cpath d='M121.8,6.8 C126.4,6.5 131,7.5 135.6,7.7 C140.2,8 144.8,7.7 149.4,8.3 L149.4,10.5 C144.8,10.1 140.2,10.6 135.6,10.2 C131,10.5 126.4,9.6 121.8,10 Z' fill='%235b574f' opacity='.9'/%3E%3Cpath d='M180.1,7.7 C183.3,7.5 186.5,8.5 189.7,8.6 C193,9.1 196.2,8.2 199.4,9 L199.4,11.3 C196.2,10.7 193,11 189.7,10.5 C186.5,10.6 183.3,9.7 180.1,9.7 Z' fill='%235b574f' opacity='.93'/%3E%3Cpath d='M215.6,6.3 C219.3,5.6 223,6.5 226.8,6 C230.5,5.9 234.3,5 238,5.5 L238,7.9 C234.3,7.7 230.5,8.5 226.8,8.3 C223,8.7 219.3,8.2 215.6,8.5 Z' fill='%235b574f' opacity='.77'/%3E%3C/svg%3E")}
/* Under the cursor the hand presses down and the run closes up. Same ink, same box, so the
   line does not move by a pixel between the two states. */
.prose a:hover,.prose a:focus-visible{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 240 16' preserveAspectRatio='none'%3E%3Cpath d='M0,7.3 C16,6.9 32,7.8 48,7.5 C64,8.1 80,7.1 96,7.6 C112,7.1 128,8.4 144,8 C160,8 176,7.2 192,7.6 C208,7.2 224,8.2 240,7.5 L240,10.2 C224,9.6 208,11 192,10.5 C176,10.8 160,10.2 144,10.6 C128,9.8 112,10.8 96,10.3 C80,10.7 64,9.6 48,10.1 C32,9.7 16,10.6 0,10 Z' fill='%235b574f' opacity='.86'/%3E%3C/svg%3E")}
.dark .prose a{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 240 16' preserveAspectRatio='none'%3E%3Cpath d='M1.3,8.8 C4.5,8.7 7.8,9.7 11,9.6 C14.3,10.2 17.5,9.5 20.8,10.1 L20.8,12.8 C17.5,12.4 14.3,13 11,12.4 C7.8,12.7 4.5,11.9 1.3,12.2 Z' fill='%238f8a80' opacity='.73'/%3E%3Cpath d='M35.1,7.7 C39.3,7.1 43.4,8 47.6,7.7 C51.8,8.2 56,7 60.2,7.7 L60.2,9.9 C56,9.8 51.8,10.6 47.6,10.5 C43.4,10.9 39.3,10.3 35.1,10.9 Z' fill='%238f8a80' opacity='.89'/%3E%3Cpath d='M88.3,6.4 C91.8,5.9 95.3,6.8 98.9,6.2 C102.4,6.9 105.9,5.9 109.5,6.3 L109.5,8.8 C105.9,8.6 102.4,9.4 98.9,8.7 C95.3,9.1 91.8,8.6 88.3,8.9 Z' fill='%238f8a80' opacity='.89'/%3E%3Cpath d='M121.8,6.8 C126.4,6.5 131,7.5 135.6,7.7 C140.2,8 144.8,7.7 149.4,8.3 L149.4,10.5 C144.8,10.1 140.2,10.6 135.6,10.2 C131,10.5 126.4,9.6 121.8,10 Z' fill='%238f8a80' opacity='.9'/%3E%3Cpath d='M180.1,7.7 C183.3,7.5 186.5,8.5 189.7,8.6 C193,9.1 196.2,8.2 199.4,9 L199.4,11.3 C196.2,10.7 193,11 189.7,10.5 C186.5,10.6 183.3,9.7 180.1,9.7 Z' fill='%238f8a80' opacity='.93'/%3E%3Cpath d='M215.6,6.3 C219.3,5.6 223,6.5 226.8,6 C230.5,5.9 234.3,5 238,5.5 L238,7.9 C234.3,7.7 230.5,8.5 226.8,8.3 C223,8.7 219.3,8.2 215.6,8.5 Z' fill='%238f8a80' opacity='.77'/%3E%3C/svg%3E")}
.dark .prose a:hover,.dark .prose a:focus-visible{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 240 16' preserveAspectRatio='none'%3E%3Cpath d='M0,7.3 C16,6.9 32,7.8 48,7.5 C64,8.1 80,7.1 96,7.6 C112,7.1 128,8.4 144,8 C160,8 176,7.2 192,7.6 C208,7.2 224,8.2 240,7.5 L240,10.2 C224,9.6 208,11 192,10.5 C176,10.8 160,10.2 144,10.6 C128,9.8 112,10.8 96,10.3 C80,10.7 64,9.6 48,10.1 C32,9.7 16,10.6 0,10 Z' fill='%238f8a80' opacity='.86'/%3E%3C/svg%3E")}
/* A footnote marker and a heading anchor are not prose links and must not be underlined:
   the first is a superscript numeral, the second is the heading itself. */
.prose a.fn-ref,.prose sup a,.prose h1 a,.prose h2 a,.prose h3 a,.prose h4 a,.prose h5 a{
  background-image:none;padding-bottom:0}
.prose ul{list-style:disc;padding-left:1.4em}
.prose ol{list-style:decimal;padding-left:1.4em}
.prose li{margin:calc(var(--sp) * .25) 0}
.prose blockquote{border-left:2px solid var(--c-rule);margin-left:0;
  padding-left:var(--sp);color:var(--c-meta)}
/* Code is the ONE role that is not the reading face, and it is one face for both forms.
   It used to be two: inline code inherited the reading font while a fenced block asked for
   var(--font-mono), which nothing ever defined — so the same code role rendered in a book
   serif on one line and the browser's ui-monospace three lines later. --font-mono is now a
   real token (JetBrains Mono, self-hosted, declared in font-faces.ts). The face costs
   nothing on a post with no code: unicode-range means the browser fetches a file only when
   a glyph actually needs it. */
/* Declared on .prose rather than :root so a palette swap on the html element re-derives it
   with no second rule. Read by the block panel below. */
.prose{--c-code-panel:color-mix(in srgb, var(--c-rule) 20%, var(--c-bg))}
.prose code{font-family:var(--font-mono);font-size:var(--fs-code);
  line-height:var(--lh-code);letter-spacing:var(--ls-code)}
.prose :not(pre) > code{background:var(--c-rule);padding:.15em .38em}
/* A CODE BLOCK IS A PANEL, and until 2026-08-15 it was not one on any palette.

   pre carried padding, a radius and an overflow rule -- and no background of its own,
   because Shiki writes one as an INLINE style on every block it highlights. Its light theme
   (vitesse-light) writes #ffffff. This site's paper is #fcfcfc. So a code block was a white
   rectangle on a near-white page: the radius had nothing to round, the padding read as an
   indent, and the block differed from the prose around it only by being monospace. Reported
   as "tu dung thut lui lai thoi chu ko co gi khac biet".

   Two things are wrong with letting Shiki own it, and the colour is the smaller. A hex from
   a third-party theme is baked into HTML that is CACHED under a hash of its Markdown, so it
   outlives any palette the reader picks -- the same trap render/math.ts refuses for its
   error colour, and the rule CLAUDE.md states as public colours coming only from tokens.

   So the panel is derived from the palette: a fifth of the rule colour mixed into the page
   colour, which is a shade off the paper on every one of the six and stays right in dark
   with no second declaration. The hairline is the rule the tables already draw with -- a
   frame rather than a left bar, because the left bar is the blockquote's mark and two things
   sharing one mark is how a reader stops reading it.

   The important flag is not decoration: an inline style beats any selector without it. Only
   the block needs one -- measured across the fixture, Shiki emits no span backgrounds here,
   so a second rule to flatten them would guard nothing. */
.prose pre{padding:var(--sp);border-radius:var(--radius,.5rem);overflow-x:auto;font-size:var(--fs-code);
  line-height:var(--lh-code);letter-spacing:var(--ls-code);
  background:var(--c-code-panel);border:1px solid var(--c-rule)}
.prose pre.shiki{background:var(--c-code-panel)!important}
/* The two marks a block with no language still earns -- see render/plain-code.ts for why
   these two and nothing else.

   WEIGHT FIRST, COLOUR SECOND, and that order was decided by measuring rather than by taste.
   Written as colour alone, both marks came out at rgb(18,18,18) and rgb(22,22,22) against
   body text at rgb(38,38,38) -- invisible, because the DEFAULT palette is monochrome and
   --c-link and --c-accent are near-black in it on purpose. A device that does nothing on the
   palette most installs run is not a device.

   So the mark is weight, which every palette has, and the colour rides along for the ones
   that have a hue to give. Both kinds get the SAME treatment: a quoted literal and a $VAR
   are one category here -- machine values sitting inside human words -- and inventing a
   second distinction between them would be the guessing this whole file refuses. */
.prose pre.plain-code .tk-s,.prose pre.plain-code .tk-v{font-weight:600;color:var(--c-heading)}
.prose pre code{font-size:inherit;line-height:inherit;letter-spacing:inherit}
/* A SECTION BREAK, not a divider. A printed book never rules a line across the text
   block to change subject: it leaves white space, and marks it with something small and
   centred so the reader knows the gap is deliberate rather than a page ending. The
   full-width rule stays for the STRUCTURAL separations, which are a different job: the
   footnote rule, the top of the comment thread, the pager. Those are edges of the
   document; this is a pause inside it.
   Width in em, so it grows with the reader's type and with book mode's scale. */
.prose hr:not(.fn-rule){width:6em;margin:2.6em auto;border-top:1px solid var(--c-rule)}
/* A TABLE THAT WILL NOT FIT SCROLLS THE ARTICLE, NEVER THE PAGE.
   Measured at 390px: a five-column table is 484px at its narrowest, and a table cannot be
   squeezed below its own content -- so with visible overflow the DOCUMENT went to 516px and
   every paragraph in the piece panned sideways with it. A pre has carried overflow-x since
   it was written and .math-block was given it on purpose; this was the third case, and the
   only one nothing said out loud.
   The scroll sits on .prose rather than on the table, and that is forced rather than
   preferred: overflow is ignored on a display:table box, and switching the table to
   display:block makes the anonymous table inside it shrink-to-fit -- measured 607px down to
   345px on a laptop, which would quietly restyle every table on every site in order to fix
   a phone bug. :has() keeps the scroll container off the articles that have no table, and a
   browser without :has() is left exactly where it is today. */
.prose:has(table){overflow-x:auto}
/* EVERY NUMBER BELOW COMES FROM A VARIABLE, and the fallback in each var() is this file's
   own default — the same arrangement settings-css.ts uses, so the sheet renders correctly
   before any settings CSS is injected and a saved choice still wins when it is. The selectors
   stay here: a stylesheet whose values are variables ships once and caches; one assembled per
   site cannot. content/settings-table.ts decides what the values are.

   --tbl-min-col is the phone fix, and it is off by default because it is a CHOICE. .prose
   has carried overflow-x:auto for wide tables since it was written, and for a table of
   sentences it had never once engaged: a table that can shrink never overflows, so instead of
   scrolling it compressed. Measured at 375px on a two-column reference table -- first column
   105px, one row 551px tall, seven rows totalling 2,334px, no scrollbar. A floor on the cell
   is what lets the scroll container finally do its job; a floor on a timeline of three short
   columns, which fits a phone comfortably today, would send it sideways for nothing. */
.prose table{border-collapse:collapse;width:100%}
.prose th,.prose td{
  border-width:var(--tbl-rule-y, 1px) var(--tbl-rule-x, 1px);
  border-style:solid;border-color:var(--c-rule);
  padding:calc(var(--sp) * .4 * var(--tbl-pad, 1)) calc(var(--sp) * .6 * var(--tbl-pad, 1));
  min-width:var(--tbl-min-col, 0);text-align:left}
/* The header's own separation, drawn whatever the grid says: it marks the head off from the
   data and is not one of the table's lines. */
.prose th{background:var(--tbl-head-bg, color-mix(in srgb, var(--c-text) 6%, var(--c-bg)));
  color:var(--tbl-head-fg, var(--c-heading));
  border-bottom-width:var(--tbl-head-rule, 1px)}
/* Banding under the text, never beside it: transparent when the setting is off, so this
   rule costs a blog that never turns it on exactly nothing. */
.prose tbody tr:nth-child(even){background:var(--tbl-stripe, transparent)}
/* The left column as the heading for its row -- true of five of the six tables this was
   measured against, where the first column names the row and the rest answers it. */
.prose tbody td:first-child{font-weight:var(--tbl-col1-weight, inherit)}

/* BOOK TYPOGRAPHY (features.bookText). A printed book leads a paragraph with nothing but
   an indent; on screen that reads as a wall, so a small lead stays. A paragraph that OPENS
   something is never indented — the indent says "this continues", and after a heading
   there is nothing to continue from. */
.book-text .prose p{margin-top:.65em;text-indent:1.6em}
.book-text .prose > p:first-child{text-indent:0}
.book-text .prose :is(blockquote,figure,pre,ul,ol,hr,table,.gallery,.video-embed) + p{
  text-indent:0;margin-top:1.4em}
/* A heading keeps the tighter lead the rhythm rules give it: restating 1.4em here would
   undo, in book mode only, the one thing that binds a heading to its own section. */
.book-text .prose :is(h1,h2,h3,h4,h5) + p{text-indent:0}
.book-text .prose li p,.book-text .prose blockquote p{text-indent:0}
@media (min-width:600px){
  .book-text .prose p,.book-text .prose li{text-align:justify;hyphens:auto;
    hyphenate-limit-chars:6 3 3;
    -webkit-hyphenate-limit-before:3;-webkit-hyphenate-limit-after:3}
  /* The limits are what keep auto-hyphenation from reading like a ransom note: no word
     under six letters is broken, and never fewer than three letters on either side of the
     hyphen — "ty-po" and "a-bout" are the breaks a book's compositor would refuse.
     Irrelevant to Vietnamese (its syllables never hyphenate) and load-bearing for English
     and German, where justify without limits breaks greedily. The -webkit- longhands are
     Safari's older spelling of the same two numbers. */
}

/* The highlighter is NOT here any more. It weighed ~21 of the public sheet's 29 KB gzipped
   — 280 SVG data-URIs — and most pages never show a stroke, so ink.css.ts now builds it as
   two standalone halves that web/assets.ts hashes into their own immutable files, linked
   only when a page's HTML contains a mark or an underline (ADR 0027). The editor still
   sees every stroke: build-admin.ts appends the whole pen (INK_CSS) after this constant,
   because a stroke you cannot see while you are writing is a stroke you cannot place. */

/* Maths, here for the third time for the same reason: the editor is a .prose surface, and a
   formula you cannot see while you are writing is a formula you cannot check. */
/* A formula inherits the reading colour and nothing else. Size is left to the browser: a
   MathML fragment is already set relative to its surrounding text, and forcing it to
   --fs-body would flatten the smaller script sizes inside a subscript or an integral. */
.prose math{color:var(--c-text)}

/* THE ONE RULE THAT EARNS ITS PLACE. A derivation is routinely wider than the measure, and
   an element that cannot scroll widens the PAGE instead — which on a phone means every
   paragraph in the article gets a horizontal scrollbar because of one formula. A code block
   answers this on the element itself; a table cannot be wrapped (the golden compare fixes
   its markup byte for byte) so prose.css.ts scrolls the article around it. A formula has
   a wrapper of its own, so it takes the direct answer. In all three the page never moves. */
.math-block{overflow-x:auto;overflow-y:hidden;margin:1.4em 0}

/* Display maths is centred, which is the convention every printed text follows, and it is
   also what makes an overflowing formula obviously overflowing rather than merely long. */
.math-block math{margin-inline:auto}

/* Book mode indents paragraphs; a formula is not a paragraph and must not carry the indent
   into its own block. The same exemption the headings take. */
.book-text .prose .math-block{text-indent:0}

/* A formula Temml could not parse, shown as the writer's own source so it can be corrected
   on the page where it is visibly wrong. Deliberately NOT red: colour here would have to be
   a literal (the palettes carry no error token), and a cached body cannot be restyled later.
   The dotted rule under mono text already reads as "this is not finished" in every palette. */
.math-error{font-family:var(--font-mono);font-size:var(--fs-code);
  line-height:var(--lh-code);letter-spacing:var(--ls-code);
  border-bottom:1px dotted var(--c-rule);opacity:.75}

/* An archive heading is chrome, not the reader's words: it stays in --font-sans and it is
   BOLD, where a post title is 600. Both come from the frozen tree's own markup. */
.listing-head{margin:0 0 2rem}
.listing-head h1{font-size:var(--fs-h1);line-height:var(--lh-h1);
  letter-spacing:var(--ls-h1);color:var(--c-heading);margin:0;font-weight:var(--fw-title,700)}
.lower{text-transform:lowercase}
/* Type ROLES, ported from the frozen tree. A card composes these rather than declaring
   its own sizes, which is why the listing and the article agree without anyone keeping
   two numbers in step. Every size is the owner's --fs-* setting times --type-scale, so
   nothing here is a literal. */
.fs-h1{font-size:var(--fs-h1);line-height:var(--lh-h1);letter-spacing:var(--ls-h1)}
.fs-h2{font-size:var(--fs-h2);line-height:var(--lh-h2);letter-spacing:var(--ls-h2)}
.fs-h3{font-size:var(--fs-h3);line-height:var(--lh-h3);letter-spacing:var(--ls-h3)}
.t-small{font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small)}
.t-body{font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--ls-body)}
.text-meta{color:var(--c-meta)}
/* One fact of a meta line stays on one line. The reading time is a bracketed number and
   then two words, and at 390px it broke between them on EVERY card of every listing —
   "[4]" orphaned at the end of one line and "min read" starting the next. Measured
   2026-08-22. The line as a whole still wraps between its facts, which is what it should do;
   this only stops a fact wrapping through its own middle. */
.meta-part{white-space:nowrap}
.text-text{color:var(--c-text)}
.reading-font{font-family:var(--font-reading)}
.font-semibold{font-weight:var(--fw-heading,600)}
.mt-2{margin-top:.5rem}
.mt-3{margin-top:.75rem}
/* ONE hover signature for every link outside the body copy: an accent underline. */
.link-accent{color:var(--c-heading);text-decoration:none}
.link-accent:hover{text-decoration:underline;text-decoration-color:var(--c-accent);
  text-decoration-thickness:1px;text-underline-offset:4px}

/* THE CLICK, for everything on the reading site a finger can press. Pressing lands at
   once - the 1px of travel and the carved-in shadow arrive with transition-duration:0 -
   and only the release is sprung, on whatever transition the control already carries.
   The shadow is the palette's own heading ink diluted (an engine without color-mix just
   loses the shading, never the button), so every palette carves with its own ink. The
   book arrows keep their translateY centring (colour only, no travel) and the resume
   pill composes its centring with the dip. data-motion=off and reduced-motion keep the
   shadow and drop the travel, like every press in this product. */
.icon-btn:active,.code-copy:active,form.subscribe button:active,.theme-menu button:active,
.lightbox button:active,.to-top:active,.book-x:active,.comment-form button:active{
  transform:translateY(1px);transition-duration:0s;
  box-shadow:inset 0 1.5px 2.5px color-mix(in srgb,var(--c-heading) 22%,transparent)}
.resume-pill:active{transform:translateX(-50%) translateY(1px);transition-duration:0s;
  box-shadow:inset 0 1.5px 2.5px color-mix(in srgb,var(--c-heading) 22%,transparent)}
.book-arrow:active{color:var(--c-heading)}
html[data-motion=off] .icon-btn:active,html[data-motion=off] .code-copy:active,
html[data-motion=off] form.subscribe button:active,html[data-motion=off] .theme-menu button:active,
html[data-motion=off] .lightbox button:active,html[data-motion=off] .to-top:active,
html[data-motion=off] .book-x:active,html[data-motion=off] .comment-form button:active{transform:none}
html[data-motion=off] .resume-pill:active{transform:translateX(-50%)}
@media (prefers-reduced-motion:reduce){
  .icon-btn:active,.code-copy:active,form.subscribe button:active,.theme-menu button:active,
  .lightbox button:active,.to-top:active,.book-x:active,.comment-form button:active{transform:none}
  .resume-pill:active{transform:translateX(-50%)}
}

/* Cards are separated by SPACE, not by a rule. The border-bottom here was mine, not the
   frozen tree's, and it turned a quiet feed into a table. The gap has to be wide enough to
   read as a break rather than a paragraph space, which is what 4rem buys. */
.post-list{display:flex;flex-direction:column;gap:4rem}
.post-list > article > p:first-of-type{margin:0}
/* The timeline groups its cards by year, so the gap moves onto the cards themselves: the
   year marker is zero-height and sticky, and a flex gap would still reserve a row for it. */
.post-list.tl-feed{display:block}
.tl-feed .tl-yr article{margin-top:4rem}
.tl-feed > .tl-yr:first-child > article:first-of-type{margin-top:0}
[data-list=grid] .tl-yr{display:contents}
[data-list=grid] .tl-feed article{margin-top:0} /* the grid supplies its own gap */
/* A node ON the spine, not under it. The spine is .post-list::after, a pseudo-element of
   the LIST, so it paints after the list's children and drew straight over every month
   dot; and unlike the year tag the mark carried no background, so the hairline ran
   through the label too. Both fixed the way the year already solved it: a --c-bg mask
   that breaks the line, and a stacking order above it. */
.tl-mark{align-items:center;gap:.5rem;white-space:nowrap;color:var(--c-meta);
  background:var(--c-bg);padding:.1rem 3rem .1rem 0;z-index:1}
/* The sticky year is a --c-bg tag, so months sliding up to the top pass UNDER it and
   disappear instead of overlapping; the right padding widens the mask to cover the
   longest month label. */
.tl-year-tag{align-items:center;gap:.5rem;white-space:nowrap;color:var(--c-heading);
  font-weight:var(--fw-heading,600);background:var(--c-bg);padding:.1rem 3rem .1rem 0;
  font-size:var(--fs-h3);line-height:var(--lh-h3);
  letter-spacing:var(--ls-h3)}
.tl-year-tag .tl-dot{background:var(--c-accent)}
.tl-dot{flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:var(--c-meta)}
.empty{color:var(--c-meta)}

/* The year archive. Rows, not cards: this page exists to be scanned, and 200 excerpts is
   the listing page the reader came here to skip. Sizes are roles, so it follows the owner's
   typography like everything else. */
.arc-jump{display:flex;flex-wrap:wrap;gap:.75rem 1.25rem;margin:0 0 2.5rem;
  font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small);
  font-variant-numeric:tabular-nums}
.arc-yr + .arc-yr{margin-top:3rem}
/* Sticky, so the year stays named through a long one; the --c-bg band is what stops rows
   sliding out from under it, the same trick the timeline's year tag uses. */
.arc-yr h2{position:sticky;top:0;z-index:1;background:var(--c-bg);
  margin:0 0 1.25rem;padding:.25rem 0;display:flex;align-items:baseline;gap:.5rem;
  color:var(--c-heading);font-weight:var(--fw-heading,600);
  font-size:var(--fs-h3);line-height:var(--lh-h3);letter-spacing:var(--ls-h3)}
.arc-count{color:var(--c-meta);font-weight:400;
  font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small)}
.arc-yr ul{list-style:none;padding:0;margin:0}
.arc-yr li{display:flex;gap:1rem;align-items:baseline;padding:.35rem 0}
/* 5ch is exactly "MM-DD" in tabular figures, so every title starts on the same column
   without a table and without a fixed px width that a larger type setting would break. */
.arc-yr time{flex:0 0 5ch;color:var(--c-meta);font-variant-numeric:tabular-nums;
  font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small)}
/* On a phone the fixed date column costs a fifth of the line and a wrapped title ranges
   under it, so the date goes above its title instead. */
@media (max-width:34rem){
  .arc-yr li{display:block;padding:.5rem 0}
  .arc-yr time{display:block}
}
.pager{display:flex;justify-content:space-between;align-items:center;gap:1rem;
  border-top:1px solid var(--c-rule);padding-top:1rem;margin-top:1rem;
  font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small)}
.pager-count{color:var(--c-meta)}
form.search{display:flex;gap:.5rem;margin:0 0 2rem}
/* min-width:0 is what stops this row leaving the viewport. An <input> carries an intrinsic
   width from its size attribute, a flex item will not shrink below its own min-content by
   default, so at 390px the pair measured wider than the column and the button's right
   border sat off-screen: the page scrolled sideways. form.subscribe already carried this
   rule; the search form was written from the same shape and lost it. */
form.search input{min-width:0;flex:1;padding:.5rem .75rem;border:1px solid var(--c-rule);
  border-radius:var(--radius,.5rem);background:var(--c-bg);color:var(--c-text);font:inherit}
/* nowrap because the label is what pushed the row wide: "Tìm kiếm" broke over two lines and
   took the whole control to 78px tall to make room for itself. */
form.search button{padding:.5rem 1rem;border:1px solid var(--c-rule);border-radius:var(--radius,.5rem);
  background:var(--c-bg);color:var(--c-heading);font:inherit;cursor:pointer;white-space:nowrap}
/* Stacked on a phone, exactly as the sign-up form stacks and at the same width. */
@media (max-width:639px){form.search{flex-direction:column}}
/* The series box, a bordered card at the TOP of the post: part 3 of 6 comes BEFORE reading.
   Refined 2026-08-31 — the name is the card's title in heading ink, a hairline parts head
   from list, and the current part wears the rail's own 2px accent "you are here" bar. */
aside.series{border:1px solid var(--c-rule);border-radius:var(--radius,.5rem);padding:1.25rem 1.5rem;
  margin:2rem 0 0;font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small)}
aside.series .series-head{margin:0 0 .875rem;color:var(--c-meta)}
aside.series .series-head a{font-weight:var(--fw-heading,600)}
aside.series ol{margin:0;border-top:1px solid var(--c-rule);padding:1rem 0 0 1.25rem}
aside.series li + li{margin-top:.65rem}
aside.series li a{color:var(--c-meta);text-decoration:none}
aside.series li a:hover{color:var(--c-heading)}
aside.series li[aria-current]{position:relative;color:var(--c-heading);font-weight:var(--fw-heading,600)}
aside.series li[aria-current]::after{content:"";position:absolute;left:-2.75rem;top:3px;bottom:3px;
  width:2px;background:var(--c-accent)}
p.tags{margin-top:1.5rem;font-size:var(--fs-small);line-height:var(--lh-small);
  letter-spacing:var(--ls-small);color:var(--c-meta)}


.video-embed,.video-file{margin:calc(var(--sp) * 2) 0}
.video-embed{position:relative;padding-top:56.25%}
.video-embed iframe{position:absolute;inset:0;width:100%;height:100%;border:0}
.video-file video{width:100%;display:block}
.audio-embed iframe{width:100%;height:9.25rem;border:0}

.callout{border-left:2px solid var(--c-accent);
  padding:calc(var(--sp) * .75) 0 calc(var(--sp) * .75) var(--sp);
  margin:calc(var(--sp) * 1.75) 0}
.callout-label{font-weight:var(--fw-heading,600);color:var(--c-heading);margin:0 0 calc(var(--sp) * .35)}
.callout p:last-child{margin-bottom:0}

/* applyFootnotes already emits an <hr class="fn-rule">; a border-top here as well
   drew TWO lines above the notes. Caught by opening the page, not by reading it. */
.prose .fn-rule{margin-top:2.5em}
.footnotes{font-size:var(--fs-small);line-height:var(--lh-small);
  letter-spacing:var(--ls-small);color:var(--c-meta)}
.footnotes ol{padding-left:calc(var(--sp) * 1.25)}
sup.fnref a{text-decoration:none}

footer.site{padding:3rem 0;text-align:center;color:var(--c-meta);
  font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small)}
.footer-text{margin:0}
footer.site a{text-decoration:underline;text-underline-offset:2px}
footer.site a:hover{color:var(--c-text)}

/* Shiki emits a light colour inline and a --shiki-dark var; the dark palette swaps them.

   The TOKEN colours only. The background half of this rule used to ride along, painting
   #121212 from Shiki's dark theme over whatever palette the reader had chosen -- the same
   defect the light side had (see prose.css.ts), just less visible because a dark theme's
   grey sits closer to a dark palette's ground. The panel is --c-code-panel on both sides
   now, so all six palettes own their code block in both modes. */
.dark .shiki,.dark .shiki span{color:var(--shiki-dark)!important}

figure{margin:calc(var(--sp) * 2) 0}
figure img{display:block;margin:0 auto;border-radius:.5rem}
figcaption{color:var(--c-meta);font-size:var(--fs-caption);line-height:var(--lh-caption);
  letter-spacing:var(--ls-caption);text-align:center;margin-top:calc(var(--sp) * .5)}
.img-left img{margin-left:0}
.img-right img{margin-right:0}
.img-wide{margin-left:calc(-1 * clamp(0px,4vw,4rem));margin-right:calc(-1 * clamp(0px,4vw,4rem))}
/* A 30% figure (#third). Alone it is a small centred plate; with an align it floats and the
   words run around it, magazine-fashion — the one fragment that changes how TEXT lays out.
   The img fills its figure, so the figure's width is the whole geometry. Floats exist
   nowhere else in the prose, so headings and the footnote rule clear them: a section
   boundary should never wrap around a picture from the section before. */
.img-third{width:30%}
.img-third img{width:100%}
.img-center.img-third{margin-left:auto;margin-right:auto}
.img-left.img-third{float:left;margin:calc(var(--sp) * .35) var(--sp) calc(var(--sp) * .5) 0}
.img-right.img-third{float:right;margin:calc(var(--sp) * .35) 0 calc(var(--sp) * .5) var(--sp)}
.img-left.img-third figcaption,.img-right.img-third figcaption{text-align:left}
/* THE FRAME.
   Drawn on the IMG, not on a wrapper, and that is the whole design. An img with padding
   shows its background in the padding box, so the mat costs no extra element and no markup
   change - and the caption stays outside it, where a caption belongs. box-sizing is
   border-box for everything, so a framed picture is exactly as wide as an unframed one and
   nothing below it moves.

   THROUGH VARIABLES, so the site-wide default and a per-picture override can both exist
   without either knowing about the other - the same contract the gallery ratio already
   uses, and for the same hard reason: a rendered body is cached under a hash of its INPUT,
   so a default that rewrote the markup would leave every already-rendered post wearing the
   old frame until something unrelated evicted it. Through CSS it is instant instead.
   :root carries the site default (pageStyles, from settings). A figure class carries an
   override, including the override that says NO frame - which has to be sayable out loud
   once a site default exists to disagree with.

   THE STEPS ARE NAMED, and the site default points at a NAME rather than a length. That
   indirection is what makes the phone rule survive: the settings block is inlined AFTER the
   linked sheet, so a site default written as a length would outrank the media query and a
   thick default would keep its desktop mat on a 350px column. Pointing at --fig-step-* lets
   the media query redefine the step underneath it.

   Border WIDTH is the variable, not the border. A transparent 1px border still takes its
   2px out of a border-box picture, so an unframed image would quietly shrink by two pixels
   the day this shipped.

   The LINE is mixed rather than picked. The rule token is the site's divider colour and it
   is far too pale to hold a photograph (measured on the default palette: #ebebeb against a
   pale sky is invisible at 1px), while the meta colour at full strength reads as a heavy
   border. color-mix() lands between the two and stays inside the palette, so it still
   follows the theme. The plain rule-colour declaration before it is the fallback for an
   engine with no color-mix: a paler frame, never a missing one.

   INK inverts by itself: the heading colour is near-black on a light palette and near-white
   on a dark one, so one declaration gives a dark mat on paper and a light mat at night
   without a media query or a second token. */
:root{--fig-step-thin:calc(var(--sp) * .5);--fig-step-med:var(--sp);--fig-step-thick:calc(var(--sp) * 1.75)}
figure img{padding:var(--fig-pad,var(--fig-default-pad,0));
  background:var(--fig-mat,var(--fig-default-mat,transparent));
  border:var(--fig-bw,var(--fig-default-bw,0)) solid var(--fig-line,var(--fig-default-line,transparent))}
.img-frame{--fig-pad:var(--fig-step-med);--fig-mat:var(--c-bg);--fig-bw:1px;--fig-line:var(--c-rule);
  --fig-line:color-mix(in srgb,var(--c-rule),var(--c-meta) 35%)}
.img-frame-thin{--fig-pad:var(--fig-step-thin)}
.img-frame-thick{--fig-pad:var(--fig-step-thick)}
/* The mat is the ink itself, so the line would only draw a second edge on top of it. */
.img-frame-ink{--fig-mat:var(--c-heading);--fig-line:var(--c-heading)}
/* Said out loud, because silence means "whatever the site says". */
.img-noframe{--fig-pad:0;--fig-mat:transparent;--fig-bw:0}
.prose h2,.prose h3,.prose .fn-rule{clear:both}
.prose::after{content:"";display:table;clear:both}
.gallery{display:grid;gap:calc(var(--sp) * .5);margin:calc(var(--sp) * 2) 0}
.gallery figure{margin:0}
.gallery-cols-2{grid-template-columns:repeat(2,1fr)}
.gallery-cols-3{grid-template-columns:repeat(3,1fr)}
.gallery-cols-4{grid-template-columns:repeat(4,1fr)}
/* Gallery shape and captions, as VARIABLES, so two places can set them without either
   knowing about the other: the site default on :root (pageStyles, from settings) and a
   per-gallery override on the tile, which wins on specificity. The var() fallbacks are what
   a gallery did before any of this existed, so an untouched site is unchanged.
   picture is inline, and an inline box is a poor containing block for a percentage width. */
.gallery picture{display:block}
.gallery figure img{aspect-ratio:var(--gallery-ratio,auto);width:var(--gallery-w,auto);object-fit:cover}
.gallery figcaption{display:var(--gallery-cap,block)}
/* Width rides with the ratio: a cropped tile fills its cell, an uncropped one must not. */
.gallery .g-asis{--gallery-ratio:auto;--gallery-w:auto}
.gallery .g-1x1{--gallery-ratio:1/1;--gallery-w:100%}
.gallery .g-3x2{--gallery-ratio:3/2;--gallery-w:100%}
.gallery .g-4x3{--gallery-ratio:4/3;--gallery-w:100%}
.gallery .g-cap{--gallery-cap:block}
.gallery .g-nocap{--gallery-cap:none}

/* THE RAIL: the listing sidebar and the post's table of contents, which are one piece of
   furniture wearing two sets of contents. All server-rendered, so these rules apply with
   or without JavaScript; only the aria-current highlight and the mobile drawer come from
   the bundle.

   BELOW the rail breakpoint it sits above the article, in normal flow, exactly as written
   here. ABOVE it, the rules generated by singleRailCss move it into the left gutter. The
   frozen tree also had a slide-out drawer and a toggle island for narrow screens; a list
   that simply sits above the article needs neither, so that island is not ported. */
:root{--rail-w:250px;--rail-gap:40px;
  /* Gap between a rail row's text and the accent marker beside it. */
  --rail-pad:14px;
  /* Space between the header and the first line of content. The rail's top matches it, so
     the rail's first line is level with the article's first line. */
  --rail-top:3rem}

/* Mobile FIRST: the rail is a slide-out drawer opened from the header menu button. The
   injected geometry promotes it into the gutter above the breakpoint. ONE piece of DOM
   serves both, which is why there is no second copy of the sidebar to keep in step. */
/* overflow-x is spelled out, and it is not decoration. A box with overflow-y:auto and no
   overflow-x computes overflow-x to auto as well, so the drawer became a horizontal
   scroller the moment anything inside it was a pixel too wide - which is how a gutter rule
   meant for the desktop rail taught every phone to pan the sidebar 32px sideways. */
.rail{position:fixed;top:0;bottom:0;left:0;z-index:40;width:min(300px,84vw);
  overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding:4.5rem 1.25rem 2rem;
  background:var(--c-bg);border-right:1px solid var(--c-rule);
  transform:translateX(-100%);transition:transform var(--dur-base) ease}
html[data-rail=open] .rail{transform:none}
/* Two-rail listings only: on mobile there is no gutter, so the LEFT rail is hidden and its
   blocks appear in the right rail's drawer through .drawer-only. */
.rail-left{display:none}
/* Tap anywhere else to close. No dim: the drawer already owns a solid surface. */
.rail-scrim{position:fixed;inset:0;z-index:39}
.rail-inner{position:sticky;top:2.5rem}
.rail-inner > * + *,.drawer-only > * + *{margin-top:1.75rem}
.rail h2{margin:0 0 .75rem;padding-left:var(--rail-pad);font-weight:var(--fw-heading,600);color:var(--c-heading);
  font-size:var(--fs-small);line-height:var(--lh-small);
  letter-spacing:var(--ls-small)}
.rail ul{list-style:none;margin:0;padding:0}
.rail li{margin-top:.5rem}
.rail li:first-child{margin-top:0}
.rail-row{position:relative;display:flex;justify-content:space-between;gap:.875rem;
  padding-left:var(--rail-pad);color:var(--c-meta)}
.rail-row:hover,.rail-tags a:hover{color:var(--c-heading)}
/* The one accent mark: a hairline beside the row you are already reading. In the drawer it
   sits left of the text; in the gutter the rail flips and it faces the divider. */
/* ::after, not ::before. A row can be BOTH aria-current and .rail-lead (the bullet) or
   .rail-sub (the IDE chrome's leading slash), and all of them wanted the SAME
   pseudo-element: the marker's empty content won, and the slash came out painted in the
   accent colour as a red diagonal at the row's right edge. Two marks, two elements. */
.rail-row[aria-current]::after{content:"";position:absolute;left:0;top:3px;bottom:3px;
  width:2px;background:var(--c-accent)}
/* Counts sit in their own right-aligned column so the labels stay aligned however many
   digits a count carries. The column is exactly as wide as the widest count on the page
   (--count-w, in ch, exact because the digits are tabular); a fixed em width would leave
   dead space on every single-digit row. */
.rail-count{min-width:var(--count-w,1ch);text-align:right;flex-shrink:0;
  font-variant-numeric:tabular-nums}
.rail-tags{display:flex;flex-wrap:wrap;column-gap:.75rem;row-gap:.25rem;padding-left:var(--rail-pad)}
.rail-tags a{color:var(--c-meta)}
/* Tags are many and short: a wrapped run of plain words, no chips, no boxes. */
.rail-tags.lower a{text-transform:lowercase}
/* The brackets are CSS, not markup, so the IDE chrome can swap them for square ones and
   switching it off puts them back. They used to be typed into the sidebar renderer, which
   is why the taxonomy read "(7)" while the list beside it read "[7]". */
/* No opacity. --c-meta is 4.56:1 against the page and passes AA with 0.06 to spare; at .6
   the count measured 2.26:1, which is a fail at any size. It only ever looked acceptable
   because the IDE chrome resets the opacity to 1, so the site the owner sees was never the
   one shipping the failure: switching that off produced unreadable counts. */
.term-count{margin-left:.25rem;font-variant-numeric:tabular-nums}
.term-count::before{content:"("}
.term-count::after{content:")"}
.rail-row.is-active,.rail-tags a.is-active{font-weight:500;color:var(--c-heading)}
.rail-tags a.is-active{text-decoration:underline;text-decoration-color:var(--c-accent);
  text-underline-offset:4px}
/* No panel: no border, no shadow, no background, just type sitting on the page. It had a
   left border and a padded box, which read as a widget parked beside the article.

   Nest visually ONLY when the post MIXES levels: an H2 row carries one bigger dot as a
   section marker and an H3 row simply goes smaller. So it reads as a few strong markers
   over quieter children rather than a column of identical bullets. The dot is an inline
   ::before, so it flows for BOTH rail orientations with no per-side handling. */
.rail-lead::before{content:"•";font-size:.72em;margin-inline-end:.5em;vertical-align:.12em;
  color:var(--c-meta)}
.rail-sub{font-size:var(--fs-caption);line-height:var(--lh-caption);
  letter-spacing:var(--ls-caption)}
.toc-end{margin-top:1rem}
/* Below the rail breakpoint the ToC is the drawer, and a post with a long index needs the
   whole column: the listing rail is not on this page to share it with. */
.toc li{margin-top:.5rem}
/* --- islands -------------------------------------------------------------------
   Every rule below styles an element the browser bundle CREATES. None of it applies
   to the server-rendered page, so a reader with JavaScript off sees no gaps: the
   elements simply never exist. */

/* font-family explicitly, because this button lives INSIDE .prose pre and would otherwise
   inherit the code face. It is chrome sitting on top of code, not code. */
.code-copy{position:absolute;top:.4rem;right:.4rem;padding:.15rem .5rem;font-size:var(--fs-caption);
  line-height:var(--lh-caption);letter-spacing:var(--ls-caption);font-family:var(--font-sans);
  border:1px solid var(--c-rule);background:var(--c-bg);color:var(--c-meta);cursor:pointer;opacity:0;transition:opacity var(--dur-fast)}
.prose pre{position:relative}
.prose pre:hover .code-copy,.code-copy:focus-visible{opacity:1}

/* The reading-progress bar has NO script behind it: a scroll-driven animation reads the
   document's own scroll position. It therefore works with JavaScript off, and runs off the
   main thread. On an engine without scroll timelines the bar would sit at zero forever, so
   the @supports rule removes it entirely rather than leaving a dead hairline on the page.
   NOTE: no backticks anywhere in this file. It is one template literal, and a backtick in
   a comment ends the string. That has now cost two debugging sessions. */
.progress{display:none;position:fixed;inset-inline:0;top:0;height:2px;z-index:50}
.progress-fill{height:100%;background:var(--c-heading);transform:scaleX(0);transform-origin:0 50%}
@supports (animation-timeline:scroll()){
  .progress{display:block}
  .progress-fill{animation:read-progress linear both;animation-timeline:scroll(root block)}
}
@keyframes read-progress{to{transform:scaleX(1)}}


.to-top{position:fixed;bottom:1.25rem;right:1.25rem;z-index:40;display:flex;width:2.5rem;height:2.5rem;
  align-items:center;justify-content:center;border:1px solid var(--c-rule);border-radius:999px;
  background:var(--c-bg);color:var(--c-meta);cursor:pointer;opacity:0;pointer-events:none;transition:opacity var(--dur-base),color var(--dur-base)}
.to-top.shown{opacity:1;pointer-events:auto}
.to-top:hover{color:var(--c-heading)}

/* The way back into a half-read post (assets/js/resume.ts). The to-top button's own
   manners: paper, hairline, quiet corner — bottom CENTRE, because it speaks about the
   reader's place in the text rather than about the page. It leaves on its own the moment
   the reader starts scrolling; a control that answers a question nobody asked has to go
   quietly. */
.resume-pill{position:fixed;bottom:1.25rem;left:50%;transform:translateX(-50%);z-index:40;
  max-width:min(92vw,26rem);padding:.55rem 1.1rem;border:1px solid var(--c-rule);border-radius:999px;
  background:var(--c-bg);color:var(--c-body);cursor:pointer;font-size:var(--fs-small);
  line-height:var(--lh-small);letter-spacing:var(--ls-small);opacity:0;pointer-events:none;transition:opacity var(--dur-base),color var(--dur-base)}
.resume-pill.shown{opacity:1;pointer-events:auto}
.resume-pill:hover{color:var(--c-heading)}

/* The quote control, raised on a selection (assets/js/quote.ts).
   SOLID INK, and the first cut was not: it borrowed the to-top button's hairline-on-paper
   and came out a pale grey pill sitting on top of grey text — the owner reported it as
   hard to see, and it was. The to-top button can be quiet because it waits in an empty corner; this one
   appears IN the text, over the words, for two seconds. A control that interrupts reading
   has to look deliberate or it reads as a rendering fault. Ink and paper, inverted, is the
   loudest thing this palette can say without introducing a colour.
   Positioned in DOCUMENT space (absolute, not fixed): a fixed control has to be re-placed
   on every scroll frame, and this one is dismissed by scrolling anyway. */
.quote-copy{position:absolute;z-index:40;padding:.34rem .8rem;border:0;
  border-radius:999px;background:var(--c-heading);color:var(--c-bg);cursor:pointer;
  font-family:var(--font-sans);font-size:var(--fs-small);line-height:var(--lh-small);
  white-space:nowrap;
  letter-spacing:var(--ls-small);transition:opacity var(--dur-base)}
.quote-copy:hover{opacity:.85}
.quote-copy[hidden]{display:none}

/* A <dialog>, so Escape, focus trapping and the inert background come from the browser.
   The viewer is deliberately NOT themed: a light backdrop behind a photograph is a worse
   reading of the photograph, and readers expect a lightbox to be dark. */
.lightbox[open]{display:flex}
.lightbox{width:100%;max-width:100%;height:100%;max-height:100%;border:0;overflow:hidden;
  flex-direction:column;align-items:center;justify-content:center;gap:.75rem;padding:1rem;
  background:rgba(0,0,0,.9);color:#fff}
.lightbox::backdrop{background:rgba(0,0,0,.9)}
.lightbox-caption:empty{display:none}
.lightbox-img{max-height:85vh;max-width:100%;object-fit:contain}
.lightbox-caption{max-width:42rem;text-align:center;font-size:var(--fs-small);
  line-height:var(--lh-small);letter-spacing:var(--ls-small);color:rgba(255,255,255,.7);margin:0}
.lightbox button{position:absolute;display:flex;align-items:center;justify-content:center;
  border:0;border-radius:999px;background:transparent;color:rgba(255,255,255,.8);cursor:pointer;line-height:1}
.lightbox button:hover{background:rgba(255,255,255,.1);color:#fff}
.lightbox-close{top:.75rem;right:.75rem;width:2.5rem;height:2.5rem;font-size:1.5rem}
.lightbox-prev,.lightbox-next{top:50%;transform:translateY(-50%);width:3rem;height:3rem;font-size:1.875rem}
.lightbox-prev{left:.5rem}
.lightbox-next{right:.5rem}
.lightbox-count{position:absolute;bottom:1rem;font-size:var(--fs-caption);
  line-height:var(--lh-caption);letter-spacing:var(--ls-caption);
  font-variant-numeric:tabular-nums;color:rgba(255,255,255,.6)}

/* Scroll reveal: a card eases in as it enters the viewport. This is what the owner meant
   by the fade at the foot of the feed going missing - the markup has carried a .reveal
   class since M2 and NO rule ever matched it, so the cards simply appeared.

   GUARDED three ways, exactly as the frozen tree guards it: it may only ever HIDE content
   where it can also reveal it. Needs view() timelines, motion on, and no reduced-motion
   preference; anything else leaves .reveal a normal, fully visible element. There is no
   blank-page failure mode. */
@supports (animation-timeline:view()){
  @media (prefers-reduced-motion:no-preference){
    html[data-motion=on] .reveal{animation:reveal-in linear both;animation-timeline:view();
      /* Finishes in the lower third, where the eye is - not at the very bottom edge. */
      animation-range:entry 0% cover 20%}
  }
}
@keyframes reveal-in{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}}
/* The same reveal for engines with no view() timeline (Firefox today). Armed by the island
   ONLY on those engines, and only for cards that are not already on screen, so nothing
   above the fold can flash. */
@media (prefers-reduced-motion:no-preference){
  html[data-reveal-js=on] .reveal:not(.is-in){opacity:0;transform:translateY(24px)}
  html[data-reveal-js=on] .reveal{transition:opacity var(--dur-slow) ease,transform var(--dur-slow) ease}
}

/* Chunked feed. The server renders every card, so a reader with no JavaScript gets the
   whole archive and a crawler sees all of it; the island hides what is past the first page
   and hands it back a chunk at a time on scroll. The <noscript> counterpart is emitted with
   the list, so the hiding only ever applies where something can undo it. */
html[data-chunked] .post-list article[data-more]{display:none}

.preview-note{border:1px solid var(--c-rule);background:var(--c-rule);color:var(--c-meta);
  border-radius:.5rem;padding:.5rem 1rem;font-size:var(--fs-small);
  line-height:var(--lh-small);letter-spacing:var(--ls-small);margin:0 0 1.5rem}

/* Two ways to the same place: the reader's system preference, and the owner's Motion
   switch in Settings. The switch had no effect at all until this rule existed. */
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
html[data-motion=off] *{animation:none!important;transition:none!important}
/* Book mode. Its OWN standard rather than the site theme: paper and ink, not the reader's
   palette, and the same on a dark site as a light one. Carried over from the frozen tree.
   The columns come from column-width, so the BROWSER paginates and turning a page is one
   transform on the flow rather than a measurement loop fighting the layout engine. */
.book-mode-toggle{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer;
  text-underline-offset:3px}
.book-mode-toggle:hover{color:var(--c-heading);text-decoration:underline}
@media (max-width:767px){.meta-book,.book-mode-toggle{display:none}}

/* The phone's doorway into the reader. The desktop entries (the meta line, the info
   panel) are hidden under 768px because the meta line is cramped there — so this button
   is the mobile entry, and it speaks the to-top button's exact language: same circle, same
   border, same colours, parked one slot above it in the same column, and — the part that
   keeps it out of the way — the same appearing act: nothing until the reader has scrolled
   past the first viewport, then a fade-in. A reader who never scrolls never sees it. */
.book-fab{position:fixed;bottom:4.5rem;right:1.25rem;z-index:40;display:none;width:2.5rem;
  height:2.5rem;align-items:center;justify-content:center;border:1px solid var(--c-rule);
  border-radius:999px;background:var(--c-bg);color:var(--c-meta);cursor:pointer;opacity:0;
  pointer-events:none;transition:opacity var(--dur-base),color var(--dur-base)}
.book-fab.shown{opacity:1;pointer-events:auto}
.book-fab:hover{color:var(--c-heading)}
@media (max-width:767px){.book-fab{display:flex}}

body:has(.book-overlay[open]){overflow:hidden}
.book-overlay[open]{display:grid}
/* Book mode is its OWN standard, not the site theme and not dark mode: a printed page. ONE
   flat warm-paper background across the whole reader, near-black ink, with a grain baked
   into the background so a flat screen reads as printed stock. Overriding the theme TOKENS
   here recolours everything inside — prose text, headings, links, rules — in one place, and
   the base page keeps its own, so closing the reader restores the previous colours by
   itself. They must be the real --c-* tokens: anything else leaves the body text following
   the site theme, and dark mode then shows white ink on white paper. */
.book-overlay{position:fixed;inset:0;z-index:60;width:100%;max-width:100%;height:100%;
  max-height:100%;border:0;padding:0;grid-template-rows:auto 1fr;
  /* The overlay is the READING face throughout, chrome included: the running head is the
     article's own title. Tracking has to be stated for the same reason the family does, or
     it inherits the mono-chrome correction from body and sets a book serif at -0.05em.
     Measured 2026-07-29: the running head was running -0.7px per character. */
  font-family:var(--font-reading);letter-spacing:var(--ls-body);
  /* The stock was recut 2026-08-21, to read as classic rather than aged. What read as AGE was two things: a paper pulled hard toward yellow
     (#f9f4ec) and a grain printed at 0.62 opacity, together doing an impression of foxed
     stock. The paper is now a quiet warm ivory and the grain drops to a texture you feel
     more than see; the drop cap, the asterism and the spine — the CLASSIC half — stay. */
  --book-paper:#faf8f3;--c-bg:var(--book-paper);
  /* Reading text runs 5% larger in here — it MULTIPLIES the owner's --fs-* roles, so it
     tracks the site's own type setting rather than replacing it. Was 1.15 from 2026-07-29
     until it was revised on 2026-08-21 as slightly too large by default. 1.05 keeps
     the reader a touch more generous than the article without the large-print feel, and
     the A−/A+ control in the chrome (book.ts) now lets each reader move it themselves —
     that override rides as an inline style, so this remains only the default. */
  --type-scale:1.05;
  /* --c-meta measured 3.30:1 at #8d8676 on the OLD paper, which fails AA, and it is the
     running head and the page count: the two things a reader checks WITHOUT stopping to
     read. #6f6a5c re-measured 2026-08-21 on the new stock: 5.08:1, and still reads as
     pencil beside the ink. */
  --c-text:#211f1a;--c-heading:#16130d;--c-meta:#6f6a5c;--c-link:#2f2c25;
  --c-accent:#2f2c25;--c-rule:#e2ddd2;color:var(--c-text);
  background-color:var(--book-paper);background-blend-mode:multiply;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.24'/%3E%3C/svg%3E")}
.book-overlay::backdrop{background:#faf8f3}
.book-chrome{position:relative;display:flex;align-items:center;justify-content:center;
  min-height:56px;padding:0 clamp(16px,4vw,48px)}
.book-top{border-bottom:1px solid var(--c-rule)}
.book-title{font-size:var(--fs-small);line-height:var(--lh-small);
  letter-spacing:var(--ls-small);font-weight:400;color:var(--c-meta);text-align:center;
  /* The page count, the size buttons and the close button sit in an absolutely positioned
     box on the right, so they take part in no layout and a centred title runs straight
     under them. On a phone the running head printed over the counter: "owning your ow1 / 5".
     THE RESERVATION IS TWICE THE BOX, and getting that wrong is how the title landed on top
     of A− again on 2026-08-27: the head is CENTRED, so half of whatever width it is allowed
     grows to the right of the middle. Reserving the box once leaves the right half of a wide
     title free to run under it. The box is ~208px plus its clamp(12px,4vw,44px) offset and a
     gap, so 540 = 2 x 270. Only bites on a narrow screen. */
  max-width:min(70%,calc(100% - 540px),720px);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Three THINGS, not four glyphs: the size control, the page count, the way out. The gaps
   say so — the pair is one box, the count sits at arm's length from it, and the close
   button is further still. Before this they were four items 16px apart in one weight and
   one colour, which reads as a string of characters ("A− A+ 1/3 ✕") rather than as
   controls, and that is exactly what the owner reported on 2026-08-27. */
.book-topright{position:absolute;right:clamp(12px,4vw,44px);top:0;height:100%;
  display:flex;align-items:center;gap:18px}
/* On a phone the reserved right box leaves the running head ~75px — three letters and an
   ellipsis pressed against A−. A chrome that can only stammer the title is better off
   silent: the reader opened this article seconds ago and the page count keeps the row. */
@media (max-width:519px){.book-title{display:none}}
/* The way out, and it should look like a button rather than another character in the row:
   a round target the pointer can find, which fills faintly under the hand. 34px is the
   smallest circle that still swallows a fingertip on the tablet this mode is really for. */
/* Further out than the gap between the pair and the count: leaving is not the third item
   in a set of reading controls, and the extra 8px is the whole difference between reading
   the row as "size, page, close" and as three equal buttons. */
.book-x{margin-left:8px;
  background:none;border:0;cursor:pointer;color:var(--c-meta);font-size:1rem;
  line-height:1;width:34px;height:34px;padding:0;border-radius:999px;
  display:flex;align-items:center;justify-content:center;
  transition:background-color .12s ease,color .12s ease}
.book-x:hover{color:var(--c-heading);background:color-mix(in srgb,var(--c-rule) 55%,transparent)}
@media (prefers-reduced-motion:reduce){.book-x{transition:none}}
/* The reader's own hand on the type: a small a and a large A, plain glyphs on the paper.
   The pill-with-a-rule cut before this one drew a segmented control, and the owner read
   it as exactly that — buttons, with a dark seam between them on first paint. The size
   difference IS the label, so nothing else is drawn.
   The two font sizes would misalign under flex centering, and an earlier cut shipped that
   — so both glyphs sit on the SAME baseline instead: line-height 1,
   both buttons bottom-aligned with one shared padding, and neither glyph has a descender,
   which makes the bottoms the baseline. */
.book-sizes{display:flex;align-items:flex-end}
.book-size{background:none;border:0;cursor:pointer;color:var(--c-meta);line-height:1;
  font-family:var(--font-reading);height:34px;min-width:32px;padding:0 7px 9px;
  display:flex;align-items:flex-end;justify-content:center;
  transition:color .12s ease}
.book-smaller{font-size:.8em}
.book-larger{font-size:1.2em}
.book-size:hover:not([disabled]){color:var(--c-heading)}
.book-size[disabled]{opacity:.4;cursor:default}
@media (prefers-reduced-motion:reduce){.book-size{transition:none}}
.book-count{font-size:var(--fs-caption);line-height:var(--lh-caption);
  letter-spacing:var(--ls-caption);color:var(--c-meta);font-variant-numeric:tabular-nums}
/* Holds the dialog's initial focus (see book.ts) — a reading surface, not a control,
   so it never wears a ring. */
.book-stage{position:relative;display:flex;align-items:center;justify-content:center;
  min-height:0;padding:clamp(20px,4vh,52px) 0;outline:none}
/* The reading area just CLIPS the flowing columns: no sheet, no shadow, so the one paper
   background and its grain show through everywhere. */
/* A flex:1 here silently beat the measured width — a flex item with a basis of 0 and
   grow 1 fills the stage no matter what the inline style says, so the spread ran the
   full viewport and the two facing pages became four. */
/* The crossfade between spreads: dim, jump, come back. Without it the turn is a hard cut,
   which is what "not smooth any more" meant. 200ms, matching the frozen tree, and the
   motion switch above zeroes it like everything else. */
.book-viewport{position:relative;height:100%;flex:0 0 auto;max-width:100%;overflow:hidden;
  padding:clamp(4px,2vh,24px) 0;transition:opacity var(--dur-fast) ease}
.book-flow{height:100%;column-gap:56px;column-width:var(--book-col-w,340px);column-fill:auto;
  max-width:none;
  /* Oldstyle figures and discretionary ligatures. Both were missing from every subset
     until scripts/ops/subset-fonts.py put them back, so this had never rendered. */
  font-feature-settings:"onum" 1,"liga" 1,"dlig" 1}
/* Media stays column-width and never taller than a page, so nothing overflows the spread. */
.book-flow :is(img,video,iframe,pre,table,blockquote,figure){break-inside:avoid}
.book-flow :is(img,video,iframe){max-width:100%;max-height:var(--book-page-h,70vh);object-fit:contain}
.book-flow pre{max-height:var(--book-page-h,70vh);overflow:auto}
/* A "wide" image has NO effect in here: it renders at column width like any other figure,
   so it can never spill into the next page. This overrides the desktop gutter-widening the
   rail geometry injects, which otherwise leaks in because the flow is also .prose. */
.book-flow.prose figure.img-wide,.book-flow.prose .video-wide{
  width:100%;max-width:100%;margin-left:auto;margin-right:auto}
/* The first column opens flush with the top of the page. */
.book-flow.prose > :first-child{margin-top:0}
/* Drop cap: the first paragraph opens with a large raised initial spanning about three
   lines, which is the classic chapter opening. The line beside it is not also indented. */
.book-flow.prose > p:first-child::first-letter{float:left;margin:.02em .09em 0 0;
  font-size:3.1em;line-height:.72;font-weight:600;color:var(--c-heading)}
.book-flow.prose > p:first-child{text-indent:0}
/* In the reader the same break becomes an asterism: more room, so the ornament can be a
   real one. The width and the top border are reset explicitly because the article's short
   rule is the more specific selector and would otherwise draw a line under the mark. */
.book-flow hr,.book-flow hr:not(.fn-rule){border:0;border-top:0;width:auto;height:auto;
  margin:1.5em 0;text-align:center;background:none}
.book-flow hr::before{content:"⁂";color:var(--c-meta);font-size:1.05em;letter-spacing:.35em}
/* A faint spine down the centre gutter. It sits on the viewport, so it stays put while the
   pages flip beneath it. */
.book-viewport::after{content:"";position:absolute;top:7%;bottom:7%;left:50%;width:1px;
  background:var(--c-rule);opacity:.7;pointer-events:none}
/* One page, so there is no gutter for a spine to sit in. */
.book-viewport[data-pages="1"]::after{display:none}
.book-arrow{position:absolute;top:50%;transform:translateY(-50%);z-index:2;background:none;
  border:0;cursor:pointer;color:var(--c-meta);font-size:2rem;line-height:1;padding:12px 16px}
.book-arrow:hover{color:var(--c-heading)}
.book-prev{left:clamp(4px,2vw,28px)}
.book-next{right:clamp(4px,2vw,28px)}
/* Under 640px the arrows retire: they are hover furniture sized for a mouse, and they sat
   on top of a margin the phone no longer spares. The page turns by swipe or by a tap in
   the outer thirds (book.ts) — the e-reader gestures a thumb already knows. */
@media (max-width:639px){.book-arrow{display:none}}

/* Comments and sign-up. The FORM is server-rendered markup, so these rules apply with or
   without JavaScript; the comment thread is built by the island, so its rules only ever
   match once the script has run. */
/* Grid view. The attribute is set by the island; with no script the list stays a list,
   which is the shape every reader gets by default anyway. */
[data-list="grid"] .post-list{display:grid;gap:1.5rem;grid-template-columns:repeat(auto-fill,minmax(15rem,1fr))}
[data-list="grid"] .post-list > article{margin:0}
[data-list="grid"] .post-list h2{font-size:var(--fs-h3);line-height:var(--lh-h3);
  letter-spacing:var(--ls-h3)}
[data-list="grid"] .post-list .t-body{display:none}
.listing-sentinel{height:1px}

/* Cards ease in as they enter the viewport, in CSS. The frozen tree shipped an
   IntersectionObserver fallback for engines without scroll-driven animations; 04-frontend.md
   called for deleting it, and this is that deletion. An engine without support simply shows
   the cards, which is the correct end state anyway. Motion is skipped entirely when the
   reader has asked for less of it. */
@supports (animation-timeline:view()){
  @media (prefers-reduced-motion:no-preference){
    .post-list > article{animation:card-in linear both;animation-timeline:view();animation-range:entry 0% entry 40%}
  }
}
@keyframes card-in{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}

.site-bar{display:flex;align-items:center;justify-content:space-between;gap:1rem}
/* -0.625rem optically aligns the LAST icon's glyph with the column's right margin: the
   40px button centres a 20px glyph, so the glyph sits 10px inside the button edge. Pulling
   the row right by that 10px lands it flush, matching the logo's flush-left edge. */
.site-actions{display:flex;align-items:center;gap:.125rem;flex-shrink:0;margin-right:-.625rem}
/* The owner's menu, in the same cluster as the controls, from 60rem up (narrower than that
   the row is a title and five controls already). The .5rem past the bar's own 1rem gap holds
   the first control further off than the next link, so it is not read as one of them. */
.site-menu{display:none}
@media (min-width:60rem){
  .site-menu{display:flex;align-items:center;gap:1.25rem;margin-left:auto;min-width:0;padding-right:.5rem}
  .site-menu a{color:var(--c-meta);text-decoration:none;white-space:nowrap}.site-menu a:hover{color:var(--c-heading)}
}
.icon-btn{display:flex;align-items:center;justify-content:center;width:2.5rem;height:2.5rem;
  border:0;border-radius:var(--radius,.5rem);background:none;color:var(--c-meta);cursor:pointer;text-decoration:none}
.icon-btn:hover{color:var(--c-heading);background:var(--c-rule)}
/* The short token beside each icon. Absent unless the IDE chrome is on: with the switch
   off this header is exactly the header it has always been. */
.btn-token{display:none;font-size:var(--fs-caption);line-height:var(--lh-caption);
  letter-spacing:var(--ls-caption)}

/* The overlays. Both are dialogs, so Escape and the inert background are the browser's. */
.overlay[open]{display:flex}
.overlay{flex-direction:column;border:1px solid var(--c-rule);border-radius:var(--radius,.5rem);padding:1.25rem;
  width:min(36rem,92vw);max-height:70vh;background:var(--c-bg);color:var(--c-text);margin-top:8vh}
.overlay::backdrop{background:rgba(0,0,0,.4)}
.search-close{position:absolute;top:.5rem;right:.5rem;border:0;background:none;color:var(--c-meta);
  font-size:1.25rem;line-height:1;cursor:pointer}
.search-input{padding:.6rem .75rem;border:1px solid var(--c-rule);border-radius:var(--radius,.5rem);
  background:var(--c-bg);color:var(--c-text);font:inherit;margin-right:2rem}
.search-results{list-style:none;padding:0;margin:1rem 0 0;overflow-y:auto}
.search-results li{margin:0 0 .6rem}
.search-results a{color:var(--c-heading);text-decoration:none}
.search-results a:hover{text-decoration:underline}


/* The thread runs at --fs-small throughout, which is the frozen tree's setting and the
   reason it holds: this is a conversation ABOUT the article, one step below it, and a
   comment set at the same size as the body reads as a continuation of the piece. The
   heading is h3, not h2 — h2 belongs to the reader's own subheadings inside the article,
   and a louder comments heading than any heading in the writing is the wrong emphasis.
   The port had it at h2. */
#comments{border-top:1px solid var(--c-rule);margin-top:3rem;padding-top:1.5rem;
  font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small)}
#comments h2{font-size:var(--fs-h3);line-height:var(--lh-h3);letter-spacing:var(--ls-h3);
  color:var(--c-heading);font-weight:var(--fw-heading,600);margin:0 0 1.5rem}
.comment-list,.comment-replies{list-style:none;padding:0;margin:0}
/* Structure from a rule between top-level comments, not from margins alone. A thread with
   only whitespace between entries reads as one long block, and the reply indent was too
   shallow to say which entry a reply belonged to. */
.comment{margin:0}
.comment-list>.comment+.comment{border-top:1px solid var(--c-rule);margin-top:1.5rem;padding-top:1.5rem}
.comment-replies{margin:1.25rem 0 0;padding-left:1.25rem;border-left:1px solid var(--c-rule)}
.comment-replies .comment+.comment{margin-top:1.25rem}
.comment-meta{color:var(--c-meta);margin:0 0 .5rem}
.comment-name{color:var(--c-heading);font-weight:var(--fw-heading,600)}
/* A comment is somebody's WORDS, so it is set in the reading face like the article is —
   the frozen tree did this and the port dropped it, which left every comment in the chrome
   font. On this site that is JetBrains Mono, so the thread was rendering as monospace.

   And at the ARTICLE's size, not the thread's. The #comments section runs at --fs-small
   because it is chrome, and the body inherited that: the same face as the piece, two steps
   smaller
   than the piece, which reads as a caption rather than as somebody talking. The rest of the
   thread — the meta line, the reply link, the whole form — stays small. Only the words grow.
   Tracking and leading come with the size, or the line spacing stays tuned for a smaller
   face and the paragraph sets too tight. */
.comment-body{font-family:var(--font-reading);font-size:var(--fs-body);
  line-height:var(--lh-body);letter-spacing:var(--ls-body);color:var(--c-text)}
.comment-body p:last-child{margin-bottom:0}
.comment-reply{border:0;background:none;padding:0;margin-top:.5rem;color:var(--c-meta);
  font:inherit;cursor:pointer;text-decoration:underline}
.comment-reply:hover{color:var(--c-heading)}
/* The empty state is meta, not body: "no comments yet" is the absence of a conversation,
   and setting it at reading size made it the loudest thing under the article. */
#comments .empty{color:var(--c-meta);margin:0}
/* The form is a CARD, on the same terms as the newsletter block sitting directly above it
   on every post: same border, same radius, same padding. Before this it was the only thing
   on the page with no boundary at all — a Google button, three fields, a textarea, a
   Turnstile widget and a submit, each floating separately on the page background. That is
   what made the section read as belonging to some other site. */
.comment-form{margin-top:2rem;border:1px solid var(--c-rule);border-radius:var(--radius,.5rem);padding:1.25rem}
/* A reply form opens INSIDE the thread, where a second bordered card boxes a box. */
.comment .comment-form{margin-top:.75rem;padding:0;border:0}
/* Name and email are short. Full width each, they turned a three-field form into a column
   of wide empty boxes; side by side they read as one block of details. */
.comment-fields{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.comment-fields .comment-field:last-child{grid-column:1/-1}
@media (max-width:639px){.comment-fields{grid-template-columns:1fr}}
.comment-field{margin:0}
.comment-field label{display:block;color:var(--c-meta);margin-bottom:.375rem}
.comment-body-field{margin-top:1rem}
.comment-form input,.comment-form textarea{width:100%;padding:.5rem .75rem;border:1px solid var(--c-rule);
  border-radius:var(--radius,.5rem);background:var(--c-bg);color:var(--c-text);font:inherit}
/* The focus treatment the newsletter field already had, applied to the same-looking field. */
.comment-form input:focus,.comment-form textarea:focus{border-color:var(--c-heading)}
.comment-form textarea{display:block;resize:vertical}
/* Verification and the action share one line, the submit pushed to the far end. It wraps
   below 640px because the Turnstile widget is a fixed 300px and will not share the row. */
.comment-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;margin-top:1rem}
.comment-form .comment-actions button{margin:0 0 0 auto}
@media (max-width:639px){.comment-form .comment-actions button{margin-left:0}}
.comment-form button{padding:.5rem 1rem;border:1px solid var(--c-rule);border-radius:var(--radius,.5rem);
  background:var(--c-bg);color:var(--c-heading);font:inherit;font-weight:500;cursor:pointer}
.comment-form button:hover{background:var(--c-rule)}
.comment-status:empty{display:none}
.comment-status{color:var(--c-meta);margin:.75rem 0 0}
/* The identity strip. The sign-in control is an anchor and the sign-out control a button,
   so both are given the surrounding type explicitly rather than a UA default. */
/* Ruled off from the fields below it, so the two ways in read as a choice rather than as a
   button that happens to sit above a form. */
.comment-identity{margin:0 0 1rem;padding-bottom:1rem;border-bottom:1px solid var(--c-rule);
  color:var(--c-meta)}
.comment-identity strong{color:var(--c-heading);font-weight:var(--fw-heading,600)}
.comment-google{display:inline-block;padding:.5rem 1rem;border:1px solid var(--c-rule);
  border-radius:var(--radius,.5rem);color:var(--c-heading);font:inherit;text-decoration:none}
.comment-google:hover{border-color:var(--c-heading)}
/* Two classes deep on purpose: the comment-form button rule above is more specific than a
   lone class, so a one-class rule here loses and sign-out renders as a second Post button. */
.comment-form .comment-signout{margin:0;border:0;background:none;padding:0;
  color:var(--c-meta);font:inherit;cursor:pointer;text-decoration:underline}
.comment-form .comment-signout:hover{color:var(--c-heading)}
/* The sign-up card: a bordered panel at the end of an article, and the same markup the
   header's mail button opens as an overlay. It was a bare form with a top rule, which read
   as another section of the article rather than as an invitation. */
/* The sign-up card IS the modal panel: it is already a bordered card on theme tokens, so
   the overlay strips its own frame rather than drawing a second one around it. */
.subscribe-overlay{border:0;padding:0;background:none;width:min(28rem,92vw);margin-top:12vh}
.subscribe-overlay .subscribe-card{background:var(--c-bg);
  box-shadow:0 25px 50px -12px rgba(0,0,0,.25)}

/* The theme menu. The island builds these two elements and nothing styled them, so the
   rows rendered as unstyled blocks that pushed the header apart instead of a dropdown. */
.theme-wrap{position:relative;display:inline-flex}
.theme-menu{position:absolute;right:0;top:100%;margin-top:.5rem;z-index:50;width:11rem;
  overflow:hidden;border:1px solid var(--c-rule);background:var(--c-bg);padding:.25rem 0;
  box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1)}
.theme-menu[hidden]{display:none}
.theme-menu button{display:flex;width:100%;align-items:center;justify-content:space-between;
  padding:.5rem .75rem;border:0;background:none;cursor:pointer;text-align:left;font:inherit;
  font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small);
  color:var(--c-meta)}
.theme-menu button:hover{background:var(--c-rule)}
.theme-menu button.is-current{font-weight:var(--fw-heading,600);color:var(--c-heading)}
/* The tick marks the active mode, as in the frozen tree. It is decoration on a row whose
   state is already carried by the class, so it belongs in CSS, not in the bundle. */
.theme-menu button.is-current::after{content:"✓"}

.subscribe-card{border:1px solid var(--c-rule);border-radius:var(--radius,.5rem);padding:1.25rem;
  font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small)}
.subscribe-card h2{font-size:inherit;font-weight:var(--fw-heading,600);color:var(--c-heading);margin:0 0 .75rem}
form.subscribe{display:flex;gap:.5rem;margin:0}
/* The honeypot: parked, not display:none — some form fillers skip boxless fields. */
form.subscribe .hp{position:absolute;left:-9999px;width:1px;height:1px;flex:none;border:0;padding:0;opacity:0;pointer-events:none}
form.subscribe input{min-width:0;flex:1;padding:.5rem .75rem;border:1px solid var(--c-rule);
  border-radius:var(--radius,.5rem);background:var(--c-bg);color:var(--c-text);font:inherit}
/* The border darkening stays, the outline:none does not: it was cancelling the site's one
   focus ring on the only field in the header, so keyboard focus vanished here alone. */
form.subscribe input:focus{border-color:var(--c-heading)}
form.subscribe button{padding:.5rem 1rem;border:1px solid var(--c-rule);border-radius:var(--radius,.5rem);
  background:var(--c-bg);color:var(--c-heading);font:inherit;font-weight:500;cursor:pointer}
form.subscribe button:hover{background:var(--c-rule)}
form.subscribe button:disabled{opacity:.5}
@media (max-width:639px){form.subscribe{flex-direction:column}}
.subscribe-status:empty{display:none}
.subscribe-status{color:var(--c-meta);margin:.5rem 0 0}
/* The page is WIDER THAN THE READING COLUMN, and that is done by widening --shell-w for the
   whole document rather than by breaking this one element out of it.
   Photographed both ways. --shell-w is a measure tuned for one column of prose (672px by
   default), and three across inside it is not three columns, it is three slivers: the
   secondary headline came out in a 110px column, five ragged lines deep. Breaking only
   .front out fixed that and left the site header and footer still centred on the old narrow
   measure, visibly unrelated to the page under them. The override is emitted per render,
   beside the column counts, because the width is a setting. */
/* The gap under the lead's category line, declared once because TWO rules need the same
   number: the kicker's own margin, and the offset that drops the secondary column past it. */
.front{display:flex;flex-direction:column;gap:2.5rem;--fc-cat-gap:.35rem}
/* Every row after the first is ruled off. The rule is the section break; there is no box. */
.front-row+.front-row{border-top:1px solid var(--c-rule);padding-top:2.5rem}
.front-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:.35rem 1.25rem;margin:0 0 1.5rem}
/* The way on to the whole archive, on the right of the last heading. An auto left margin
   rather than a spacer, so it stays at the far edge with the topic links wrapping in front of
   it and drops to its own line, still at the edge, when the heading runs out of room. */
.front-more{margin-left:auto;color:var(--c-meta)}
/* A row label is a LABEL, so it is smaller than the headlines under it.
   It started at --fs-h4 and photographed as a competitor: h4 in a monospace chrome face
   reads wider and heavier than h3 in Literata, so the two sat at the same visual weight and
   the eye had no order to follow. Dropping it a step puts the headlines back on top, which
   is what the rest of this page already does with the label on the related-posts block. */
.front-label{font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small);
  font-weight:var(--fw-heading,600);color:var(--c-heading);margin:0}
.front-label a{color:inherit;text-decoration:none}
/* The topic links, one step quieter again so the label reads first. Wrapped, never scrolled:
   a row that scrolls sideways on a phone hides half of itself and nothing says so. */
.front-topics{display:flex;flex-wrap:wrap;gap:.25rem .75rem;margin:0;color:var(--c-meta);
  font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small)}
.front-topics a{color:inherit}
.front-grid{display:grid;gap:1.75rem}
.front-lines{display:flex;flex-direction:column;gap:1rem}
/* A row that ended up with ONE item still holds a reading measure. Left to fill the grid it
   set a standfirst 1120px wide, which is not a line anybody reads; and a lone full-bleed
   card reads as a mistake rather than as a row. */
.front-grid.cols-1{max-width:42rem}

/* ----- one item ---------------------------------------------------------- */
.fc{margin:0;min-width:0}
.fc-cat{margin:0 0 var(--fc-cat-gap);color:var(--c-meta)}
.fc-title{margin:0;font-weight:var(--fw-heading,600);color:var(--c-heading);
  font-size:var(--fs-h3);line-height:var(--lh-h3);letter-spacing:var(--ls-h3)}
.fc-title a{color:inherit;text-decoration:none}
.fc-title a:hover{text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:2px}
.fc-deck{margin:.5rem 0 0;color:var(--c-text);
  font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small)}
.fc-meta{margin:.5rem 0 0}
/* The lead's opening lines. Body size and the reading face, because it IS the body: setting
   it a step down would read as a second standfirst rather than as the start of the piece. */
.fc-intro{margin:.75rem 0 0;color:var(--c-text);font-family:var(--font-reading);
  font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--ls-body)}
.fc-media{margin:0 0 .75rem}
/* Reserve the box before the bytes arrive, so nothing below jumps as images land. A front
   page is mostly images-not-yet-loaded on a first visit, which is when this matters most. */
.fc-media img{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover;
  background:var(--c-rule)}

/* ----- the lead ---------------------------------------------------------- */
.fc-lead .fc-title{font-size:var(--fs-h2);line-height:var(--lh-h2);letter-spacing:var(--ls-h2)}
.fc-lead .fc-deck{font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--ls-body)}
.front-lead-row{display:flex;flex-direction:column;gap:1.75rem}
/* Headlines stacked under the lead, ruled off from each other and from it. */
.front-secondary{display:flex;flex-direction:column;gap:1rem;
  border-top:1px solid var(--c-rule);padding-top:1.25rem}
.fc-line .fc-title{font-size:var(--fs-h4);line-height:var(--lh-h4);letter-spacing:var(--ls-h4)}

/* ----- the text kind ----------------------------------------------------- */
/* One step louder everywhere, because the words are all there is. */
.front-text .fc-lead .fc-title{font-size:var(--fs-h1);line-height:var(--lh-h1);letter-spacing:var(--ls-h1)}
.front-text .fc-title{font-size:var(--fs-h3);line-height:var(--lh-h3);letter-spacing:var(--ls-h3)}
.front-text .fc-deck{font-family:var(--font-reading)}

/* ----- one column, then two, then three ---------------------------------- */
/* MOBILE FIRST, and all three counts live here rather than being emitted per render.
   They were emitted per render at first, into the page's inline style, which comes after
   this sheet — so the two-column rule won at every width and the phone got columns it could not
   fit, running the page off the side of the screen. The counts are sanitized to 1, 2 or 3,
   so there is nothing here that settings could ask for and this file could not already say. */
.front-grid{grid-template-columns:minmax(0,1fr)}
@media (min-width:641px){
  .front-grid.cols-2,.front-grid.cols-3{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media (min-width:901px){
  .front-grid.cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media (max-width:640px){
  .front{gap:2rem}
  .front-row+.front-row{padding-top:2rem}
}

/* ----- the lead's two columns, on a wide screen only --------------------- */
/* The picture is AFTER the text in source order, which is what puts the headline above the
   image on a phone with no work at all. Here the grid puts it back on the right. Doing it
   the other way round means a phone reader scrolls past a photograph to find out what it is
   of, which is what the NYT front page avoids too. */
@media (min-width:901px){
  .front-image .fc-lead.has-media{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);
    gap:2rem;align-items:start}
  .front-image .fc-lead.has-media .fc-media{margin:0}
  .front-image .fc-lead.has-media .fc-media img{aspect-ratio:3/2}
  /* 2:1, not 3:1. At 3:1 the secondary column was 110px wide and every headline in it
     broke to five ragged lines. Aligning to start keeps the taller column from stretching
     the shorter one, which is what left a quarter of this row empty. */
  .front-lead-row{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:2.5rem;
    align-items:start}
  .front-lead-row .front-secondary{border-top:0;border-left:1px solid var(--c-rule);
    padding:0 0 0 1.75rem}
  /* Two top-aligned columns where only ONE of them opens with a category line put the
     secondary headlines 30px above the lead headline, measured at 1440, so the smaller
     column read first. The offset is that line: its size times its leading, plus the gap the
     kicker itself uses. Only applied when the lead actually prints one — a lead with no
     category has nothing to align past. */
  .front-lead-row.has-kicker .front-secondary{
    padding-top:calc(var(--fs-small) * var(--lh-small) + var(--fc-cat-gap))}
}

/* --- the hero, above an article ------------------------------------------------
   The reading column, and no wider. A breakout version existed for one afternoon
   and was measured out of existence: the rails sit eight pixels from the column and
   travel with it, so anything wider prints the picture over the table of contents.
   render/rail-css.ts reached the same answer for in-body pictures before this.

   3:2, ALWAYS, and it is not a question the owner is asked. One blog's covers should
   look like one blog's covers, and the shape doing that job belongs to the design.
   Fixing it also removes the case that made a chooser look necessary: an ordinary
   977x1400 portrait scan is 963px tall inside a 672px column, a whole screen of
   picture standing between the headline and the first sentence.

   The ratio pins the box, so the space is reserved before the file arrives, and
   object-fit crops the overflow rather than squashing the photograph. The 70vh cap
   stays underneath as a floor for a very wide reading column, where even 3:2 is tall. */
/* Above the headline, so the gap that matters is the one UNDER it. */
.post-hero{margin:0 0 calc(var(--sp) * 1.5)}
.post-hero img{display:block;width:100%;height:auto;aspect-ratio:3/2;max-height:70vh;
  object-fit:cover;border-radius:var(--radius,.5rem)}

/* --- the thumbnail, on a list row ----------------------------------------------
   'side' turns the card into a two-column grid; the words keep their order in the
   markup and the picture is placed, so a screen reader and a no-CSS reader both get
   the headline first. A phone drops back to one column: 96px of picture beside a
   headline at 375px leaves the headline nowhere to go. */
/* CROPPED, ALWAYS, and the shape is not a setting.
   A gallery gets to choose because a gallery IS the photographs; a list thumbnail is
   chrome, and its job is recognition. Measured with three real files on 2026-08-29 —
   ratios 0.70, 2.10 and 0.72 — an uncropped column read as a tall block, a thin strip
   and a tall block, which is not a list. The aspect-ratio also reserves the box before
   the file lands: every thumbnail measured height 0 until then. */
.card-thumb img{display:block;width:100%;height:auto;object-fit:cover;
  border-radius:var(--radius,.5rem)}
.post-list article[data-thumb=side] .card-thumb img{aspect-ratio:1/1}
.post-list article[data-thumb=top] .card-thumb img{aspect-ratio:3/2}
.post-list article[data-thumb=top] .card-thumb{margin:0 0 calc(var(--sp) * .75)}

/* FLOATED, not a grid column. A two-column grid pins the words beside the picture for the
   whole card, so a 96px square against four lines of standfirst leaves a hole under the
   picture and the row reads as a table with an empty cell. Floating lets the text run past
   the picture and close up underneath it, which is what the space is for.

   'flow-root' contains the float: without it a card shorter than its own picture would let
   the picture hang into the card below. It does not clip, so the timeline marker — absolutely
   positioned out in the gutter by rail-css.ts — is untouched. */
.post-list article[data-thumb=side]{display:flow-root}
.post-list article[data-thumb=side] .card-thumb{float:left;width:96px;
  margin:.2rem calc(var(--sp) * 1.1) calc(var(--sp) * .45) 0}

/* A phone has ~230px left beside a 96px picture, which is not a column for a headline.
   Smaller picture, tighter gutter: the words still wrap under it, just sooner. */
@media (max-width:559px){
  .post-list article[data-thumb=side] .card-thumb{width:72px;
    margin:.2rem calc(var(--sp) * .8) calc(var(--sp) * .4) 0}
}

/* --- the author box ------------------------------------------------------------
   Same quiet register as '.related' above it: this is chrome at the end of the
   words, not a second article. The portrait is the one round thing on the page and
   keeps its own shape whatever '--radius' says — a square avatar at radius 0 reads
   as a missing image. */
.author-box{display:flex;gap:calc(var(--sp) * .9);align-items:flex-start;
  margin:calc(var(--sp) * 2) 0 0;padding:calc(var(--sp) * 1.1) 0 0;
  border-top:1px solid var(--c-rule)}
.author-face{width:48px;height:48px;border-radius:50%;object-fit:cover;flex:none}
.author-text{min-width:0}
.author-name{margin:0;font-weight:var(--fw-heading,600);color:var(--c-heading);
  font-size:var(--fs-small);line-height:var(--lh-small);letter-spacing:var(--ls-small)}
.author-bio{margin:.35rem 0 0}

@media (max-width:639px){
/* iOS Safari zooms the whole page when a focused control is set below 16px, and --fs-small
   measures 14px on this site: tapping the sign-up field shifted the layout sideways and left
   it there. A FLOOR rather than a size, so a larger type role still wins. Each selector
   names the rule that set the size, because font:inherit on those carries the same
   specificity as a bare element selector would. */
form.search input,form.subscribe input,.search-input,
.comment-form input,.comment-form textarea{font-size:max(16px,1em)}

/* THE GUTTER IS THE MEASURE, on a phone and nowhere else.
   The shell has always taken 2rem a side. Above the phone breakpoint that padding is dead
   weight -- the 42rem max-width binds first -- so it only ever spends anything on the one
   screen with nothing to spare. Measured at 390px: 64px of 390 went to gutters and the
   article set 33 characters to the line, against 67 on a laptop. Half the measure, on the
   site whose lead essay argues that 66 is the number.
   1.5rem, not 1rem. It is the whole win: at 390px both give 37 characters and 9 lines
   where 2rem gave 33 and 10, so going narrower buys nothing measurable and only pushes the
   words against the bezel. On a 360px Android the same step goes 30 to 33.
   max() rather than a plain length, so a notch in landscape still wins. */
.wrap{padding-left:max(1.5rem,env(safe-area-inset-left,0px));
  padding-right:max(1.5rem,env(safe-area-inset-right,0px))}

/* The drawer IS the navigation on a phone, and its rows measured 22px tall, 26px apart.
   padding-block rather than a height: a row is a flex line whose label can wrap to two
   lines, and a fixed height would clip the second one.
   margin-top goes to ZERO here, and that is the half that was missing: the desktop list's
   .5rem row gap ADDED to the touch padding, so the drawer aired out to a ~27px void
   between rows ("cách xa nhau quá") — the padding alone already separates and already
   carries the tap target, so the phone drops the desktop gap instead of stacking both. */
.rail-row{padding-block:.6rem}
.rail li{margin-top:0}
.rail-tags a{padding-block:.4rem}
footer.site a{display:inline-block;padding-block:.35rem}
/* The newspaper's own links, which the three rules above did for every other surface and
   which arrived after them. A kicker and a topic link are standalone links on their own
   line -- not words inside a sentence -- so the inline-text exemption does not cover them,
   and they measured 20px tall against the 24px minimum. inline-block because padding-block
   does nothing to an inline box's hit area. */
.fc-cat a,.front-topics a,.front-label a{display:inline-block;padding-block:.2rem}

/* Two solid surfaces separated by one hairline. With nothing dimmed, the strip of page left
   beside the open drawer read as part of the drawer itself, so the tap-to-close area was
   invisible. Faint on purpose: the drawer is a list, not a modal. */
.rail-scrim{background:rgba(0,0,0,.15)}

/* A floated 30% figure at 390px is a 107px picture with a 35-character column squeezed to
   20 beside it -- the wrap costs more than the picture earns. On a phone the figure gives
   up the float and sits centred at a bit over half the measure, still visibly smaller than
   a full-width plate, which is the part of its meaning a phone can keep.
   Doubled class selectors, not a bare .img-third: the float rules are two-class strong and
   source order alone would lose to them. */
/* A frame costs the picture twice its width on every side, and the column is 350px here.
   28px of mat each way leaves a 294px photograph, which is a picture of a frame. Every
   weight drops on a phone, and redefining the STEPS rather than the classes is what also
   moves the site-wide default: that default is inlined after this sheet and points at these
   same names, so it lands here too instead of outranking the media query. */
:root{--fig-step-thin:calc(var(--sp) * .375);--fig-step-med:calc(var(--sp) * .625);
  --fig-step-thick:var(--sp)}
.img-third,.img-left.img-third,.img-right.img-third{float:none;width:60%;
  margin:calc(var(--sp) * 2) auto}
/* TWO COLUMNS, WHATEVER THE COUNT SAYS. The column rule picks 2, 3 or 4 from how many
   pictures are in the run, which is the right question on a desktop and the wrong one on a
   350px column: measured 2026-08-28 at 390px, a run of five drew 109x72px tiles and a run
   of ten drew 80x53px. A photograph 80 pixels wide is not a photograph, and the lightbox
   behind it does not help — nobody taps what they cannot make out. Two columns give about
   165px, which is a picture you can recognise and decide to open.
   The tiles state 47vw below this same width in their own sizes attribute, so what the
   browser fetches matches what it draws. */
.gallery-cols-3,.gallery-cols-4{grid-template-columns:repeat(2,1fr)}

/* SIX CONTROLS DO NOT FIT A FOLDED PHONE. A folding phone's cover screen is 280px wide,
   and the bar's arithmetic stops working there: five 2.5rem controls and their gaps are
   202px, the title's shortest word is 75px, and the 232px between the gutters cannot hold
   both. Measured 2026-08-31 on the home page: the row ran 18px past the edge, and the
   clipped control was the menu button. Wrap costs nothing above that width -- a flex row
   only folds when the arithmetic fails -- so every phone that fits keeps the one-line bar
   it was measured into. margin-left:auto keeps the folded row against the right edge,
   where the controls already live; it stays inside this media block because above 60rem
   the slack already belongs to .site-menu's own auto margin, and a second one would split
   it. */
.site-bar{flex-wrap:wrap;row-gap:.25rem}
.site-actions{margin-left:auto}
}

/* Copy sat behind pre:hover, and a touch screen never hovers: the button existed on a phone
   but was transparent, so copying a code block was a lucky tap. Keyed on the POINTER rather
   than the width, because a touchscreen laptop has the same problem at desktop width. */
@media (hover:none){.code-copy{opacity:1}}

/* The home indicator and the notch. Both resolve to 0px on a device that has neither, which
   is why the fallback is in the env() rather than in a second rule. */
.to-top{bottom:calc(1.25rem + env(safe-area-inset-bottom,0px))}
.rail{padding-left:calc(1.25rem + env(safe-area-inset-left,0px))}

@media print{
/* ── 1. The furniture ─────────────────────────────────────────────────────────────────
   Controls, conversation and invitations. Each one is a thing to DO, and none of them can
   be done on paper. Comments and related posts go too, and that is a judgement rather than
   an oversight: the reader asked for this essay, not for thirty replies to it and a list of
   links they cannot follow.
   !important, deliberately and only here. A print rule has to win against every screen rule
   in a 40 KB sheet, including ones written after this file — .book-overlay[open] sets
   display:grid, nav.toc.rail outranks .toc on specificity, and .to-top.shown re-enables what
   .to-top left off. Losing any one of those puts a fixed black circle over the text.
   .rail-toggle is NOT in this list and does not need to be: chrome.ts pushes it into the
   .site-actions div above, so it is already gone. The test that checks every selector here
   is a selector the site still uses is what said so. */
.progress,.site-actions,.toc,.to-top,.book-fab,.meta-book,.skip-link,.quote-copy,
.book-overlay,.lightbox,.subscribe-overlay,.subscribe-card,form.subscribe,
#comments,.related,.read-next,.resume-pill,footer.site{display:none!important}
/* ...and the rule drawn ABOVE each of them, which would otherwise be left ruling off the
   end of the essay against nothing. The divider belongs to the block it introduces, and
   :has is how a stylesheet says that without the renderer having to know. */
hr:has(+ .related),hr:has(+ #comments){display:none!important}

/* The scroll reveal, undone. A listing card eases in as it crosses the viewport, and the
   progress of a view() timeline on paper is whatever it was on screen - which for every card
   below the fold is zero. Measured 2026-08-27 on the front page: of the ten cards the feed
   had rendered, seven printed at less than full opacity and five at opacity 0. They still
   took their space, so the sheet was two posts and a wall of white, and the reader who
   pressed Print got three pages of it.
   This sheet was written against an ARTICLE, which carries no reveal class at all, and that
   is how the hole stayed open: the one page that was tested was the one page immune to it.
   Every listing prints - the front page, a category, a tag, the archive, a search result.
   The JS fallback's hidden state is undone too, for an engine with no view() timelines. */
.reveal{animation:none!important;opacity:1!important;transform:none!important}

/* ── 2. The palette of a sheet of paper ───────────────────────────────────────────────
   The TOKENS are redefined, not the rules: every public rule already paints in var(--c-*),
   so one block turns the whole site into ink on paper and nothing downstream has to know.
   This is the same job content/themes.ts does for a palette, and paper is a palette.
   !important because the palette blocks land AFTER this sheet in the document and
   html.dark wins on both order and specificity — a reader printing at night should not
   be handed a black page, and most browsers would drop the background and print white
   text on white anyway.
   --c-accent is left ALONE on purpose. It is the pen: the highlighter, the circled word,
   the underline drawn by hand. Those are the reason a page from this site looks like this
   site, and a colour printer should give the reader the marks the writer made. */
:root{--c-bg:#fff!important;--c-text:#1a1a1a!important;--c-heading:#000!important;
  --c-meta:#555!important;--c-link:#1a1a1a!important;--c-rule:#c8c8c8!important}

/* ── 3. The page ──────────────────────────────────────────────────────────────────────
   16mm inner, 18mm head and foot: room for a thumb, and the head margin larger than the
   inner is the same instinct as the article this site opens with. */
@page{margin:16mm 18mm}
html,body{background:#fff}
/* The shell width is a SCREEN measure (42rem inside a viewport, sized against a reading
   distance of arm's length). Paper is closer and A4 is wider, so the block is set in the
   paper's own units: 150mm at the 12pt a default 16px body prints as is about 70
   characters, the measure this site's own essay argues for. Dropped entirely at first,
   and the line ran
   the full 178mm of printable width — 110 characters, and the eye loses its place on the
   return sweep long before that.
   On .wrap rather than on main, because the masthead is in there too: measured on main
   alone, the site name sat hard against the paper's edge with the text block starting
   25mm inside it, and a header that does not stand over its own column reads as a
   printing fault. */
.wrap{max-width:150mm;padding:0;margin:0 auto}
.with-rail{display:block}
main{padding:0}

/* The masthead earns its ink once, as provenance: which site this came off. */
header.site{padding:0 0 .6rem;margin-bottom:1.6rem;border-bottom:.5pt solid var(--c-rule)}

/* The type is the OWNER'S, at the size and leading they set, and this block deliberately
   does not touch either.
   It did at first — 10.5pt body, 20pt title, the sizes a book is set at — and
   check:type-roles refused it: "a size on the reader's page that the owner cannot set".
   The guard is right and the reasoning is worth keeping. A blog that has chosen a large
   reading size has usually chosen it for a reason, and paper is not where to quietly undo
   that. A browser prints 1px as 1/96in, so a 16px body lands at 12pt, which is a page of
   a large-print edition rather than a mis-set one.
   What paper does need is hyphenation: the screen measure is set for a viewport and the
   printed one is fixed, so an unhyphenated rag opens holes that no reader can close. */
.prose{hyphens:auto}

/* A link on paper is a dead end unless it says where it went. Only in the prose, only when
   it leaves the site, and never for a footnote marker or an anchor — those point at a place
   already on the page. */
.prose a{text-decoration:underline}
.prose a[href^="http"]::after{content:" <" attr(href) ">";font-size:.85em;
  color:var(--c-meta);word-break:break-all}
.prose a[href^="#"]::after,.prose a.footnote-ref::after,.prose .footnotes a::after{content:none}

/* ── 4. What may not be cut in half ───────────────────────────────────────────────────
   A heading at the foot of a page is a heading for nothing; a table split across a fold is
   a table read twice. */
h1,h2,h3,h4,h5{break-after:avoid-page;break-inside:avoid}
figure,table,pre,blockquote,.callout,.math-block,.series,li{break-inside:avoid}
p,li{orphans:2;widows:2}
img{max-width:100%!important;height:auto}
/* A code block scrolls on a screen and cannot on paper, so it wraps instead of losing the
   right-hand end of every long line. */
pre{white-space:pre-wrap;word-wrap:break-word;border:.5pt solid var(--c-rule)}
table{width:100%;border-collapse:collapse}
}

