*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
/* 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)}
hr{border:0;border-top:1px solid var(--c-rule);margin:2.5rem 0}

/* 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. */
.wrap{max-width:var(--shell-w,42rem);margin:0 auto;padding:0 2rem;
  display:flex;min-height:100vh;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}

header.site{padding:1.75rem 0}
header.site .title{font-family:var(--font-sans);font-weight: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. */
header.site .logo-dark{display:none}
html.dark header.site .title:has(.logo-dark) .logo{display:none}
html.dark header.site .logo-dark{display:block}
header.site .tagline{color:var(--c-meta);font-size:var(--fs-small);
  line-height:var(--lh-small);letter-spacing:var(--ls-small);margin:.75rem 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. */
.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: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: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)}
.prose > * + *{margin-top:1.4em}
.prose h1,.prose h2,.prose h3,.prose h4,.prose h5{color:var(--c-heading);font-weight: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)}
/* A body link carries a permanent faint rule that warms to the accent on hover, which is
   the opposite signature to the chrome links (.link-accent) and deliberately so. */
.prose a{color:var(--c-link);text-decoration:underline;text-underline-offset:.18em;
  text-decoration-thickness:1px;text-decoration-color:var(--c-rule)}
.prose a:hover{text-decoration-color:var(--c-accent)}
.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. */
.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}
.prose pre{padding:var(--sp);border-radius:.5rem;overflow-x:auto;font-size:var(--fs-code);
  line-height:var(--lh-code);letter-spacing:var(--ls-code)}
.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 1.15. */
.prose hr:not(.fn-rule){width:6em;margin:2.6em auto;border-top:1px solid var(--c-rule)}
.prose table{border-collapse:collapse;width:100%}
.prose th,.prose td{border:1px solid var(--c-rule);
  padding:calc(var(--sp) * .4) calc(var(--sp) * .6);text-align:left}

/* 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}
}

/* 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: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)}
.text-text{color:var(--c-text)}
.reading-font{font-family:var(--font-reading)}
.font-semibold{font-weight: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}

/* 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: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)}
.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}
form.search input{flex:1;padding:.5rem .75rem;border:1px solid var(--c-rule);border-radius:.35rem;
  background:var(--c-bg);color:var(--c-text);font:inherit}
form.search button{padding:.5rem 1rem;border:1px solid var(--c-rule);border-radius:.35rem;
  background:var(--c-bg);color:var(--c-heading);font:inherit;cursor:pointer}
/* The series box: a bordered card at the TOP of the post, as the frozen tree had it. The
   port turned it into a plain rule at the foot of the article, which is the wrong end — the
   point of it is knowing you are in part 3 of 6 BEFORE reading, not after. */
aside.series{border:1px solid var(--c-rule);border-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;color:var(--c-meta)}
aside.series ol{margin:1rem 0 0;padding-left:1.25rem}
aside.series li{margin-top:.5rem}
aside.series li a{color:var(--c-meta);text-decoration:none}
aside.series li a:hover{color:var(--c-heading)}
/* The part you are reading is not a link, and it is the one thing in the card set in the
   heading colour: the card answers "where am I" before it answers "what else is there". */
aside.series li[aria-current]{color:var(--c-heading);font-weight:600}
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)}

figure{margin:calc(var(--sp) * 2) 0}
figure img{display:block;margin:0 auto;border-radius:.25rem}
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))}
.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)}

.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: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. */
.dark .shiki,.dark .shiki span{color:var(--shiki-dark)!important;background-color:var(--shiki-dark-bg)!important}

/* 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. */
.rail{position:fixed;top:0;bottom:0;left:0;z-index:40;width:min(300px,84vw);
  overflow-y:auto;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 .25s 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: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. */
.rail-row[aria-current]::before{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]". */
.term-count{margin-left:.25rem;opacity:.6;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 .15s}
.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 .2s,color .2s}
.to-top.shown{opacity:1;pointer-events:auto}
.to-top:hover{color:var(--c-heading)}

/* 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 .5s ease,transform .5s 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}

/* 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
   scrollLeft assignment 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}}

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);
  --book-paper:#f9f4ec;--c-bg:var(--book-paper);
  /* Reading text runs 15% larger in here. It MULTIPLIES the owner's --fs-* roles, so it
     tracks the site's own type setting rather than replacing it. */
  --type-scale:1.15;
  --c-text:#211f1a;--c-heading:#16130d;--c-meta:#8d8676;--c-link:#2f2c25;
  --c-accent:#2f2c25;--c-rule:#d8cfbc;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.72' 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.62'/%3E%3C/svg%3E")}
.book-overlay::backdrop{background:#f9f4ec}
.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;
  max-width:min(70%,720px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.book-topright{position:absolute;right:clamp(12px,4vw,44px);top:0;height:100%;
  display:flex;align-items:center;gap:16px}
.book-x{background:none;border:0;cursor:pointer;color:var(--c-meta);font-size:1rem;
  line-height:1;padding:8px}
.book-x:hover{color:var(--c-heading)}
.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}
.book-stage{position:relative;display:flex;align-items:center;justify-content:center;
  min-height:0;padding:clamp(20px,4vh,52px) 0}
/* 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 .13s ease}
.book-flow{height:100%;column-gap:56px;column-width:var(--book-col-w,340px);column-fill:auto;
  max-width:none;
  /* Western book touches: oldstyle figures and discretionary ligatures where the reading
     face carries them. A sans quietly ignores both. */
  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}
.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)}

/* 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}
.icon-btn{display:flex;align-items:center;justify-content:center;width:2.5rem;height:2.5rem;
  border:0;border-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 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:.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:.35rem;
  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 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: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:.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:600;color:var(--c-heading);margin:0 0 .75rem}
form.subscribe{display:flex;gap:.5rem;margin:0}
form.subscribe input{min-width:0;flex:1;padding:.5rem .75rem;border:1px solid var(--c-rule);
  border-radius:.5rem;background:var(--c-bg);color:var(--c-text);font:inherit}
form.subscribe input:focus{outline:none;border-color:var(--c-heading)}
form.subscribe button{padding:.5rem 1rem;border:1px solid var(--c-rule);border-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 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:600;margin:0 0 1.5rem}
.comment-list,.comment-replies{list-style:none;padding:0;margin:0}
.comment-replies{margin-left:1.25rem;padding-left:1rem;border-left:1px solid var(--c-rule)}
.comment{margin:0 0 1.5rem}
.comment-meta{color:var(--c-meta);margin:0 0 .35rem}
.comment-name{color:var(--c-heading);font-weight: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. */
/* Tracking too, not just the family: #comments is chrome and carries the mono-chrome
   correction, which a comment written in the reading face has no business inheriting. */
.comment-body{font-family:var(--font-reading);letter-spacing:var(--ls-small);
  color:var(--c-text)}
.comment-body p:last-child{margin-bottom:0}
.comment-reply{border:0;background:none;padding:0;margin-top:.35rem;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}
.comment-form{margin-top:1.5rem}
.comment-field{margin:0 0 .75rem}
.comment-field label{display:block;color:var(--c-meta);margin-bottom:.25rem}
.comment-form input,.comment-form textarea{width:100%;padding:.5rem .75rem;border:1px solid var(--c-rule);
  border-radius:.35rem;background:var(--c-bg);color:var(--c-text);font:inherit}
.comment-form button{margin-top:.75rem;padding:.5rem 1rem;border:1px solid var(--c-rule);border-radius:.35rem;
  background:var(--c-bg);color:var(--c-heading);font:inherit;cursor:pointer}
.comment-status:empty{display:none}
.comment-status{color:var(--c-meta);margin:.5rem 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. */
.comment-identity{margin:0 0 .75rem;color:var(--c-meta)}
.comment-identity strong{color:var(--c-heading);font-weight:600}
.comment-google{display:inline-block;padding:.5rem 1rem;border:1px solid var(--c-rule);
  border-radius:.35rem;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)}

/* 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}
/* --- IDE CHROME (settings.ideChrome, html[data-ide-chrome=on]) -------------------
   One deliberate contrast: the reading column stays analogue - a book serif, ranged
   left, no ornament - and everything AROUND it reads as source code. The two halves
   are supposed to disagree; that disagreement is the design.

   Every rule below is additive and lives behind one attribute, so turning the switch
   off in Admin leaves not a trace of it. Nothing here touches .prose, the post title,
   the card excerpts or the comment bodies: those are the reader's own words and they
   are the half that must NOT look technical.

   Colour comes from theme tokens only, as everywhere else. The two roles a code
   editor actually distinguishes are the comment and the literal, so that is what is
   borrowed: labels are comments (--c-meta), counts and dates are literals
   (--c-text). No third colour, and no hex.

   The literal was --c-accent for one deploy. This blog's Mono accent is red, so every
   date and count read as a link that was not one. --c-text is the ink the article is
   already set in: a clear step darker than the label, and silent. */

/* Labels are comments. The marker is CSS, not markup, so the heading a screen reader
   and a feed see stays the plain word.

   ONE list, covering every chrome label the site has. It used to be the rail alone, and
   the treatment stopped halfway down the article: the rail said "// Muc luc" and then the
   related posts, the sign-up card, the comment thread and the series head - all of them
   furniture, none of them the reader's words - said nothing at all. The tagline is in
   here because that is exactly what it is: the line under the name that says what this
   file is for. */
html[data-ide-chrome=on] .rail h2::before,
html[data-ide-chrome=on] header.site .tagline::before,
html[data-ide-chrome=on] aside.series .series-head::before,
html[data-ide-chrome=on] .related h2::before,
html[data-ide-chrome=on] .subscribe-card h2::before,
html[data-ide-chrome=on] #comments h2::before,
html[data-ide-chrome=on] .empty::before,
/* The one thing in the panel that DOES something rather than states something, so it takes
   the label's marker rather than a literal's brackets. */
html[data-ide-chrome=on] .info-action::before{content:"// ";color:var(--c-meta)}
/* ...except in the comment thread. "Be the first to comment" is an invitation to the
   reader, not a label on a section, and the owner asked for the marker off it. */
html[data-ide-chrome=on] #comments .empty::before{content:none}

/* Counts are literals, bracketed like an index — every count, in the same brackets. The
   rail's own counts were bracketed and the taxonomy's were in ROUND ones, because those
   came from the markup rather than from here; the pager's "1 / 5" had nothing. The
   parentheses now come from CSS too, so this can swap them for square without touching a
   renderer, and switching the chrome off puts them back. */
/* Dates and figures are literals too, and they take the SAME brackets. That is the whole
   of the syntax highlighting, and it is why the counts in a meta line are wrapped in
   markup: "1,240 tu - 6 phut doc" has to be able to set the digits apart from the units.

   The brackets are a shade LIGHTER than what they hold. They are punctuation, not the
   value, and at the same weight as the digits a meta line reads as a row of boxes rather
   than as a date followed by two figures. */
   ONE bracket for every literal, and the term counts are in it too. They were a filled
   ring for one deploy, on the reasoning that a term cloud has no sequence to punctuate;
   the owner looked at it and said it was ugly, which settles it. Brackets everywhere is
   also the simpler rule to hold. */
html[data-ide-chrome=on] .rail-count,
html[data-ide-chrome=on] .term-count,
html[data-ide-chrome=on] .pager-count,
html[data-ide-chrome=on] .t-small time,
html[data-ide-chrome=on] .comment-meta time,
html[data-ide-chrome=on] .related p,
html[data-ide-chrome=on] .num{color:var(--c-text)}
html[data-ide-chrome=on] .term-count{opacity:1}
html[data-ide-chrome=on] .rail-count::before,
html[data-ide-chrome=on] .term-count::before,
html[data-ide-chrome=on] .pager-count::before,
html[data-ide-chrome=on] .t-small time::before,
html[data-ide-chrome=on] .comment-meta time::before,
html[data-ide-chrome=on] .related p::before,
html[data-ide-chrome=on] .num::before{content:"[";color:var(--c-meta)}
html[data-ide-chrome=on] .rail-count::after,
html[data-ide-chrome=on] .term-count::after,
html[data-ide-chrome=on] .pager-count::after,
html[data-ide-chrome=on] .t-small time::after,
html[data-ide-chrome=on] .comment-meta time::after,
html[data-ide-chrome=on] .related p::after,
html[data-ide-chrome=on] .num::after{content:"]";color:var(--c-meta)}
/* Darker inside the info panel than anywhere else, at the owner's ask: the same ink the
   contents list gives the row you are on. That panel is the only place a desktop reader
   sees the date and the length, so it has to carry the hierarchy by itself — everywhere
   else the literal sits in a line that already has a heading over it. */
html[data-ide-chrome=on] .post-info time,
html[data-ide-chrome=on] .post-info .num{color:var(--c-heading)}

/* A post's tags and categories are a comma-separated run already. In brackets they read
   as the array literal they are: tags: [css, typography]. The label and its colon are
   the markup's; only the brackets are ours. Unscoped, because the same run appears in the
   article footer on a narrow screen and in the right-hand info panel on a wide one. */
html[data-ide-chrome=on] .term-list::before{content:"[";color:var(--c-meta)}
html[data-ide-chrome=on] .term-list::after{content:"]";color:var(--c-meta)}

/* Two more lists that were bare: the related posts and the parts of a series. Both are
   indexed rather than counted, so they take the same brackets ranged in their own column
   — a references panel, not a bulleted list. The series had decimal markers, which is the
   one place the switch REPLACES something rather than adding to it. */
html[data-ide-chrome=on] .related ul{counter-reset:rel}
html[data-ide-chrome=on] .related li{counter-increment:rel;position:relative;padding-left:4ch}
html[data-ide-chrome=on] .related li::before{content:"[" counter(rel) "]";position:absolute;
  left:0;top:0;color:var(--c-meta);font-variant-numeric:tabular-nums}
html[data-ide-chrome=on] aside.series ol{list-style:none;padding-left:0;counter-reset:part}
html[data-ide-chrome=on] aside.series li{counter-increment:part;position:relative;padding-left:4ch}
html[data-ide-chrome=on] aside.series li::before{content:"[" counter(part) "]";position:absolute;
  left:0;top:0;color:var(--c-meta);font-variant-numeric:tabular-nums}

/* The feed's right gutter is a year over its months, which is a PATH and not a count — so
   it takes a separator rather than brackets. The year is sticky, so exactly one is ever on
   screen, and the month markers below it read as the next segment: 2026/ ... Thang 6.
   The tag is a flex row with a .5rem gap, and a pseudo-element is a flex ITEM, so the gap
   would push the slash a space clear of the digits. The negative margin cancels it. */
html[data-ide-chrome=on] .tl-year-tag::after{content:"/";margin-left:-.5rem;color:var(--c-meta)}

/* THE GUTTER. A counter on the list, ranged right in its own column, exactly as an
   editor numbers lines. It is decoration, so it is aria-hidden by being generated
   content on the <li> rather than inside the link - the row's accessible name is
   still the label and its count.

   It is --c-meta and not the hairline token, which measured 1.16:1 against the page:
   invisible, and a generated counter is still announced by some screen readers. A real
   editor's gutter is perfectly legible; what makes it a gutter is where it sits and
   that its figures are tabular, not that you cannot read it.

   The column is in ch of the mono chrome, so two digits always fit and the labels
   stay aligned however long the list runs. Tabular figures are what stop the numbers
   shifting the labels as the count crosses 9. */
/* The rail keeps the alignment it has with the switch off: ranged right, hugging the
   article. An earlier pass ranged it LEFT to put the gutter where an editor puts it;
   the owner asked for the opposite, so the GUTTER moves to the far right instead and
   nothing else about the rail changes. The active marker stays where the gutter layout
   put it, at the row's right edge, which is now 3.5ch clear of the numbers. */
html[data-ide-chrome=on] .rail ul{counter-reset:ln}
/* The number sits in a small ring ON the rail's divider, the same language the feed's
   year and month markers already speak: a node on a hairline. It costs the rail NO
   width - the ring lives out on the line, past the text - which is why the row keeps its
   normal padding and nothing here reserves a column.

   -22px = the divider's own -13px offset, minus half the 18px ring. The ring carries a
   --c-bg fill so the line breaks around it rather than running through it. */
html[data-ide-chrome=on] .rail li{counter-increment:ln;position:relative}
html[data-ide-chrome=on] .rail li::before{content:counter(ln);position:absolute;
  top:.05em;right:-23px;width:18px;height:18px;border-radius:50%;
  display:flex;align-items:center;justify-content:center;
  background:var(--c-bg);border:1px solid var(--c-rule);color:var(--c-meta);
  /* Air around the ring, the way the feed's markers get it: their --c-bg mask is a
     text line tall, so the spine stops well clear of the dot instead of touching it.
     A ring-sized mask left the hairline running into the border. The shadow paints
     the same break and costs no layout. */
  box-shadow:0 0 0 5px var(--c-bg);
  font-size:.62em;font-variant-numeric:tabular-nums;pointer-events:none}
/* .rail-inner scrolls a long index, and overflow-y:auto clips horizontally too - so the
   ring would be cut in half by its own container. The box grows right by the overhang and
   pads it back, which puts the ring inside the padding box where nothing clips it. */
html[data-ide-chrome=on] .rail-inner{width:calc(100% + 24px);padding-right:24px}
/* The active marker sits at the row's right edge, which used to be the rail's edge and
   nothing else - now the ring is out past it, so at 0 the hairline read as belonging to
   the ring rather than to the row it marks. Pulled in, but only 3px: 6px put it close
   enough to the words to crowd them, which is the opposite complaint. The row's own
   padding is 14px, so this leaves 11px of air on the text side and 10px to the circle. */
html[data-ide-chrome=on] .rail-row[aria-current]::before{right:3px}
/* The article index numbers its HEADINGS and nothing else. Its first row is the post
   title (a click is "back to the top") and its last is the jump to tags / categories /
   comments - neither is a line of the piece, and numbering them made the index look one
   heading longer than the post.

   Keyed on POSITION and on .toc-end, deliberately not on .is-active: the ToC island moves
   that class to whichever heading you are level with, so a rule hanging off it would drop
   the number from a different row as you scrolled. */
html[data-ide-chrome=on] .toc li:first-child,
html[data-ide-chrome=on] .toc li:has(.toc-end){counter-increment:none}
html[data-ide-chrome=on] .toc li:first-child::before,
html[data-ide-chrome=on] .toc li:has(.toc-end)::before{content:none}

/* The tag cloud is a run of words, not a list, so it has no lines to number. */
html[data-ide-chrome=on] .rail-tags{counter-reset:none}