/* ———————————————————————————————*/
/*   Stylesheet — blog
/*   Design system lifted 1:1 from veravandeseyp.com
/* ———————————————————————————————*/

/* ————————————————————————*/
/* Fonts
/* ————————————————————————*/

/* IBM Plex Mono — body face. SIL Open Font License 1.1; the licence text
   travels with the files in assets/fonts/. Self-hosted rather than pulled
   from a CDN so the page has no third-party font request. */

@font-face {
	font-family: 'plex-mono';
	src: url(../fonts/IBMPlexMono-Regular.woff2) format('woff2'),
	     url(../fonts/IBMPlexMono-Regular.woff) format('woff');
	font-style: normal;
	font-weight: 400;
	font-display: swap;
}

@font-face {
	font-family: 'plex-mono';
	src: url(../fonts/IBMPlexMono-Italic.woff2) format('woff2');
	font-style: italic;
	font-weight: 400;
	font-display: swap;
}

@font-face {
	font-family: 'plex-mono';
	src: url(../fonts/IBMPlexMono-Medium.woff2) format('woff2'),
	     url(../fonts/IBMPlexMono-Medium.woff) format('woff');
	font-style: normal;
	font-weight: 500;
	font-display: swap;
}

/* The variable display face — used on every pill / tag / button.
   woff2 first; the .ttf stays as the last resort because it is the only
   format that carries the variable axes on very old browsers. */
@font-face {
	font-family: 'dumpling';
	src: url(../fonts/koosveratypeGX.woff2) format('woff2-variations'),
	     url(../fonts/koosveratypeGX.woff) format('woff-variations'),
	     url(../fonts/koosveratypeGX.ttf) format('truetype-variations');
	font-display: swap;
}

@font-face {
	font-family: 'dumpling-extra';
	src: url(../fonts/dumplingGX.woff2) format('woff2-variations'),
	     url(../fonts/dumplingGX.woff) format('woff-variations'),
	     url(../fonts/dumplingGX.ttf) format('truetype-variations');
	font-display: swap;
}

/* ————————————————————————*/
/* Tokens
/* ————————————————————————*/

:root {
	/* base */
	--black: #000000;
	--gray: #A7AFB6;
	--gray-dark: #474C53;

	/* muted accent set (from the :root block the site prints inline) */
	--red: #E30F00;
	--green: #7E3500;
	--pink: #FEB8D5;
	--yellow: #DACCCC;
	--blue: #555577;
	--purple: #C1B3FE;

	/* neon tag / inline-link palette */
	--tag-blue: #79B9FF;
	--tag-green: #00FF00;
	--tag-yellow: #FFFF00;
	--tag-magenta: #FF00FF;
	--tag-orange: #FF4700;
	--tag-purple: #9940FF;

	/* rhythm */
	--m: 10px;
	--chamfer: 5px;

	/* ── measures ──────────────────────────────────────────────────────────
	   The page is a centred column, not a left-hugging one. Text inside it
	   stays left-aligned; only the column itself is centred.

	   --measure  the reading column: long-form text sits at a comfortable
	              line length and nothing wider ever pushes it around.
	   --wide     the breakout width for images, diagrams and code, so a
	              figure can be considerably wider than the prose and still
	              share its centre line.
	   --page     the widest container (used by the index, where rows need
	              the room for a code, a title and a date).
	   --col      whichever of those the current page centres on; set per
	              template below so the header, content and footer all line
	              up on the same left edge. */
	--measure: 66ch;
	--wide: 1100px;
	--page: 1180px;
	--col: var(--page);

	/* Motion, borrowed wholesale from veravandeseyp.com: everything moves in
	   0.25s / 0.5s on a plain ease, and entrances are choreographed with a
	   short delay rather than triggered by scrolling. */
	--t-fast: 0.25s;
	--t-base: 0.5s;

	--font-mono: 'plex-mono', ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
	--font-display: 'dumpling', ui-monospace, Menlo, Consolas, monospace;

	/* Pills, tags and buttons are all set at this size in 'dumpling'. */
	--display-size: 26px;
}

/* ————————————————————————*/
/* Reset
/* ————————————————————————*/

* {
	margin: 0;
	padding: 0;
	line-height: 1.4;
}

*:focus {
	outline: none !important;
}

*::selection {
	background-color: var(--green);
	color: var(--black);
}

html {
	font-size: 18px;
	line-height: 22px;
	background-color: var(--black);
}

body {
	background-color: var(--black);
	color: var(--gray);
	width: 100%;
	position: relative;
	font-size: 18px;
	line-height: 22px;
	height: auto;
	box-sizing: border-box;
	scroll-behavior: smooth;
	font-family: var(--font-mono);
	padding: var(--m);
	padding-top: calc(var(--m) * 5.5);
	-webkit-font-smoothing: antialiased;
}

/* An article centres on the reading measure so the title, the body and the
   header pill above them all share one left edge. The index keeps the wider
   column because its rows carry a code, a title and a date across the line. */
body.t-post { --col: var(--measure); }

/* The content and the footer are centred on the reading column. The header is
   deliberately not: it is fixed chrome rather than part of the article, so it
   runs the full width of the window and sits in the corner of the screen
   instead of tracking the text below it. */
main,
.footer {
	width: 100%;
	max-width: var(--col);
	margin-inline: auto;
}

img {
	user-select: none;
	max-width: 100%;
	display: block;
}

a {
	text-decoration: none;
	color: inherit;
}

p {
	line-height: 1.4;
	color: var(--gray);
	margin-bottom: 1.4rem;
}

p:last-of-type {
	margin-bottom: 0;
}

hr {
	border: 0;
	border-top: 1px solid var(--gray-dark);
	margin: var(--m) 0;
}

.mono { font-family: var(--font-mono); }
.dumpling, .dumpling * { font-family: var(--font-display); }
.uppercase { text-transform: uppercase; }
.dim { color: var(--gray-dark); }

/* ————————————————————————*/
/* Chamfered corners — the signature shape
/* ————————————————————————*/

.corners {
	clip-path: polygon(
		var(--chamfer) 0%,
		calc(100% - var(--chamfer)) 0%,
		100% var(--chamfer),
		100% calc(100% - var(--chamfer)),
		calc(100% - var(--chamfer)) 100%,
		var(--chamfer) 100%,
		0% calc(100% - var(--chamfer)),
		0% var(--chamfer)
	);
}

/* ————————————————————————*/
/* Buttons / pills
/* ————————————————————————*/

.button {
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	text-align: center;
	padding: 1px var(--m);
	line-height: 1;
	font-family: var(--font-display);
	font-size: var(--display-size);
	background-color: var(--gray);
	color: var(--black);
	text-transform: uppercase;
	cursor: pointer;
	white-space: nowrap;
	transition: background-color 0.1s linear, color 0.1s linear;
}

.button:hover {
	background-color: #fff;
	color: var(--black);
}

/* Outlined pill: gray shell + black inner = a 1px rule, no border property. */
.button-subtitle {
	display: flex;
	position: relative;
	padding: 1px;
	line-height: 1;
	background-color: var(--gray);
	color: var(--gray);
	text-transform: uppercase;
	cursor: pointer;
	white-space: nowrap;
}

.button-subtitle-inner {
	display: flex;
	align-items: center;
	justify-content: center;
	background-color: var(--black);
	color: var(--gray);
	font-family: var(--font-display);
	font-size: var(--display-size);
	line-height: 1;
	padding: 0 calc(var(--m) - 2px);
	transition: background-color 0.1s linear, color 0.1s linear;
}

.button-subtitle:hover .button-subtitle-inner {
	background-color: var(--gray);
	color: var(--black);
}

/* ————————————————————————*/
/* Tags
/* ————————————————————————*/

.tag {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	font-family: var(--font-display);
	font-size: var(--display-size);
	line-height: 1;
	padding: 1px var(--m);
	text-transform: uppercase;
	color: var(--black);
	background-color: var(--gray);
	white-space: nowrap;
	cursor: pointer;

	/* The tag keeps its own colour; hovering lifts it to full brightness and
	   deepens the text, which is the same move the outlined pills make. */
	transition: filter var(--t-fast) ease, transform var(--t-fast) ease;
}

.tag:hover {
	filter: brightness(1.35);
	transform: translateY(-2px) scale(1.06);
}

@media (prefers-reduced-motion: reduce) {
	.tag:hover { transform: none; }
}

.tag.tl,
.tag.ai      { background-color: var(--tag-blue); }
.tag.type    { background-color: var(--tag-green); }
.tag.md,
.tag.obj     { background-color: var(--tag-yellow); }
.tag.pr      { background-color: var(--tag-magenta); }
.tag.id      { background-color: var(--tag-orange); }
.tag.web     { background-color: var(--tag-purple); }

/* Inline links in prose take the same neon palette. */
.prose a,
a.inline {
	color: var(--tag-purple);
	transition: opacity 0.1s linear;
}

.prose a:hover,
a.inline:hover { opacity: 0.65; }

a.c-tl, a.c-ai  { color: var(--tag-blue); }
a.c-type        { color: var(--tag-green); }
a.c-md, a.c-obj { color: var(--tag-yellow); }
a.c-pr          { color: var(--tag-magenta); }
a.c-id          { color: var(--tag-orange); }
a.c-web         { color: var(--tag-purple); }

/* ————————————————————————*/
/* Header
/* ————————————————————————*/

.header {
	position: fixed;
	top: var(--m);
	left: var(--m);
	right: var(--m);
	z-index: 100;
	display: flex;
	align-items: flex-start;
	justify-content: space-between;
	gap: calc(var(--m) / 2);
	pointer-events: none;
}

.header > * { pointer-events: auto; }

.header-items {
	display: flex;
	align-items: flex-start;
	gap: calc(var(--m) / 2);
	flex-wrap: wrap;
}

/* the circular +/× */
.invert {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 30px;
	height: 30px;
	flex: 0 0 30px;
	border-radius: 50%;
	background-color: var(--gray);
	color: var(--black);
	font-family: var(--font-mono);
	font-size: 22px;
	line-height: 1;
	cursor: pointer;
	user-select: none;
	transition: background-color 0.1s linear, transform 0.15s ease;
}

.invert:hover {
	background-color: #fff;
	transform: rotate(90deg);
}

.header .button,
.header .button-subtitle-inner { height: 30px; }

.header .button-subtitle { align-self: flex-start; }

.header-right {
	display: flex;
	gap: var(--m);
	align-items: flex-start;
}

/* ————————————————————————*/
/* Post list (index)
/* ————————————————————————*/

.list {
	display: block;
	width: 100%;
	border-bottom: 1px solid var(--gray-dark);
}

.row {
	display: grid;
	grid-template-columns: 175px minmax(0, 1fr) auto;
	align-items: start;
	gap: var(--m);
	padding: calc(var(--m) * 1.6) 0;
	border-top: 1px solid var(--gray-dark);
	color: var(--gray);
}

.row-code { display: flex; }

.row-main { min-width: 0; }

.row-title {
	color: var(--gray);
	margin-bottom: calc(var(--m) / 2);
	transition: color 0.1s linear;
}

.row-excerpt {
	color: var(--gray-dark);
	max-width: 70ch;
}

/* Optional thumbnail strip, the portfolio's row treatment. Only appears when
   a post actually has images attached. */
.row-thumbs {
	display: flex;
	gap: 2px;
	margin-top: var(--m);

	/* Four 92px thumbnails plus their gaps need 374px, which is more than a
	   320px phone has once the page padding is taken out. Wrapping costs a
	   little vertical space and stops the row pushing the page sideways. */
	flex-wrap: wrap;
}

.row-thumb {
	flex: 0 0 auto;
	width: 92px;
	height: 62px;
	background-color: #0d0f10;
	overflow: hidden;
	line-height: 0;
}

.row-thumb img {
	width: 100%;
	height: 100%;
	object-fit: cover;
	display: block;
	opacity: 0.75;
	transition: opacity 0.1s linear;
}

.row:hover .row-thumb img { opacity: 1; }

.row-meta {
	display: flex;
	align-items: center;
	gap: calc(var(--m) / 2);
	justify-content: flex-end;
	flex-wrap: wrap;
}

.row-date {
	color: var(--gray-dark);
	white-space: nowrap;
	font-variant-numeric: tabular-nums;
	transition: color 0.1s linear;
}

.row:hover .row-title { color: #fff; }
.row:hover .row-excerpt { color: var(--gray); }
.row:hover .row-date { color: var(--gray); }
.row:hover .button-subtitle-inner {
	background-color: var(--gray);
	color: var(--black);
}

/* ————————————————————————*/
/* Article
/* ————————————————————————*/

/* The date sits a step below body copy: it is a label on the article, not
   part of it. */
.article-head .dim {
	font-size: 15px;
}

/* The head carries the gap down to the body itself now that the URL line
   that used to sit between them is gone. */
.article-head {
	display: flex;
	align-items: center;
	flex-wrap: wrap;
	gap: calc(var(--m) / 2);
	margin-bottom: calc(var(--m) * 3.5);
}

.article-title {
	font-size: 18px;
	font-weight: normal;
	line-height: 1.4;
	color: var(--gray);
	margin: 0 calc(var(--m) / 2);
}

.prose {
	max-width: var(--measure);
	color: var(--gray);
}

.prose h2,
.prose h3 {
	font-size: 18px;
	font-weight: normal;
	color: #fff;
	text-transform: uppercase;
	margin: calc(var(--m) * 3) 0 calc(var(--m) * 1.4);
}

.prose ul,
.prose ol {
	margin: 0 0 1.4rem var(--m);
	padding-left: var(--m);
	color: var(--gray);
}

.prose li { margin-bottom: calc(var(--m) / 2); }

.prose blockquote {
	border-left: 1px solid var(--gray-dark);
	padding-left: var(--m);
	color: var(--gray-dark);
	margin-bottom: 1.4rem;
}

.prose code {
	font-family: var(--font-mono);
	color: var(--tag-green);
}

.prose pre {
	background-color: #0d0f10;
	color: var(--gray);
	padding: var(--m) calc(var(--m) * 1.5);
	clip-path: polygon(
		var(--chamfer) 0%, calc(100% - var(--chamfer)) 0%,
		100% var(--chamfer), 100% calc(100% - var(--chamfer)),
		calc(100% - var(--chamfer)) 100%, var(--chamfer) 100%,
		0% calc(100% - var(--chamfer)), 0% var(--chamfer)
	);
	margin-bottom: 1.4rem;
	line-height: 1.6;
	border: 1px solid var(--gray-dark);

	/* Break out of the reading column so code has room, staying centred on
	   the same axis as the prose. The 50% + translate pair widens the box
	   around its own centre, so it grows equally into both margins instead
	   of running off to one side. */
	/* Sized so the listings clear their longest line with room to spare —
	   at 18px mono this holds ~86 characters, against a longest line of 79.
	   Sitting exactly on the boundary meant sub-pixel rounding decided
	   whether a line wrapped. */
	width: min(1000px, calc(100vw - var(--m) * 2));
	margin-left: 50%;
	transform: translateX(-50%);
	box-sizing: border-box;

	/* No horizontal scrollbar. The listings are formatted to fit this width,
	   and anything that still runs long soft-wraps rather than opening a
	   scroll track — a grey bar sitting under every block was the least
	   attractive thing on the page. overflow stays clipped so a stray
	   unbreakable token can never widen the box. */
	white-space: pre-wrap;
	overflow-wrap: break-word;
	overflow-x: hidden;
}

.prose pre code {
	color: inherit;
	white-space: inherit;
}

/* ————————————————————————*/
/* Figures
/* ————————————————————————*/

/* The prose column is capped at 66ch. `wide` and `full` step outside it
   without leaving the page padding, using negative margins rather than a
   second wrapper, so an image can breathe inside a running article. */

.figure {
	margin: calc(var(--m) * 3) 0;
}

.figure:first-child { margin-top: 0; }

.figure-frame {
	background-color: #0d0f10;
	line-height: 0;
	overflow: hidden;
}

.figure img {
	width: 100%;
	height: auto;
	display: block;
}

/* The caption sits on the reading column rather than on the figure's own
   edge: centred inside a breakout figure, its left edge lands back on the
   prose margin, so the page keeps one vertical line down the text. */
.figure figcaption {
	margin-top: calc(var(--m) / 2);
	margin-inline: auto;
	color: var(--gray-dark);
	max-width: var(--measure);
}

.figure figcaption::before {
	content: '↳ ';
}

/* Both breakouts widen around their own centre, so a diagram sits on the
   same axis as the text it interrupts rather than drifting left. */
.figure.is-wide {
	width: min(var(--wide), calc(100vw - var(--m) * 2));
	margin-left: 50%;
	transform: translateX(-50%);
}

.figure.is-full {
	width: calc(100vw - var(--m) * 2);
	margin-left: 50%;
	transform: translateX(-50%);
}

.figure.is-missing .figure-frame {
	line-height: 1.4;
	padding: var(--m);
	color: var(--tag-orange);
	background-color: #140a06;
}

/* Side-by-side strip, the same object as the thumbnails on a project row. */
.gallery {
	display: grid;
	grid-auto-flow: column;
	grid-auto-columns: 1fr;
	gap: calc(var(--m) / 2);
	align-items: start;
}

.gallery-item {
	background-color: #0d0f10;
	line-height: 0;
	overflow: hidden;
}

.gallery-item img {
	width: 100%;
	height: auto;
	display: block;
}

.prose img { margin-bottom: 1.4rem; }
.prose .figure img { margin-bottom: 0; }

.prose strong { color: #fff; font-weight: normal; }

/* ————————————————————————*/
/* Related strip
/* ————————————————————————*/

/* ————————————————————————*/
/* Filter bar
/* ————————————————————————*/

.filter-tags {
	display: flex;
	flex-wrap: wrap;
	gap: calc(var(--m) / 2);
	margin-bottom: calc(var(--m) * 2);
}

.filter-tag { display: flex; }

.filter-tag.off .tag { background-color: var(--gray-dark); color: var(--black); }

/* ————————————————————————*/
/* Footer
/* ————————————————————————*/

.footer {
	margin-top: calc(var(--m) * 6);
	padding-top: calc(var(--m) * 1.6);
	border-top: 1px solid var(--gray-dark);
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: var(--m);
	flex-wrap: wrap;
}

.footer-links {
	display: flex;
	gap: var(--m);
	flex-wrap: wrap;
}

.footer-note { color: var(--gray-dark); }

/* ————————————————————————*/
/* About
/* ————————————————————————*/

.about-layout {
	display: grid;
	grid-template-columns: minmax(0, 1fr) 320px;
	gap: calc(var(--m) * 4);
	align-items: start;
}

.about-side {
	display: flex;
	flex-direction: column;
	gap: calc(var(--m) / 2);
	align-items: flex-start;
}

.about-side .button,
.about-side .button-subtitle { margin-bottom: calc(var(--m) / 2); }

/* ————————————————————————*/
/* Motion
/* ————————————————————————*/

/* The vocabulary here is taken from veravandeseyp.com, which animates on two
   occasions only: when the page loads, and when you point at something. There
   is no parallax and nothing tied to scroll position on the original — the
   page arrives, settles, and then responds to the cursor. Timings are 0.25s
   or 0.5s on a plain ease; the round mark rotates out of 45°; text links open
   their letter-spacing rather than underlining.

   Long-form posts are the one place this blog goes further: a diagram sitting
   four screens down has no entrance to speak of, so figures and code fade up
   as they come into view. That behaviour is added by JS (see main.js) and the
   rules below only bite once the `js-reveal` class is on <html>, so with no
   JS every element is simply visible. */

@keyframes appear {
	0%   { opacity: 0; }
	100% { opacity: 1; }
}

/* Content arrives with a small upward drift. */
@keyframes rise {
	0%   { opacity: 0; transform: translateY(6px); }
	100% { opacity: 1; transform: none; }
}

/* The round +/× mark unwinds from 45°, exactly as the header mark does on
   the original site. */
@keyframes xAppear {
	0%   { transform: rotate(45deg); }
	100% { transform: rotate(0deg); }
}

/* ── entrances ──────────────────────────────────────────────────────────── */

.invert {
	animation: xAppear var(--t-base) ease 1 normal both;
}

.header .button-subtitle {
	animation: appear var(--t-base) ease 1 normal both;
}

/* The index: rows come in one after another. The delay is small enough that
   the whole list has landed well before the eye reaches the bottom of it. */
.row {
	animation: rise var(--t-base) ease 1 normal both;
}

.row:nth-child(1) { animation-delay: 0.04s; }
.row:nth-child(2) { animation-delay: 0.08s; }
.row:nth-child(3) { animation-delay: 0.12s; }
.row:nth-child(4) { animation-delay: 0.16s; }
.row:nth-child(5) { animation-delay: 0.20s; }
.row:nth-child(6) { animation-delay: 0.24s; }
.row:nth-child(7) { animation-delay: 0.28s; }
.row:nth-child(8) { animation-delay: 0.32s; }
.row:nth-child(n+9) { animation-delay: 0.36s; }

/* An article opens with its head, then its first screen of text. */
.article-head { animation: rise var(--t-base) ease 0.04s 1 normal both; }
.prose        { animation: appear var(--t-base) ease 0.12s 1 normal both; }

/* ── hover ──────────────────────────────────────────────────────────────── */

/* Links in running text open up instead of underlining. */
.prose a {
	transition: letter-spacing var(--t-fast) ease, color var(--t-fast) ease;
}

.prose a:hover {
	letter-spacing: 0.06em;
}

/* The row already lightens on hover; give it the same easing as everything
   else so the list feels like one material. */
.row-title,
.row-excerpt,
.row-date {
	transition: color var(--t-fast) ease;
}

.row-thumb img {
	transition: opacity var(--t-base) ease, transform var(--t-base) ease;
}

.row:hover .row-thumb img {
	transform: scale(1.03);
}

.figure-frame img {
	transition: transform var(--t-base) ease;
}

/* ── reveal on scroll (JS-gated, articles only) ─────────────────────────── */

.js-reveal .reveal {
	opacity: 0;
	transform: translateY(10px);
}

.js-reveal .reveal.is-in {
	opacity: 1;
	transform: none;
	transition: opacity var(--t-base) ease, transform var(--t-base) ease;
}

/* A figure that breaks out of the column is already translated to sit on the
   centre line, so its reveal has to ride on top of that offset rather than
   replace it — otherwise it would jump sideways as it fades in. */
.js-reveal .figure.is-wide.reveal,
.js-reveal .figure.is-full.reveal,
.js-reveal .prose pre.reveal {
	transform: translateX(-50%) translateY(10px);
}

.js-reveal .figure.is-wide.reveal.is-in,
.js-reveal .figure.is-full.reveal.is-in,
.js-reveal .prose pre.reveal.is-in {
	transform: translateX(-50%);
}

/* ── reduced motion ─────────────────────────────────────────────────────── */

/* Anyone who has asked their system for less movement gets the finished
   layout immediately, with the hover colour changes left intact. */
@media (prefers-reduced-motion: reduce) {
	*,
	*::before,
	*::after {
		animation-duration: 0.01ms !important;
		animation-delay: 0ms !important;
		transition-duration: 0.01ms !important;
	}

	.js-reveal .reveal { opacity: 1; transform: none; }

	.js-reveal .figure.is-wide.reveal,
	.js-reveal .figure.is-full.reveal,
	.js-reveal .prose pre.reveal { transform: translateX(-50%); }

	.prose a:hover { letter-spacing: normal; }
	.row:hover .row-thumb img { transform: none; }

	html { scroll-behavior: auto; }
	body { scroll-behavior: auto; }
}

/* ————————————————————————*/
/* Article furniture
/* ————————————————————————*/

/* A long technical post needs more signposting than a short note. Everything
   below is built from the parts the rest of the site already uses — hairlines,
   mono capitals, the chamfer, the neon accents — so the article reads as the
   same object, just with more joinery. */

/* Section heads sit under a hairline that runs the width of the reading
   column, so scrolling past one registers as a change of subject. */
.prose h2 {
	font-size: 18px;
	font-weight: 500;
	color: #fff;
	text-transform: uppercase;
	letter-spacing: 0.08em;
	border-top: 1px solid var(--gray-dark);
	padding-top: calc(var(--m) * 1.4);
	margin: calc(var(--m) * 5) 0 calc(var(--m) * 1.6);
}

.prose h2:first-child { margin-top: 0; }

.prose h3 {
	font-size: 18px;
	font-weight: normal;
	color: #fff;
	text-transform: uppercase;
	letter-spacing: 0.06em;
	margin: calc(var(--m) * 3) 0 calc(var(--m) * 1.2);
}

/* Inline code reads as a chip rather than as coloured prose — at mono sizes
   the colour alone is too weak a signal for an identifier sitting mid-line. */
.prose p > code,
.prose li > code {
	background-color: #0d0f10;
	padding: 1px 5px;
	color: var(--tag-green);

	/* An endpoint like GET /api/publications/<publication_id> is one
	   unbreakable token to the line breaker, and on a phone it is wider than
	   the column it sits in. `anywhere` lets it break and, unlike
	   break-word, also lowers the element's min-content width so it cannot
	   push the whole page sideways. */
	overflow-wrap: anywhere;
}

/* Fenced blocks get their language in the corner. The class lives on <code>,
   so :has() is what lets the label hang off the positioned <pre>; where it is
   unsupported the block simply renders without a label. */
.prose pre {
	position: relative;
	padding-top: calc(var(--m) * 2.4);
}

.prose pre::before {
	position: absolute;
	top: calc(var(--m) * 0.8);
	right: calc(var(--m) * 1.5);
	font-size: 13px;
	letter-spacing: 0.1em;
	text-transform: uppercase;
	color: var(--gray-dark);
	content: '';
}

.prose pre:has(code[class*="language-json"])::before       { content: 'json'; }
.prose pre:has(code[class*="language-javascript"])::before { content: 'javascript'; }
.prose pre:has(code[class*="language-text"])::before        { content: 'plain'; }
.prose pre:has(code[class*="language-bash"])::before        { content: 'shell'; }

/* Numbered steps: the numeral is set in the accent rather than left to the
   browser's default marker, which sits at the wrong weight against mono. */
.prose ol {
	list-style: none;
	counter-reset: step;
	margin-left: 0;
	padding-left: 0;
}

.prose ol > li {
	counter-increment: step;
	position: relative;
	padding-left: calc(var(--m) * 3.4);
	margin-bottom: calc(var(--m) * 1.2);
}

.prose ol > li::before {
	content: counter(step);
	position: absolute;
	left: 0;
	top: 0;
	color: var(--tag-green);
}

.prose ul {
	list-style: none;
	margin-left: 0;
	padding-left: 0;
}

.prose ul > li {
	position: relative;
	padding-left: calc(var(--m) * 3.4);
	margin-bottom: calc(var(--m) * 1.2);
}

.prose ul > li::before {
	content: '—';
	position: absolute;
	left: 0;
	top: 0;
	color: var(--gray-dark);
}

/* The diagrams are black-on-black by design, so without an edge they bleed
   into the page. A hairline gives them a boundary; the chamfer clips its
   corners to match every other panel on the site. */
.figure-frame {
	border: 1px solid var(--gray-dark);
}

/* Breakout figures earn more air than an inline one — they are meant to
   interrupt the reading, not sit inside it. */
.figure.is-wide,
.figure.is-full {
	margin-top: calc(var(--m) * 5);
	margin-bottom: calc(var(--m) * 5);
}

/* ── reading progress ───────────────────────────────────────────────────── */

/* A hairline across the very top, filled in proportion to scroll depth. It
   only exists on pages that set it (articles), and only once JS reports a
   width — so no bar appears on the index or without scripting. */
.progress {
	position: fixed;
	top: 0;
	left: 0;
	height: 2px;
	width: 0;
	background-color: var(--gray);
	z-index: 200;
	pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
	.progress { transition: none; }
}

/* ————————————————————————*/
/* Line breaking
/* ————————————————————————*/

/* `pretty` asks the browser to look ahead over the last few lines of a block
   and avoid leaving a single word stranded on its own. `balance` evens out
   the line lengths of short blocks instead, which is what headings and
   captions want. Both are progressive: a browser that does not implement
   them just breaks lines the way it always did. */
p,
li,
.row-excerpt,
.figure figcaption {
	text-wrap: pretty;
}

.prose h2,
.prose h3,
.article-title,
.row-title {
	text-wrap: balance;
}

/* Code is exempt. Its line breaks are meaningful, and the listings are sized
   not to wrap in the first place. */
.prose pre,
.prose pre code {
	text-wrap: wrap;
}

/* ————————————————————————*/
/* What to read next
/* ————————————————————————*/

/* The end of an article is where a reader decides whether to stay. It gets a
   card for the obvious next thing and, under it, a plain list of everything
   else, rather than dropping straight into the footer. */

.post-next {
	margin-top: calc(var(--m) * 7);
	padding-top: calc(var(--m) * 2);
	border-top: 1px solid var(--gray-dark);
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
	gap: var(--m);
}

.post-next-card,
.post-next-empty {
	display: block;
	background-color: #0d0f10;
	padding: calc(var(--m) * 1.6);
	color: var(--gray);
	transition: background-color var(--t-fast) ease, transform var(--t-fast) ease;
}

.post-next-card:hover {
	background-color: #14181a;
	transform: translateY(-2px);
}

.post-next-label {
	font-size: 15px;
	text-transform: uppercase;
	letter-spacing: 0.08em;
	color: var(--gray-dark);
	margin-bottom: var(--m);
}

.post-next-title {
	color: #fff;
	margin-bottom: calc(var(--m) / 2);
}

.post-next-excerpt {
	color: var(--gray-dark);
	margin-bottom: calc(var(--m) * 1.6);
}

.post-next-meta {
	display: flex;
	align-items: center;
	gap: var(--m);
	flex-wrap: wrap;
}

.post-next-date { color: var(--gray-dark); font-size: 15px; }

/* The empty state keeps the same footprint as a card, so a one-post site
   still ends deliberately instead of stopping dead. */
.post-next-empty {
	background-color: transparent;
	border: 1px dashed var(--gray-dark);
	padding: calc(var(--m) * 1.6);
}

.post-next-empty p { color: var(--gray-dark); margin: 0; }

/* ── the list underneath ────────────────────────────────────────────────── */

.other-articles {
	margin-top: calc(var(--m) * 4);
}

.other-articles-head {
	font-size: 18px;
	font-weight: normal;
	color: #fff;
	text-transform: uppercase;
	letter-spacing: 0.08em;
	margin-bottom: var(--m);
}

.other-articles-list { list-style: none; margin: 0; padding: 0; }

.other-articles-list li { border-top: 1px solid var(--gray-dark); }

.other-articles-list a {
	display: grid;
	grid-template-columns: auto minmax(0, 1fr) auto;
	align-items: baseline;
	gap: var(--m);
	padding: calc(var(--m) * 1.2) 0;
	color: var(--gray);
	transition: color var(--t-fast) ease;
}

.other-articles-list a:hover { color: #fff; }

.other-articles-code { color: var(--gray-dark); }
.other-articles-date { color: var(--gray-dark); font-size: 15px; }

@media (max-width: 787px) {
	.other-articles-list a {
		grid-template-columns: minmax(0, 1fr);
		gap: calc(var(--m) / 3);
	}
}

@media (prefers-reduced-motion: reduce) {
	.post-next-card:hover { transform: none; }
}
